feat: 用户管理新增导入

This commit is contained in:
xlsea 2025-05-08 17:47:53 +08:00
parent 5de60e2a83
commit 62460bba90
11 changed files with 243 additions and 26 deletions

View File

@ -75,7 +75,7 @@ function handleExport() {
</NPopconfirm> </NPopconfirm>
<NButton v-if="showExport" size="small" ghost @click="handleExport"> <NButton v-if="showExport" size="small" ghost @click="handleExport">
<template #icon> <template #icon>
<icon-ic-round-download class="text-icon" /> <icon-material-symbols:download-2-rounded class="text-icon" />
</template> </template>
导出 导出
</NButton> </NButton>

View File

@ -11,6 +11,8 @@ defineOptions({
interface Props { interface Props {
action?: string; action?: string;
data?: Record<string, any>;
defaultUpload?: boolean;
showTip?: boolean; showTip?: boolean;
max?: number; max?: number;
accept?: string; accept?: string;
@ -20,6 +22,8 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
action: `/resource/oss/upload`, action: `/resource/oss/upload`,
data: undefined,
defaultUpload: true,
showTip: true, showTip: true,
max: 5, max: 5,
accept: '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf', accept: '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf',
@ -29,6 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
const attrs: UploadProps = useAttrs(); const attrs: UploadProps = useAttrs();
const value = defineModel<CommonType.IdType[]>('value', { required: false, default: [] });
let fileNum = 0; let fileNum = 0;
const fileList = ref<UploadFileInfo[]>([]); const fileList = ref<UploadFileInfo[]>([]);
const needRelaodData = defineModel<boolean>('needRelaodData', { const needRelaodData = defineModel<boolean>('needRelaodData', {
@ -39,6 +45,7 @@ watch(
() => fileList.value, () => fileList.value,
newValue => { newValue => {
needRelaodData.value = newValue.length > 0; needRelaodData.value = newValue.length > 0;
value.value = newValue.map(item => item.id);
} }
); );
@ -126,11 +133,13 @@ async function handleRemove(file: UploadFileInfo) {
v-bind="attrs" v-bind="attrs"
v-model:file-list="fileList" v-model:file-list="fileList"
:action="`${baseURL}${action}`" :action="`${baseURL}${action}`"
:data="data"
:headers="headers" :headers="headers"
:max="max" :max="max"
:accept="accept" :accept="accept"
multiple :multiple="max > 1"
directory-dnd directory-dnd
:default-upload="defaultUpload"
:list-type="uploadType === 'image' ? 'image-card' : 'text'" :list-type="uploadType === 'image' ? 'image-card' : 'text'"
:is-error-state="isErrorState" :is-error-state="isErrorState"
@finish="handleFinish" @finish="handleFinish"
@ -145,12 +154,12 @@ async function handleRemove(file: UploadFileInfo) {
<NText class="text-16px">点击或者拖动文件到该区域来上传</NText> <NText class="text-16px">点击或者拖动文件到该区域来上传</NText>
<NP v-if="showTip" depth="3" class="mt-8px text-center"> <NP v-if="showTip" depth="3" class="mt-8px text-center">
请上传 请上传
<template v-if="max"> <template v-if="fileSize">
大小不超过 大小不超过
<b class="text-red-500">{{ max }}MB</b> <b class="text-red-500">{{ fileSize }}MB</b>
</template> </template>
<template v-if="accept"> <template v-if="accept">
格式为 格式为
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b> <b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
</template> </template>
的文件 的文件
@ -159,12 +168,12 @@ async function handleRemove(file: UploadFileInfo) {
</NUpload> </NUpload>
<NP v-if="showTip && uploadType === 'image'" depth="3" class="mt-12px"> <NP v-if="showTip && uploadType === 'image'" depth="3" class="mt-12px">
请上传 请上传
<template v-if="max"> <template v-if="fileSize">
大小不超过 大小不超过
<b class="text-red-500">{{ max }}MB</b> <b class="text-red-500">{{ fileSize }}MB</b>
</template> </template>
<template v-if="accept"> <template v-if="accept">
格式为 格式为
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b> <b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
</template> </template>
的文件 的文件

View File

@ -58,6 +58,32 @@ export function fetchGetDeptTree() {
}); });
} }
/** 重置用户密码 */
export function fetchResetUserPassword(userId: CommonType.IdType, password: string) {
return request<boolean>({
url: '/system/user/resetPwd',
method: 'put',
data: { userId, password }
});
}
/** 根据用户编号获取授权角色 */
export function fetchGetAuthRole(userId: CommonType.IdType) {
return request<Api.System.AuthRole>({
url: `/system/user/authRole/${userId}`,
method: 'get'
});
}
/** 用户授权角色 */
export function fetchAuthUserRole(userId: CommonType.IdType, roleIds: CommonType.IdType[]) {
return request<boolean>({
url: '/system/user/authRole',
method: 'put',
data: { userId, roleIds }
});
}
/** 修改用户基本信息 */ /** 修改用户基本信息 */
export function fetchUpdateUserProfile(data: Api.System.UserProfileOperateParams) { export function fetchUpdateUserProfile(data: Api.System.UserProfileOperateParams) {
return request<boolean>({ return request<boolean>({

View File

@ -6,7 +6,6 @@ import { fetchGetUserInfo, fetchLogin, fetchLogout } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
import { $t } from '@/locales';
import { useRouteStore } from '../route'; import { useRouteStore } from '../route';
import { useTabStore } from '../tab'; import { useTabStore } from '../tab';
import { clearAuthStorage, getToken } from './shared'; import { clearAuthStorage, getToken } from './shared';
@ -81,11 +80,11 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
if (pass) { if (pass) {
await redirectFromLogin(redirect); await redirectFromLogin(redirect);
window.$notification?.success({ // window.$notification?.success({
title: $t('page.login.common.loginSuccess'), // title: $t('page.login.common.loginSuccess'),
content: $t('page.login.common.welcomeBack', { userName: userInfo.user?.nickName }), // content: $t('page.login.common.welcomeBack', { userName: userInfo.user?.nickName }),
duration: 4500 // duration: 4500
}); // });
} }
} else { } else {
resetStore(); resetStore();

View File

@ -23,7 +23,7 @@ declare namespace Api {
/** 备注 */ /** 备注 */
remark?: string; remark?: string;
/** 角色ID */ /** 角色ID */
roleId: number; roleId: CommonType.IdType;
/** 角色权限字符串 */ /** 角色权限字符串 */
roleKey: string; roleKey: string;
/** 角色名称 */ /** 角色名称 */
@ -144,6 +144,12 @@ declare namespace Api {
/** user list */ /** user list */
type UserList = Common.PaginatingQueryRecord<User>; type UserList = Common.PaginatingQueryRecord<User>;
/** auth role */
type AuthRole = {
user: User;
roles: Role[];
};
/** social */ /** social */
type Social = Common.CommonRecord<{ type Social = Common.CommonRecord<{
/** 用户ID */ /** 用户ID */

View File

@ -47,7 +47,9 @@ declare module 'vue' {
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default'] IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default'] IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalLogo: typeof import('~icons/local/logo')['default'] IconLocalLogo: typeof import('~icons/local/logo')['default']
'IconMaterialSymbols:download2Rounded': typeof import('~icons/material-symbols/download2-rounded')['default']
'IconMaterialSymbols:syncRounded': typeof import('~icons/material-symbols/sync-rounded')['default'] 'IconMaterialSymbols:syncRounded': typeof import('~icons/material-symbols/sync-rounded')['default']
'IconMaterialSymbols:upload2Rounded': typeof import('~icons/material-symbols/upload2-rounded')['default']
IconMaterialSymbolsHelpOutline: typeof import('~icons/material-symbols/help-outline')['default'] IconMaterialSymbolsHelpOutline: typeof import('~icons/material-symbols/help-outline')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default'] IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default'] IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']

View File

@ -50,7 +50,7 @@ export function createServiceConfig(env: Env.ImportMeta) {
* @param env - the current env * @param env - the current env
* @param isProxy - if use proxy * @param isProxy - if use proxy
*/ */
export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) { export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean = env.DEV && env.VITE_HTTP_PROXY === 'Y') {
const { baseURL, other, proxyPattern } = createServiceConfig(env); const { baseURL, other, proxyPattern } = createServiceConfig(env);
const otherBaseURL = {} as Record<App.Service.OtherBaseURLKey, string>; const otherBaseURL = {} as Record<App.Service.OtherBaseURLKey, string>;

View File

@ -44,7 +44,13 @@ async function unbindSsoAccount(socialId: string) {
endBtnLoading(); endBtnLoading();
} }
const socialSources = [ const socialSources: {
key: Api.System.SocialSource;
icon?: string;
localIcon?: string;
color: string;
name: string;
}[] = [
{ key: 'wechat_open', icon: 'ic:outline-wechat', color: '#44b549', name: '微信' }, { key: 'wechat_open', icon: 'ic:outline-wechat', color: '#44b549', name: '微信' },
{ key: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' }, { key: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' },
{ key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' }, { key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' },
@ -92,9 +98,7 @@ function getSocial(key: string) {
:style="{ color: source.color }" :style="{ color: source.color }"
/> />
<div class="text-16px font-medium">{{ source.name }}</div> <div class="text-16px font-medium">{{ source.name }}</div>
<NButton type="primary" size="small" @click="bindSsoAccount(source.key as Api.System.SocialSource)"> <NButton type="primary" size="small" @click="bindSsoAccount(source.key)">绑定</NButton>
绑定
</NButton>
</div> </div>
</template> </template>
</NCard> </NCard>

View File

@ -1,7 +1,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { ref } from 'vue'; import { ref } from 'vue';
import { NButton } from 'naive-ui'; import { NButton } from 'naive-ui';
import { useLoading } from '@sa/hooks'; import { useBoolean, useLoading } from '@sa/hooks';
import { fetchBatchDeleteUser, fetchGetDeptTree, fetchGetUserList } from '@/service/api/system'; import { fetchBatchDeleteUser, fetchGetDeptTree, fetchGetUserList } from '@/service/api/system';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useTable, useTableOperate } from '@/hooks/common/table'; import { useTable, useTableOperate } from '@/hooks/common/table';
@ -11,6 +11,7 @@ import ButtonIcon from '@/components/custom/button-icon.vue';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import { $t } from '@/locales'; import { $t } from '@/locales';
import UserOperateDrawer from './modules/user-operate-drawer.vue'; import UserOperateDrawer from './modules/user-operate-drawer.vue';
import UserImportModal from './modules/user-import-modal.vue';
import UserSearch from './modules/user-search.vue'; import UserSearch from './modules/user-search.vue';
defineOptions({ defineOptions({
@ -23,6 +24,8 @@ useDict('sys_normal_disable');
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const appStore = useAppStore(); const appStore = useAppStore();
const { bool: importVisible, setTrue: openImportModal } = useBoolean();
const { const {
columns, columns,
columnChecks, columnChecks,
@ -198,6 +201,10 @@ function handleResetTreeData() {
deptPattern.value = undefined; deptPattern.value = undefined;
getTreeData(); getTreeData();
} }
function handleImport() {
openImportModal();
}
</script> </script>
<template> <template>
@ -247,7 +254,16 @@ function handleResetTreeData() {
@add="handleAdd" @add="handleAdd"
@delete="handleBatchDelete" @delete="handleBatchDelete"
@refresh="getData" @refresh="getData"
/> >
<template #after>
<NButton v-if="hasAuth('system:user:import')" size="small" ghost @click="handleImport">
<template #icon>
<icon-material-symbols:upload-2-rounded class="text-icon" />
</template>
导入
</NButton>
</template>
</TableHeaderOperation>
</template> </template>
<NDataTable <NDataTable
v-model:checked-row-keys="checkedRowKeys" v-model:checked-row-keys="checkedRowKeys"
@ -262,6 +278,7 @@ function handleResetTreeData() {
:pagination="mobilePagination" :pagination="mobilePagination"
class="h-full" class="h-full"
/> />
<UserImportModal v-model:visible="importVisible" @submitted="getDataByPage" />
<UserOperateDrawer <UserOperateDrawer
v-model:visible="drawerVisible" v-model:visible="drawerVisible"
:operate-type="operateType" :operate-type="operateType"

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
// import { fetchUserResetPwd } from '@/service/api/system';
import { ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { getToken } from '@/store/modules/auth/shared';
import { useDownload } from '@/hooks/business/download';
import { getServiceBaseURL } from '@/utils/service';
import type FileUpload from '@/components/custom/file-upload.vue';
defineOptions({
name: 'UserImportModal'
});
interface Emits {
(e: 'submitted'): void;
}
const { download } = useDownload();
const { baseURL } = getServiceBaseURL(import.meta.env);
const headers: Record<string, string> = {
Authorization: `Bearer ${getToken()}`,
clientid: import.meta.env.VITE_APP_CLIENT_ID!
};
const emit = defineEmits<Emits>();
const uploadRef = ref<typeof FileUpload>();
const message = ref<string>('');
const success = ref<boolean>(false);
const visible = defineModel<boolean>('visible', {
default: false
});
const data = ref<Record<string, any>>({
updateSupport: false
});
const fileList = ref<UploadFileInfo[]>([]);
function closeDrawer() {
visible.value = false;
if (success.value) {
emit('submitted');
}
}
async function handleSubmit() {
fileList.value.forEach(item => {
item.status = 'pending';
});
uploadRef.value?.submit();
}
function isErrorState(xhr: XMLHttpRequest) {
const responseText = xhr?.responseText;
const response = JSON.parse(responseText);
return response.code !== 200;
}
function handleFinish(options: { file: UploadFileInfo; event?: ProgressEvent }) {
const { file, event } = options;
// @ts-expect-error Ignore type errors
const responseText = event?.target?.responseText;
const response = JSON.parse(responseText);
message.value = response.msg;
window.$message?.success('导入成功');
success.value = true;
return file;
}
function handleError(options: { file: UploadFileInfo; event?: ProgressEvent }) {
const { event } = options;
// @ts-expect-error Ignore type errors
const responseText = event?.target?.responseText;
const msg = JSON.parse(responseText).msg;
message.value = msg;
window.$message?.error(msg || '导入失败');
success.value = false;
}
function handleDownloadTemplate() {
download('/system/user/importTemplate', {}, `user_template_${new Date().getTime()}.xlsx`);
}
watch(visible, () => {
if (visible.value) {
fileList.value = [];
success.value = false;
message.value = '';
}
});
</script>
<template>
<NModal
v-model:show="visible"
title="导入用户"
preset="card"
:bordered="false"
display-directive="show"
class="max-w-90% w-600px"
@close="closeDrawer"
>
<NUpload
ref="uploadRef"
v-model:file-list="fileList"
:action="`${baseURL}/system/user/importData`"
:headers="headers"
:data="data"
:max="1"
:file-size="50"
accept=".xls,.xlsx"
:multiple="false"
directory-dnd
:default-upload="false"
list-type="text"
:is-error-state="isErrorState"
@finish="handleFinish"
@error="handleError"
>
<NUploadDragger>
<div class="mb-12px flex-center">
<SvgIcon icon="material-symbols:unarchive-outline" class="text-58px color-#d8d8db dark:color-#a1a1a2" />
</div>
<NText class="text-16px">点击或者拖动文件到该区域来上传</NText>
<NP depth="3" class="mt-8px text-center">
请上传大小不超过
<b class="text-red-500">50MB</b>
且格式为
<b class="text-red-500">xls/xlsx</b>
的文件
</NP>
</NUploadDragger>
</NUpload>
<div class="flex-center">
<NCheckbox v-model="data.updateSupport">是否更新已经存在的用户数据</NCheckbox>
</div>
<NAlert v-if="message" title="导入结果" :type="success ? 'success' : 'error'" :bordered="false">
{{ message }}
</NAlert>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="handleDownloadTemplate">下载模板</NButton>
<NButton type="primary" @click="handleSubmit">导入用户</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped></style>

View File

@ -70,7 +70,7 @@ type RuleKey = Extract<keyof Model, 'userName' | 'nickName' | 'password' | 'stat
const rules: Record<RuleKey, App.Global.FormRule[]> = { const rules: Record<RuleKey, App.Global.FormRule[]> = {
userName: [createRequiredRule('用户名称不能为空')], userName: [createRequiredRule('用户名称不能为空')],
nickName: [createRequiredRule('用户昵称不能为空')], nickName: [createRequiredRule('用户昵称不能为空')],
password: [{ ...patternRules.pwd, required: true }], password: [{ ...patternRules.pwd, required: props.operateType === 'add' }],
phonenumber: [patternRules.phone], phonenumber: [patternRules.phone],
status: [createRequiredRule('帐号状态不能为空')] status: [createRequiredRule('帐号状态不能为空')]
}; };
@ -95,6 +95,7 @@ function handleUpdateModelWhenEdit() {
if (props.operateType === 'edit' && props.rowData) { if (props.operateType === 'edit' && props.rowData) {
startDeptLoading(); startDeptLoading();
Object.assign(model, props.rowData); Object.assign(model, props.rowData);
model.password = '';
getUserInfo(); getUserInfo();
endDeptLoading(); endDeptLoading();
} }
@ -107,9 +108,10 @@ function closeDrawer() {
async function handleSubmit() { async function handleSubmit() {
await validate(); await validate();
const { userId, deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
// request // request
if (props.operateType === 'add') { if (props.operateType === 'add') {
const { deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
const { error } = await fetchCreateUser({ const { error } = await fetchCreateUser({
deptId, deptId,
userName, userName,
@ -125,7 +127,6 @@ async function handleSubmit() {
} }
if (props.operateType === 'edit') { if (props.operateType === 'edit') {
const { userId, deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
const { error } = await fetchUpdateUser({ const { error } = await fetchUpdateUser({
userId, userId,
deptId, deptId,
@ -183,7 +184,7 @@ watch(visible, () => {
<NFormItem v-if="operateType === 'add'" label="用户名称" path="userName"> <NFormItem v-if="operateType === 'add'" label="用户名称" path="userName">
<NInput v-model:value="model.userName" placeholder="请输入用户名称" /> <NInput v-model:value="model.userName" placeholder="请输入用户名称" />
</NFormItem> </NFormItem>
<NFormItem v-if="operateType === 'add'" label="用户密码" path="password"> <NFormItem label="用户密码" path="password">
<NInput <NInput
v-model:value="model.password" v-model:value="model.password"
type="password" type="password"