1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
/**
|
||||
* 分页查询用户列表
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
35
apps/web-antd/src/views/log/api-log/config/platform.ts
Normal file
35
apps/web-antd/src/views/log/api-log/config/platform.ts
Normal 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 || '-';
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '访问控制器' },
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
291
apps/web-antd/src/views/system/admin/_shared/admin-page.vue
Normal file
291
apps/web-antd/src/views/system/admin/_shared/admin-page.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
243
apps/web-antd/src/views/system/admin/_shared/form-schemas.ts
Normal file
243
apps/web-antd/src/views/system/admin/_shared/form-schemas.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
apps/web-antd/src/views/system/admin/_shared/role-meta.ts
Normal file
40
apps/web-antd/src/views/system/admin/_shared/role-meta.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
137
apps/web-antd/src/views/system/admin/_shared/table-config.ts
Normal file
137
apps/web-antd/src/views/system/admin/_shared/table-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
apps/web-antd/src/views/system/admin/city/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/city/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="4" />
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="9" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/clinic/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/clinic/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="8" />
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
6
apps/web-antd/src/views/system/admin/district/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/district/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="5" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/doctor/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/doctor/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="10" />
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="11" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/platform/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/platform/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="2" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/province/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/province/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="3" />
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="6" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/super/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/super/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="1" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/supplier/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/supplier/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="7" />
|
||||
</template>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user