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

@@ -19,75 +19,18 @@ const authStore = useAuthStore();
const CODE_LENGTH = 6;
const loading = ref(false);
const loginMethod = ref<'phone' | 'login_account' | 'job_number'>('phone');
const needSelectAccount = ref(false);
const accountList = ref<Array<{ id: number; login_account: string; job_number: string }>>([]);
const formSchema = computed((): VbenFormSchema[] => {
const schema: VbenFormSchema[] = [
// 1. Radio选择组最上面
{
component: 'VbenSelect',
componentProps: {
// optionType: 'button',
options: [
{ label: '手机号登录', value: 'phone' },
{ label: '登录账号登录', value: 'login_account' },
{ label: '工号登录', value: 'job_number' },
],
},
fieldName: 'login_method',
label: '登录方式',
rules: z.string().min(1, { message: '请选择登录方式' }),
defaultValue: 'phone',
dependencies: {
trigger(values, form) {
loginMethod.value = values.login_method;
// 切换登录方式时清空输入框
form.setValues({ login_value: '' });
},
triggerFields: ['login_method'],
},
},
];
// 2. 统一的登录输入框(根据登录方式动态更新 label 和 placeholder
const loginMethodValue = loginMethod.value;
let loginLabel = '';
let loginPlaceholder = '';
let loginRules: any;
if (loginMethodValue === 'phone') {
loginLabel = $t('authentication.username');
loginPlaceholder = $t('authentication.usernameTip');
loginRules = z
.string()
.min(1, { message: $t('authentication.mobileTip') })
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
});
} else if (loginMethodValue === 'login_account') {
loginLabel = '登录账号';
loginPlaceholder = '请输入登录账号';
loginRules = z.string().min(1, { message: '请输入登录账号' });
} else if (loginMethodValue === 'job_number') {
loginLabel = '工号';
loginPlaceholder = '请输入工号';
loginRules = z.string().min(1, { message: '请输入工号' });
}
schema.push({
const formSchema = computed((): VbenFormSchema[] => [
{
component: 'VbenInput',
componentProps: {
placeholder: loginPlaceholder,
placeholder: '手机号 / 登录账号 / 工号',
},
fieldName: 'login_value',
label: loginLabel,
rules: loginRules,
});
// 3. 密码输入框(常驻)
schema.push({
fieldName: 'account',
label: '账号',
rules: z.string().min(1, { message: '请输入账号' }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
@@ -95,10 +38,8 @@ const formSchema = computed((): VbenFormSchema[] => {
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
});
// 4. 验证码输入框(常驻)
schema.push({
},
{
component: 'VbenPinInput',
componentProps: {
codeLength: CODE_LENGTH,
@@ -116,54 +57,28 @@ const formSchema = computed((): VbenFormSchema[] => {
throw new Error('formApi is not ready');
}
const values = await formApi.getValues();
const loginMethodValue = values.login_method || 'phone';
const loginValue = values.login_value || '';
try {
// 统一验证 login_value 字段
await formApi.validateField('login_value');
const isValid = await formApi.isFieldValid('login_value');
if (!isValid) {
await formApi.validateField('account');
const accountValid = await formApi.isFieldValid('account');
if (!accountValid) {
loading.value = false;
const errorMsg = loginMethodValue === 'phone'
? '手机号格式不正确'
: loginMethodValue === 'login_account'
? '登录账号不能为空'
: '工号不能为空';
throw new Error(errorMsg);
throw new Error('请输入账号');
}
const password = await formApi.isFieldValid('password');
if (!password) {
const passwordValid = await formApi.isFieldValid('password');
if (!passwordValid) {
loading.value = false;
throw new Error('密码不符合要求');
}
// 根据登录方式组装参数
const params: any = {
login_method: loginMethodValue,
};
if (loginMethodValue === 'phone') {
params.username = loginValue;
} else if (loginMethodValue === 'login_account') {
params.login_account = loginValue;
} else if (loginMethodValue === 'job_number') {
params.job_number = loginValue;
}
const result = await sendVerificationCode(params);
const values = await formApi.getValues();
const result = await sendVerificationCode({
account: values.account,
password: values.password,
});
if (result) {
if (result.need_select && result.accounts && result.accounts.length > 1) {
needSelectAccount.value = true;
accountList.value = result.accounts;
message.warning('该手机号绑定了多个账号,请选择登录账号或工号');
} else {
needSelectAccount.value = false;
message.success('发送成功,请注意查收');
}
// 开发环境显示验证码
message.success('发送成功,请注意查收');
if (result.code) {
message.info(`验证码:${result.code}`, 10);
}
@@ -181,50 +96,21 @@ const formSchema = computed((): VbenFormSchema[] => {
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
});
},
]);
return schema;
});
// 自定义提交处理
async function handleLogin(values: Recordable<any>) {
const loginMethodValue = values.login_method || 'phone';
const loginValue = values.login_value || '';
// 验证必填字段
if (loginMethodValue === 'phone') {
if (!loginValue || !/^\d{11}$/.test(loginValue)) {
message.error('请输入正确的手机号');
return;
}
} else if (loginMethodValue === 'login_account') {
if (!loginValue || loginValue.trim() === '') {
message.error('请输入登录账号');
return;
}
} else if (loginMethodValue === 'job_number') {
if (!loginValue || loginValue.trim() === '') {
message.error('请输入工号');
return;
}
const account = (values.account || '').trim();
if (!account) {
message.error('请输入账号');
return;
}
const loginData: any = {
login_method: loginMethodValue,
await authStore.authLogin({
account,
password: values.password,
code: values.code,
};
// 根据登录方式组装参数
if (loginMethodValue === 'phone') {
loginData.phone = loginValue;
} else if (loginMethodValue === 'login_account') {
loginData.login_account = loginValue;
} else if (loginMethodValue === 'job_number') {
loginData.job_number = loginValue;
}
await authStore.authLogin(loginData);
});
}
</script>

View File

@@ -30,6 +30,13 @@
formatFileSize(uploadPreview.size)
}}
</div>
<div
v-if="uploadPreview.compressMeta?.usedCompress"
class="text-xs text-blue-500 dark:text-blue-400"
>
已压缩{{ formatFileSize(uploadPreview.compressMeta.originalSize) }}
{{ formatFileSize(uploadPreview.compressMeta.compressedSize) }}
</div>
</div>
<!-- 操作按钮 -->

View File

@@ -43,6 +43,7 @@ const QuickReplyBubbles = computed(() => {
import CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
// 定义 props
const props = defineProps({
@@ -226,7 +227,7 @@ const handlePasteFile = (file) => {
};
// 处理粘贴的文件
const handleFileFromPaste = (file, type) => {
const handleFileFromPaste = async (file, type) => {
// 文件大小检查
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
@@ -237,14 +238,27 @@ const handleFileFromPaste = (file, type) => {
return;
}
let uploadFile = file;
let compressMeta = null;
if (type === 'image') {
const prepared = await prepareImageForUpload(file);
if (!prepared.file) {
return;
}
uploadFile = prepared.file;
compressMeta = prepared.meta;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
uploadPreview.value = {
type,
url: e.target.result,
name: file.nick_name,
size: file.size,
file,
name: file.name,
size: uploadFile.size,
file: uploadFile,
compressMeta,
};
});

View File

@@ -2,6 +2,7 @@ import {nextTick, ref} from 'vue';
import {message} from 'ant-design-vue';
import {uploadChatFile} from "#/api/core/upload";
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
export function useFileUpload() {
const uploadPreview = ref(null);
@@ -23,7 +24,7 @@ export function useFileUpload() {
});
};
const handleFileUpload = (event) => {
const handleFileUpload = async (event) => {
if (!event.target.files || event.target.files.length === 0) return;
const file = event.target.files[0];
@@ -81,15 +82,29 @@ export function useFileUpload() {
return;
}
let uploadFile = file;
let compressMeta = null;
if (detectedType === 'image') {
const prepared = await prepareImageForUpload(file);
if (!prepared.file) {
event.target.value = '';
return;
}
uploadFile = prepared.file;
compressMeta = prepared.meta;
}
uploadChatFile({
file,
file: uploadFile,
}).then((res) => {
uploadPreview.value = {
type: detectedType,
url: res.url,
name: file.name,
size: file.size,
file,
size: uploadFile.size,
file: uploadFile,
compressMeta,
};
});
// const reader = new FileReader();

View File

@@ -15,8 +15,6 @@ import { Page, useVbenModal } from '@vben/common-ui';
import {
Button,
Col,
Collapse,
CollapsePanel,
Empty,
message,
Row,
@@ -126,6 +124,13 @@ function handleSelectPrescription(
modalApi.close();
}
/**
* 空值占位
*/
function formatField(value?: string | null): string {
return value?.trim() ? value : '—';
}
/**
* 获取药品名称列表(用于展示)
* @param recipes 药品列表
@@ -164,8 +169,8 @@ function getDrugCount(recipes: any[]): number {
<Row :gutter="16">
<!-- 西药常用方 - 只在西药Tab时显示 -->
<Col v-if="currentType === 2" :span="24" class="mb-6">
<div class="rounded-lg border border-border p-4 mb-4">
<h3 class="flex items-center gap-2 mb-4 text-base font-semibold">
<div class="mb-4 rounded-lg border border-border p-4">
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold">
<Tag color="blue">西药</Tag>
西药常用方
<span class="text-sm font-normal text-muted-foreground"
@@ -180,58 +185,58 @@ function getDrugCount(recipes: any[]): number {
commonPrescriptionData.west_prescription &&
commonPrescriptionData.west_prescription.length > 0
"
class="max-h-[60vh] overflow-y-auto pr-1"
>
<Collapse>
<CollapsePanel
v-for="(item, index) in commonPrescriptionData.west_prescription"
:key="item.id"
<div
v-for="(item, index) in commonPrescriptionData.west_prescription"
:key="item.id"
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
>
<template #header>
<div class="flex items-center justify-between w-full">
<span class="font-medium">{{
item.name || `西药处方 ${index + 1}`
}}</span>
<Tag color="blue" class="ml-auto mr-4"
>{{
getDrugCount(commonPrescriptionData.west[index])
}}种药品</Tag
>
</div>
</template>
<div>
<p v-if="item.clinical_diagnose" class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">临床诊断:</span>
<span>{{ item.clinical_diagnose }}</span>
</p>
<p v-if="item.doctor_order" class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">医嘱:</span>
<span>{{ item.doctor_order }}</span>
</p>
<p class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">药品:</span>
<span class="text-primary">{{
getDrugNames(commonPrescriptionData.west[index])
}}</span>
</p>
<div class="flex justify-end mt-4 pt-4 border-t border-border">
<Button
type="primary"
@click="
handleSelectPrescription(
item,
commonPrescriptionData.west[index],
1,
)
"
>
使用此常用方
</Button>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate font-medium">{{
item.name || `西药处方 ${index + 1}`
}}</span>
<Tag color="blue"
>{{
getDrugCount(commonPrescriptionData.west[index])
}}种药品</Tag
>
</div>
</CollapsePanel>
</Collapse>
<Button
type="primary"
size="small"
@click="
handleSelectPrescription(
item,
commonPrescriptionData.west[index],
1,
)
"
>
使用
</Button>
</div>
<div class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-muted-foreground">临床诊断:</span>
<span>{{ formatField(item.clinical_diagnose) }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">医嘱:</span>
<span>{{ formatField(item.doctor_order) }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">药品:</span>
<span class="text-primary">{{
getDrugNames(commonPrescriptionData.west[index])
}}</span>
</p>
</div>
</div>
</div>
<Empty v-else description="暂无西药常用方" />
@@ -240,8 +245,8 @@ function getDrugCount(recipes: any[]): number {
<!-- 中药常用方 - 只在中药Tab时显示 -->
<Col v-if="currentType === 1" :span="24" class="mb-6">
<div class="rounded-lg border border-border p-4 mb-4">
<h3 class="flex items-center gap-2 mb-4 text-base font-semibold">
<div class="mb-4 rounded-lg border border-border p-4">
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold">
<Tag color="green">中药</Tag>
中药常用方
<span class="text-sm font-normal text-muted-foreground"
@@ -256,78 +261,74 @@ function getDrugCount(recipes: any[]): number {
commonPrescriptionData.chin_prescription &&
commonPrescriptionData.chin_prescription.length > 0
"
class="max-h-[60vh] overflow-y-auto pr-1"
>
<Collapse>
<CollapsePanel
v-for="(
item, index
) in commonPrescriptionData.chin_prescription"
:key="item.id"
<div
v-for="(item, index) in commonPrescriptionData.chin_prescription"
:key="item.id"
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
>
<template #header>
<div class="flex items-center justify-between w-full">
<span class="font-medium">{{
item.name || `中药处方 ${index + 1}`
}}</span>
<Tag color="green" class="ml-auto mr-4"
>{{
getDrugCount(
commonPrescriptionData.chinese[index],
)
}}种药品</Tag
>
</div>
</template>
<div>
<p v-if="item.clinical_diagnose" class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">临床诊断:</span>
<span>{{ item.clinical_diagnose }}</span>
</p>
<p v-if="item.doctor_order" class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">医嘱:</span>
<span>{{ item.doctor_order }}</span>
</p>
<p class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">调配方式:</span>
<span>{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}</span>
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
</p>
<p v-if="item.rule_type === 2" class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">委托调剂:</span>
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
</p>
<p class="mb-2 leading-relaxed">
<span class="font-medium text-muted-foreground">药品:</span>
<span class="text-primary">{{
getDrugNames(
commonPrescriptionData.chinese[index],
'drug_name',
true,
)
}}</span>
</p>
<div class="flex justify-end mt-4 pt-4 border-t border-border">
<Button
type="primary"
@click="
handleSelectPrescription(
item,
commonPrescriptionData.chinese[index],
2,
)
"
>
使用此常用方
</Button>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate font-medium">{{
item.name || `中药处方 ${index + 1}`
}}</span>
<Tag color="green"
>{{
getDrugCount(commonPrescriptionData.chinese[index])
}}种药品</Tag
>
</div>
</CollapsePanel>
</Collapse>
<Button
type="primary"
size="small"
@click="
handleSelectPrescription(
item,
commonPrescriptionData.chinese[index],
2,
)
"
>
使用
</Button>
</div>
<div class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-muted-foreground">临床诊断:</span>
<span>{{ formatField(item.clinical_diagnose) }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">医嘱:</span>
<span>{{ formatField(item.doctor_order) }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">调配方式:</span>
<span>{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}</span>
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
</p>
<p v-if="item.rule_type === 2">
<span class="font-medium text-muted-foreground">委托调剂:</span>
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">药品:</span>
<span class="text-primary">{{
getDrugNames(
commonPrescriptionData.chinese[index],
'drug_name',
true,
)
}}</span>
</p>
</div>
</div>
</div>
<Empty v-else description="暂无中药常用方" />
@@ -339,21 +340,3 @@ function getDrugCount(recipes: any[]): number {
</Page>
</Modal>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse) {
background: transparent;
border: none;
.ant-collapse-item {
margin-bottom: 8px;
border-radius: 8px !important;
overflow: hidden;
}
.ant-collapse-content {
border-top: none;
}
}
</style>

View File

@@ -99,6 +99,18 @@ const getImageSource = (imageString) => {
} else {
return `data:image/jpeg;base64,${imageString}`; // 使用Base64格式
}
};
function parseRecipeContent(content: string | Record<string, unknown>) {
if (!content) return {};
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch {
return {};
}
}
return content;
}
</script>
@@ -187,20 +199,24 @@ const getImageSource = (imageString) => {
</div>
</div>
<div v-else-if="item.prescription_type === 2 || item.prescription_type === 3 || item.prescription_type === 5 || item.prescription_type === 6 || item.prescription_type === 7">
<div class="medicine-item">
<span class="drug-name">{{
JSON.parse(recipe.content).name ||
JSON.parse(recipe.content).drug_name
}}</span>
<span class="drug-quantity">{{ JSON.parse(recipe.content).number
}}{{ JSON.parse(recipe.content).unit?.name }}</span>
<div
v-if="JSON.parse(recipe.content).useWay"
class="usage-info"
>
{{ JSON.parse(recipe.content).useWay }}
<template
v-for="drug in [parseRecipeContent(recipe.content)]"
:key="`west-${index}`"
>
<div class="medicine-item">
<span class="drug-name">{{
drug?.name || drug?.drug_name
}}<span
v-if="drug?.specification"
class="drug-spec"
>{{ drug.specification }}</span></span>
<span class="drug-quantity">{{ drug?.number
}}{{ drug?.unit?.name }}</span>
<div v-if="drug?.useWay" class="usage-info">
{{ drug.useWay }}
</div>
</div>
</div>
</template>
<div class="preparation-info">
使用方法: {{ recipe.instruction }}
</div>
@@ -357,6 +373,13 @@ const getImageSource = (imageString) => {
font-weight: 500;
}
.drug-spec {
font-size: 12px;
color: #666;
margin-left: 8px;
font-weight: normal;
}
.preparation-info {
color: #666;
margin-top: 12px;

View File

@@ -21,8 +21,6 @@ import SignatureModal from './components/SignatureModal.vue';
import {
Button,
Card,
Collapse,
CollapsePanel,
Empty,
Input,
InputGroup,
@@ -189,6 +187,10 @@ const getDrugNames = (recipes: any[], nameField = 'drug_name') => {
return recipes.map((item) => item[nameField] || item.name).join('、');
};
const formatField = (value?: string | null) => (value?.trim() ? value : '—');
const getDrugCount = (recipes: any[]) => recipes?.length || 0;
// ==================== 常用方编辑弹窗 ====================
const [EditCommonPrescriptionModals, EditCommonPrescriptionModalApi] =
@@ -553,50 +555,61 @@ const handleViewSignature = () => {
<h3 class="mb-4 border-l-4 border-blue-500 pl-3 text-lg font-semibold">
西药常用方
</h3>
<Collapse>
<CollapsePanel
v-for="(item, index) in commonPrescriptionData.west_prescription"
:key="item.id"
:header="item.name || `西药处方 ${index + 1}`"
<div
v-for="(item, index) in commonPrescriptionData.west_prescription"
:key="item.id"
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
>
<div class="space-y-2">
<p v-if="item.clinical_diagnose">
<span class="font-medium">临床诊断:</span>
{{ item.clinical_diagnose }}
</p>
<p v-if="item.doctor_order">
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
</p>
<p>
<span class="font-medium">药品:</span>
{{ getDrugNames(commonPrescriptionData.west[index]) }}
</p>
<div class="mt-4 flex justify-end gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.west[index],
'west',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'west')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate font-medium">{{
item.name || `西药处方 ${index + 1}`
}}</span>
<Tag color="blue"
>{{ getDrugCount(commonPrescriptionData.west[index]) }}种药品</Tag
>
</div>
</CollapsePanel>
</Collapse>
<div class="flex shrink-0 gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.west[index],
'west',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'west')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
</div>
<div class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-muted-foreground">临床诊断:</span>
{{ formatField(item.clinical_diagnose) }}
</p>
<p>
<span class="font-medium text-muted-foreground">医嘱:</span>
{{ formatField(item.doctor_order) }}
</p>
<p>
<span class="font-medium text-muted-foreground">药品:</span>
{{ getDrugNames(commonPrescriptionData.west[index]) }}
</p>
</div>
</div>
</div>
<!-- 中药常用方 -->
@@ -610,62 +623,73 @@ const handleViewSignature = () => {
<h3 class="mb-4 border-l-4 border-green-500 pl-3 text-lg font-semibold">
中药常用方
</h3>
<Collapse>
<CollapsePanel
v-for="(item, index) in commonPrescriptionData.chin_prescription"
:key="item.id"
:header="item.name || `中药处方 ${index + 1}`"
<div
v-for="(item, index) in commonPrescriptionData.chin_prescription"
:key="item.id"
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
>
<div class="space-y-2">
<p v-if="item.clinical_diagnose">
<span class="font-medium">临床诊断:</span>
{{ item.clinical_diagnose }}
</p>
<p v-if="item.doctor_order">
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
</p>
<p>
<span class="font-medium">调配方式:</span>
{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
</p>
<p v-if="item.rule_type === 2">
<span class="font-medium">委托调剂配置:</span>
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
</p>
<p>
<span class="font-medium">药品:</span>
{{ getDrugNames(commonPrescriptionData.chinese[index], 'drug_name') }}
</p>
<div class="mt-4 flex justify-end gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.chinese[index],
'chinese',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'chinese')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate font-medium">{{
item.name || `中药处方 ${index + 1}`
}}</span>
<Tag color="green"
>{{ getDrugCount(commonPrescriptionData.chinese[index]) }}种药品</Tag
>
</div>
</CollapsePanel>
</Collapse>
<div class="flex shrink-0 gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.chinese[index],
'chinese',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'chinese')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
</div>
<div class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-muted-foreground">临床诊断:</span>
{{ formatField(item.clinical_diagnose) }}
</p>
<p>
<span class="font-medium text-muted-foreground">医嘱:</span>
{{ formatField(item.doctor_order) }}
</p>
<p>
<span class="font-medium text-muted-foreground">调配方式:</span>
{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
</p>
<p v-if="item.rule_type === 2">
<span class="font-medium text-muted-foreground">委托调剂配置:</span>
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
</p>
<p>
<span class="font-medium text-muted-foreground">药品:</span>
{{ getDrugNames(commonPrescriptionData.chinese[index], 'drug_name') }}
</p>
</div>
</div>
</div>
<!-- 颗粒药常用方 -->
@@ -679,50 +703,61 @@ const handleViewSignature = () => {
<h3 class="mb-4 border-l-4 border-orange-500 pl-3 text-lg font-semibold">
颗粒药常用方
</h3>
<Collapse>
<CollapsePanel
v-for="(item, index) in commonPrescriptionData.granular_prescription"
:key="item.id"
:header="item.name || `颗粒药处方 ${index + 1}`"
<div
v-for="(item, index) in commonPrescriptionData.granular_prescription"
:key="item.id"
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
>
<div class="space-y-2">
<p v-if="item.clinical_diagnose">
<span class="font-medium">临床诊断:</span>
{{ item.clinical_diagnose }}
</p>
<p v-if="item.doctor_order">
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
</p>
<p>
<span class="font-medium">药品:</span>
{{ getDrugNames(commonPrescriptionData.granular[index], 'name') }}
</p>
<div class="mt-4 flex justify-end gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.granular[index],
'granular',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'granular')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate font-medium">{{
item.name || `颗粒药处方 ${index + 1}`
}}</span>
<Tag color="orange"
>{{ getDrugCount(commonPrescriptionData.granular[index]) }}种药品</Tag
>
</div>
</CollapsePanel>
</Collapse>
<div class="flex shrink-0 gap-2">
<Button
type="primary"
size="small"
@click="
handleEditCommonPrescription(
item,
commonPrescriptionData.granular[index],
'granular',
)
"
>
编辑
</Button>
<Popconfirm
title="确定要删除这个常用方吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDeleteCommonPrescription(item.id, 'granular')"
>
<Button type="primary" danger size="small">删除</Button>
</Popconfirm>
</div>
</div>
<div class="space-y-2 text-sm leading-relaxed">
<p>
<span class="font-medium text-muted-foreground">临床诊断:</span>
{{ formatField(item.clinical_diagnose) }}
</p>
<p>
<span class="font-medium text-muted-foreground">医嘱:</span>
{{ formatField(item.doctor_order) }}
</p>
<p>
<span class="font-medium text-muted-foreground">药品:</span>
{{ getDrugNames(commonPrescriptionData.granular[index], 'name') }}
</p>
</div>
</div>
</div>
<!-- 无数据提示 -->

View File

@@ -4,8 +4,24 @@ const prefix = 'settlement/';
* 分页查询用户列表
* @param data
*/
/** 结算记录yii_ledger_log正确分页 */
export async function getSettlementLedgerLogList(data: any) {
return requestClient.get<any>(`${prefix}ledger-log-list`, { params: data });
}
/** @deprecated 请使用 getSettlementLedgerLogList */
export async function getSettlementList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
return getSettlementLedgerLogList(data);
}
/** 结算明细yii_ledger */
export async function getSettlementLedgerDetail(params: {
ledger_log_id?: number;
order_id?: number;
order_type?: number;
fee_type?: number;
}) {
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
}
/**
* 分页查询用户列表

View File

@@ -1,6 +1,9 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import {getSettlementItemsList, getSettlementList} from '#/views/finance/withdrawal/api/settlement';
import {
getSettlementItemsList,
getSettlementLedgerLogList,
} from '#/views/finance/withdrawal/api/settlement';
interface RowType {
id: string;
@@ -89,10 +92,10 @@ export const gridOptions3: VxeGridProps<RowType> = {
// { type: 'checkbox', width: 60 },
// { field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'order_no', title: '订单号' },
{ field: 'user_id', title: '名称', slots: { default: 'user_id' } },
{ field: 'money', title: '分账金额' },
// { field: 'fee_type_txt', title: '费用类型' },
// { field: 'status_txt', title: '结算状态' },
{ field: 'fee_type_txt', title: '费用类型' },
{ field: 'amount', title: '金额' },
{ field: 'type_txt', title: '类型' },
{ field: 'content', title: '说明' },
{ field: 'created_at', title: '创建时间' },
{ type: 'html', title: '操作', slots: { default: 'action' } },
],
@@ -102,7 +105,7 @@ export const gridOptions3: VxeGridProps<RowType> = {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
return await getSettlementList({
return await getSettlementLedgerLogList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,

View File

@@ -7,6 +7,10 @@ import { Descriptions } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { getIcon } from '#/util/tool';
import {
getApiOpLogOperatorLabel,
getApiOpLogPlatformLabel,
} from '../config/platform';
@@ -43,10 +47,7 @@ const [Modal, modalApi] = useVbenModal({
>
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
<Descriptions.Item label="操作人员">
<span v-if="data.platform_type === 0">{{ data?.admin?.nick_name || '获取失败' }}</span>
<span v-else-if="data.platform_type === 1">{{ data?.admin?.nick_name || '获取失败' }}</span>
<span v-else-if="data.platform_type === 2">{{ data?.user?.nickname || '获取失败' }}</span>
<span v-else>获取失败</span>
<span>{{ getApiOpLogOperatorLabel(data) }}</span>
</Descriptions.Item>
<Descriptions.Item label="URL">{{ data.url }}</Descriptions.Item>
<Descriptions.Item label="控制器">
@@ -62,11 +63,8 @@ const [Modal, modalApi] = useVbenModal({
<Descriptions.Item label="返回结果代码">
{{ data.result_code }}
</Descriptions.Item>
<Descriptions.Item label="平台类型">
<span v-if="data.platform_type === 0">平台</span>
<span v-else-if="data.platform_type === 1">诊所</span>
<span v-else-if="data.platform_type === 2">小程序</span>
<span v-else>获取失败</span>
<Descriptions.Item label="接口来源">
<span>{{ getApiOpLogPlatformLabel(data.platform_type) }}</span>
</Descriptions.Item>
<Descriptions.Item label="设备">
<Icon :icon="getIcon(data.equipment)" :size="20" />

View File

@@ -0,0 +1,35 @@
export const API_OP_LOG_PLATFORM_OPTIONS = [
{ label: '后台', value: 0 },
{ label: '用户移动端', value: 1 },
{ label: '医生移动端', value: 2 },
{ label: '互医内部接口', value: 3 },
] as const;
export function getApiOpLogPlatformLabel(
platformType: number | string | null | undefined,
): string {
const value = Number(platformType);
const found = API_OP_LOG_PLATFORM_OPTIONS.find((item) => item.value === value);
return found?.label ?? '历史/未知';
}
export function getApiOpLogOperatorLabel(row: {
platform_type?: number;
admin?: { nick_name?: string };
user?: { nickname?: string };
}): string {
const type = row.platform_type;
if (type === 3) {
return '互医中转';
}
if (type === 2) {
return row?.user?.nickname || row?.admin?.nick_name || '-';
}
if (type === 1) {
return row?.user?.nickname || '获取失败';
}
if (type === 0) {
return row?.admin?.nick_name || '获取失败';
}
return row?.admin?.nick_name || row?.user?.nickname || '-';
}

View File

@@ -1,5 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import { API_OP_LOG_PLATFORM_OPTIONS } from './platform';
// import dayjs from 'dayjs';
export const formOptions: VbenFormProps = {
@@ -18,6 +20,16 @@ export const formOptions: VbenFormProps = {
fieldName: 'router',
label: 'api接口',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [...API_OP_LOG_PLATFORM_OPTIONS],
placeholder: '全部',
},
fieldName: 'platform_type',
label: '接口来源',
},
{
component: 'RangePicker',
componentProps: {

View File

@@ -32,6 +32,12 @@ export const gridOptions: VxeGridProps<RowType> = {
slots: { default: 'admin' },
},
{ field: 'url', title: '访问路由' },
{
field: 'platform_type',
title: '接口来源',
slots: { default: 'platform_type' },
width: 120,
},
{ field: 'ip', title: '用户IP地址' },
{ field: 'ip_address', title: 'IP归属地' },
{ field: 'controller', title: '访问控制器' },

View File

@@ -11,6 +11,10 @@ import { TableAction } from '#/components/table-action';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import {
getApiOpLogOperatorLabel,
getApiOpLogPlatformLabel,
} from './config/platform';
import { getIcon } from '#/util/tool';
import {Icon} from "#/components/icon";
@@ -46,10 +50,10 @@ const showModal = (data = {}, isUpdate = false) => {
<Tag v-else color="red"> 访问失败 </Tag>
</template>
<template #admin="{ row }">
<span v-if="row.platform_type === 0">{{ row?.admin?.nick_name || '获取失败' }}</span>
<span v-else-if="row.platform_type === 1">{{ row?.admin?.nick_name || '获取失败' }}</span>
<span v-else-if="row.platform_type === 2">{{ row?.user?.nickname || '获取失败' }}</span>
<span v-else>获取失败</span>
<span>{{ getApiOpLogOperatorLabel(row) }}</span>
</template>
<template #platform_type="{ row }">
<span>{{ getApiOpLogPlatformLabel(row.platform_type) }}</span>
</template>
<template #toolbar-tools></template>
<template #equipment="{ row }">

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',