feat(project): 添加vben5前端

This commit is contained in:
wcg
2026-01-04 13:45:07 +08:00
parent 2c0689fe02
commit 51ee3fb460
839 changed files with 74231 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
<template>
<div>
ele版本会使用这个文件 只是为了不报错`未找到对应组件`才新建的这个文件
无实际意义
</div>
</template>

View File

@@ -0,0 +1,210 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getPopupContainer } from '@vben/utils';
import { z } from '#/adapter/form';
import { getDictOptions } from '#/utils/dict';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
},
{
component: 'Input',
fieldName: 'nick',
label: '用户昵称',
},
{
component: 'Input',
fieldName: 'phone',
label: '手机号码',
},
{
component: 'Select',
componentProps: {
getPopupContainer,
options: getDictOptions(DictEnum.SYS_NORMAL_DISABLE),
},
fieldName: 'state',
label: '用户状态',
},
{
component: 'RangePicker',
fieldName: 'creationTime',
label: '创建时间',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
field: 'userName',
title: '账号',
minWidth: 80,
},
{
field: 'nick',
title: '昵称',
minWidth: 130,
},
{
field: 'icon',
title: '头像',
slots: { default: 'avatar' },
minWidth: 80,
},
{
field: 'deptName',
title: '部门',
minWidth: 120,
},
{
field: 'phone',
title: '手机号',
formatter({ cellValue }) {
return cellValue || '暂无';
},
minWidth: 120,
},
{
field: 'state',
title: '状态',
slots: { default: 'status' },
minWidth: 100,
},
{
field: 'creationTime',
title: '创建时间',
minWidth: 150,
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];
export const drawerSchema: FormSchemaGetter = () => [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
},
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
rules: 'required',
},
{
component: 'InputPassword',
fieldName: 'password',
label: '用户密码',
rules: 'required',
},
{
component: 'Input',
fieldName: 'nick',
label: '用户昵称',
rules: 'required',
},
{
component: 'TreeSelect',
// 在drawer里更新 这里不需要默认的componentProps
defaultValue: undefined,
fieldName: 'deptId',
label: '所属部门',
rules: 'selectRequired',
},
{
component: 'Input',
fieldName: 'phone',
label: '手机号码',
defaultValue: undefined,
rules: z
.string()
.regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码')
.optional()
.or(z.literal('')),
},
{
component: 'Input',
fieldName: 'email',
defaultValue: undefined,
label: '邮箱',
/**
* z.literal 是 Zod 中的一种类型,用于定义一个特定的字面量值。
* 它可以用于确保输入的值与指定的字面量完全匹配。
* 例如,你可以使用 z.literal 来确保某个字段的值只能是特定的字符串、数字、布尔值等。
* 即空字符串也可通过校验
*/
rules: z.string().email('请输入正确的邮箱').optional().or(z.literal('')),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getDictOptions(DictEnum.SYS_USER_SEX),
optionType: 'button',
},
defaultValue: '0',
fieldName: 'sex',
formItemClass: 'col-span-2 lg:col-span-1',
label: '性别',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: '启用', value: true },
{ label: '禁用', value: false },
],
optionType: 'button',
},
defaultValue: true,
fieldName: 'state',
formItemClass: 'col-span-2 lg:col-span-1',
label: '状态',
},
{
component: 'Select',
componentProps: {
disabled: true,
getPopupContainer,
mode: 'multiple',
optionFilterProp: 'label',
optionLabelProp: 'label',
placeholder: '请先选择部门',
},
fieldName: 'postIds',
label: '岗位',
},
{
component: 'Select',
componentProps: {
getPopupContainer,
mode: 'multiple',
optionFilterProp: 'title',
optionLabelProp: 'title',
},
fieldName: 'roleIds',
label: '角色',
},
{
component: 'Textarea',
fieldName: 'remark',
formItemClass: 'items-start',
label: '备注',
},
];

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { DeptGetListOutputDto } from '#/api/system/user/model';
import { onMounted, ref } from 'vue';
import { SyncOutlined } from '@ant-design/icons-vue';
import { Empty, InputSearch, Skeleton, Tree } from 'ant-design-vue';
import { getDeptTree } from '#/api/system/user';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<{ showSearch?: boolean }>(), { showSearch: true });
const emit = defineEmits<{
/**
* 点击刷新按钮的事件
*/
reload: [];
/**
* 点击节点的事件
*/
select: [];
}>();
const selectDeptId = defineModel('selectDeptId', {
required: true,
type: Array as PropType<string[]>,
});
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
/** 部门数据源 */
const deptTreeArray = ref<DeptGetListOutputDto[]>([]);
/** 骨架屏加载 */
const showTreeSkeleton = ref<boolean>(true);
async function loadTree() {
showTreeSkeleton.value = true;
searchValue.value = '';
selectDeptId.value = [];
const ret = await getDeptTree();
deptTreeArray.value = ret;
showTreeSkeleton.value = false;
}
async function handleReload() {
await loadTree();
emit('reload');
}
onMounted(loadTree);
</script>
<template>
<div :class="$attrs.class">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<!-- 固定在顶部 必须加上bg-background背景色 否则会产生'穿透'效果 -->
<div
v-if="showSearch"
class="bg-background z-100 sticky left-0 top-0 p-[8px]"
>
<InputSearch
v-model:value="searchValue"
:placeholder="$t('pages.common.search')"
size="small"
allow-clear
>
<template #enterButton>
<a-button @click="handleReload">
<SyncOutlined class="text-primary" />
</a-button>
</template>
</InputSearch>
</div>
<div class="h-full overflow-x-hidden px-[8px]">
<Tree
v-bind="$attrs"
v-if="deptTreeArray.length > 0"
v-model:selected-keys="selectDeptId"
:class="$attrs.class"
:field-names="{
title: 'deptName',
key: 'id',
children: 'children',
}"
:show-line="{ showLeafIcon: false }"
:tree-data="deptTreeArray as any"
:virtual="false"
default-expand-all
@select="$emit('select')"
>
<template #title="{ deptName }">
<span v-if="deptName.includes(searchValue)">
{{ deptName.substring(0, deptName.indexOf(searchValue)) }}
<span class="text-primary">{{ searchValue }}</span>
{{
deptName.substring(
deptName.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>{{ deptName }}</span>
</template>
</Tree>
<!-- 仅本人数据权限 可以考虑直接不显示 -->
<div v-else class="mt-5">
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="无部门数据"
/>
</div>
</div>
</div>
</Skeleton>
</div>
</template>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { User } from '#/api/system/user/model';
import { ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import { getVxePopupContainer } from '@vben/utils';
import {
Avatar,
Dropdown,
Menu,
MenuItem,
Modal,
Popconfirm,
Space,
} from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
userExport,
userList,
userRemove,
userUpdate,
} from '#/api/system/user';
import { TableSwitch } from '#/components/table';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
import DeptTree from './dept-tree.vue';
import userDrawer from './user-drawer.vue';
import userImportModal from './user-import-modal.vue';
import userInfoModal from './user-info-modal.vue';
import userResetPwdModal from './user-reset-pwd-modal.vue';
/**
* 导入
*/
const [UserImpotModal, userImportModalApi] = useVbenModal({
connectedComponent: userImportModal,
});
function handleImport() {
userImportModalApi.open();
}
// 左边部门用
const selectDeptId = ref<string[]>([]);
const formOptions: VbenFormProps = {
schema: querySchema(),
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectDeptId.value = [];
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
formApi.setLatestSubmissionValues(formValues);
await reload(formValues);
},
// 日期选择格式化
fieldMappingTime: [
[
'creationTime',
['startTime', 'endTime'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'default',
checkMethod: ({ row }) => row?.id !== 1,
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 部门树选择处理
if (selectDeptId.value.length === 1) {
formValues.deptId = selectDeptId.value[0];
} else {
Reflect.deleteProperty(formValues, 'deptId');
}
return await userList({
SkipCount: page.currentPage,
MaxResultCount: page.pageSize,
...formValues,
});
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 48,
},
rowConfig: {
keyField: 'id',
},
id: 'system-user-index',
};
// @ts-expect-error 类型实例化过深
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [UserDrawer, userDrawerApi] = useVbenDrawer({
connectedComponent: userDrawer,
});
function handleAdd() {
userDrawerApi.setData({});
userDrawerApi.open();
}
function handleEdit(row: User) {
userDrawerApi.setData({ id: row.id });
userDrawerApi.open();
}
async function handleDelete(row: User) {
await userRemove([row.id]);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: User) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await userRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(userExport, '用户管理', tableApi.formApi.form.values, {
fieldMappingTime: formOptions.fieldMappingTime,
});
}
const [UserInfoModal, userInfoModalApi] = useVbenModal({
connectedComponent: userInfoModal,
});
function handleUserInfo(row: User) {
userInfoModalApi.setData({ userId: row.id });
userInfoModalApi.open();
}
const [UserResetPwdModal, userResetPwdModalApi] = useVbenModal({
connectedComponent: userResetPwdModal,
});
function handleResetPwd(record: User) {
userResetPwdModalApi.setData({ record });
userResetPwdModalApi.open();
}
const { hasAccessByCodes } = useAccess();
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<DeptTree
v-model:select-dept-id="selectDeptId"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="() => tableApi.reload()"
/>
<BasicTable class="flex-1 overflow-hidden" table-title="用户列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['system:user:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
v-access:code="['system:user:import']"
@click="handleImport"
>
{{ $t('pages.common.import') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['system:user:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['system:user:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #avatar="{ row }">
<!-- 可能要判断空字符串情况 所以没有使用?? -->
<Avatar :src="row.icon || preferences.app.defaultAvatar" />
</template>
<template #status="{ row }">
<TableSwitch
v-model:value="row.state"
:api="() => userUpdate(row)"
:disabled="
row.id === '1' || !hasAccessByCodes(['system:user:edit'])
"
@reload="() => tableApi.query()"
/>
</template>
<template #action="{ row }">
<template v-if="row.id !== '1'">
<Space>
<ghost-button
v-access:code="['system:user:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['system:user:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
<Dropdown placement="bottomRight">
<template #overlay>
<Menu>
<MenuItem key="1" @click="handleUserInfo(row)">
用户信息
</MenuItem>
<span v-access:code="['system:user:resetPwd']">
<MenuItem key="2" @click="handleResetPwd(row)">
重置密码
</MenuItem>
</span>
</Menu>
</template>
<a-button size="small" type="link">
{{ $t('pages.common.more') }}
</a-button>
</Dropdown>
</template>
</template>
</BasicTable>
</div>
<UserImpotModal @reload="tableApi.query()" />
<UserDrawer @reload="tableApi.query()" />
<UserInfoModal />
<UserResetPwdModal />
</Page>
</template>

View File

@@ -0,0 +1,348 @@
<script setup lang="ts">
import type { Role } from '#/api/system/user/model';
import { computed, h, onMounted, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { addFullName, cloneDeep, getPopupContainer } from '@vben/utils';
import { Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { configInfoByKey } from '#/api/system/config';
import { postOptionSelect } from '#/api/system/post';
import { roleOptionSelect } from '#/api/system/role';
import {
findUserInfo,
getDeptTree,
userAdd,
userUpdate,
} from '#/api/system/user';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { authScopeOptions } from '#/views/system/role/data';
import { drawerSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
componentProps: {
class: 'w-full',
},
labelWidth: 80,
},
schema: drawerSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
/**
* 生成角色的自定义label
* 也可以用option插槽来做
* renderComponentContent: () => ({
option: ({value, label, [disabled, key, title]}) => '',
}),
*/
function genRoleOptionlabel(role: Role) {
const found = authScopeOptions.find((item) => item.value === role.dataScope);
if (!found) {
return role.roleName;
}
return h('div', { class: 'flex items-center gap-[6px]' }, [
h('span', null, role.roleName),
h(Tag, { color: found.color }, () => found.label),
]);
}
/**
* 根据部门ID加载岗位列表
* @param deptId 部门ID
*/
async function setupPostOptions(deptId?: string) {
if (!deptId) {
// 没有选择部门时,显示提示
formApi.updateSchema([
{
componentProps: {
disabled: true,
options: [],
placeholder: '请先选择部门',
},
fieldName: 'postIds',
},
]);
// 清空已选岗位
formApi.setFieldValue('postIds', []);
return;
}
try {
const postListResp = await postOptionSelect(deptId);
// 确保返回的是数组
const postList = Array.isArray(postListResp) ? postListResp : [];
const options = postList.map((item) => ({
label: item.postName,
value: item.id,
}));
const placeholder = options.length > 0 ? '请选择岗位' : '该部门暂无岗位';
formApi.updateSchema([
{
componentProps: {
disabled: options.length === 0,
options,
placeholder,
},
fieldName: 'postIds',
},
]);
// 部门变化时清空已选岗位
formApi.setFieldValue('postIds', []);
} catch (error) {
console.error('加载岗位信息失败:', error);
formApi.updateSchema([
{
componentProps: {
disabled: true,
options: [],
placeholder: '加载岗位失败',
},
fieldName: 'postIds',
},
]);
}
}
/**
* 初始化部门选择
*/
async function setupDeptSelect() {
try {
// updateSchema
const deptTree = await getDeptTree();
// 确保返回的是数组
const deptList = Array.isArray(deptTree) ? deptTree : [];
// 选中后显示在输入框的值 即父节点 / 子节点
addFullName(deptList, 'deptName', ' / ');
formApi.updateSchema([
{
componentProps: {
class: 'w-full',
fieldNames: {
label: 'deptName',
key: 'id',
value: 'id',
children: 'children',
},
getPopupContainer,
placeholder: '请选择',
showSearch: true,
treeData: deptList,
treeDefaultExpandAll: true,
treeLine: { showLeafIcon: false },
// 筛选的字段
treeNodeFilterProp: 'deptName',
// 选中后显示在输入框的值
treeNodeLabelProp: 'fullName',
// 部门选择变化时加载对应岗位
onChange: (value: string) => {
setupPostOptions(value);
},
},
fieldName: 'deptId',
},
]);
} catch (error) {
console.error('加载部门树失败:', error);
// 加载失败时设置空树
formApi.updateSchema([
{
componentProps: {
placeholder: '加载部门失败',
treeData: [],
},
fieldName: 'deptId',
},
]);
}
}
const defaultPassword = ref('');
onMounted(async () => {
const password = await configInfoByKey('sys.user.initPassword');
if (password) {
defaultPassword.value = password;
}
});
/**
* 新增时候 从参数设置获取默认密码
*/
async function loadDefaultPassword(update: boolean) {
if (!update && defaultPassword.value) {
formApi.setFieldValue('password', defaultPassword.value);
}
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
// 需要重置岗位选择
formApi.updateSchema([
{
componentProps: {
disabled: true,
options: [],
placeholder: '请先选择部门',
},
fieldName: 'postIds',
},
]);
return null;
}
drawerApi.drawerLoading(true);
try {
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
/** update时 禁用用户名修改 不显示密码框 */
formApi.updateSchema([
{ componentProps: { disabled: isUpdate.value }, fieldName: 'userName' },
{
dependencies: { show: () => !isUpdate.value, triggerFields: ['id'] },
fieldName: 'password',
},
]);
let user: any | null = null;
if (isUpdate.value && id) {
// 编辑模式从用户详情中获取用户信息含岗位、角色ID
user = await findUserInfo(id);
}
// 角色下拉统一使用 roleOptionSelect
const roleListResp = await roleOptionSelect();
const allRoles = Array.isArray(roleListResp) ? (roleListResp as Role[]) : [];
const userRoles = user?.roles ?? [];
const posts = user?.posts ?? [];
const postIds = posts.map((item: any) => item.id);
const roleIds = userRoles.map((item: any) => item.roleId ?? item.id);
const postOptions = posts.map((item: any) => ({
label: item.postName,
value: item.id,
}));
formApi.updateSchema([
{
componentProps: {
// title用于选中后回填到输入框 默认为label
optionLabelProp: 'title',
options: allRoles.map((item: any) => ({
label: genRoleOptionlabel(item),
// title用于选中后回填到输入框 默认为label
title: item.roleName,
value: item.roleId ?? item.id,
})),
},
fieldName: 'roleIds',
},
]);
// 部门选择、初始密码
const promises = [
setupDeptSelect(),
loadDefaultPassword(isUpdate.value),
];
if (user) {
// 编辑模式:使用用户已有的岗位数据
formApi.updateSchema([
{
componentProps: {
disabled: false,
options: postOptions,
placeholder: '请选择岗位',
},
fieldName: 'postIds',
},
]);
// 处理用户数据,确保 phone 字段是字符串类型
const userData = {
...user,
// 将数字类型的 phone 转换为字符串null/undefined 转为空字符串
phone: user.phone != null ? String(user.phone) : '',
};
promises.push(
// 添加基础信息
formApi.setValues(userData),
// 添加角色和岗位
formApi.setFieldValue('postIds', postIds),
formApi.setFieldValue('roleIds', roleIds),
);
} else {
// 新增模式:等待选择部门后再加载岗位
await setupPostOptions();
}
// 并行处理
await Promise.all(promises);
await markInitialized();
} catch (error) {
console.error('加载用户信息失败:', error);
} finally {
drawerApi.drawerLoading(false);
}
},
});
async function handleConfirm() {
try {
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? userUpdate(data) : userAdd(data));
resetInitialized();
emit('reload');
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.lock(false);
}
}
async function handleClosed() {
formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
import { h, ref, unref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ExcelIcon, InBoxIcon } from '@vben/icons';
import { Modal, Switch, Upload } from 'ant-design-vue';
import { downloadImportTemplate, userImportData } from '#/api/system/user';
import { commonDownloadExcel } from '#/utils/file/download';
const emit = defineEmits<{ reload: [] }>();
const UploadDragger = Upload.Dragger;
const [BasicModal, modalApi] = useVbenModal({
onCancel: handleCancel,
onConfirm: handleSubmit,
});
const fileList = ref<UploadFile[]>([]);
const checked = ref(false);
async function handleSubmit() {
try {
modalApi.modalLoading(true);
if (fileList.value.length !== 1) {
handleCancel();
return;
}
const data = {
file: fileList.value[0]!.originFileObj as Blob,
updateSupport: unref(checked),
};
const { code, msg } = await userImportData(data);
let modal = Modal.success;
if (code === 200) {
emit('reload');
} else {
modal = Modal.error;
}
handleCancel();
modal({
content: h('div', {
class: 'max-h-[260px] overflow-y-auto',
innerHTML: msg, // 后台已经处理xss问题
}),
title: '提示',
});
} catch (error) {
console.warn(error);
modalApi.close();
} finally {
modalApi.modalLoading(false);
}
}
function handleCancel() {
modalApi.close();
fileList.value = [];
checked.value = false;
}
</script>
<template>
<BasicModal
:close-on-click-modal="false"
:fullscreen-button="false"
title="用户导入"
>
<!-- z-index不设置会遮挡模板下载loading -->
<!-- 手动处理 而不是放入文件就上传 -->
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="true"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
>
<p class="ant-upload-drag-icon flex items-center justify-center">
<InBoxIcon class="text-primary size-[48px]" />
</p>
<p class="ant-upload-text">点击或者拖拽到此处上传文件</p>
</UploadDragger>
<div class="mt-2 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span>允许导入xlsx, xls文件</span>
<a-button
type="link"
@click="commonDownloadExcel(downloadImportTemplate, '用户导入模板')"
>
<div class="flex items-center gap-[4px]">
<ExcelIcon />
<span>下载模板</span>
</div>
</a-button>
</div>
<div class="flex items-center gap-2">
<span :class="{ 'text-red-500': checked }">
是否更新/覆盖已存在的用户数据
</span>
<Switch v-model:checked="checked" />
</div>
</div>
</BasicModal>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import { computed, shallowRef } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar, Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
import { findUserInfo } from '#/api/system/user';
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: handleOpenChange,
onClosed() {
currentUser.value = null;
},
});
const currentUser = shallowRef<null | User>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
modalApi.modalLoading(true);
const { userId } = modalApi.getData() as { userId: number | string };
const response = await findUserInfo(userId);
// 新接口直接返回完整的用户数据包含posts和roles数组
currentUser.value = response as User;
modalApi.modalLoading(false);
}
const sexLabel = computed(() => {
if (!currentUser.value) {
return '-';
}
const { sex } = currentUser.value;
if (sex === 'Man') return '男';
if (sex === 'Woman') return '女';
return '-';
});
</script>
<template>
<BasicModal :footer="false" :fullscreen-button="false" title="用户信息">
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
<DescriptionsItem label="用户ID">
{{ currentUser.id }}
</DescriptionsItem>
<DescriptionsItem label="头像">
<Avatar v-if="currentUser.icon" :src="currentUser.icon" :size="48" />
<span v-else>-</span>
</DescriptionsItem>
<DescriptionsItem label="姓名">
{{ currentUser.name || '-' }}
</DescriptionsItem>
<DescriptionsItem label="昵称">
{{ currentUser.nick || '-' }}
</DescriptionsItem>
<DescriptionsItem label="用户名">
{{ currentUser.userName || '-' }}
</DescriptionsItem>
<DescriptionsItem label="年龄">
{{ currentUser.age || '-' }}
</DescriptionsItem>
<DescriptionsItem label="性别">
{{ sexLabel }}
</DescriptionsItem>
<DescriptionsItem label="用户状态">
<Tag :color="currentUser.state ? 'success' : 'error'">
{{ currentUser.state ? '启用' : '禁用' }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="手机号">
{{ currentUser.phone || '-' }}
</DescriptionsItem>
<DescriptionsItem label="邮箱">
{{ currentUser.email || '-' }}
</DescriptionsItem>
<DescriptionsItem label="地址">
{{ currentUser.address || '-' }}
</DescriptionsItem>
<DescriptionsItem label="IP地址">
{{ currentUser.ip || '-' }}
</DescriptionsItem>
<DescriptionsItem label="个人简介">
{{ currentUser.introduction || '-' }}
</DescriptionsItem>
<DescriptionsItem label="岗位">
<div
v-if="currentUser.posts && currentUser.posts.length > 0"
class="flex flex-wrap gap-0.5"
>
<Tag v-for="item in currentUser.posts" :key="item.postId">
{{ item.postName }}
</Tag>
</div>
<span v-else>-</span>
</DescriptionsItem>
<DescriptionsItem label="角色">
<div
v-if="currentUser.roles && currentUser.roles.length > 0"
class="flex flex-wrap gap-0.5"
>
<Tag v-for="item in currentUser.roles" :key="item.roleId">
{{ item.roleName }}
</Tag>
</div>
<span v-else>-</span>
</DescriptionsItem>
<DescriptionsItem label="创建时间">
{{ currentUser.creationTime }}
</DescriptionsItem>
<DescriptionsItem label="备注">
{{ currentUser.remark || '-' }}
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ResetPwdParam, User } from '#/api/system/user/model';
import { ref } from 'vue';
import { useVbenModal, z } from '@vben/common-ui';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { userResetPassword } from '#/api/system/user';
const emit = defineEmits<{ reload: [] }>();
const [BasicModal, modalApi] = useVbenModal({
onClosed: handleClosed,
onConfirm: handleSubmit,
onOpenChange: handleOpenChange,
});
const [BasicForm, formApi] = useVbenForm({
schema: [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
label: '用户ID',
rules: 'required',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入新的密码, 密码长度为5 - 20',
},
fieldName: 'password',
label: '新的密码',
rules: z
.string()
.min(5, { message: '密码长度为5 - 20' })
.max(20, { message: '密码长度为5 - 20' }),
},
],
showDefaultActions: false,
commonConfig: {
labelWidth: 80,
},
});
const currentUser = ref<null | User>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
modalApi.modalLoading(true);
const { record } = modalApi.getData() as { record: User };
currentUser.value = record;
await formApi.setValues({ id: record.id });
modalApi.modalLoading(false);
}
async function handleSubmit() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = await formApi.getValues();
await userResetPassword(data as ResetPwdParam);
emit('reload');
handleClosed();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
async function handleClosed() {
modalApi.close();
await formApi.resetForm();
currentUser.value = null;
}
</script>
<template>
<BasicModal
:close-on-click-modal="false"
:fullscreen-button="false"
title="重置密码"
>
<div class="flex flex-col gap-[12px]">
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
<DescriptionsItem label="用户ID">
{{ currentUser.id }}
</DescriptionsItem>
<DescriptionsItem label="用户名">
{{ currentUser.userName }}
</DescriptionsItem>
<DescriptionsItem label="昵称">
{{ currentUser.nick }}
</DescriptionsItem>
</Descriptions>
<BasicForm />
</div>
</BasicModal>
</template>