feat(projects): 新增用户管理页面

This commit is contained in:
xlsea
2025-11-18 18:00:08 +08:00
parent 27cae7562d
commit 6381b4f0be
12 changed files with 1129 additions and 0 deletions
+2
View File
@@ -268,6 +268,8 @@ const local: App.I18n.Schema = {
500: 'Server Error',
'iframe-page': 'Iframe',
home: 'Home',
system: 'System Management',
system_user: 'User Management',
monitor: 'Monitor',
'social-callback': 'Social Callback',
monitor_cache: 'Cache Monitor',
+2
View File
@@ -265,6 +265,8 @@ const local: App.I18n.Schema = {
500: '服务器错误',
'iframe-page': '外链页面',
home: '首页',
system: '系统管理',
system_user: '用户管理',
monitor: '系统监控',
'social-callback': '单点登录回调',
monitor_cache: '缓存监控',
+1
View File
@@ -26,5 +26,6 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
demo_tree: () => import("@/views/demo/tree/index.vue"),
home: () => import("@/views/home/index.vue"),
monitor_cache: () => import("@/views/monitor/cache/index.vue"),
system_user: () => import("@/views/system/user/index.vue"),
tool_gen: () => import("@/views/tool/gen/index.vue"),
};
+22
View File
@@ -135,6 +135,28 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'system',
path: '/system',
component: 'layout.base',
meta: {
title: 'system',
i18nKey: 'route.system',
localIcon: 'menu-system',
order: 1
},
children: [
{
name: 'system_user',
path: '/system/user',
component: 'view.system_user',
meta: {
title: 'system_user',
i18nKey: 'route.system_user'
}
}
]
},
{
name: 'tool',
path: '/tool',
+2
View File
@@ -179,6 +179,8 @@ const routeMap: RouteMap = {
"monitor": "/monitor",
"monitor_cache": "/monitor/cache",
"social-callback": "/social-callback",
"system": "/system",
"system_user": "/system/user",
"tool": "/tool",
"tool_gen": "/tool/gen",
"user-center": "/user-center"
+10
View File
@@ -34,6 +34,7 @@ declare module 'vue' {
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconLocalBanner: typeof import('~icons/local/banner')['default']
IconLocalLogo: typeof import('~icons/local/logo')['default']
'IconMaterialSymbols:uploadRounded': typeof import('~icons/material-symbols/upload-rounded')['default']
IconMaterialSymbolsAdd: typeof import('~icons/material-symbols/add')['default']
IconMaterialSymbolsDeleteOutline: typeof import('~icons/material-symbols/delete-outline')['default']
IconMaterialSymbolsDownloadRounded: typeof import('~icons/material-symbols/download-rounded')['default']
@@ -81,9 +82,13 @@ declare module 'vue' {
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
@@ -91,6 +96,7 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NP: typeof import('naive-ui')['NP']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NRadio: typeof import('naive-ui')['NRadio']
@@ -105,9 +111,13 @@ declare module 'vue' {
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NThing: typeof import('naive-ui')['NThing']
NTooltip: typeof import('naive-ui')['NTooltip']
NTree: typeof import('naive-ui')['NTree']
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
NWatermark: typeof import('naive-ui')['NWatermark']
OssUpload: typeof import('./../components/custom/oss-upload.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
+4
View File
@@ -33,6 +33,8 @@ declare module "@elegant-router/types" {
"monitor": "/monitor";
"monitor_cache": "/monitor/cache";
"social-callback": "/social-callback";
"system": "/system";
"system_user": "/system/user";
"tool": "/tool";
"tool_gen": "/tool/gen";
"user-center": "/user-center";
@@ -80,6 +82,7 @@ declare module "@elegant-router/types" {
| "login"
| "monitor"
| "social-callback"
| "system"
| "tool"
| "user-center"
>;
@@ -110,6 +113,7 @@ declare module "@elegant-router/types" {
| "demo_tree"
| "home"
| "monitor_cache"
| "system_user"
| "tool_gen"
>;
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="tsx">
import { computed, ref } from 'vue';
import { NAvatar, NButton, NDivider, NEllipsis } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks';
import { jsonClone } from '@sa/utils';
import { fetchBatchDeleteUser, fetchGetDeptTree, fetchGetUserList, fetchUpdateUserStatus } from '@/service/api/system';
import { useAppStore } from '@/store/modules/app';
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { useAuth } from '@/hooks/business/auth';
import { useDownload } from '@/hooks/business/download';
import ButtonIcon from '@/components/custom/button-icon.vue';
import { $t } from '@/locales';
import StatusSwitch from '@/components/custom/status-switch.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import UserOperateDrawer from './modules/user-operate-drawer.vue';
import UserImportModal from './modules/user-import-modal.vue';
import UserPasswordDrawer from './modules/user-password-drawer.vue';
import UserSearch from './modules/user-search.vue';
defineOptions({
name: 'UserList'
});
useDict('sys_user_sex');
useDict('sys_normal_disable');
const { hasAuth } = useAuth();
const appStore = useAppStore();
const { download } = useDownload();
const { bool: importVisible, setTrue: openImportModal } = useBoolean();
const { bool: passwordVisible, setTrue: openPasswordDrawer } = useBoolean();
const searchParams = ref<Api.System.UserSearchParams>({
pageNum: 1,
pageSize: 10,
deptId: null,
userName: null,
nickName: null,
phonenumber: null,
status: null,
params: {}
});
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination, scrollX } =
useNaivePaginatedTable({
api: () => fetchGetUserList(searchParams.value),
transform: response => defaultTransform(response),
onPaginationParamsChange: params => {
searchParams.value.pageNum = params.page;
searchParams.value.pageSize = params.pageSize;
},
columns: () => [
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'userName',
title: $t('page.system.user.userName'),
align: 'left',
width: 220,
ellipsis: true,
render: row => {
return (
<div class="flex items-center justify-center gap-2">
<NAvatar src={row.avatar} class="bg-primary">
{row.avatar ? undefined : row.nickName.charAt(0)}
</NAvatar>
<div class="max-w-160px flex flex-col">
<NEllipsis>{row.userName}</NEllipsis>
<NEllipsis>{row.nickName}</NEllipsis>
</div>
</div>
);
}
},
{
key: 'sex',
title: $t('page.system.user.sex'),
align: 'center',
width: 80,
ellipsis: true,
render(row) {
return <DictTag value={row.sex} dictCode="sys_user_sex" />;
}
},
{
key: 'deptName',
title: $t('page.system.user.deptName'),
align: 'center',
width: 120,
ellipsis: true
},
{
key: 'email',
title: $t('page.system.user.email'),
align: 'center',
width: 120,
ellipsis: true
},
{
key: 'phonenumber',
title: $t('page.system.user.phonenumber'),
align: 'center',
width: 120,
ellipsis: true
},
{
key: 'status',
title: $t('page.system.user.status'),
align: 'center',
width: 80,
render(row) {
return (
<StatusSwitch
v-model:value={row.status}
disabled={row.userId === 1}
info={row.userName}
onSubmitted={(value, callback) => handleStatusChange(row, value, callback)}
/>
);
}
},
{
key: 'createTime',
title: $t('page.system.user.createTime'),
align: 'center',
width: 120
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 150,
render: row => {
if (row.userId === 1) return null;
const editBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:drive-file-rename-outline-outline"
tooltipContent={$t('common.edit')}
onClick={() => edit(row.userId!)}
/>
);
};
const passwordBtn = () => {
return (
<ButtonIcon
text
type="primary"
icon="material-symbols:key-vertical-outline"
tooltipContent="重置密码"
onClick={() => handleResetPwd(row.userId!)}
/>
);
};
const deleteBtn = () => {
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={$t('common.delete')}
popconfirmContent={$t('common.confirmDelete')}
onPositiveClick={() => handleDelete(row.userId!)}
/>
);
};
const buttons = [];
if (hasAuth('system:user:edit')) buttons.push(editBtn());
if (hasAuth('system:user:resetPwd')) buttons.push(passwordBtn());
if (hasAuth('system:user:remove')) buttons.push(deleteBtn());
return (
<div class="flex-center gap-8px">
{buttons.map((btn, index) => (
<>
{index !== 0 && <NDivider vertical />}
{btn}
</>
))}
</div>
);
}
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, 'userId', getData);
async function handleBatchDelete() {
// request
const { error } = await fetchBatchDeleteUser(checkedRowKeys.value);
if (error) return;
onBatchDeleted();
}
async function handleDelete(userId: CommonType.IdType) {
// request
const { error } = await fetchBatchDeleteUser([userId]);
if (error) return;
onDeleted();
}
async function edit(userId: CommonType.IdType) {
handleEdit(userId);
}
async function handleResetPwd(userId: CommonType.IdType) {
const findItem = data.value.find(item => item.userId === userId) || null;
editingData.value = jsonClone(findItem);
openPasswordDrawer();
}
const { loading: treeLoading, startLoading: startTreeLoading, endLoading: endTreeLoading } = useLoading();
const deptPattern = ref<string>();
const deptData = ref<Api.Common.CommonTreeRecord>([]);
const selectedKeys = ref<string[]>([]);
async function getTreeData() {
startTreeLoading();
const { data: tree, error } = await fetchGetDeptTree();
if (!error) {
deptData.value = tree;
}
endTreeLoading();
}
getTreeData();
function handleClickTree(keys: string[]) {
searchParams.value.deptId = keys.length ? keys[0] : null;
checkedRowKeys.value = [];
getDataByPage();
}
function handleResetTreeData() {
deptPattern.value = undefined;
getTreeData();
}
function handleImport() {
openImportModal();
}
/** 处理状态切换 */
async function handleStatusChange(
row: Api.System.User,
value: Api.Common.EnableStatus,
callback: (flag: boolean) => void
) {
const { error } = await fetchUpdateUserStatus({
userId: row.userId,
status: value
});
callback(!error);
if (!error) {
window.$message?.success($t('page.system.user.statusChangeSuccess'));
getData();
}
}
function handleExport() {
download('/system/user/export', searchParams, `${$t('page.system.user.title')}_${new Date().getTime()}.xlsx`);
}
const expandedKeys = ref<CommonType.IdType[]>([100]);
const selectable = computed(() => {
return !loading.value;
});
</script>
<template>
<TableSiderLayout :sider-title="$t('page.system.dept.title')">
<template #header-extra>
<NButton size="small" text class="h-18px" @click.stop="() => handleResetTreeData()">
<template #icon>
<SvgIcon icon="ic:round-refresh" />
</template>
</NButton>
</template>
<template #sider>
<NInput v-model:value="deptPattern" clearable :placeholder="$t('common.keywordSearch')" />
<NSpin class="dept-tree" :show="treeLoading">
<NTree
v-model:expanded-keys="expandedKeys"
v-model:selected-keys="selectedKeys"
block-node
show-line
:data="deptData as []"
:show-irrelevant-nodes="false"
:pattern="deptPattern"
class="infinite-scroll h-full min-h-200px py-3"
key-field="id"
label-field="label"
virtual-scroll
:selectable="selectable"
@update:selected-keys="handleClickTree"
>
<template #empty>
<NEmpty :description="$t('page.system.dept.empty')" class="h-full min-h-200px justify-center" />
</template>
</NTree>
</NSpin>
</template>
<div class="h-full flex-col-stretch gap-12px overflow-hidden lt-sm:overflow-auto">
<UserSearch v-model:model="searchParams" @search="getDataByPage" />
<TableRowCheckAlert v-model:checked-row-keys="checkedRowKeys" />
<NCard :title="$t('page.system.user.title')" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-add="hasAuth('system:user:add')"
:show-delete="hasAuth('system:user:remove')"
:show-export="hasAuth('system:user:export')"
@add="handleAdd"
@delete="handleBatchDelete"
@export="handleExport"
@refresh="getData"
>
<template #after>
<NButton v-if="hasAuth('system:user:import')" size="small" ghost @click="handleImport">
<template #icon>
<icon-material-symbols:upload-rounded class="text-icon" />
</template>
{{ $t('common.import') }}
</NButton>
</template>
</TableHeaderOperation>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="scrollX"
:loading="loading"
remote
:row-key="row => row.userId"
:pagination="mobilePagination"
class="h-full"
/>
<UserImportModal v-model:visible="importVisible" @submitted="getData" />
<UserOperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
:dept-data="deptData"
:dept-id="searchParams.deptId"
@submitted="getDataByPage"
/>
<UserPasswordDrawer v-model:visible="passwordVisible" :row-data="editingData" />
</NCard>
</div>
</TableSiderLayout>
</template>
<style scoped lang="scss">
.dept-tree {
.n-button {
--n-padding: 8px !important;
}
:deep(.n-tree__empty) {
height: 100%;
justify-content: center;
}
:deep(.n-spin-content) {
height: 100%;
}
:deep(.infinite-scroll) {
height: calc(100vh - 228px - var(--calc-footer-height, 0px)) !important;
max-height: calc(100vh - 228px - var(--calc-footer-height, 0px)) !important;
}
@media screen and (max-width: 1024px) {
:deep(.infinite-scroll) {
height: calc(100vh - 227px - var(--calc-footer-height, 0px)) !important;
max-height: calc(100vh - 227px - var(--calc-footer-height, 0px)) !important;
}
}
:deep(.n-tree-node) {
height: 30px;
}
:deep(.n-tree-node-switcher) {
height: 30px;
}
:deep(.n-tree-node-switcher__icon) {
font-size: 16px !important;
height: 16px !important;
width: 16px !important;
}
}
:deep(.n-data-table-wrapper),
:deep(.n-data-table-base-table),
:deep(.n-data-table-base-table-body) {
height: 100%;
}
@media screen and (max-width: 800px) {
:deep(.n-data-table-base-table-body) {
max-height: calc(100vh - 400px - var(--calc-footer-height, 0px));
}
}
@media screen and (max-width: 802px) {
:deep(.n-data-table-base-table-body) {
max-height: calc(100vh - 473px - var(--calc-footer-height, 0px));
}
}
:deep(.n-card-header__main) {
min-width: 69px !important;
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { h, 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';
import { $t } from '@/locales';
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($t('common.importSuccess'));
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(() => h('div', { innerHTML: msg || $t('common.importFail') }));
success.value = false;
}
function handleDownloadTemplate() {
download(
'/system/user/importTemplate',
{},
`${$t('page.system.user.title')}_${$t('common.importTemplate')}_${new Date().getTime()}.xlsx`
);
}
watch(visible, () => {
if (visible.value) {
data.value.updateSupport = false;
fileList.value = [];
success.value = false;
message.value = '';
}
});
</script>
<template>
<NModal
v-model:show="visible"
:title="$t('common.import')"
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">{{ $t('common.importTip') }}</NText>
<NP depth="3" class="mt-8px text-center">
{{ $t('common.importSize') }}
<b class="text-red-500">50MB</b>
{{ $t('common.importFormat') }}
<b class="text-red-500">xls/xlsx</b>
{{ $t('common.importEnd') }}
</NP>
</NUploadDragger>
</NUpload>
<div class="flex-center">
<NCheckbox v-model:checked="data.updateSupport">{{ $t('common.updateExisting') }}</NCheckbox>
</div>
<NAlert v-if="message" :title="$t('common.importResult')" :type="success ? 'success' : 'error'" :bordered="false">
<NScrollbar class="max-h-200px">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="message" />
</NScrollbar>
</NAlert>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="handleDownloadTemplate">{{ $t('common.downloadTemplate') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.import') }}</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped></style>
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { jsonClone } from '@sa/utils';
import { useLoading } from '@sa/hooks';
import { fetchCreateUser, fetchGetUserInfo, fetchUpdateUser } from '@/service/api/system';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'UserOperateDrawer'
});
interface Props {
/** the type of operation */
operateType: NaiveUI.TableOperateType;
/** the edit row data */
rowData?: Api.System.User | null;
/** the dept tree data */
deptData?: Api.Common.CommonTreeRecord;
/** the dept id */
deptId?: CommonType.IdType | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { loading, startLoading, endLoading } = useLoading();
const { loading: deptLoading, startLoading: startDeptLoading, endLoading: endDeptLoading } = useLoading();
const { formRef, validate, restoreValidation } = useNaiveForm();
const { createRequiredRule, patternRules } = useFormRules();
const title = computed(() => {
const titles: Record<NaiveUI.TableOperateType, string> = {
add: $t('page.system.user.addUser'),
edit: $t('page.system.user.editUser')
};
return titles[props.operateType];
});
type Model = Api.System.UserOperateParams;
const model = ref<Model>(createDefaultModel());
const roleOptions = ref<CommonType.Option<CommonType.IdType>[]>([]);
function createDefaultModel(): Model {
return {
deptId: null,
userName: '',
nickName: '',
email: '',
phonenumber: '',
sex: '0',
password: '',
status: '0',
roleIds: [],
postIds: [],
remark: ''
};
}
type RuleKey = Extract<keyof Model, 'userName' | 'nickName' | 'password' | 'status' | 'phonenumber' | 'roleIds'>;
const rules: Record<RuleKey, App.Global.FormRule[]> = {
userName: [createRequiredRule($t('page.system.user.form.userName.required'))],
nickName: [createRequiredRule($t('page.system.user.form.nickName.required'))],
password: [{ ...patternRules.pwd, required: props.operateType === 'add' }],
phonenumber: [patternRules.phone],
status: [createRequiredRule($t('page.system.user.form.status.required'))],
roleIds: [{ ...createRequiredRule('请选择角色'), type: 'array' }]
};
async function getUserInfo(id: CommonType.IdType = '') {
startLoading();
const { error, data } = await fetchGetUserInfo(id);
if (!error) {
model.value.roleIds = data.roleIds;
model.value.postIds = data.postIds;
roleOptions.value = data.roles.map(role => ({
label: role.roleName,
value: role.roleId
}));
}
endLoading();
}
function handleUpdateModelWhenEdit() {
model.value = createDefaultModel();
if (props.operateType === 'add') {
getUserInfo();
model.value.deptId = props.deptId;
return;
}
if (props.operateType === 'edit' && props.rowData) {
startDeptLoading();
Object.assign(model.value, jsonClone(props.rowData));
model.value.password = '';
getUserInfo(props.rowData.userId);
endDeptLoading();
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const { userId, deptId, userName, nickName, email, phonenumber, sex, password, status, roleIds, postIds, remark } =
model.value;
// request
if (props.operateType === 'add') {
const { error } = await fetchCreateUser({
deptId,
userName,
password,
nickName,
email,
phonenumber,
sex,
status,
roleIds,
postIds,
remark
});
if (error) return;
}
if (props.operateType === 'edit') {
const { error } = await fetchUpdateUser({
userId,
deptId,
userName,
nickName,
email,
phonenumber,
sex,
status,
roleIds,
postIds,
remark
});
if (error) return;
}
window.$message?.success($t('common.updateSuccess'));
closeDrawer();
emit('submitted');
}
watch(visible, () => {
if (visible.value) {
handleUpdateModelWhenEdit();
restoreValidation();
}
});
</script>
<template>
<NDrawer v-model:show="visible" display-directive="show" :width="800" class="max-w-90%">
<NDrawerContent :title="title" :native-scrollbar="false" closable>
<NSpin :show="loading">
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem :label="$t('page.system.user.nickName')" path="nickName">
<NInput v-model:value="model.nickName" :placeholder="$t('page.system.user.form.nickName.required')" />
</NFormItem>
<NFormItem :label="$t('page.system.user.deptName')" path="deptId">
<NTreeSelect
v-model:value="model.deptId"
:loading="deptLoading"
clearable
:options="deptData as []"
label-field="label"
key-field="id"
:default-expanded-keys="deptData?.length ? [deptData[0].id] : []"
:placeholder="$t('page.system.user.form.deptId.required')"
/>
</NFormItem>
<NFormItem :label="$t('page.system.user.phonenumber')" path="phonenumber">
<NInput v-model:value="model.phonenumber" :placeholder="$t('page.system.user.form.phonenumber.required')" />
</NFormItem>
<NFormItem :label="$t('page.system.user.email')" path="email">
<NInput v-model:value="model.email" :placeholder="$t('page.system.user.form.email.required')" />
</NFormItem>
<NFormItem v-if="operateType === 'add'" :label="$t('page.system.user.userName')" path="userName">
<NInput v-model:value="model.userName" :placeholder="$t('page.system.user.form.userName.required')" />
</NFormItem>
<NFormItem v-if="operateType === 'add'" :label="$t('page.system.user.password')" path="password">
<NInput
v-model:value="model.password"
type="password"
show-password-on="click"
:input-props="{ autocomplete: 'off' }"
:placeholder="$t('page.system.user.form.password.required')"
/>
</NFormItem>
<NFormItem :label="$t('page.system.user.sex')" path="sex">
<DictRadio
v-model:value="model.sex"
dict-code="sys_user_sex"
:placeholder="$t('page.system.user.form.sex.required')"
/>
</NFormItem>
<NFormItem :label="$t('page.system.user.postIds')" path="postIds">
<PostSelect v-model:value="model.postIds" :dept-id="model.deptId" multiple clearable />
</NFormItem>
<NFormItem :label="$t('page.system.user.roleIds')" path="roleIds">
<NSelect
v-model:value="model.roleIds"
:loading="loading"
:options="roleOptions"
multiple
clearable
placeholder="请选择角色"
/>
</NFormItem>
<NFormItem :label="$t('page.system.user.status')" path="status">
<DictRadio v-model:value="model.status" dict-code="sys_normal_disable" />
</NFormItem>
<NFormItem :label="$t('page.system.user.remark')" path="remark">
<NInput v-model:value="model.remark" :placeholder="$t('page.system.user.form.remark.required')" />
</NFormItem>
</NForm>
</NSpin>
<template #footer>
<NSpace :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.save') }}</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { fetchResetUserPassword } from '@/service/api/system';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'UserPasswordDrawer'
});
interface Props {
/** the edit row data */
rowData?: Api.System.User | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, restoreValidation } = useNaiveForm();
const { patternRules } = useFormRules();
type Model = Api.System.UserOperateParams & { deptName: string };
const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
deptId: null,
userName: '',
nickName: '',
deptName: '',
password: ''
};
}
type RuleKey = Extract<keyof Model, 'password'>;
const rules: Record<RuleKey, App.Global.FormRule[]> = {
password: [{ ...patternRules.pwd }]
};
function handleUpdateModelWhenEdit() {
Object.assign(model, props.rowData);
model.password = '';
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const { userId, password } = model;
// request
const { error } = await fetchResetUserPassword(userId!, password!);
if (error) return;
window.$message?.success($t('common.updateSuccess'));
closeDrawer();
emit('submitted');
}
watch(visible, () => {
if (visible.value) {
handleUpdateModelWhenEdit();
restoreValidation();
}
});
</script>
<template>
<NDrawer v-model:show="visible" display-directive="show" :width="800" class="max-w-90%">
<NDrawerContent title="重置密码" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem :label="$t('page.system.user.nickName')" path="nickName">
<NInput v-model:value="model.nickName" disabled />
</NFormItem>
<NFormItem :label="$t('page.system.user.deptName')" path="deptName">
<NInput v-model:value="model.deptName" disabled />
</NFormItem>
<NFormItem :label="$t('page.system.user.userName')" path="userName">
<NInput v-model:value="model.userName" disabled />
</NFormItem>
<NFormItem :label="$t('page.system.user.password')" path="password">
<NInput
v-model:value="model.password"
type="password"
show-password-on="click"
:input-props="{ autocomplete: 'off' }"
:placeholder="$t('page.system.user.form.password.required')"
/>
</NFormItem>
</NForm>
<template #footer>
<NSpace :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.save') }}</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, toRaw } from 'vue';
import { NDatePicker } from 'naive-ui';
import { jsonClone } from '@sa/utils';
import { useNaiveForm } from '@/hooks/common/form';
import { $t } from '@/locales';
defineOptions({
name: 'UserSearch'
});
interface Emits {
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const { formRef, validate, restoreValidation } = useNaiveForm();
const dateRangeCreateTime = ref<[string, string] | null>(null);
const datePickerRef = ref<InstanceType<typeof NDatePicker>>();
const model = defineModel<Api.System.UserSearchParams>('model', { required: true });
const defaultModel = jsonClone(toRaw(model.value));
function onDateRangeCreateTimeUpdate(value: [string, string] | null) {
const params = model.value.params!;
if (value && value.length === 2) {
[params.beginTime, params.endTime] = value;
} else {
params.beginTime = undefined;
params.endTime = undefined;
}
}
function resetModel() {
dateRangeCreateTime.value = null;
Object.assign(model.value, defaultModel);
}
async function reset() {
await restoreValidation();
resetModel();
emit('search');
}
async function search() {
await validate();
emit('search');
}
</script>
<template>
<NCard :bordered="false" size="small" class="table-search card-wrapper">
<NCollapse>
<NCollapseItem :title="$t('common.search')" name="user-search">
<NForm ref="formRef" :model="model" label-placement="left" :label-width="80">
<NGrid responsive="screen" item-responsive>
<NFormItemGi span="24 s:12 m:6" :label="$t('page.system.user.userName')" path="userName" class="pr-24px">
<NInput v-model:value="model.userName" :placeholder="$t('page.system.user.form.userName.required')" />
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" :label="$t('page.system.user.nickName')" path="nickName" class="pr-24px">
<NInput v-model:value="model.nickName" :placeholder="$t('page.system.user.form.nickName.required')" />
</NFormItemGi>
<NFormItemGi
span="24 s:12 m:6"
:label="$t('page.system.user.phonenumber')"
path="phonenumber"
class="pr-24px"
>
<NInput
v-model:value="model.phonenumber"
:placeholder="$t('page.system.user.form.phonenumber.required')"
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:6" :label="$t('page.system.user.status')" path="status" class="pr-24px">
<DictSelect
v-model:value="model.status"
:placeholder="$t('page.system.user.form.status.required')"
dict-code="sys_normal_disable"
clearable
/>
</NFormItemGi>
<NFormItemGi
span="24 s:12 m:12"
:label="$t('page.system.user.createTime')"
path="createTime"
class="pr-24px"
>
<NDatePicker
ref="datePickerRef"
v-model:formatted-value="dateRangeCreateTime"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
clearable
@update:formatted-value="onDateRangeCreateTimeUpdate"
/>
</NFormItemGi>
<NFormItemGi span="24 s:12 m:12" class="pr-24px">
<NSpace class="w-full" justify="end">
<NButton @click="reset">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
{{ $t('common.reset') }}
</NButton>
<NButton type="primary" ghost @click="search">
<template #icon>
<icon-ic-round-search class="text-icon" />
</template>
{{ $t('common.search') }}
</NButton>
</NSpace>
</NFormItemGi>
</NGrid>
</NForm>
</NCollapseItem>
</NCollapse>
</NCard>
</template>
<style scoped></style>