1. 管理员管理模块拆分

2. 图片上传自动压缩(大于850kb)
3. 省市区选择组件重构,支持搜索
This commit is contained in:
李琦
2026-05-27 09:34:08 +08:00
parent 68fd7b0045
commit 402da17e3d
56 changed files with 4188 additions and 13664 deletions

View File

@@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
createAdmin,
deleteAdmin,
generateLoginAccount,
resetPassword,
updateAdmin,
} from '#/views/system/admin/api';
import { normalizeAdminPayload } from './admin-payload';
import { createAdminModalFormProps } from './form-schemas';
import { createAdminSearchOptions } from './search-config';
import { formatAddressDisplay } from '#/util/address-index';
import { createAdminGridOptions } from './table-config';
import { getRoleMeta } from './role-meta';
function formatAdminRegion(row: {
province_id?: number;
city_id?: number;
}): string {
if (row.province_id && row.city_id) {
return formatAddressDisplay([row.province_id, row.city_id]);
}
return '-';
}
const DEFAULT_AVATAR =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20251022/20251022104156946505ca86d97626c08882f31797dbcef49c.png';
const props = defineProps<{
roleId: number;
}>();
const meta = computed(() => getRoleMeta(props.roleId));
const hasTopTableDropDownActions = ref(false);
const isUpdate = ref(false);
const modalGridApi = ref();
const [Form, formApi] = useVbenForm(
createAdminModalFormProps(props.roleId, meta.value.formType),
);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (e.valid) {
const values = await formApi.getValues();
const payload = normalizeAdminPayload(
{ ...values, role_id: props.roleId },
meta.value.formType,
);
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateAdmin : createAdmin;
submitApi(payload)
.then(() => {
message.success('保存成功');
modalGridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
},
onOpenChange(isOpen: boolean) {
const data = modalApi.getData<Record<string, any>>();
modalGridApi.value = isOpen ? data?.gridApi : null;
if (isOpen) {
const { values = {}, update = false } = data ?? {};
isUpdate.value = !!update;
if (update && values?.id) {
const formValues = { ...values };
if (formValues.province_id && formValues.city_id) {
formValues.address = [formValues.province_id, formValues.city_id];
}
formValues.role_id = props.roleId;
formApi.setValues(formValues);
return;
}
isUpdate.value = false;
formApi.resetForm();
formApi.setValues({
role_id: props.roleId,
avatar: DEFAULT_AVATAR,
});
}
},
});
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: createAdminSearchOptions(),
gridOptions: createAdminGridOptions(props.roleId, meta.value.formType),
gridEvents,
});
const showModal = (data: Record<string, any> = {}, update = false) => {
modalApi.setData({
values: data,
update,
gridApi,
});
modalApi.open();
};
const deleteApi = (row: number | false) => {
let ids: number[] = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
}
deleteAdmin({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
const resetPasswordApi = (id: number) => {
resetPassword(id).then(() => {
message.success('重置成功!');
gridApi.query();
});
};
const generateAccountApi = async (row: { id: number }) => {
try {
const result = await generateLoginAccount(row.id);
if (result.login_account) {
message.success('生成账号成功');
gridApi.query();
}
} catch (error: any) {
message.error(error.message || '生成账号失败');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success('复制成功');
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('复制成功');
} catch {
message.error('复制失败');
}
document.body.removeChild(textArea);
}
};
</script>
<template>
<Page auto-content-height :title="meta.title">
<Modal
:title="`${isUpdate ? '编辑' : '新增'}${meta.title}`"
class="w-[60%]"
>
<Form />
</Modal>
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: () => showModal({}, false),
},
]"
:drop-down-actions="[
{
label: '删除',
icon: 'ant-design:delete-outlined',
ifShow: hasTopTableDropDownActions,
popConfirm: {
title: '确定删除吗?',
confirm: deleteApi.bind(null, false),
},
},
]"
>
<template #more>
<Button style="margin-left: 16px">
批量操作
<Icon icon="ant-design:down-outlined" />
</Button>
</template>
</TableAction>
</template>
<template #avatar="{ row }">
<Image :src="row.avatar" height="30" width="30" />
</template>
<template #region="{ row }">
{{ formatAdminRegion(row) }}
</template>
<template #login_account="{ row }">
<span v-if="row.login_account">
<Button
type="link"
size="small"
@click="copyToClipboard(row.login_account)"
>
{{ row.login_account }}
</Button>
</span>
<Button
v-else
type="link"
size="small"
@click="generateAccountApi(row)"
>
生成账号
</Button>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
onClick: showModal.bind(null, row, true),
},
{
label: '删除',
type: 'link',
icon: 'ant-design:delete-outlined',
size: 'small',
popConfirm: {
title: '确定删除吗?',
confirm: deleteApi.bind(null, row.id),
},
},
]"
:drop-down-actions="[
{
label: '重置密码',
type: 'link',
icon: 'bitcoin-icons:refresh-filled',
size: 'small',
popConfirm: {
title: '确定重置密码吗?',
confirm: resetPasswordApi.bind(null, row.id),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,81 @@
import type { AdminFormType } from './role-meta';
const UNUSED_ID_FIELDS = [
'store_id',
'doctor_id',
'pharmacist_id',
'supplier_id',
] as const;
function isEmptyId(value: unknown): boolean {
return value === '' || value === null || value === undefined;
}
function toZeroOrOmit(
payload: Record<string, unknown>,
field: string,
omitWhenEmpty: boolean,
): void {
if (!(field in payload)) {
return;
}
if (isEmptyId(payload[field])) {
if (omitWhenEmpty) {
delete payload[field];
} else {
payload[field] = 0;
}
}
}
/**
* 提交前归一化:避免整型列收到 '';不削弱各角色必填校验(在 validate 之后调用)。
*/
export function normalizeAdminPayload(
values: Record<string, unknown>,
formType: AdminFormType,
): Record<string, unknown> {
const payload = { ...values };
delete payload.confirmPassword;
switch (formType) {
case 'basic':
for (const field of UNUSED_ID_FIELDS) {
toZeroOrOmit(payload, field, true);
}
break;
case 'address':
for (const field of UNUSED_ID_FIELDS) {
toZeroOrOmit(payload, field, true);
}
break;
case 'supplier':
toZeroOrOmit(payload, 'store_id', true);
toZeroOrOmit(payload, 'doctor_id', true);
toZeroOrOmit(payload, 'pharmacist_id', true);
break;
case 'clinic':
toZeroOrOmit(payload, 'doctor_id', true);
toZeroOrOmit(payload, 'pharmacist_id', true);
toZeroOrOmit(payload, 'supplier_id', true);
break;
case 'doctor':
toZeroOrOmit(payload, 'pharmacist_id', true);
toZeroOrOmit(payload, 'supplier_id', true);
if (isEmptyId(payload.doctor_id)) {
payload.doctor_id = 0;
}
break;
case 'pharmacist':
toZeroOrOmit(payload, 'doctor_id', true);
toZeroOrOmit(payload, 'supplier_id', true);
if (isEmptyId(payload.pharmacist_id)) {
payload.pharmacist_id = 0;
}
break;
default:
break;
}
return payload;
}

View File

@@ -0,0 +1,243 @@
import type { VbenFormProps } from '#/adapter/form';
import { useUserStore } from '@vben/stores';
import { z } from '#/adapter/form';
import { getStoreOption } from '#/views/system/store/api';
import { getSupplierOption } from '#/views/system/supplier/api';
import type { AdminFormType } from './role-meta';
import { ROLE_CITY_MANAGER, ROLE_SALESPERSON } from './role-meta';
const defaultPassword = 'Xk123456@';
const defaultAvatar =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20251022/20251022104156946505ca86d97626c08882f31797dbcef49c.png';
function passwordFields() {
return [
{
fieldName: 'password',
label: '密码',
component: 'InputPassword',
help: '5-18位数字、字母、特殊字符组成。',
componentProps: {
placeholder: '请输入密码',
allowClear: true,
},
defaultValue: defaultPassword,
rules: z
.string()
.regex(
/^(?=.*[A-Z0-9])(?=.*[!@#$%^&*])[A-Z0-9!@#$%^&*]{5,18}$/i,
'密码由5-18位数字、字母、特殊字符组成。',
),
dependencies: {
if({ id }: { id?: number }) {
return !id;
},
triggerFields: ['id'],
},
formItemClass: 'col-span-6',
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'InputPassword',
componentProps: {
placeholder: '请输入确认密码',
allowClear: true,
},
defaultValue: defaultPassword,
rules: z
.string()
.regex(/[\w!@#$%^&*]{5,18}/, '密码由5-18位数字、字母、特殊字符组成。'),
dependencies: {
if({ id }: { id?: number }) {
return !id;
},
triggerFields: ['id', 'confirmPassword'],
rules: (values: { password?: string }) => {
return z
.string()
.regex(
/[\w!@#$%^&*]{5,18}/,
'密码由5-18位数字、字母、特殊字符组成。',
)
.refine(
(confirmPassword) => confirmPassword === values.password,
{ message: '确认密码必须与密码一致' },
);
},
},
formItemClass: 'col-span-6',
},
];
}
function shouldShowAddress(roleId: number, formType: AdminFormType): boolean {
if (formType !== 'address') {
return false;
}
const userStore = useUserStore();
const currentRoleId = (userStore.userInfo as { roles?: { id?: number } })
?.roles?.id;
if (
roleId === ROLE_SALESPERSON &&
currentRoleId === ROLE_CITY_MANAGER
) {
return false;
}
return true;
}
export function createAdminModalFormProps(
roleId: number,
formType: AdminFormType,
): VbenFormProps {
const schema: VbenFormProps['schema'] = [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
component: 'VbenInput',
fieldName: 'role_id',
label: '角色ID',
defaultValue: roleId,
dependencies: {
show: false,
triggerFields: ['role_id'],
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入管理员昵称',
},
fieldName: 'nick_name',
label: '昵称',
rules: 'required',
formItemClass: 'col-span-6',
},
{
component: 'Avatar',
fieldName: 'avatar',
label: '头像',
rules: 'required',
formItemClass: 'col-span-6',
defaultValue: defaultAvatar,
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入管理员手机号码',
},
fieldName: 'phone',
label: '手机号',
rules: 'required',
formItemClass: 'col-span-6',
},
];
if (shouldShowAddress(roleId, formType)) {
schema.push({
component: 'RegionAddressPicker',
fieldName: 'address',
label: '省市区',
rules: 'required',
formItemClass: 'col-span-12',
});
}
if (formType === 'supplier') {
schema.push({
component: 'ApiSelect',
componentProps: {
allowClear: true,
filterOption: true,
showSearch: true,
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
api: getSupplierOption,
placeholder: '请选择',
},
fieldName: 'supplier_id',
formItemClass: 'col-span-6',
label: '所属供应商',
rules: 'required',
});
}
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
schema.push({
component: 'ApiSelect',
componentProps: {
allowClear: true,
filterOption: (input: string, option: { label?: string }) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()),
showSearch: true,
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
api: getStoreOption,
placeholder: '请选择诊所',
},
fieldName: 'store_id',
formItemClass: 'col-span-6',
label: '所属诊所',
rules: 'required',
});
}
if (formType === 'doctor') {
schema.push({
component: 'VbenInput',
componentProps: {
placeholder: '关联医生档案 ID(可选,建议在医生管理中创建)',
},
fieldName: 'doctor_id',
label: '医生ID',
formItemClass: 'col-span-6',
});
}
if (formType === 'pharmacist') {
schema.push({
component: 'VbenInput',
componentProps: {
placeholder: '关联药师档案 ID(可选)',
},
fieldName: 'pharmacist_id',
label: '药师ID',
formItemClass: 'col-span-6',
});
}
schema.push(...passwordFields());
return {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema,
showDefaultActions: false,
};
}

View File

@@ -0,0 +1,40 @@
export type AdminFormType =
| 'basic'
| 'address'
| 'supplier'
| 'clinic'
| 'doctor'
| 'pharmacist';
export interface AdminRoleMeta {
id: number;
title: string;
slug: string;
formType: AdminFormType;
}
/** 与后端 RoleEnum 一致 */
export const ADMIN_ROLE_LIST: AdminRoleMeta[] = [
{ id: 1, title: '超级管理员', slug: 'super', formType: 'basic' },
{ id: 2, title: '系统管理员', slug: 'platform', formType: 'basic' },
{ id: 3, title: '省级管理员', slug: 'province', formType: 'address' },
{ id: 4, title: '市级管理员', slug: 'city', formType: 'address' },
{ id: 5, title: '区级管理员', slug: 'district', formType: 'address' },
{ id: 6, title: '业务员', slug: 'salesperson', formType: 'address' },
{ id: 7, title: '供应商', slug: 'supplier', formType: 'supplier' },
{ id: 8, title: '诊所管理员', slug: 'clinic', formType: 'clinic' },
{ id: 9, title: '诊所员工', slug: 'clinic-staff', formType: 'clinic' },
{ id: 10, title: '医生', slug: 'doctor', formType: 'doctor' },
{ id: 11, title: '药师', slug: 'pharmacist', formType: 'pharmacist' },
];
export const ROLE_CITY_MANAGER = 4;
export const ROLE_SALESPERSON = 6;
export function getRoleMeta(roleId: number): AdminRoleMeta {
const meta = ADMIN_ROLE_LIST.find((r) => r.id === roleId);
if (!meta) {
throw new Error(`Unknown admin role id: ${roleId}`);
}
return meta;
}

View File

@@ -0,0 +1,33 @@
import type { VbenFormProps } from '#/adapter/form';
export function createAdminSearchOptions(): VbenFormProps {
return {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入名称',
},
defaultValue: '',
fieldName: 'nick_name',
label: '管理员名称',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '输入手机号码',
},
defaultValue: '',
fieldName: 'phone',
label: '手机号码',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};
}

View File

@@ -0,0 +1,137 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getAdminList } from '#/views/system/admin/api';
import type { AdminFormType } from './role-meta';
interface RowType {
id: string;
nick_name: string;
email: string;
avatar: string;
open_id: string;
code: string;
phone: string;
login_account: string;
desc: string;
created_at: string;
store_id?: number;
doctor_id?: number;
pharmacist_id?: number;
province_id?: number;
city_id?: number;
}
function buildColumns(formType: AdminFormType) {
const cols: VxeGridProps<RowType>['columns'] = [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'nick_name', align: 'left', title: '名称' },
{
field: 'avatar',
align: 'left',
title: '头像',
slots: { default: 'avatar' },
width: 130,
},
{ field: 'open_id', title: 'Open ID' },
];
if (formType === 'address' || formType === 'supplier') {
cols.push({ field: 'code', title: '业务推广码' });
}
if (formType === 'address') {
cols.push({
field: 'region',
title: '省市区',
minWidth: 180,
slots: { default: 'region' },
});
}
if (formType === 'supplier') {
cols.push({ field: 'supplier.name', title: '所属供应商' });
}
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
cols.push({ field: 'store_id', title: '诊所ID', width: 100 });
}
if (formType === 'doctor') {
cols.push({ field: 'doctor_id', title: '医生ID', width: 100 });
}
if (formType === 'pharmacist') {
cols.push({ field: 'pharmacist_id', title: '药师ID', width: 100 });
}
if (formType === 'basic') {
cols.push({ field: 'platform.name', title: '所属平台' });
}
cols.push(
{ field: 'phone', title: '手机号码' },
{
field: 'login_account',
align: 'left',
title: '登录账号',
slots: { default: 'login_account' },
width: 150,
},
{ field: 'email', title: '邮箱' },
{ field: 'created_at', title: '注册时间' },
{ type: 'html', title: '操作', width: 200, slots: { default: 'action' } },
);
return cols;
}
export function createAdminGridOptions(
roleId: number,
formType: AdminFormType,
): VxeGridProps<RowType> {
return {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: buildColumns(formType),
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getAdminList({
page: page.currentPage,
pageSize: page.pageSize,
role_id: roleId,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};
}

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="4" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="9" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="8" />
</template>

View File

@@ -3,7 +3,6 @@ import type { VbenFormProps } from '#/adapter/form';
import { z } from '#/adapter/form';
import { getRoleOption } from '#/views/system/role/api';
import { getSupplierOption } from '#/views/system/supplier/api';
import {addressOption} from "#/util/address";
const defaultPassword = 'Xk123456@';
@@ -86,11 +85,7 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
component: 'Cascader',
componentProps: {
placeholder: '请选省市区',
options: addressOption,
},
component: 'RegionAddressPicker',
dependencies: {
show: (values) => {
return values.role_id === pharmacistId || values.role_id === cityId || values.role_id === districtId;

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="5" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="10" />
</template>

View File

@@ -1,197 +1,13 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref } from 'vue';
const router = useRouter();
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { deleteAdmin, resetPassword, generateLoginAccount } from './api';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents,
onMounted(() => {
router.replace('/system/admin/platform');
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
// 表单值
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const deleteApi = (row: any) => {
let ids = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
}
deleteAdmin({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
const resetPasswordApi = (id: number) => {
resetPassword(id).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
const generateAccountApi = async (row: any) => {
try {
const result = await generateLoginAccount(row.id);
if (result.login_account) {
message.success('生成账号成功');
gridApi.query();
}
} catch (error: any) {
message.error(error.message || '生成账号失败');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success('复制成功');
} catch (error) {
// 降级方案:使用传统方法
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('复制成功');
} catch (err) {
message.error('复制失败');
}
document.body.removeChild(textArea);
}
};
</script>
<template>
<Page auto-content-height title="管理员管理">
<FormModal />
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: 'ant-design:plus-outlined',
// auth: ['超级管理员', 'sys:user:save'],
onClick: showModal.bind(null),
},
]"
:drop-down-actions="[
{
label: '删除',
icon: 'ant-design:delete-outlined',
ifShow: hasTopTableDropDownActions,
// auth: ['超级管理员', 'sys:user:save'],
popConfirm: {
title: '确定删除吗?',
confirm: deleteApi.bind(null, false),
},
},
]"
>
<template #more>
<Button style="margin-left: 16px">
批量操作
<Icon icon="ant-design:down-outlined" />
</Button>
</template>
</TableAction>
</template>
<template #avatar="{ row }">
<Image :src="row.avatar" height="30" width="30" />
</template>
<template #login_account="{ row }">
<span v-if="row.login_account">
<Button type="link" size="small" @click="copyToClipboard(row.login_account)">
{{ row.login_account }}
</Button>
</span>
<Button v-else type="link" size="small" @click="generateAccountApi(row)">
生成账号
</Button>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
// auth: ['admin', 'sys:role:detail'],
onClick: showModal.bind(null, row, true),
},
{
label: '删除',
type: 'link',
icon: 'ant-design:delete-outlined',
size: 'small',
// auth: ['admin', 'sys:role:detail'],
popConfirm: {
title: '确定删除吗?',
confirm: deleteApi.bind(null, row.id),
},
},
]"
:drop-down-actions="[
{
label: '重置密码',
type: 'link',
icon: 'bitcoin-icons:refresh-filled',
size: 'small',
popConfirm: {
title: '确定重置密码吗?',
confirm: resetPasswordApi.bind(null, row.id),
},
},
]"
/>
</template>
</Grid>
</Page>
<div />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="11" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="2" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="3" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="6" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="1" />
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
import AdminPage from '../_shared/admin-page.vue';
</script>
<template>
<AdminPage :role-id="7" />
</template>

View File

@@ -1,6 +1,5 @@
import type { VbenFormProps } from '#/adapter/form';
import { addressOption } from '#/util/address.ts';
// 药店管理表单配置
// 注意:已移除类型选择字段,固定为type=1(药店类型)
@@ -178,12 +177,7 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 省市区级联选择字段
component: 'Cascader',
componentProps: {
placeholder: '请选省市区',
options: addressOption,
},
component: 'RegionAddressPicker',
fieldName: 'address',
label: '省市区',
rules: 'required',

View File

@@ -1,648 +0,0 @@
<script lang="ts" setup>
/**
* 菜单搜索选择组件(自定义实现)
*
* @description 自定义菜单搜索下拉选择器,不依赖antd的Select组件
* - 支持模糊搜索菜单 title
* - 支持多选
* - 只显示叶子节点(有 path 和 component 的菜单项)
* - 支持 roleMenuIds 过滤
* @author 系统
* @date 2024
*/
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import { Input, Spin, Tag } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { $t } from '#/locales';
import { getMenuTreeOption, searchMenu } from '#/views/system/menu/api';
import { Icon } from '#/components/icon';
// ==================== Props 定义 ====================
interface Props {
/**
* 选中的菜单ID列表
*/
modelValue?: number[];
/**
* 是否按用户过滤
*/
filterByUser?: boolean;
/**
* 角色已授权的菜单ID列表
*/
roleMenuIds?: number[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
filterByUser: true,
roleMenuIds: undefined,
});
// ==================== Emits 定义 ====================
const emit = defineEmits<{
/**
* 选中值变化时触发
* @param value 选中的菜单ID列表
*/
(e: 'update:modelValue', value: number[]): void;
}>();
// ==================== 响应式数据 ====================
/**
* 搜索关键词
*/
const searchKeyword = ref('');
/**
* 是否正在搜索
*/
const isSearching = ref(false);
/**
* 扁平化的菜单列表(搜索结果或已选中的菜单)
*/
const menuList = ref<any[]>([]);
/**
* 已选中菜单的完整信息(用于显示标签)
*/
const selectedMenuMap = ref<Map<number, any>>(new Map());
/**
* 过滤后的菜单列表(搜索结果直接来自后端,不需要前端过滤)
*/
const filteredMenuList = computed(() => {
return menuList.value;
});
/**
* 已选中的菜单列表(从 selectedMenuMap 获取)
*/
const selectedMenus = computed(() => {
if (!props.modelValue || props.modelValue.length === 0) {
return [];
}
return props.modelValue
.map((id) => selectedMenuMap.value.get(id))
.filter(Boolean);
});
/**
* 是否显示下拉列表
*/
const showDropdown = ref(false);
/**
* 组件容器引用
*/
const containerRef = ref<HTMLElement | null>(null);
/**
* 当前高亮的选项索引(用于键盘导航)
*/
const highlightIndex = ref(-1);
// ==================== 方法定义 ====================
/**
* 从树形数据中提取所有叶子节点
* @param nodes 树形节点数组
* @param roleMenuIds 角色已授权的菜单ID列表
*/
const flattenLeafNodes = (nodes: any[], roleMenuIds?: number[]): any[] => {
const leafNodes: any[] = [];
const traverse = (node: any) => {
const hasPath = node.path && node.path.trim() !== '';
const hasComponent = node.component && node.component.trim() !== '';
const noChildren = !node.children || node.children.length === 0;
// 如果是叶子节点
if (hasPath && hasComponent && noChildren) {
// 如果提供了 roleMenuIds,需要检查是否在授权列表中
if (roleMenuIds === undefined || roleMenuIds.includes(node.id)) {
leafNodes.push({
id: node.id,
title: node.title,
icon: node.icon,
path: node.path,
component: node.component,
});
}
}
// 递归遍历子节点
if (node.children && node.children.length > 0) {
node.children.forEach(traverse);
}
};
nodes.forEach(traverse);
return leafNodes;
};
/**
* 搜索菜单
* @param keyword 搜索关键词
*/
const searchMenus = debounce(async (keyword: string) => {
if (!keyword || keyword.length < 1) {
menuList.value = [];
showDropdown.value = false;
return;
}
isSearching.value = true;
showDropdown.value = true;
try {
const res = await searchMenu({
keyword: keyword,
filter_by_user: props.filterByUser ? 1 : 0,
});
// 如果提供了 roleMenuIds,需要过滤
let filteredRes = res;
if (props.roleMenuIds !== undefined && props.roleMenuIds.length > 0) {
filteredRes = res.filter((menu: any) => props.roleMenuIds!.includes(menu.id));
}
// 更新已选中菜单的信息(如果搜索结果中包含)
filteredRes.forEach((menu: any) => {
if (props.modelValue?.includes(menu.id)) {
selectedMenuMap.value.set(menu.id, menu);
}
});
menuList.value = filteredRes;
} catch (error) {
console.error('搜索菜单失败:', error);
menuList.value = [];
} finally {
isSearching.value = false;
}
}, 300);
/**
* 处理输入变化
* @param e 输入事件
*/
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
searchKeyword.value = target.value;
highlightIndex.value = -1;
if (target.value) {
isSearching.value = true;
searchMenus(target.value);
} else {
showDropdown.value = false;
}
}
/**
* 处理输入框获得焦点
*/
function handleFocus() {
if (filteredMenuList.value.length > 0 && searchKeyword.value) {
showDropdown.value = true;
}
}
/**
* 处理选中菜单
* @param menu 选中的菜单数据
*/
function handleSelectMenu(menu: any) {
const currentValue = props.modelValue || [];
const isSelected = currentValue.includes(menu.id);
if (isSelected) {
// 取消选中
const newValue = currentValue.filter((id) => id !== menu.id);
selectedMenuMap.value.delete(menu.id);
emit('update:modelValue', newValue);
} else {
// 选中
const newValue = [...currentValue, menu.id];
selectedMenuMap.value.set(menu.id, menu);
emit('update:modelValue', newValue);
}
// 不清空搜索关键词,保持下拉显示
highlightIndex.value = -1;
}
/**
* 处理移除菜单
* @param menuId 菜单ID
*/
function handleRemoveMenu(menuId: number) {
const currentValue = props.modelValue || [];
const newValue = currentValue.filter((id) => id !== menuId);
selectedMenuMap.value.delete(menuId);
emit('update:modelValue', newValue);
}
/**
* 处理键盘事件
* @param e 键盘事件
*/
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown.value || filteredMenuList.value.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex.value = Math.min(
highlightIndex.value + 1,
filteredMenuList.value.length - 1,
);
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
break;
case 'Enter':
e.preventDefault();
if (highlightIndex.value >= 0) {
handleSelectMenu(filteredMenuList.value[highlightIndex.value]);
}
break;
case 'Escape':
showDropdown.value = false;
highlightIndex.value = -1;
break;
}
}
/**
* 处理点击外部关闭下拉
* @param e 点击事件
*/
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false;
highlightIndex.value = -1;
}
}
/**
* 初始化数据(用于显示已选中的菜单)
*/
const initData = async () => {
try {
// 如果需要显示已选中的菜单,加载完整列表
if (props.modelValue && props.modelValue.length > 0) {
const res = await getMenuTreeOption({
filterByUser: props.filterByUser ? 1 : 0,
});
// 扁平化为叶子节点列表
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
// 更新已选中菜单的完整信息
allMenus.forEach((menu) => {
if (props.modelValue!.includes(menu.id)) {
selectedMenuMap.value.set(menu.id, menu);
}
});
}
// 搜索时 menuList 会被更新,初始时为空
menuList.value = [];
} catch (error) {
console.error('获取菜单列表失败:', error);
}
};
// ==================== 生命周期 ====================
onMounted(() => {
document.addEventListener('click', handleClickOutside);
initData();
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
// ==================== 监听 ====================
/**
* 监听 roleMenuIds 变化,重新初始化数据
*/
watch(
() => props.roleMenuIds,
(newVal) => {
if (newVal !== undefined) {
initData();
}
},
{ deep: true, immediate: true }
);
/**
* 监听 filterByUser 变化,重新初始化数据
*/
watch(
() => props.filterByUser,
() => {
initData();
}
);
/**
* 监听 modelValue 变化,更新已选中菜单信息
*/
watch(
() => props.modelValue,
async (newVal) => {
if (newVal && newVal.length > 0) {
// 检查是否有缺失的菜单信息
const missingIds = newVal.filter((id) => !selectedMenuMap.value.has(id));
if (missingIds.length > 0) {
// 加载缺失的菜单信息
try {
const res = await getMenuTreeOption({
filterByUser: props.filterByUser ? 1 : 0,
});
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
allMenus.forEach((menu) => {
if (missingIds.includes(menu.id)) {
selectedMenuMap.value.set(menu.id, menu);
}
});
} catch (error) {
console.error('获取菜单信息失败:', error);
}
}
// 清理已取消选中的菜单
selectedMenuMap.value.forEach((menu, id) => {
if (!newVal.includes(id)) {
selectedMenuMap.value.delete(id);
}
});
} else {
// 清空已选中菜单
selectedMenuMap.value.clear();
}
},
{ immediate: true }
);
// ==================== 暴露方法 ====================
/**
* 暴露方法供父组件调用
*/
defineExpose({
refresh: initData,
});
</script>
<template>
<div ref="containerRef" class="menu-search-select">
<!-- 搜索输入框 -->
<Input
v-model:value="searchKeyword"
placeholder="输入菜单名称搜索..."
allow-clear
@input="handleInput"
@focus="handleFocus"
@keydown="handleKeydown"
>
<template #prefix>
<LoadingOutlined v-if="isSearching" class="text-gray-400" />
<SearchOutlined v-else class="text-gray-400" />
</template>
</Input>
<!-- 已选中的菜单标签 -->
<div v-if="selectedMenus.length > 0" class="selected-tags">
<Tag
v-for="menu in selectedMenus"
:key="menu.id"
closable
class="mb-2"
@close="handleRemoveMenu(menu.id)"
>
<Icon v-if="menu.icon" :icon="menu.icon" class="mr-1" />
{{ $t(menu.title) }}
</Tag>
</div>
<!-- 提示信息 -->
<div class="mt-2 text-sm text-gray-500">
已选择 {{ selectedMenus.length }} 个菜单项
</div>
<!-- 下拉列表 -->
<div v-if="showDropdown" class="menu-dropdown">
<!-- 加载中 -->
<div v-if="isSearching" class="menu-dropdown__loading">
<Spin size="small" />
<span>搜索中...</span>
</div>
<!-- 无结果 -->
<div
v-else-if="filteredMenuList.length === 0"
class="menu-dropdown__empty"
>
暂无匹配菜单
</div>
<!-- 结果列表 -->
<div v-else class="menu-dropdown__list">
<div
v-for="(menu, index) in filteredMenuList"
:key="menu.id"
class="menu-item"
:class="{
'menu-item--active': index === highlightIndex,
'menu-item--selected': props.modelValue?.includes(menu.id),
}"
@click="handleSelectMenu(menu)"
@mouseenter="highlightIndex = index"
>
<div class="menu-item__content">
<Icon v-if="menu.icon" :icon="menu.icon" class="menu-item__icon" />
<div class="menu-item__info">
<div class="menu-item__title">{{ $t(menu.title) }}</div>
<div class="menu-item__path">{{ menu.path }}</div>
</div>
</div>
<div v-if="props.modelValue?.includes(menu.id)" class="menu-item__check">
✓
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.menu-search-select {
position: relative;
width: 100%;
}
.selected-tags {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.menu-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1050;
margin-top: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
max-height: 400px;
overflow: hidden;
&__loading,
&__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: #999;
font-size: 14px;
}
&__list {
max-height: 400px;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover,
&--active {
background-color: #f5f7fa;
}
&--selected {
background-color: #e6f7ff;
}
&__content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
&__icon {
flex-shrink: 0;
font-size: 16px;
color: #666;
}
&__info {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__title {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
&__path {
font-size: 12px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__check {
flex-shrink: 0;
color: #1890ff;
font-weight: bold;
font-size: 16px;
}
}
/* 暗色模式适配 */
.dark {
.menu-dropdown {
background: #1f2937;
border-color: #374151;
}
.menu-item {
&:hover,
&--active {
background-color: #374151;
}
&--selected {
background-color: #1e3a5f;
}
&__title {
color: #f3f4f6;
}
&__path {
color: #9ca3af;
}
&__icon {
color: #d1d5db;
}
}
}
</style>

View File

@@ -0,0 +1,595 @@
<script lang="ts" setup>
import type { SortableOptions } from 'sortablejs';
import type Sortable from 'sortablejs';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Empty, Input } from 'ant-design-vue';
import { SearchOutlined } from '@ant-design/icons-vue';
import { Icon } from '#/components/icon';
import { $t } from '#/locales';
export interface QuickNavMenuItem {
id: number;
title: string;
icon?: string;
path: string;
component?: string;
}
export interface SavedQuickNavItem {
menu_id: number;
title?: string;
icon?: string;
url?: string;
}
interface Props {
modelValue?: number[];
menuTree?: any[];
roleMenuIds?: number[];
savedItems?: SavedQuickNavItem[];
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
menuTree: () => [],
roleMenuIds: () => [],
savedItems: () => [],
loading: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: number[]): void;
}>();
const searchKeyword = ref('');
const availableListRef = ref<HTMLElement | null>(null);
const selectedListRef = ref<HTMLElement | null>(null);
const availableSortable = ref<Sortable | null>(null);
const selectedSortable = ref<Sortable | null>(null);
/** 从菜单树提取角色可授权的叶子页面(支持父级授权继承) */
function flattenAuthorizedLeafNodes(
nodes: any[],
roleMenuIds: number[],
): QuickNavMenuItem[] {
const roleIdSet = new Set(roleMenuIds);
const hasRoleFilter = roleMenuIds.length > 0;
const leafNodes: QuickNavMenuItem[] = [];
const traverse = (node: any, parentAuthorized: boolean) => {
const nodeAuthorized = roleIdSet.has(node.id);
const isAuthorized = !hasRoleFilter || parentAuthorized || nodeAuthorized;
const nextParentAuthorized = isAuthorized;
const hasPath = node.path && String(node.path).trim() !== '';
const hasComponent =
node.component && String(node.component).trim() !== '';
const noChildren = !node.children || node.children.length === 0;
if (hasPath && hasComponent && noChildren && isAuthorized) {
leafNodes.push({
id: node.id,
title: node.title,
icon: node.icon,
path: node.path,
component: node.component,
});
}
if (node.children?.length) {
node.children.forEach((child: any) =>
traverse(child, nextParentAuthorized),
);
}
};
nodes.forEach((node) => traverse(node, false));
return leafNodes;
}
const allPoolMenus = computed(() =>
flattenAuthorizedLeafNodes(props.menuTree, props.roleMenuIds),
);
const menuMap = computed(() => {
const map = new Map<number, QuickNavMenuItem>();
allPoolMenus.value.forEach((m) => map.set(m.id, m));
return map;
});
const savedItemMap = computed(() => {
const map = new Map<number, SavedQuickNavItem>();
(props.savedItems || []).forEach((item) => {
if (item.menu_id) {
map.set(item.menu_id, item);
}
});
return map;
});
const selectedMenus = computed(() =>
(props.modelValue || [])
.map((id) => {
const fromPool = menuMap.value.get(id);
if (fromPool) return fromPool;
const saved = savedItemMap.value.get(id);
if (saved) {
return {
id: saved.menu_id,
title: saved.title || '',
icon: saved.icon || '',
path: saved.url || '',
} as QuickNavMenuItem;
}
return null;
})
.filter((m): m is QuickNavMenuItem => !!m),
);
const availableMenus = computed(() => {
const selectedSet = new Set(props.modelValue || []);
const keyword = searchKeyword.value.trim().toLowerCase();
return allPoolMenus.value.filter((menu) => {
if (selectedSet.has(menu.id)) return false;
if (!keyword) return true;
const title = $t(menu.title).toLowerCase();
return (
title.includes(keyword) ||
menu.path.toLowerCase().includes(keyword)
);
});
});
const hasNoRoleMenus = computed(
() => props.roleMenuIds.length === 0 && !props.loading,
);
function getIdsFromList(el: HTMLElement | null): number[] {
if (!el) return [];
return [...el.querySelectorAll('[data-menu-id]')].map((node) =>
Number((node as HTMLElement).dataset.menuId),
);
}
function syncSelectedFromDom() {
const ids = getIdsFromList(selectedListRef.value);
emit('update:modelValue', ids);
}
function destroySortables() {
availableSortable.value?.destroy();
selectedSortable.value?.destroy();
availableSortable.value = null;
selectedSortable.value = null;
}
async function createSortable(
el: HTMLElement,
options: SortableOptions = {},
): Promise<Sortable> {
const mod = await import(
// @ts-expect-error sortablejs modular esm path
'sortablejs/modular/sortable.complete.esm.js'
);
return mod.default.create(el, {
animation: 200,
...options,
}) as Sortable;
}
async function initSortables() {
destroySortables();
await nextTick();
const groupName = 'quickNav';
if (availableListRef.value) {
availableSortable.value = await createSortable(availableListRef.value, {
group: {
name: groupName,
pull: 'clone',
put: true,
},
sort: false,
ghostClass: 'quick-nav-item--ghost',
chosenClass: 'quick-nav-item--chosen',
dragClass: 'quick-nav-item--drag',
onAdd(evt) {
// 从右侧拖回左侧:以右侧列表为准同步
syncSelectedFromDom();
evt.item?.remove();
nextTick(() => initSortables());
},
});
}
if (selectedListRef.value) {
selectedSortable.value = await createSortable(selectedListRef.value, {
group: {
name: groupName,
pull: true,
put: true,
},
ghostClass: 'quick-nav-item--ghost',
chosenClass: 'quick-nav-item--chosen',
dragClass: 'quick-nav-item--drag',
onAdd(evt) {
syncSelectedFromDom();
evt.item?.remove();
nextTick(() => initSortables());
},
onRemove() {
syncSelectedFromDom();
},
onUpdate() {
syncSelectedFromDom();
},
onEnd() {
syncSelectedFromDom();
nextTick(() => initSortables());
},
});
}
}
function handleAuthorize(menuId: number) {
const current = props.modelValue || [];
if (current.includes(menuId)) return;
emit('update:modelValue', [...current, menuId]);
}
function handleRemove(menuId: number) {
const next = (props.modelValue || []).filter((id) => id !== menuId);
emit('update:modelValue', next);
}
watch(
() => [
props.modelValue,
props.menuTree,
props.roleMenuIds,
props.savedItems,
props.loading,
],
() => {
if (!props.loading) {
nextTick(() => initSortables());
}
},
{ deep: true },
);
watch(searchKeyword, () => {
nextTick(() => initSortables());
});
onMounted(() => {
if (!props.loading) {
initSortables();
}
});
onUnmounted(() => {
destroySortables();
});
</script>
<template>
<div class="quick-nav-transfer">
<div
class="mb-4 rounded-md border-l-4 border-blue-500 bg-blue-50 px-4 py-3 dark:border-blue-400 dark:bg-blue-900/20"
>
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">
左侧点击或拖拽到右侧即可授权;拖回左侧或点击移除可取消;右侧可拖拽排序。
</p>
</div>
<div class="quick-nav-transfer__panels">
<!-- 左侧:可授权 -->
<div class="quick-nav-transfer__panel">
<div class="quick-nav-transfer__panel-header">
<span class="font-medium">可授权菜单</span>
<span class="text-xs text-gray-500">
{{ availableMenus.length }} / {{ allPoolMenus.length }}
</span>
</div>
<Input
v-model:value="searchKeyword"
allow-clear
class="mb-3"
placeholder="搜索菜单名称或路径..."
>
<template #prefix>
<SearchOutlined class="text-gray-400" />
</template>
</Input>
<div class="quick-nav-transfer__list-wrap">
<Empty
v-if="hasNoRoleMenus"
class="quick-nav-transfer__empty-hint"
description="该角色暂无已授权菜单,请先在「授权菜单」中配置"
/>
<Empty
v-else-if="availableMenus.length === 0 && !loading"
class="quick-nav-transfer__empty-hint"
description="暂无可授权菜单(已全部添加或搜索无结果)"
/>
<ul ref="availableListRef" class="quick-nav-transfer__list">
<li
v-for="menu in availableMenus"
:key="`avail-${menu.id}`"
:data-menu-id="menu.id"
class="quick-nav-item quick-nav-item--available"
@click="handleAuthorize(menu.id)"
>
<div class="quick-nav-item__handle" @click.stop>
<Icon icon="ant-design:menu-outlined" class="text-base" />
</div>
<Icon
v-if="menu.icon"
:icon="menu.icon"
class="quick-nav-item__icon"
/>
<div class="quick-nav-item__info">
<div class="quick-nav-item__title">{{ $t(menu.title) }}</div>
<div class="quick-nav-item__path">{{ menu.path }}</div>
</div>
<button
class="quick-nav-item__add"
type="button"
title="添加到快捷导航"
@click.stop="handleAuthorize(menu.id)"
>
<Icon icon="ant-design:plus-outlined" />
</button>
</li>
</ul>
</div>
</div>
<!-- 右侧:已配置 -->
<div class="quick-nav-transfer__panel">
<div class="quick-nav-transfer__panel-header">
<span class="font-medium">已配置快捷导航</span>
<span class="text-xs text-gray-500">
{{ selectedMenus.length }} 项
</span>
</div>
<div class="quick-nav-transfer__list-wrap">
<Empty
v-if="selectedMenus.length === 0 && !loading"
class="quick-nav-transfer__empty-hint"
description="从左侧点击或拖拽菜单到此处,添加后可拖拽调整顺序"
/>
<ul ref="selectedListRef" class="quick-nav-transfer__list">
<li
v-for="(menu, index) in selectedMenus"
:key="`sel-${menu.id}`"
:data-menu-id="menu.id"
class="quick-nav-item quick-nav-item--selected"
>
<div class="quick-nav-item__handle">
<Icon icon="ant-design:menu-outlined" class="text-base" />
</div>
<Icon
v-if="menu.icon"
:icon="menu.icon"
class="quick-nav-item__icon"
/>
<div class="quick-nav-item__info">
<div class="quick-nav-item__title">{{ $t(menu.title) }}</div>
<div class="quick-nav-item__path">{{ menu.path }}</div>
</div>
<div class="quick-nav-item__order">{{ index + 1 }}</div>
<button
class="quick-nav-item__remove"
type="button"
title="移除"
@click.stop="handleRemove(menu.id)"
>
<Icon icon="ant-design:close-outlined" />
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.quick-nav-transfer {
height: 100%;
display: flex;
flex-direction: column;
&__panels {
display: flex;
flex: 1;
gap: 16px;
min-height: 0;
}
&__panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 12px;
border: 1px solid var(--ant-color-border, #e5e7eb);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
&__panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__list-wrap {
flex: 1;
min-height: 320px;
max-height: calc(100vh - 280px);
overflow-y: auto;
}
&__empty-hint {
margin-bottom: 12px;
}
&__list {
margin: 0;
padding: 0;
list-style: none;
}
}
.quick-nav-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
cursor: grab;
transition:
box-shadow 0.2s,
border-color 0.2s;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.12);
}
&--available {
cursor: pointer;
}
&--selected {
border-color: #91caff;
background: #f0f7ff;
}
&--ghost {
opacity: 0.4;
}
&--chosen {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
&--drag {
opacity: 0.85;
}
&__handle {
flex-shrink: 0;
color: #999;
}
&__icon {
flex-shrink: 0;
font-size: 18px;
color: #666;
}
&__info {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__path {
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__order {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e6f4ff;
color: #1677ff;
font-size: 12px;
font-weight: 500;
}
&__add,
&__remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
&__add {
color: #999;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
&__remove {
color: #999;
&:hover {
color: #ff4d4f;
background: #fff1f0;
}
}
}
.dark {
.quick-nav-transfer__panel {
background: #1f2937;
border-color: #374151;
}
.quick-nav-item {
background: #1f2937;
border-color: #374151;
&--selected {
background: #1e3a5f;
border-color: #2563eb;
}
&__title {
color: #f3f4f6;
}
}
}
</style>

View File

@@ -3,95 +3,43 @@ import { ref, computed, nextTick } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, message, Card, Row, Col, Empty, Popconfirm } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { getMenuTreeOption } from '#/views/system/menu/api';
import { getRoleQuickNavList, deleteRoleQuickNav, batchSaveRoleQuickNav } from '#/views/system/role-quick-nav/api';
import { getRoleQuickNavList, batchSaveRoleQuickNav } from '#/views/system/role-quick-nav/api';
import { getMenuIdsByRoleIds } from '../api';
import MenuSearchSelect from './menu-search-select.vue';
import QuickNavTransfer from './quick-nav-transfer.vue';
const record = ref();
const quickNavList = ref<any[]>([]);
const menuTreeData = ref<any[]>([]);
const checkedMenuIds = ref<number[]>([]);
const savedQuickNavItems = ref<any[]>([]);
const loading = ref(false);
const roleMenuIds = ref<number[]>([]); // 角色已授权的菜单ID列表
const roleMenuIds = ref<number[]>([]);
// 计算标题:快捷导航管理 - 角色名称
const drawerTitle = computed(() => {
const roleName = record.value?.name || '';
return roleName ? `快捷导航管理 - ${roleName}` : '快捷导航管理';
});
// 从菜单树中获取菜单信息
const getMenuById = (menuId: number) => {
const findMenu = (nodes: any[]): any => {
for (const node of nodes) {
if (node.id === menuId) {
return node;
}
if (node.children && node.children.length > 0) {
const found = findMenu(node.children);
if (found) return found;
}
}
return null;
};
return findMenu(menuTreeData.value);
};
// 合并已保存和新选择的快捷导航
const displayQuickNavs = computed(() => {
// 已保存的快捷导航
const saved = quickNavList.value.map((item) => ({
...item,
isSaved: true,
}));
// 新选择但未保存的快捷导航(从菜单树查找)
const newSelected = checkedMenuIds.value
.filter((id) => !quickNavList.value.some((item) => item.menu_id === id))
.map((menuId, index) => {
const menu = getMenuById(menuId);
if (!menu) return null;
return {
id: menuId,
menu_id: menuId,
title: menu.title || '',
icon: menu.icon || '',
url: menu.path || '',
color: ['#1fdaca', '#bf0c2c', '#e18525', '#4daf1bc9', '#00d8ff'][index % 5] || '#00d8ff',
sort: quickNavList.value.length + index + 1,
isSaved: false,
};
})
.filter(Boolean);
return [...saved, ...newSelected];
});
// 获取角色已授权的菜单ID列表
const fetchRoleMenuIds = async () => {
if (!record.value?.id) {
roleMenuIds.value = undefined; // 改为 undefined
roleMenuIds.value = [];
return;
}
try {
const res = await getMenuIdsByRoleIds({
id: record.value.id,
});
// 空数组也改为 undefined,表示该角色没有授权任何菜单
roleMenuIds.value = Array.isArray(res) && res.length > 0 ? res : undefined;
roleMenuIds.value = Array.isArray(res) ? res : [];
} catch (error) {
console.error('获取角色菜单ID失败:', error);
message.error('获取角色菜单ID失败');
roleMenuIds.value = undefined; // 改为 undefined
roleMenuIds.value = [];
}
};
// 获取菜单树数据
const fetchMenuTree = async () => {
try {
const res = await getMenuTreeOption({
@@ -104,58 +52,51 @@ const fetchMenuTree = async () => {
}
};
// 获取快捷导航列表
const fetchQuickNavList = async () => {
if (!record.value?.id) return;
loading.value = true;
try {
const res = await getRoleQuickNavList({
role_id: record.value.id,
page: 1,
pageSize: 1000, // 获取所有数据
pageSize: 1000,
});
// 处理返回的数据格式
const items = res.items || res.data || res || [];
// 按sort排序,然后按id排序
quickNavList.value = items.sort((a: any, b: any) => {
const sorted = [...items].sort((a: any, b: any) => {
if (a.sort !== b.sort) {
return (a.sort || 0) - (b.sort || 0);
}
return (a.id || 0) - (b.id || 0);
});
// 设置已选中的菜单ID
checkedMenuIds.value = items.map((item: any) => item.menu_id).filter(Boolean);
// 使用 nextTick 确保 MenuTreeSelector 能正确响应
savedQuickNavItems.value = sorted;
checkedMenuIds.value = sorted
.map((item: any) => item.menu_id)
.filter(Boolean);
await nextTick();
} catch (error) {
console.error('获取快捷导航列表失败:', error);
message.error('获取快捷导航列表失败');
quickNavList.value = [];
checkedMenuIds.value = [];
savedQuickNavItems.value = [];
} finally {
loading.value = false;
}
};
// 批量保存快捷导航
const handleSave = async () => {
if (!record.value?.id) {
message.error('角色ID不能为空');
return;
}
if (checkedMenuIds.value.length === 0) {
message.warning('请至少选择一个菜单');
return;
}
DrawerApi.setState({
loading: true,
confirmLoading: true,
});
try {
await batchSaveRoleQuickNav({
role_id: record.value.id,
@@ -163,7 +104,6 @@ const handleSave = async () => {
});
message.success('保存成功');
await fetchQuickNavList();
DrawerApi.close();
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败');
@@ -175,49 +115,21 @@ const handleSave = async () => {
}
};
// 删除快捷导航(从已选中移除)
const handleRemove = (menuId: number) => {
checkedMenuIds.value = checkedMenuIds.value.filter((id) => id !== menuId);
message.success('已移除');
};
// 删除已保存的快捷导航(从数据库删除)
const handleDelete = async (id: number) => {
try {
// 找到要删除的快捷导航
const item = quickNavList.value.find((item) => item.id === id);
if (item && item.menu_id) {
// 从选中列表中移除
checkedMenuIds.value = checkedMenuIds.value.filter((menuId) => menuId !== item.menu_id);
}
await deleteRoleQuickNav({ ids: [id] });
message.success('删除成功');
await fetchQuickNavList();
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败');
}
};
const [Drawer, DrawerApi] = useVbenDrawer({
onOpenChange(isOpen) {
record.value = isOpen ? DrawerApi.getData()?.record : {};
if (isOpen) {
checkedMenuIds.value = [];
savedQuickNavItems.value = [];
roleMenuIds.value = [];
menuTreeData.value = [];
DrawerApi.setState({
loading: true,
});
// 先获取角色已授权的菜单ID,再加载菜单树,最后加载快捷导航列表
fetchRoleMenuIds()
.then(() => {
// 等待 MenuTreeSelector 初始化完成,响应 roleMenuIds 的变化
return nextTick();
})
.then(() => nextTick())
.then(() => fetchMenuTree())
.then(() => {
// 等待菜单树准备好后再获取快捷导航列表
return nextTick();
})
.then(() => nextTick())
.then(() => fetchQuickNavList())
.finally(() => {
DrawerApi.setState({
@@ -229,7 +141,6 @@ const [Drawer, DrawerApi] = useVbenDrawer({
onConfirm: handleSave,
});
// 暴露DrawerApi供父组件调用
defineExpose({
DrawerApi,
});
@@ -237,106 +148,12 @@ defineExpose({
<template>
<Drawer :title="drawerTitle" class="w-[95%]">
<div class="flex h-full gap-4">
<!-- 左侧:菜单树选择 -->
<div class="w-1/2 border-r pr-4">
<MenuSearchSelect
v-model="checkedMenuIds"
:filter-by-user="true"
:role-menu-ids="roleMenuIds"
/>
</div>
<!-- 右侧:已选择的快捷导航卡片列表 -->
<div class="w-1/2 pl-4 flex flex-col">
<div class="mb-4">
<div class="text-sm text-gray-500 mb-2">
已选择 {{ displayQuickNavs.length }} 个快捷导航
<span v-if="quickNavList.length > 0" class="ml-2 text-gray-400">
(已保存 {{ quickNavList.length }} 个)
</span>
</div>
</div>
<div v-if="loading" class="text-center py-8 flex-1">
<Icon icon="ant-design:loading-outlined" class="text-2xl animate-spin" />
</div>
<Empty
v-else-if="displayQuickNavs.length === 0"
description="请在左侧选择菜单"
class="flex-1"
/>
<div v-else class="flex-1 overflow-y-auto">
<Row :gutter="[16, 16]">
<Col
v-for="item in displayQuickNavs"
:key="`${item.menu_id}-${item.isSaved ? 'saved' : 'new'}`"
:xs="24"
:sm="12"
>
<Card
class="quick-nav-card"
:style="{
borderTop: `4px solid ${item.color || '#00d8ff'}`,
}"
hoverable
>
<div class="flex flex-col items-center justify-center p-4 min-h-[180px]">
<!-- 图标 -->
<div
class="mb-3 flex items-center justify-center"
:style="{
color: item.color || '#00d8ff',
}"
>
<Icon :icon="item.icon" class="text-4xl" />
</div>
<!-- 标题 -->
<div class="mb-2 text-center font-medium text-base truncate w-full">
{{ item.title }}
</div>
<!-- 跳转地址 -->
<div class="mb-3 text-center text-xs text-gray-500 truncate w-full px-2">
{{ item.url }}
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 mt-auto">
<Popconfirm
:title="item.isSaved ? '确定要删除这个快捷导航吗?' : '确定要移除这个快捷导航吗?'"
@confirm="item.isSaved ? handleDelete(item.id) : handleRemove(item.menu_id)"
>
<Button type="link" size="small" danger @click.stop>
<Icon icon="ant-design:delete-outlined" />
{{ item.isSaved ? '删除' : '移除' }}
</Button>
</Popconfirm>
</div>
</div>
</Card>
</Col>
</Row>
</div>
</div>
</div>
<QuickNavTransfer
v-model="checkedMenuIds"
:loading="loading"
:menu-tree="menuTreeData"
:role-menu-ids="roleMenuIds"
:saved-items="savedQuickNavItems"
/>
</Drawer>
</template>
<style scoped>
.quick-nav-card {
transition: all 0.3s;
height: 100%;
}
.quick-nav-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.quick-nav-card :deep(.ant-card-body) {
padding: 0;
height: 100%;
}
</style>

View File

@@ -1,6 +1,5 @@
import type { VbenFormProps } from '#/adapter/form';
import { addressOption } from '#/util/address.ts';
// 诊所|药店信息录入表单配置
export const modalFormProps: VbenFormProps = {
@@ -169,11 +168,7 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
component: 'Cascader',
componentProps: {
placeholder: '请选省市区',
options: addressOption,
},
component: 'RegionAddressPicker',
fieldName: 'address',
label: '省市区',
rules: 'required',

View File

@@ -1,6 +1,5 @@
import type { VbenFormProps } from '#/adapter/form';
import { addressOption } from '#/util/address.ts';
// 诊所管理表单配置
// 注意:已移除类型选择字段,固定为type=0(诊所类型)
@@ -200,11 +199,7 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
component: 'Cascader',
componentProps: {
placeholder: '请选省市区',
options: addressOption,
},
component: 'RegionAddressPicker',
fieldName: 'address',
label: '省市区',
rules: 'required',