feat(projects): 新增用户管理页面
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -265,6 +265,8 @@ const local: App.I18n.Schema = {
|
||||
500: '服务器错误',
|
||||
'iframe-page': '外链页面',
|
||||
home: '首页',
|
||||
system: '系统管理',
|
||||
system_user: '用户管理',
|
||||
monitor: '系统监控',
|
||||
'social-callback': '单点登录回调',
|
||||
monitor_cache: '缓存监控',
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+10
@@ -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']
|
||||
|
||||
Vendored
+4
@@ -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"
|
||||
>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user