1. 管理员管理模块拆分

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

View File

@@ -46,8 +46,12 @@
"dayjs": "catalog:",
"markdown-it": "^14.1.0",
"pinia": "catalog:",
"sortablejs": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/sortablejs": "catalog:"
}
}

View File

@@ -3,13 +3,15 @@ import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
login_method?: 'phone' | 'login_account' | 'job_number'; // 登录方式类型
account: string;
password?: string;
username?: string;
phone?: string; // 手机号当login_method为phone时
code?: string;
login_account?: string; // 登录账号当login_method为login_account时
job_number?: string; // 工号当login_method为job_number时)
/** @deprecated 兼容旧客户端 */
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
login_account?: string;
job_number?: string;
}
/** 登录接口返回值 */
@@ -24,13 +26,8 @@ export namespace AuthApi {
/** 发送验证码返回结果 */
export interface SendCodeResult {
code?: string; // 开发环境返回验证码
accounts?: Array<{
id: number;
login_account: string;
job_number: string;
}>; // 如果手机号绑定多个账号,返回账号列表
need_select?: boolean; // 是否需要选择账号/工号
code?: string;
need_select?: boolean;
}
}
@@ -38,12 +35,20 @@ export namespace AuthApi {
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
const params: any = {
login_method: data.login_method || 'phone', // 添加默认值
if (data.account) {
return requestClient.post<AuthApi.LoginResult>('login', {
account: data.account,
password: data.password,
code: data.code,
});
}
const params: Record<string, unknown> = {
login_method: data.login_method || 'phone',
password: data.password,
code: data.code,
};
if (data.login_method === 'phone') {
params.phone = data.phone || data.username;
} else if (data.login_method === 'login_account') {
@@ -51,23 +56,32 @@ export async function loginApi(data: AuthApi.LoginParams) {
} else if (data.login_method === 'job_number') {
params.job_number = data.job_number;
}
return requestClient.post<AuthApi.LoginResult>('login', params);
}
/**
* 发送验证码
*/
export async function sendVerificationCode(params: {
account?: string;
password?: string;
login_method?: 'phone' | 'login_account' | 'job_number';
username?: string;
phone?: string;
login_account?: string;
job_number?: string;
}) {
const requestParams: any = {
if (params.account) {
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', {
account: params.account,
});
}
const requestParams: Record<string, unknown> = {
login_method: params.login_method || 'phone',
};
if (params.login_method === 'phone') {
requestParams.phone = params.phone || params.username;
} else if (params.login_method === 'login_account') {
@@ -75,8 +89,11 @@ export async function sendVerificationCode(params: {
} else if (params.login_method === 'job_number') {
requestParams.job_number = params.job_number;
}
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', requestParams);
return requestClient.post<AuthApi.SendCodeResult>(
'send-verification-code',
requestParams,
);
}
/**

View File

@@ -5,6 +5,10 @@ import { Upload } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -24,28 +28,29 @@ const mValue = useVModel(props, 'value', emits, {
});
/**
* 自定义上传请求处理函数
*
*
* 该函数根据preferences配置的上传方式选择使用OSS直传或后端上传
* - 'direct': OSS直传模式文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式文件先上传到后端服务器再由后端上传到OSS
*
*
* 上传方式说明:
* 1. OSS直传direct
* - 调用uploadToOss函数直接上传到OSS
* - 减少服务器负载,上传速度更快
*
*
* 2. 后端上传backend
* - 调用uploadFile API通过后端服务器上传
* - 兼容现有功能,所有上传逻辑由后端统一处理
*
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 上传成功后将URL赋值给mValue触发组件更新
*
*
* @param e 上传事件对象包含file字段文件对象
*/
const customRequest = async (e: any) => {
try {
const actualFile = resolveUploadFile(e.file as File);
// 从preferences中读取上传方式配置
// uploadMethod可能的值'direct'OSS直传或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
@@ -57,13 +62,13 @@ const customRequest = async (e: any) => {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: e.file as File,
file: actualFile,
});
} else {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会将文件发送到后端API/upload/image
data = await uploadFile({
file: e.file,
file: actualFile,
});
}
@@ -83,6 +88,7 @@ const handleRemove = (e: Event) => {
</script>
<template>
<Upload
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
@@ -102,6 +108,7 @@ const handleRemove = (e: Event) => {
.m-avatar-wrap {
position: relative;
height: 102px;
overflow: hidden;
.m-avatar-icon-delete {
position: absolute;
top: 0;

View File

@@ -10,6 +10,7 @@ import { message } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
import { preferences } from '@vben/preferences';
const props = defineProps({
@@ -164,6 +165,12 @@ const insertImageToEditor = (quill: any, imageUrl: string): void => {
*/
const uploadImage = async (file: File): Promise<string> => {
try {
const { file: preparedFile } = await prepareImageForUpload(file);
if (!preparedFile) {
isUpload.value = false;
return '';
}
// 显示上传中的提示
message.loading({ content: '图片上传中...', key: 'imageUpload' });
@@ -178,7 +185,7 @@ const uploadImage = async (file: File): Promise<string> => {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: file,
file: preparedFile,
// 如果需要显示上传进度可以添加onProgress回调
// onProgress: (percent) => {
// message.loading({ content: `图片上传中... ${percent}%`, key: 'imageUpload' });
@@ -188,7 +195,7 @@ const uploadImage = async (file: File): Promise<string> => {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会将文件发送到后端API/upload/image
data = await uploadFile({
file,
file: preparedFile,
});
}

View File

@@ -0,0 +1,709 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Input, Popover } from 'ant-design-vue';
import type {
AddressListRow,
AddressNode,
AddressSearchHit,
AddressValue,
BreadcrumbItem,
RegionLevel,
SelectionContext,
} from '#/util/address-index';
import {
applySearchHit,
buildAddressOutput,
buildBreadcrumbFromContext,
canCompleteAfterSelect,
formatAddressDisplay,
getInitialPickerLevel,
getLevelSearchPlaceholder,
getNextLevel,
isMunicipalityProvince,
levelOptionsToRows,
resolveAddressValue,
rowHasNextLevel,
searchAddressHits,
searchHitsToRows,
truncateContextToBreadcrumb,
} from '#/util/address-index';
defineOptions({
name: 'RegionAddressPicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: AddressValue | null;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: AddressValue | undefined];
}>();
const mValue = useVModel(props, 'value', emits, {
passive: true,
});
const open = ref(false);
const searchKeyword = ref('');
const currentLevel = ref<RegionLevel>('province');
const activeIndex = ref(0);
const listRef = ref<HTMLElement | null>(null);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const triggerWrapRef = ref<HTMLElement | null>(null);
const ctx = ref<SelectionContext>({
provinceId: undefined,
cityId: undefined,
districtId: undefined,
});
const displayText = computed(() => formatAddressDisplay(mValue.value));
const breadcrumbItems = computed(() => buildBreadcrumbFromContext(ctx.value));
const isSearching = computed(() => !!searchKeyword.value.trim());
const listRows = computed((): AddressListRow[] => {
if (isSearching.value) {
return searchHitsToRows(
searchAddressHits(searchKeyword.value, ctx.value),
);
}
return levelOptionsToRows(currentLevel.value, ctx.value);
});
const searchPlaceholder = computed(() =>
getLevelSearchPlaceholder(currentLevel.value),
);
function syncContextFromValue(val?: AddressValue | null) {
const resolved = resolveAddressValue(val ?? undefined);
ctx.value = {
provinceId: resolved.provinceId,
cityId: resolved.cityId,
districtId: resolved.districtId,
};
}
function resetPickerToValue(val?: AddressValue | null) {
syncContextFromValue(val);
const resolved = resolveAddressValue(val ?? undefined);
currentLevel.value = getInitialPickerLevel(resolved);
searchKeyword.value = '';
activeIndex.value = 0;
}
function resetActiveIndex() {
activeIndex.value = listRows.value.length > 0 ? 0 : -1;
}
function closePanel(output?: AddressValue) {
if (output !== undefined) {
mValue.value = output;
}
open.value = false;
searchKeyword.value = '';
}
function completeSelection() {
closePanel(
buildAddressOutput(
ctx.value.provinceId,
ctx.value.cityId,
ctx.value.districtId,
),
);
}
function applySelectionAtLevel(level: RegionLevel, item: AddressNode) {
if (level === 'province') {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
} else if (level === 'city') {
ctx.value = {
...ctx.value,
cityId: item.value,
districtId: undefined,
};
} else {
ctx.value = {
...ctx.value,
districtId: item.value,
};
}
}
function searchHitFromRow(row: AddressListRow): AddressSearchHit {
return {
level: row.level,
node: row.node,
pathLabel: row.label,
complete: row.complete,
};
}
function advanceAfterRowSelect(row: AddressListRow) {
searchKeyword.value = '';
if (row.complete) {
completeSelection();
return;
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
return;
}
completeSelection();
}
function onConfirmRow(row: AddressListRow) {
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
currentLevel.value = isMunicipalityProvince(hit.node.value)
? 'district'
: 'city';
searchKeyword.value = '';
resetActiveIndex();
return;
}
ctx.value = applySearchHit(hit, ctx.value);
advanceAfterRowSelect(row);
return;
}
onSelectItem(row.node, row.level);
}
function onSelectItem(item: AddressNode, level = currentLevel.value) {
if (level === 'province' && (ctx.value.cityId || ctx.value.districtId)) {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
searchKeyword.value = '';
currentLevel.value = isMunicipalityProvince(item.value)
? 'district'
: 'city';
resetActiveIndex();
return;
}
applySelectionAtLevel(level, item);
const row: AddressListRow = {
level,
node: item,
label: item.label,
complete: canCompleteAfterSelect(level, ctx.value, item),
};
advanceAfterRowSelect(row);
}
function confirmActiveItem() {
const rows = listRows.value;
if (activeIndex.value < 0 || activeIndex.value >= rows.length) {
return;
}
onConfirmRow(rows[activeIndex.value]!);
}
function drillActiveRow() {
const rows = listRows.value;
const row = rows[activeIndex.value];
if (!row || !rowHasNextLevel(row, ctx.value)) {
return;
}
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
} else {
ctx.value = applySearchHit(hit, ctx.value);
}
searchKeyword.value = '';
} else {
applySelectionAtLevel(row.level, row.node);
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
resetActiveIndex();
}
}
function goBackOneLevel() {
if (currentLevel.value === 'province') {
return;
}
if (currentLevel.value === 'district') {
if (isMunicipalityProvince(ctx.value.provinceId)) {
ctx.value = { provinceId: ctx.value.provinceId };
currentLevel.value = 'province';
} else {
ctx.value = {
provinceId: ctx.value.provinceId,
cityId: ctx.value.cityId,
};
currentLevel.value = 'city';
}
} else if (currentLevel.value === 'city') {
ctx.value = {};
currentLevel.value = 'province';
}
searchKeyword.value = '';
resetActiveIndex();
}
function scrollActiveIntoView() {
nextTick(() => {
const el = listRef.value?.querySelector(
`[data-index="${activeIndex.value}"]`,
);
el?.scrollIntoView({ block: 'nearest' });
});
}
function getPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (
(triggerNode.closest('[role="dialog"]') as HTMLElement | null) ??
document.body
);
}
function resolveSearchInputEl(): HTMLInputElement | null {
const inst = searchInputRef.value as
| (InstanceType<typeof Input> & { input?: HTMLInputElement })
| null;
if (inst?.input instanceof HTMLInputElement) {
return inst.input;
}
const scope =
(triggerWrapRef.value?.closest('[role="dialog"]') as HTMLElement | null) ??
document.body;
return scope.querySelector(
'.region-address-picker-popover .region-address-search input',
);
}
function focusSearchInput() {
const tryFocus = (): boolean => {
const inst = searchInputRef.value;
if (inst?.focus) {
inst.focus();
return true;
}
const input = resolveSearchInputEl();
if (input) {
input.focus();
return true;
}
return false;
};
const schedule = (attempt: number) => {
if (!open.value || attempt > 8) {
return;
}
if (tryFocus()) {
return;
}
requestAnimationFrame(() => {
if (tryFocus()) {
return;
}
const delay = attempt < 3 ? 0 : attempt < 6 ? 50 : 100;
setTimeout(() => schedule(attempt + 1), delay);
});
};
nextTick(() => schedule(0));
}
function handleKeydown(e: KeyboardEvent) {
if (!open.value) {
return;
}
const rows = listRows.value;
switch (e.key) {
case 'ArrowDown': {
if (rows.length === 0) {
return;
}
e.preventDefault();
if (activeIndex.value < 0) {
activeIndex.value = 0;
} else {
activeIndex.value = (activeIndex.value + 1) % rows.length;
}
scrollActiveIntoView();
break;
}
case 'ArrowUp': {
if (rows.length === 0) {
return;
}
e.preventDefault();
activeIndex.value =
activeIndex.value <= 0 ? rows.length - 1 : activeIndex.value - 1;
scrollActiveIntoView();
break;
}
case 'Enter': {
if (rows.length === 0) {
return;
}
e.preventDefault();
confirmActiveItem();
break;
}
case 'ArrowLeft': {
e.preventDefault();
goBackOneLevel();
break;
}
case 'ArrowRight': {
if (rows.length === 0 || activeIndex.value < 0) {
return;
}
e.preventDefault();
drillActiveRow();
break;
}
default:
break;
}
}
function onBreadcrumbClick(item: BreadcrumbItem) {
ctx.value = truncateContextToBreadcrumb(ctx.value, item);
if (item.level === 'province') {
currentLevel.value = isMunicipalityProvince(item.id) ? 'district' : 'city';
} else if (item.level === 'city') {
currentLevel.value = 'district';
} else {
currentLevel.value = 'district';
}
searchKeyword.value = '';
resetActiveIndex();
}
function goProvinceLevel() {
ctx.value = {};
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onClear() {
ctx.value = {};
mValue.value = undefined;
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onOpenChange(v: boolean) {
open.value = v;
if (v) {
resetPickerToValue(mValue.value);
}
}
watch(open, (v) => {
if (v) {
focusSearchInput();
}
}, { flush: 'post' });
watch(listRows, () => {
if (open.value) {
resetActiveIndex();
}
});
watch(
() => props.value,
(val) => {
if (!open.value) {
syncContextFromValue(val ?? undefined);
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<Popover
v-model:open="open"
:trigger="['click']"
placement="bottomLeft"
overlay-class-name="region-address-picker-popover"
:get-popup-container="getPopupContainer"
@open-change="onOpenChange"
>
<template #content>
<div class="region-address-panel" @click.stop>
<div v-if="breadcrumbItems.length" class="region-address-breadcrumb-row">
<div class="region-address-breadcrumb">
<template
v-for="(item, idx) in breadcrumbItems"
:key="`${item.level}-${item.id}`"
>
<span v-if="idx > 0" class="region-address-breadcrumb-sep">/</span>
<button
type="button"
class="region-address-breadcrumb-item"
@click="onBreadcrumbClick(item)"
>
{{ item.label }}
</button>
</template>
</div>
<button
type="button"
class="region-address-repick-province"
@click="goProvinceLevel"
>
重选省份
</button>
</div>
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
:placeholder="searchPlaceholder"
allow-clear
class="region-address-search"
@keydown="handleKeydown"
/>
<ul ref="listRef" class="region-address-list">
<li
v-for="(row, index) in listRows"
:key="`${row.level}-${row.node.value}`"
:class="[
'region-address-list-item',
{ 'region-address-list-item-active': index === activeIndex },
]"
:data-index="index"
@click="onConfirmRow(row)"
@mouseenter="activeIndex = index"
>
{{ row.label }}
</li>
<li v-if="listRows.length === 0" class="region-address-list-empty">
暂无数据
</li>
</ul>
</div>
</template>
<div
ref="triggerWrapRef"
class="region-address-trigger-wrap"
@click="!disabled && (open = true)"
>
<Input
:disabled="disabled"
:value="displayText"
class="region-address-trigger"
placeholder="请选择省市区"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span
class="region-address-clear"
@click.stop="onClear"
>×</span>
</template>
</Input>
</div>
</Popover>
</template>
<style scoped>
.region-address-trigger-wrap {
width: 100%;
}
.region-address-trigger {
width: 100%;
cursor: pointer;
}
.region-address-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: rgba(0, 0, 0, 0.35);
cursor: pointer;
font-size: 12px;
}
.region-address-clear:hover {
color: rgba(0, 0, 0, 0.65);
}
.region-address-panel {
width: 320px;
}
.region-address-breadcrumb-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.region-address-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
font-size: 13px;
}
.region-address-repick-province {
flex-shrink: 0;
padding: 0;
border: none;
background: none;
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.region-address-repick-province:hover {
color: #1677ff;
}
.region-address-breadcrumb-sep {
color: rgba(0, 0, 0, 0.35);
margin: 0 4px;
}
.region-address-breadcrumb-item {
padding: 0;
border: none;
background: none;
color: #1677ff;
cursor: pointer;
font-size: 13px;
}
.region-address-breadcrumb-item:hover {
color: #4096ff;
}
.region-address-search {
margin-bottom: 8px;
}
.region-address-list {
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.region-address-list-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.region-address-list-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.region-address-list-item-active {
background: #e6f4ff;
}
.region-address-list-item-active:hover {
background: #e6f4ff;
}
.region-address-list-empty {
padding: 16px 12px;
color: rgba(0, 0, 0, 0.45);
text-align: center;
}
/* 暗色模式适配(对齐 drug-search-select / menu-search-select */
.dark {
.region-address-clear {
color: rgba(255, 255, 255, 0.35);
}
.region-address-clear:hover {
color: rgba(255, 255, 255, 0.65);
}
.region-address-repick-province {
color: rgba(255, 255, 255, 0.45);
}
.region-address-repick-province:hover {
color: #60a5fa;
}
.region-address-breadcrumb-sep {
color: rgba(255, 255, 255, 0.35);
}
.region-address-breadcrumb-item {
color: #60a5fa;
}
.region-address-breadcrumb-item:hover {
color: #93c5fd;
}
.region-address-list-item {
color: #f3f4f6;
}
.region-address-list-item:hover {
background: #374151;
}
.region-address-list-item-active,
.region-address-list-item-active:hover {
background: #1e3a5f;
}
.region-address-list-empty {
color: #9ca3af;
}
}
</style>

View File

@@ -7,6 +7,10 @@ import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -163,6 +167,7 @@ const initSortable = () => {
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项用于在UI中显示上传状态
const uploadingFile: FileItem = {
@@ -192,7 +197,7 @@ const customRequest = async (options: any) => {
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: file as File,
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围0-100表示上传百分比
@@ -208,7 +213,7 @@ const customRequest = async (options: any) => {
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: file,
file: actualFile,
});
}
@@ -292,6 +297,7 @@ onMounted(() => {
<div ref="uploadListRef">
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"

View File

@@ -6,6 +6,10 @@ import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -97,6 +101,7 @@ const updateModelValue = () => {
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项用于在UI中显示上传状态
const uploadingFile: FileItem = {
@@ -126,7 +131,7 @@ const customRequest = async (options: any) => {
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: file as File,
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围0-100表示上传百分比
@@ -142,7 +147,7 @@ const customRequest = async (options: any) => {
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: file,
file: actualFile,
});
}
@@ -209,6 +214,7 @@ const showUploadButton = computed(() =>
<div>
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Button, Image, Modal, Spin } from 'ant-design-vue';
import {
compressImageFile,
formatFileSize,
formatSavingsPercent,
} from '#/utils/image-compress';
export type ImageCompressChoice = 'compressed' | 'original' | 'cancel';
export interface ImageCompressModalResult {
choice: ImageCompressChoice;
file: File | null;
meta?: {
originalSize: number;
compressedSize?: number;
usedCompress: boolean;
};
}
const props = defineProps<{
file: File;
}>();
const emit = defineEmits<{
resolve: [result: ImageCompressModalResult];
}>();
type ModalStatus = 'compressing' | 'ready' | 'gifSkipped' | 'error';
const visible = ref(true);
const status = ref<ModalStatus>('compressing');
const progressText = ref('正在压缩,请稍候…');
const errorMessage = ref('');
const originalPreviewUrl = ref('');
const compressedPreviewUrl = ref('');
const compressedFile = ref<File | null>(null);
const originalSize = ref(0);
const compressedSize = ref(0);
const sizeText = computed(() => {
if (!originalSize.value) return '';
const originalText = formatFileSize(originalSize.value);
if (status.value === 'gifSkipped') {
return `原图 ${originalText}`;
}
if (compressedSize.value && status.value === 'ready') {
const savings = formatSavingsPercent(
originalSize.value,
compressedSize.value,
);
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}${savings}`;
}
if (compressedSize.value) {
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}`;
}
return `原图 ${originalText}`;
});
const originalButtonLabel = computed(() => {
if (!originalSize.value) return '使用原图';
return `使用原图 (${formatFileSize(originalSize.value)})`;
});
const compressedButtonLabel = computed(() => {
if (!compressedSize.value) return '使用压缩图';
return `使用压缩图 (${formatFileSize(compressedSize.value)})`;
});
function revokePreviewUrls() {
if (originalPreviewUrl.value) {
URL.revokeObjectURL(originalPreviewUrl.value);
originalPreviewUrl.value = '';
}
if (compressedPreviewUrl.value) {
URL.revokeObjectURL(compressedPreviewUrl.value);
compressedPreviewUrl.value = '';
}
}
function finish(choice: ImageCompressChoice) {
const useCompressed = choice === 'compressed' && status.value === 'ready';
const selectedFile =
choice === 'cancel'
? null
: useCompressed && compressedFile.value
? compressedFile.value
: props.file;
emit('resolve', {
choice,
file: selectedFile,
meta:
choice === 'cancel'
? undefined
: {
originalSize: originalSize.value,
compressedSize: compressedSize.value || undefined,
usedCompress: useCompressed,
},
});
visible.value = false;
}
async function startCompress() {
originalSize.value = props.file.size;
revokePreviewUrls();
originalPreviewUrl.value = URL.createObjectURL(props.file);
if (props.file.type === 'image/gif') {
status.value = 'gifSkipped';
progressText.value = '';
return;
}
status.value = 'compressing';
progressText.value = '正在压缩,请稍候…';
try {
const result = await compressImageFile(props.file, {
onProgress: (message) => {
progressText.value = message;
},
});
compressedFile.value = result.file;
compressedSize.value = result.file.size;
if (result.skipped) {
status.value = 'gifSkipped';
return;
}
compressedPreviewUrl.value = URL.createObjectURL(result.file);
status.value = 'ready';
progressText.value = '';
} catch (error) {
status.value = 'error';
errorMessage.value =
error instanceof Error ? error.message : '图片压缩失败,请使用原图或取消';
progressText.value = '';
}
}
onMounted(() => {
startCompress();
});
onBeforeUnmount(() => {
revokePreviewUrls();
});
</script>
<template>
<Modal
v-model:visible="visible"
title="图片较大"
:width="640"
:mask-closable="false"
:closable="false"
:footer="null"
@cancel="finish('cancel')"
>
<div class="image-compress-modal">
<p v-if="sizeText" class="size-text">{{ sizeText }}</p>
<div v-if="status === 'compressing'" class="compressing-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<div class="compressing-status">
<Spin />
<span class="progress-text">{{ progressText }}</span>
</div>
</div>
<div v-else-if="status === 'ready'" class="compare-panel">
<div class="preview-column">
<div class="preview-label">原图</div>
<Image
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(originalSize) }}</div>
<span class="preview-hint">点击预览</span>
</div>
<div class="preview-column">
<div class="preview-label">压缩后</div>
<Image
:src="compressedPreviewUrl"
alt="压缩图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(compressedSize) }}</div>
<span class="preview-hint">点击预览</span>
</div>
</div>
<div v-else-if="status === 'gifSkipped'" class="gif-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<p class="hint-text">GIF 不支持压缩将上传原图</p>
</div>
<div v-else class="error-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<p class="error-text">{{ errorMessage }}</p>
</div>
<div class="footer-actions">
<Button @click="finish('cancel')">取消</Button>
<Button
v-if="status === 'ready' || status === 'error'"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
<Button
v-if="status === 'ready'"
type="primary"
@click="finish('compressed')"
>
{{ compressedButtonLabel }}
</Button>
<Button
v-if="status === 'gifSkipped'"
type="primary"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
</div>
</div>
</Modal>
</template>
<style lang="less" scoped>
.image-compress-modal {
.size-text {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
text-align: center;
}
.preview-single,
.preview-column {
display: flex;
flex-direction: column;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 220px;
cursor: pointer;
:deep(.ant-image-img) {
max-width: 100%;
max-height: 220px;
object-fit: contain;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
}
.preview-label {
margin-bottom: 8px;
font-weight: 500;
}
.preview-size {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.preview-hint {
margin-top: 4px;
color: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.compressing-panel,
.gif-panel,
.error-panel {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.compressing-status {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
}
.progress-text {
color: rgba(0, 0, 0, 0.65);
}
.compare-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.hint-text,
.error-text {
margin: 0;
color: rgba(0, 0, 0, 0.65);
text-align: center;
}
.error-text {
color: #ff4d4f;
}
.footer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
}
</style>

View File

@@ -36,17 +36,17 @@ export const overridesPreferences = defineOverridesPreferences({
version: '1.0.0',
/**
* 文件上传方式配置
*
*
* 可选值:
* - 'direct': OSS直传模式文件直接从浏览器上传到阿里云OSS推荐性能更好
* - 'backend': 后端上传模式文件先上传到后端服务器再由后端上传到OSS兼容模式
*
*
* 默认值:'direct'OSS直传
*
*
* 说明:
* - OSS直传模式减少服务器负载上传速度更快但需要后端提供签名接口
* - 后端上传模式:兼容现有功能,所有上传逻辑由后端处理
*
*
* 切换方式:
* - 可以通过修改此配置值来切换上传方式
* - 配置会持久化到本地存储,刷新页面后仍然有效
@@ -78,6 +78,6 @@ export const overridesPreferences = defineOverridesPreferences({
icpLink: 'https://beian.miit.gov.cn/#/Integrated/recordQuery',
},
tabbar: {
enable: false,
enable: true,
},
});

View File

@@ -2,6 +2,75 @@ import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout } from '#/layouts';
const adminRoleRoutes: RouteRecordRaw[] = [
{
meta: { title: '超级管理员' },
name: 'SystemAdminSuper',
path: '/system/admin/super',
component: () => import('#/views/system/admin/super/index.vue'),
},
{
meta: { title: '系统管理员' },
name: 'SystemAdminPlatform',
path: '/system/admin/platform',
component: () => import('#/views/system/admin/platform/index.vue'),
},
{
meta: { title: '省级管理员' },
name: 'SystemAdminProvince',
path: '/system/admin/province',
component: () => import('#/views/system/admin/province/index.vue'),
},
{
meta: { title: '市级管理员' },
name: 'SystemAdminCity',
path: '/system/admin/city',
component: () => import('#/views/system/admin/city/index.vue'),
},
{
meta: { title: '区级管理员' },
name: 'SystemAdminDistrict',
path: '/system/admin/district',
component: () => import('#/views/system/admin/district/index.vue'),
},
{
meta: { title: '业务员' },
name: 'SystemAdminSalesperson',
path: '/system/admin/salesperson',
component: () => import('#/views/system/admin/salesperson/index.vue'),
},
{
meta: { title: '供应商' },
name: 'SystemAdminSupplier',
path: '/system/admin/supplier',
component: () => import('#/views/system/admin/supplier/index.vue'),
},
{
meta: { title: '诊所管理员' },
name: 'SystemAdminClinic',
path: '/system/admin/clinic',
component: () => import('#/views/system/admin/clinic/index.vue'),
},
{
meta: { title: '诊所员工' },
name: 'SystemAdminClinicStaff',
path: '/system/admin/clinic-staff',
component: () => import('#/views/system/admin/clinic-staff/index.vue'),
},
{
meta: { title: '医生' },
name: 'SystemAdminDoctor',
path: '/system/admin/doctor',
component: () => import('#/views/system/admin/doctor/index.vue'),
},
{
meta: { title: '药师' },
name: 'SystemAdminPharmacist',
path: '/system/admin/pharmacist',
component: () => import('#/views/system/admin/pharmacist/index.vue'),
},
];
const routes: RouteRecordRaw[] = [
{
component: BasicLayout,
@@ -14,13 +83,12 @@ const routes: RouteRecordRaw[] = [
name: 'System',
path: '/system',
children: [
...adminRoleRoutes,
{
meta: {
title: '管理员管理',
},
meta: { title: '管理员管理' },
name: 'SystemUser',
path: '/system/user',
component: () => import('#/views/system/admin/index.vue'),
redirect: '/system/admin/platform',
},
{
meta: {

View File

@@ -0,0 +1,664 @@
import { addressOption } from '#/util/address';
export interface AddressNode {
value: number;
label: string;
pvalue?: number;
}
export interface ProvinceIndex {
province: AddressNode;
children: AddressNode[];
isMunicipality: boolean;
}
export interface ResolvedAddress {
provinceId?: number;
cityId?: number;
districtId?: number;
province?: AddressNode;
city?: AddressNode;
district?: AddressNode;
}
export type AddressValue = [number, number];
export type RegionLevel = 'province' | 'city' | 'district';
export interface BreadcrumbItem {
level: RegionLevel;
id: number;
label: string;
}
export interface SelectionContext {
provinceId?: number;
cityId?: number;
districtId?: number;
}
/** 跨级搜索结果 */
export interface AddressSearchHit {
level: RegionLevel;
node: AddressNode;
pathLabel: string;
complete: boolean;
}
/** 列表面板统一行 */
export interface AddressListRow {
level: RegionLevel;
node: AddressNode;
label: string;
complete: boolean;
}
const MUNICIPALITY_LABELS = new Set([
'北京市',
'天津市',
'上海市',
'重庆市',
]);
let provinceIndexCache: ProvinceIndex[] | null = null;
function isPrefectureCityLabel(label: string): boolean {
return label.endsWith('市') && !label.endsWith('自治区');
}
function isDistrictLikeLabel(label: string): boolean {
return /(?:区|县|旗)$/.test(label);
}
function detectMunicipality(province: AddressNode, children: AddressNode[]): boolean {
if (MUNICIPALITY_LABELS.has(province.label)) {
return true;
}
if (!province.label.endsWith('市') || children.length === 0) {
return false;
}
const districtCount = children.filter((c) => isDistrictLikeLabel(c.label)).length;
return districtCount / children.length >= 0.8;
}
function buildProvinceIndexList(): ProvinceIndex[] {
return (addressOption as AddressNode[]).map((province) => {
const children = (province as AddressNode & { children?: AddressNode[] })
.children ?? [];
return {
province: {
value: province.value,
label: province.label,
pvalue: province.pvalue,
},
children: children.map((c) => ({
value: c.value,
label: c.label,
pvalue: c.pvalue,
})),
isMunicipality: detectMunicipality(province, children),
};
});
}
function getProvinceIndexList(): ProvinceIndex[] {
if (!provinceIndexCache) {
provinceIndexCache = buildProvinceIndexList();
}
return provinceIndexCache;
}
function findProvinceIndex(provinceId?: number): ProvinceIndex | undefined {
if (!provinceId) {
return undefined;
}
return getProvinceIndexList().find((p) => p.province.value === provinceId);
}
function findChild(
provinceIndex: ProvinceIndex,
childId?: number,
): AddressNode | undefined {
if (!childId) {
return undefined;
}
return provinceIndex.children.find((c) => c.value === childId);
}
function getCityNamePrefix(label: string): string {
return label.replace(/市$/, '');
}
/** 省级列表 */
export function getProvinces(): AddressNode[] {
return getProvinceIndexList().map((p) => p.province);
}
/** 市级列表(直辖市返回空) */
export function getCities(provinceId?: number): AddressNode[] {
const index = findProvinceIndex(provinceId);
if (!index || index.isMunicipality) {
return [];
}
return index.children;
}
/** 区级列表 */
export function getDistricts(
provinceId?: number,
cityId?: number,
): AddressNode[] {
const index = findProvinceIndex(provinceId);
if (!index) {
return [];
}
if (index.isMunicipality) {
return index.children;
}
if (!cityId) {
return [];
}
const city = findChild(index, cityId);
if (!city || !isPrefectureCityLabel(city.label)) {
return [];
}
const prefix = getCityNamePrefix(city.label);
return index.children.filter(
(c) =>
c.value !== cityId &&
!isPrefectureCityLabel(c.label) &&
c.label.startsWith(prefix),
);
}
/** 当前层级候选列表 */
export function getLevelOptions(
level: RegionLevel,
ctx: SelectionContext,
): AddressNode[] {
if (level === 'province') {
return getProvinces();
}
if (level === 'city') {
return getCities(ctx.provinceId);
}
return getDistricts(ctx.provinceId, ctx.cityId);
}
/** 选择某级后的下一层级(无则 undefined */
export function getNextLevel(
currentLevel: RegionLevel,
ctx: SelectionContext,
): RegionLevel | undefined {
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return undefined;
}
if (currentLevel === 'province') {
return index.isMunicipality ? 'district' : 'city';
}
if (currentLevel === 'city') {
if (!ctx.cityId) {
return undefined;
}
const districts = getDistricts(ctx.provinceId, ctx.cityId);
return districts.length > 0 ? 'district' : undefined;
}
return undefined;
}
/** 当前层级选中后是否可直接完成(叶节点) */
export function canCompleteAfterSelect(
level: RegionLevel,
ctx: SelectionContext,
item: AddressNode,
): boolean {
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return false;
}
if (level === 'district') {
return true;
}
if (level === 'city') {
if (index.isMunicipality) {
return false;
}
if (!isPrefectureCityLabel(item.label)) {
return true;
}
return getDistricts(ctx.provinceId, item.value).length === 0;
}
return false;
}
/** 组装对外 address 值 */
export function buildAddressOutput(
provinceId?: number,
cityId?: number,
districtId?: number,
): AddressValue | undefined {
if (!provinceId) {
return undefined;
}
const leafId = districtId ?? cityId;
if (!leafId) {
return undefined;
}
return [provinceId, leafId];
}
/** 由 address 反查省/市/区 */
export function resolveAddressValue(
value?: AddressValue | null,
): ResolvedAddress {
if (!value || value.length < 2) {
return {};
}
const [provinceId, leafId] = value;
const index = findProvinceIndex(provinceId);
if (!index) {
return { provinceId };
}
const leaf = findChild(index, leafId);
if (!leaf) {
return { provinceId, province: index.province };
}
if (index.isMunicipality) {
return {
provinceId,
districtId: leafId,
province: index.province,
district: leaf,
};
}
const isLeafDistrict =
!isPrefectureCityLabel(leaf.label) && isDistrictLikeLabel(leaf.label);
if (isPrefectureCityLabel(leaf.label)) {
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
if (isLeafDistrict) {
for (const city of index.children.filter((c) =>
isPrefectureCityLabel(c.label),
)) {
const prefix = getCityNamePrefix(city.label);
if (leaf.label.startsWith(prefix)) {
return {
provinceId,
cityId: city.value,
districtId: leafId,
province: index.province,
city,
district: leaf,
};
}
}
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
/** 面包屑展示项 */
export function formatBreadcrumbItems(
resolved: ResolvedAddress,
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [];
if (resolved.provinceId && resolved.province) {
items.push({
level: 'province',
id: resolved.provinceId,
label: resolved.province.label,
});
}
const index = findProvinceIndex(resolved.provinceId);
if (index?.isMunicipality) {
if (resolved.districtId && resolved.district) {
items.push({
level: 'district',
id: resolved.districtId,
label: resolved.district.label,
});
}
return items;
}
if (resolved.cityId && resolved.city) {
items.push({
level: 'city',
id: resolved.cityId,
label: resolved.city.label,
});
}
if (resolved.districtId && resolved.district) {
items.push({
level: 'district',
id: resolved.districtId,
label: resolved.district.label,
});
}
return items;
}
/** 触发器展示文案 */
export function formatAddressDisplay(value?: AddressValue | null): string {
const items = formatBreadcrumbItems(resolveAddressValue(value));
return items.map((i) => i.label).join(' / ');
}
/** 当前层级搜索占位 */
export function getLevelSearchPlaceholder(level: RegionLevel): string {
const map: Record<RegionLevel, string> = {
province: '搜索省',
city: '搜索市/县',
district: '搜索区',
};
return map[level];
}
/** 模糊筛选 */
export function filterAddressOptions(
options: AddressNode[],
keyword: string,
): AddressNode[] {
const q = keyword.trim().toLowerCase();
if (!q) {
return options;
}
return options.filter((o) => o.label.toLowerCase().includes(q));
}
/** 从已选上下文生成面包屑(面板内) */
export function buildBreadcrumbFromContext(
ctx: SelectionContext,
): BreadcrumbItem[] {
return formatBreadcrumbItems({
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: ctx.districtId,
province: findProvinceIndex(ctx.provinceId)?.province,
city: findChild(findProvinceIndex(ctx.provinceId)!, ctx.cityId),
district: findChild(findProvinceIndex(ctx.provinceId)!, ctx.districtId),
});
}
/** 根据面板内选中生成 ResolvedAddress */
export function contextToResolved(ctx: SelectionContext): ResolvedAddress {
const index = findProvinceIndex(ctx.provinceId);
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: ctx.districtId,
province: index?.province,
city: index ? findChild(index, ctx.cityId) : undefined,
district: index ? findChild(index, ctx.districtId) : undefined,
};
}
/** 是否直辖市 */
export function isMunicipalityProvince(provinceId?: number): boolean {
return !!findProvinceIndex(provinceId)?.isMunicipality;
}
/** 打开面板时的初始层级(有回填则进入待选级,否则省) */
export function getInitialPickerLevel(
resolved: ResolvedAddress,
): RegionLevel {
const index = findProvinceIndex(resolved.provinceId);
if (!resolved.provinceId || !index) {
return 'province';
}
if (index.isMunicipality) {
return 'district';
}
if (!resolved.cityId) {
return 'city';
}
const districts = getDistricts(resolved.provinceId, resolved.cityId);
if (!resolved.districtId && districts.length > 0) {
return 'district';
}
return 'city';
}
/** 当前级候选转列表行 */
export function levelOptionsToRows(
level: RegionLevel,
ctx: SelectionContext,
): AddressListRow[] {
return getLevelOptions(level, ctx).map((node) => ({
level,
node,
label: node.label,
complete: canCompleteAfterSelect(
level,
level === 'city'
? { provinceId: ctx.provinceId, cityId: node.value }
: level === 'district'
? {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: node.value,
}
: ctx,
node,
),
}));
}
/** 跨级模糊搜索(含第三级区/县) */
export function searchAddressHits(
keyword: string,
ctx: SelectionContext,
): AddressSearchHit[] {
const q = keyword.trim().toLowerCase();
if (!q) {
return [];
}
const hits: AddressSearchHit[] = [];
const seen = new Set<string>();
const push = (hit: AddressSearchHit) => {
const key = `${hit.level}-${hit.node.value}`;
if (!seen.has(key)) {
seen.add(key);
hits.push(hit);
}
};
if (!ctx.provinceId) {
for (const p of getProvinces()) {
if (p.label.toLowerCase().includes(q)) {
push({
level: 'province',
node: p,
pathLabel: p.label,
complete: false,
});
}
}
return hits;
}
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return hits;
}
if (index.isMunicipality) {
for (const d of index.children) {
if (d.label.toLowerCase().includes(q)) {
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
const cityNode = ctx.cityId ? findChild(index, ctx.cityId) : undefined;
if (!ctx.cityId) {
for (const child of index.children) {
if (!child.label.toLowerCase().includes(q)) {
continue;
}
const rowCtx = { provinceId: ctx.provinceId, cityId: child.value };
push({
level: 'city',
node: child,
pathLabel: `${index.province.label} / ${child.label}`,
complete: canCompleteAfterSelect('city', rowCtx, child),
});
}
for (const child of index.children.filter((c) => isPrefectureCityLabel(c.label))) {
for (const d of getDistricts(ctx.provinceId, child.value)) {
if (!d.label.toLowerCase().includes(q)) {
continue;
}
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${child.label} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
if (cityNode && cityNode.label.toLowerCase().includes(q)) {
push({
level: 'city',
node: cityNode,
pathLabel: `${index.province.label} / ${cityNode.label}`,
complete: canCompleteAfterSelect('city', ctx, cityNode),
});
}
for (const d of getDistricts(ctx.provinceId, ctx.cityId)) {
if (d.label.toLowerCase().includes(q)) {
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${cityNode?.label ?? ''} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
/** 搜索命中转列表行 */
export function searchHitsToRows(hits: AddressSearchHit[]): AddressListRow[] {
return hits.map((hit) => ({
level: hit.level,
node: hit.node,
label: hit.pathLabel,
complete: hit.complete,
}));
}
/** 应用跨级搜索选中 */
export function applySearchHit(
hit: AddressSearchHit,
ctx: SelectionContext,
): SelectionContext {
if (hit.level === 'province') {
return {
provinceId: hit.node.value,
cityId: undefined,
districtId: undefined,
};
}
if (hit.level === 'city') {
return {
provinceId: ctx.provinceId,
cityId: hit.node.value,
districtId: undefined,
};
}
if (hit.level === 'district' && findProvinceIndex(ctx.provinceId)?.isMunicipality) {
return {
provinceId: ctx.provinceId,
cityId: undefined,
districtId: hit.node.value,
};
}
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: hit.node.value,
};
}
/** 高亮项是否有下一级 */
export function rowHasNextLevel(
row: AddressListRow,
ctx: SelectionContext,
): boolean {
if (row.level === 'province') {
return !!getNextLevel('province', {
provinceId: row.node.value,
cityId: undefined,
districtId: undefined,
});
}
if (row.level === 'city') {
return !!getNextLevel('city', {
provinceId: ctx.provinceId,
cityId: row.node.value,
districtId: undefined,
});
}
return false;
}
/** 点击面包屑项后截断选中并返回新上下文 */
export function truncateContextToBreadcrumb(
ctx: SelectionContext,
item: BreadcrumbItem,
): SelectionContext {
if (item.level === 'province') {
return {
provinceId: item.id,
cityId: undefined,
districtId: undefined,
};
}
if (item.level === 'city') {
return {
provinceId: ctx.provinceId,
cityId: item.id,
districtId: undefined,
};
}
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: item.id,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,149 @@
export const IMAGE_COMPRESS_THRESHOLD = 0.8 * 1024 * 1024;
export interface CompressImageOptions {
maxBytes?: number;
maxWidth?: number;
minQuality?: number;
initialQuality?: number;
onProgress?: (message: string) => void;
}
export interface CompressImageResult {
file: File;
skipped: boolean;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${Number.parseFloat(value.toFixed(2))} ${sizes[i]}`;
}
/** 压缩节省比例文案,如「约节省 42%」 */
export function formatSavingsPercent(
originalBytes: number,
compressedBytes: number,
): string {
if (originalBytes <= 0 || compressedBytes >= originalBytes) {
return '';
}
const percent = Math.round(
((originalBytes - compressedBytes) / originalBytes) * 100,
);
return percent > 0 ? `(约节省 ${percent}%` : '';
}
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片加载失败'));
};
img.src = url;
});
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('图片压缩失败'));
}
},
type,
quality,
);
});
}
function getOutputType(file: File): string {
if (file.type === 'image/png' || file.type === 'image/webp') {
return file.type;
}
return 'image/jpeg';
}
function buildCompressedFile(
blob: Blob,
originalFile: File,
outputType: string,
): File {
const ext = outputType === 'image/png' ? '.png' : '.jpg';
const baseName = originalFile.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${baseName}${ext}`, {
type: outputType,
lastModified: Date.now(),
});
}
export async function compressImageFile(
file: File,
options: CompressImageOptions = {},
): Promise<CompressImageResult> {
const {
maxBytes = IMAGE_COMPRESS_THRESHOLD,
maxWidth = 1920,
minQuality = 0.5,
initialQuality = 0.85,
onProgress,
} = options;
if (file.type === 'image/gif') {
return { file, skipped: true };
}
onProgress?.('正在加载图片…');
const img = await loadImageFromFile(file);
let width = img.naturalWidth;
let height = img.naturalHeight;
if (width > maxWidth || height > maxWidth) {
if (width >= height) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
} else {
width = Math.round((width * maxWidth) / height);
height = maxWidth;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建 Canvas 上下文');
}
ctx.drawImage(img, 0, 0, width, height);
const outputType = getOutputType(file);
let quality = initialQuality;
onProgress?.(`正在压缩(质量 ${Math.round(quality * 100)}%)…`);
let blob = await canvasToBlob(canvas, outputType, quality);
while (blob.size > maxBytes && quality > minQuality) {
quality = Math.max(minQuality, quality - 0.1);
onProgress?.(`正在压缩(质量 ${Math.round(quality * 100)}%)…`);
blob = await canvasToBlob(canvas, outputType, quality);
}
const compressedFile = buildCompressedFile(blob, file, outputType);
return { file: compressedFile, skipped: false };
}

View File

@@ -0,0 +1,45 @@
import { createApp, h } from 'vue';
import ImageCompressModal, {
type ImageCompressModalResult,
} from '#/components/modal/ImageCompressModal.vue';
import type { PrepareImageUploadMeta } from '#/utils/prepare-image-upload';
export interface OpenImageCompressModalResult {
file: File | null;
meta?: PrepareImageUploadMeta;
}
export function openImageCompressModal(
file: File,
): Promise<OpenImageCompressModalResult> {
return new Promise((resolve) => {
const container = document.createElement('div');
document.body.append(container);
const app = createApp({
render() {
return h(ImageCompressModal, {
file,
onResolve: (result: ImageCompressModalResult) => {
app.unmount();
container.remove();
if (result.choice === 'cancel' || !result.file) {
resolve({ file: null });
return;
}
resolve({
file: result.file,
meta: result.meta,
});
},
});
},
});
app.mount(container);
});
}

View File

@@ -0,0 +1,24 @@
import { IMAGE_COMPRESS_THRESHOLD } from '#/utils/image-compress';
import { openImageCompressModal } from '#/utils/open-image-compress-modal';
export interface PrepareImageUploadMeta {
originalSize: number;
compressedSize?: number;
usedCompress: boolean;
}
export async function prepareImageForUpload(
file: File,
): Promise<{ file: File | null; meta?: PrepareImageUploadMeta }> {
if (file.size <= IMAGE_COMPRESS_THRESHOLD) {
return {
file,
meta: {
originalSize: file.size,
usedCompress: false,
},
};
}
return openImageCompressModal(file);
}

View File

@@ -0,0 +1,23 @@
import { Upload } from 'ant-design-vue';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
const pendingFiles = new Map<string, File>();
export async function beforeImageUpload(file: File) {
const { file: prepared } = await prepareImageForUpload(file);
if (!prepared) {
return Upload.LIST_IGNORE;
}
if (prepared !== file) {
pendingFiles.set(file.uid, prepared);
}
return true;
}
export function resolveUploadFile(file: File & { uid?: string }): File {
const uid = file.uid ?? '';
const actualFile = pendingFiles.get(uid) ?? file;
pendingFiles.delete(uid);
return actualFile;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

45
pnpm-lock.yaml generated
View File

@@ -656,6 +656,9 @@ importers:
pinia:
specifier: 2.2.2
version: 2.2.2(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))
sortablejs:
specifier: 'catalog:'
version: 1.15.6
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.7.2)
@@ -665,6 +668,10 @@ importers:
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@types/sortablejs':
specifier: 'catalog:'
version: 1.15.8
internal/lint-configs/commitlint-config:
dependencies:
@@ -3261,21 +3268,21 @@ packages:
resolution: {integrity: sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==}
engines: {node: '>= 16'}
'@intlify/message-compiler@12.0.0-alpha.3':
resolution: {integrity: sha512-mDDTN3gfYOHhBnpnlby19UHyvMaOnzdlpsIrxUfs44R/vCATfn8pMOkE8PXD2t410xkocEj3FpDcC9XC/0v4Dg==}
engines: {node: '>= 16'}
'@intlify/message-compiler@12.0.0-alpha.4':
resolution: {integrity: sha512-F4yHuJzI2ZXtXTW2B1VhuNULAOjR5bniFSC/Z0rLWRYPO43pVgKLu+FPDoJq13OjTFq4X5hx2s855M4gqPU/pA==}
engines: {node: '>= 22'}
'@intlify/shared@10.0.5':
resolution: {integrity: sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==}
engines: {node: '>= 16'}
'@intlify/shared@11.3.0':
resolution: {integrity: sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==}
engines: {node: '>= 16'}
'@intlify/shared@11.4.4':
resolution: {integrity: sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==}
engines: {node: '>= 22'}
'@intlify/shared@12.0.0-alpha.3':
resolution: {integrity: sha512-ryaNYBvxQjyJUmVuBBg+HHUsmGnfxcEUPR0NCeG4/K9N2qtyFE35C80S15IN6iYFE2MGWLN7HfOSyg0MXZIc9w==}
engines: {node: '>= 16'}
'@intlify/shared@12.0.0-alpha.4':
resolution: {integrity: sha512-MfunEN3/yQjD1Q9HnD4Qtvg/qLYnR+tK3mEz/EEvikJO+Hu8ICe7Rqh6uusLSjpWy6ECVt2p5HGXlYA12UBweA==}
engines: {node: '>= 22'}
'@intlify/unplugin-vue-i18n@6.0.1':
resolution: {integrity: sha512-zDcGLNoaIP15JM4TGwgTHF01Y1Drwcv7pm9C2mHrGAZ3CugqyP2QEG0Vf82QVSNqgEwgB6prcAyDmjIDK1HlRQ==}
@@ -11776,8 +11783,8 @@ snapshots:
'@intlify/bundle-utils@10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))':
dependencies:
'@intlify/message-compiler': 12.0.0-alpha.3
'@intlify/shared': 12.0.0-alpha.3
'@intlify/message-compiler': 12.0.0-alpha.4
'@intlify/shared': 12.0.0-alpha.4
acorn: 8.14.0
escodegen: 2.1.0
estree-walker: 2.0.2
@@ -11798,23 +11805,23 @@ snapshots:
'@intlify/shared': 10.0.5
source-map-js: 1.2.1
'@intlify/message-compiler@12.0.0-alpha.3':
'@intlify/message-compiler@12.0.0-alpha.4':
dependencies:
'@intlify/shared': 12.0.0-alpha.3
'@intlify/shared': 12.0.0-alpha.4
source-map-js: 1.2.1
'@intlify/shared@10.0.5': {}
'@intlify/shared@11.3.0': {}
'@intlify/shared@11.4.4': {}
'@intlify/shared@12.0.0-alpha.3': {}
'@intlify/shared@12.0.0-alpha.4': {}
'@intlify/unplugin-vue-i18n@6.0.1(@vue/compiler-dom@3.5.13)(eslint@9.17.0(jiti@2.4.2))(rollup@4.28.1)(typescript@5.7.2)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
'@intlify/bundle-utils': 10.0.0(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))
'@intlify/shared': 11.3.0
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.3.0)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
'@intlify/shared': 11.4.4
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.4.4)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
'@rollup/pluginutils': 5.1.4(rollup@4.28.1)
'@typescript-eslint/scope-manager': 8.18.1
'@typescript-eslint/typescript-estree': 8.18.1(typescript@5.7.2)
@@ -11836,11 +11843,11 @@ snapshots:
- supports-color
- typescript
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.3.0)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.4.4)(@vue/compiler-dom@3.5.13)(vue-i18n@10.0.5(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))':
dependencies:
'@babel/parser': 7.26.3
optionalDependencies:
'@intlify/shared': 11.3.0
'@intlify/shared': 11.4.4
'@vue/compiler-dom': 3.5.13
vue: 3.5.13(typescript@5.7.2)
vue-i18n: 10.0.5(vue@3.5.13(typescript@5.7.2))