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>
<NButton v-if="showExport" size="small" ghost @click="handleExport">
<template #icon>
<icon-ic-round-download class="text-icon" />
<icon-material-symbols:download-2-rounded class="text-icon" />
</template>
导出
</NButton>

View File

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

View File

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

View File

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

View File

@ -47,7 +47,9 @@ declare module 'vue' {
IconIcRoundUpload: typeof import('~icons/ic/round-upload')['default']
IconLocalBanner: typeof import('~icons/local/banner')['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:upload2Rounded': typeof import('~icons/material-symbols/upload2-rounded')['default']
IconMaterialSymbolsHelpOutline: typeof import('~icons/material-symbols/help-outline')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-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 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 otherBaseURL = {} as Record<App.Service.OtherBaseURLKey, string>;

View File

@ -44,7 +44,13 @@ async function unbindSsoAccount(socialId: string) {
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: 'topiam', localIcon: 'topiam', color: '', name: 'TopIAM' },
{ key: 'maxkey', localIcon: 'maxkey', color: '', name: 'MaxKey' },
@ -92,9 +98,7 @@ function getSocial(key: string) {
:style="{ color: source.color }"
/>
<div class="text-16px font-medium">{{ source.name }}</div>
<NButton type="primary" size="small" @click="bindSsoAccount(source.key as Api.System.SocialSource)">
绑定
</NButton>
<NButton type="primary" size="small" @click="bindSsoAccount(source.key)">绑定</NButton>
</div>
</template>
</NCard>

View File

@ -1,7 +1,7 @@
<script setup lang="tsx">
import { ref } from 'vue';
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 { useAppStore } from '@/store/modules/app';
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 { $t } from '@/locales';
import UserOperateDrawer from './modules/user-operate-drawer.vue';
import UserImportModal from './modules/user-import-modal.vue';
import UserSearch from './modules/user-search.vue';
defineOptions({
@ -23,6 +24,8 @@ useDict('sys_normal_disable');
const { hasAuth } = useAuth();
const appStore = useAppStore();
const { bool: importVisible, setTrue: openImportModal } = useBoolean();
const {
columns,
columnChecks,
@ -198,6 +201,10 @@ function handleResetTreeData() {
deptPattern.value = undefined;
getTreeData();
}
function handleImport() {
openImportModal();
}
</script>
<template>
@ -247,7 +254,16 @@ function handleResetTreeData() {
@add="handleAdd"
@delete="handleBatchDelete"
@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>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
@ -262,6 +278,7 @@ function handleResetTreeData() {
:pagination="mobilePagination"
class="h-full"
/>
<UserImportModal v-model:visible="importVisible" @submitted="getDataByPage" />
<UserOperateDrawer
v-model:visible="drawerVisible"
: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[]> = {
userName: [createRequiredRule('用户名称不能为空')],
nickName: [createRequiredRule('用户昵称不能为空')],
password: [{ ...patternRules.pwd, required: true }],
password: [{ ...patternRules.pwd, required: props.operateType === 'add' }],
phonenumber: [patternRules.phone],
status: [createRequiredRule('帐号状态不能为空')]
};
@ -95,6 +95,7 @@ function handleUpdateModelWhenEdit() {
if (props.operateType === 'edit' && props.rowData) {
startDeptLoading();
Object.assign(model, props.rowData);
model.password = '';
getUserInfo();
endDeptLoading();
}
@ -107,9 +108,10 @@ function closeDrawer() {
async function handleSubmit() {
await validate();
const { userId, deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
// request
if (props.operateType === 'add') {
const { deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
const { error } = await fetchCreateUser({
deptId,
userName,
@ -125,7 +127,6 @@ async function handleSubmit() {
}
if (props.operateType === 'edit') {
const { userId, deptId, userName, nickName, email, phonenumber, sex, password, status, remark } = model;
const { error } = await fetchUpdateUser({
userId,
deptId,
@ -183,7 +184,7 @@ watch(visible, () => {
<NFormItem v-if="operateType === 'add'" label="用户名称" path="userName">
<NInput v-model:value="model.userName" placeholder="请输入用户名称" />
</NFormItem>
<NFormItem v-if="operateType === 'add'" label="用户密码" path="password">
<NFormItem label="用户密码" path="password">
<NInput
v-model:value="model.password"
type="password"