1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal file
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
664
apps/web-antd/src/util/address-index.ts
Normal file
664
apps/web-antd/src/util/address-index.ts
Normal 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
149
apps/web-antd/src/utils/image-compress.ts
Normal file
149
apps/web-antd/src/utils/image-compress.ts
Normal 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 };
|
||||
}
|
||||
45
apps/web-antd/src/utils/open-image-compress-modal.ts
Normal file
45
apps/web-antd/src/utils/open-image-compress-modal.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
24
apps/web-antd/src/utils/prepare-image-upload.ts
Normal file
24
apps/web-antd/src/utils/prepare-image-upload.ts
Normal 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);
|
||||
}
|
||||
23
apps/web-antd/src/utils/use-image-upload-pending.ts
Normal file
23
apps/web-antd/src/utils/use-image-upload-pending.ts
Normal 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;
|
||||
}
|
||||
@@ -19,75 +19,18 @@ const authStore = useAuthStore();
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
const loading = ref(false);
|
||||
const loginMethod = ref<'phone' | 'login_account' | 'job_number'>('phone');
|
||||
const needSelectAccount = ref(false);
|
||||
const accountList = ref<Array<{ id: number; login_account: string; job_number: string }>>([]);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
const schema: VbenFormSchema[] = [
|
||||
// 1. Radio选择组(最上面)
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
// optionType: 'button',
|
||||
options: [
|
||||
{ label: '手机号登录', value: 'phone' },
|
||||
{ label: '登录账号登录', value: 'login_account' },
|
||||
{ label: '工号登录', value: 'job_number' },
|
||||
],
|
||||
},
|
||||
fieldName: 'login_method',
|
||||
label: '登录方式',
|
||||
rules: z.string().min(1, { message: '请选择登录方式' }),
|
||||
defaultValue: 'phone',
|
||||
dependencies: {
|
||||
trigger(values, form) {
|
||||
loginMethod.value = values.login_method;
|
||||
// 切换登录方式时清空输入框
|
||||
form.setValues({ login_value: '' });
|
||||
},
|
||||
triggerFields: ['login_method'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 2. 统一的登录输入框(根据登录方式动态更新 label 和 placeholder)
|
||||
const loginMethodValue = loginMethod.value;
|
||||
let loginLabel = '';
|
||||
let loginPlaceholder = '';
|
||||
let loginRules: any;
|
||||
|
||||
if (loginMethodValue === 'phone') {
|
||||
loginLabel = $t('authentication.username');
|
||||
loginPlaceholder = $t('authentication.usernameTip');
|
||||
loginRules = z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.mobileTip') })
|
||||
.refine((v) => /^\d{11}$/.test(v), {
|
||||
message: $t('authentication.mobileErrortip'),
|
||||
});
|
||||
} else if (loginMethodValue === 'login_account') {
|
||||
loginLabel = '登录账号';
|
||||
loginPlaceholder = '请输入登录账号';
|
||||
loginRules = z.string().min(1, { message: '请输入登录账号' });
|
||||
} else if (loginMethodValue === 'job_number') {
|
||||
loginLabel = '工号';
|
||||
loginPlaceholder = '请输入工号';
|
||||
loginRules = z.string().min(1, { message: '请输入工号' });
|
||||
}
|
||||
|
||||
schema.push({
|
||||
const formSchema = computed((): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: loginPlaceholder,
|
||||
placeholder: '手机号 / 登录账号 / 工号',
|
||||
},
|
||||
fieldName: 'login_value',
|
||||
label: loginLabel,
|
||||
rules: loginRules,
|
||||
});
|
||||
|
||||
// 3. 密码输入框(常驻)
|
||||
schema.push({
|
||||
fieldName: 'account',
|
||||
label: '账号',
|
||||
rules: z.string().min(1, { message: '请输入账号' }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.password'),
|
||||
@@ -95,10 +38,8 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
});
|
||||
|
||||
// 4. 验证码输入框(常驻)
|
||||
schema.push({
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
@@ -116,54 +57,28 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
throw new Error('formApi is not ready');
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const loginMethodValue = values.login_method || 'phone';
|
||||
const loginValue = values.login_value || '';
|
||||
|
||||
try {
|
||||
// 统一验证 login_value 字段
|
||||
await formApi.validateField('login_value');
|
||||
const isValid = await formApi.isFieldValid('login_value');
|
||||
if (!isValid) {
|
||||
await formApi.validateField('account');
|
||||
const accountValid = await formApi.isFieldValid('account');
|
||||
if (!accountValid) {
|
||||
loading.value = false;
|
||||
const errorMsg = loginMethodValue === 'phone'
|
||||
? '手机号格式不正确'
|
||||
: loginMethodValue === 'login_account'
|
||||
? '登录账号不能为空'
|
||||
: '工号不能为空';
|
||||
throw new Error(errorMsg);
|
||||
throw new Error('请输入账号');
|
||||
}
|
||||
|
||||
const password = await formApi.isFieldValid('password');
|
||||
if (!password) {
|
||||
const passwordValid = await formApi.isFieldValid('password');
|
||||
if (!passwordValid) {
|
||||
loading.value = false;
|
||||
throw new Error('密码不符合要求');
|
||||
}
|
||||
|
||||
// 根据登录方式组装参数
|
||||
const params: any = {
|
||||
login_method: loginMethodValue,
|
||||
};
|
||||
if (loginMethodValue === 'phone') {
|
||||
params.username = loginValue;
|
||||
} else if (loginMethodValue === 'login_account') {
|
||||
params.login_account = loginValue;
|
||||
} else if (loginMethodValue === 'job_number') {
|
||||
params.job_number = loginValue;
|
||||
}
|
||||
|
||||
const result = await sendVerificationCode(params);
|
||||
const values = await formApi.getValues();
|
||||
const result = await sendVerificationCode({
|
||||
account: values.account,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (result.need_select && result.accounts && result.accounts.length > 1) {
|
||||
needSelectAccount.value = true;
|
||||
accountList.value = result.accounts;
|
||||
message.warning('该手机号绑定了多个账号,请选择登录账号或工号');
|
||||
} else {
|
||||
needSelectAccount.value = false;
|
||||
message.success('发送成功,请注意查收');
|
||||
}
|
||||
// 开发环境显示验证码
|
||||
message.success('发送成功,请注意查收');
|
||||
if (result.code) {
|
||||
message.info(`验证码:${result.code}`, 10);
|
||||
}
|
||||
@@ -181,50 +96,21 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
return schema;
|
||||
});
|
||||
|
||||
// 自定义提交处理
|
||||
async function handleLogin(values: Recordable<any>) {
|
||||
const loginMethodValue = values.login_method || 'phone';
|
||||
const loginValue = values.login_value || '';
|
||||
|
||||
// 验证必填字段
|
||||
if (loginMethodValue === 'phone') {
|
||||
if (!loginValue || !/^\d{11}$/.test(loginValue)) {
|
||||
message.error('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
} else if (loginMethodValue === 'login_account') {
|
||||
if (!loginValue || loginValue.trim() === '') {
|
||||
message.error('请输入登录账号');
|
||||
return;
|
||||
}
|
||||
} else if (loginMethodValue === 'job_number') {
|
||||
if (!loginValue || loginValue.trim() === '') {
|
||||
message.error('请输入工号');
|
||||
return;
|
||||
}
|
||||
const account = (values.account || '').trim();
|
||||
if (!account) {
|
||||
message.error('请输入账号');
|
||||
return;
|
||||
}
|
||||
|
||||
const loginData: any = {
|
||||
login_method: loginMethodValue,
|
||||
await authStore.authLogin({
|
||||
account,
|
||||
password: values.password,
|
||||
code: values.code,
|
||||
};
|
||||
|
||||
// 根据登录方式组装参数
|
||||
if (loginMethodValue === 'phone') {
|
||||
loginData.phone = loginValue;
|
||||
} else if (loginMethodValue === 'login_account') {
|
||||
loginData.login_account = loginValue;
|
||||
} else if (loginMethodValue === 'job_number') {
|
||||
loginData.job_number = loginValue;
|
||||
}
|
||||
|
||||
await authStore.authLogin(loginData);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
formatFileSize(uploadPreview.size)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="uploadPreview.compressMeta?.usedCompress"
|
||||
class="text-xs text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
已压缩:{{ formatFileSize(uploadPreview.compressMeta.originalSize) }}
|
||||
→ {{ formatFileSize(uploadPreview.compressMeta.compressedSize) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
|
||||
@@ -43,6 +43,7 @@ const QuickReplyBubbles = computed(() => {
|
||||
import CustomTextarea from './CustomTextarea.vue';
|
||||
import EmojiPicker from './EmojiPicker.vue';
|
||||
import FileUploadPreview from './FileUploadPreview.vue';
|
||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
@@ -226,7 +227,7 @@ const handlePasteFile = (file) => {
|
||||
};
|
||||
|
||||
// 处理粘贴的文件
|
||||
const handleFileFromPaste = (file, type) => {
|
||||
const handleFileFromPaste = async (file, type) => {
|
||||
// 文件大小检查
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
@@ -237,14 +238,27 @@ const handleFileFromPaste = (file, type) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadFile = file;
|
||||
let compressMeta = null;
|
||||
|
||||
if (type === 'image') {
|
||||
const prepared = await prepareImageForUpload(file);
|
||||
if (!prepared.file) {
|
||||
return;
|
||||
}
|
||||
uploadFile = prepared.file;
|
||||
compressMeta = prepared.meta;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (e) => {
|
||||
uploadPreview.value = {
|
||||
type,
|
||||
url: e.target.result,
|
||||
name: file.nick_name,
|
||||
size: file.size,
|
||||
file,
|
||||
name: file.name,
|
||||
size: uploadFile.size,
|
||||
file: uploadFile,
|
||||
compressMeta,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {nextTick, ref} from 'vue';
|
||||
|
||||
import {message} from 'ant-design-vue';
|
||||
import {uploadChatFile} from "#/api/core/upload";
|
||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadPreview = ref(null);
|
||||
@@ -23,7 +24,7 @@ export function useFileUpload() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
const handleFileUpload = async (event) => {
|
||||
if (!event.target.files || event.target.files.length === 0) return;
|
||||
|
||||
const file = event.target.files[0];
|
||||
@@ -81,15 +82,29 @@ export function useFileUpload() {
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadFile = file;
|
||||
let compressMeta = null;
|
||||
|
||||
if (detectedType === 'image') {
|
||||
const prepared = await prepareImageForUpload(file);
|
||||
if (!prepared.file) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
uploadFile = prepared.file;
|
||||
compressMeta = prepared.meta;
|
||||
}
|
||||
|
||||
uploadChatFile({
|
||||
file,
|
||||
file: uploadFile,
|
||||
}).then((res) => {
|
||||
uploadPreview.value = {
|
||||
type: detectedType,
|
||||
url: res.url,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
file,
|
||||
size: uploadFile.size,
|
||||
file: uploadFile,
|
||||
compressMeta,
|
||||
};
|
||||
});
|
||||
// const reader = new FileReader();
|
||||
|
||||
@@ -15,8 +15,6 @@ import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
CollapsePanel,
|
||||
Empty,
|
||||
message,
|
||||
Row,
|
||||
@@ -126,6 +124,13 @@ function handleSelectPrescription(
|
||||
modalApi.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 空值占位
|
||||
*/
|
||||
function formatField(value?: string | null): string {
|
||||
return value?.trim() ? value : '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取药品名称列表(用于展示)
|
||||
* @param recipes 药品列表
|
||||
@@ -164,8 +169,8 @@ function getDrugCount(recipes: any[]): number {
|
||||
<Row :gutter="16">
|
||||
<!-- 西药常用方 - 只在西药Tab时显示 -->
|
||||
<Col v-if="currentType === 2" :span="24" class="mb-6">
|
||||
<div class="rounded-lg border border-border p-4 mb-4">
|
||||
<h3 class="flex items-center gap-2 mb-4 text-base font-semibold">
|
||||
<div class="mb-4 rounded-lg border border-border p-4">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold">
|
||||
<Tag color="blue">西药</Tag>
|
||||
西药常用方
|
||||
<span class="text-sm font-normal text-muted-foreground"
|
||||
@@ -180,58 +185,58 @@ function getDrugCount(recipes: any[]): number {
|
||||
commonPrescriptionData.west_prescription &&
|
||||
commonPrescriptionData.west_prescription.length > 0
|
||||
"
|
||||
class="max-h-[60vh] overflow-y-auto pr-1"
|
||||
>
|
||||
<Collapse>
|
||||
<CollapsePanel
|
||||
v-for="(item, index) in commonPrescriptionData.west_prescription"
|
||||
:key="item.id"
|
||||
<div
|
||||
v-for="(item, index) in commonPrescriptionData.west_prescription"
|
||||
:key="item.id"
|
||||
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="font-medium">{{
|
||||
item.name || `西药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="blue" class="ml-auto mr-4"
|
||||
>{{
|
||||
getDrugCount(commonPrescriptionData.west[index])
|
||||
}}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<p v-if="item.clinical_diagnose" class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
<span>{{ item.clinical_diagnose }}</span>
|
||||
</p>
|
||||
<p v-if="item.doctor_order" class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
<span>{{ item.doctor_order }}</span>
|
||||
</p>
|
||||
<p class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
<span class="text-primary">{{
|
||||
getDrugNames(commonPrescriptionData.west[index])
|
||||
}}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end mt-4 pt-4 border-t border-border">
|
||||
<Button
|
||||
type="primary"
|
||||
@click="
|
||||
handleSelectPrescription(
|
||||
item,
|
||||
commonPrescriptionData.west[index],
|
||||
1,
|
||||
)
|
||||
"
|
||||
>
|
||||
使用此常用方
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.name || `西药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="blue"
|
||||
>{{
|
||||
getDrugCount(commonPrescriptionData.west[index])
|
||||
}}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleSelectPrescription(
|
||||
item,
|
||||
commonPrescriptionData.west[index],
|
||||
1,
|
||||
)
|
||||
"
|
||||
>
|
||||
使用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
<span>{{ formatField(item.clinical_diagnose) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
<span>{{ formatField(item.doctor_order) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
<span class="text-primary">{{
|
||||
getDrugNames(commonPrescriptionData.west[index])
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Empty v-else description="暂无西药常用方" />
|
||||
@@ -240,8 +245,8 @@ function getDrugCount(recipes: any[]): number {
|
||||
|
||||
<!-- 中药常用方 - 只在中药Tab时显示 -->
|
||||
<Col v-if="currentType === 1" :span="24" class="mb-6">
|
||||
<div class="rounded-lg border border-border p-4 mb-4">
|
||||
<h3 class="flex items-center gap-2 mb-4 text-base font-semibold">
|
||||
<div class="mb-4 rounded-lg border border-border p-4">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold">
|
||||
<Tag color="green">中药</Tag>
|
||||
中药常用方
|
||||
<span class="text-sm font-normal text-muted-foreground"
|
||||
@@ -256,78 +261,74 @@ function getDrugCount(recipes: any[]): number {
|
||||
commonPrescriptionData.chin_prescription &&
|
||||
commonPrescriptionData.chin_prescription.length > 0
|
||||
"
|
||||
class="max-h-[60vh] overflow-y-auto pr-1"
|
||||
>
|
||||
<Collapse>
|
||||
<CollapsePanel
|
||||
v-for="(
|
||||
item, index
|
||||
) in commonPrescriptionData.chin_prescription"
|
||||
:key="item.id"
|
||||
<div
|
||||
v-for="(item, index) in commonPrescriptionData.chin_prescription"
|
||||
:key="item.id"
|
||||
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="font-medium">{{
|
||||
item.name || `中药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="green" class="ml-auto mr-4"
|
||||
>{{
|
||||
getDrugCount(
|
||||
commonPrescriptionData.chinese[index],
|
||||
)
|
||||
}}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<p v-if="item.clinical_diagnose" class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
<span>{{ item.clinical_diagnose }}</span>
|
||||
</p>
|
||||
<p v-if="item.doctor_order" class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
<span>{{ item.doctor_order }}</span>
|
||||
</p>
|
||||
<p class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">调配方式:</span>
|
||||
<span>{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}</span>
|
||||
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
|
||||
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
|
||||
</p>
|
||||
<p v-if="item.rule_type === 2" class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">委托调剂:</span>
|
||||
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
|
||||
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
|
||||
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
|
||||
</p>
|
||||
<p class="mb-2 leading-relaxed">
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
<span class="text-primary">{{
|
||||
getDrugNames(
|
||||
commonPrescriptionData.chinese[index],
|
||||
'drug_name',
|
||||
true,
|
||||
)
|
||||
}}</span>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end mt-4 pt-4 border-t border-border">
|
||||
<Button
|
||||
type="primary"
|
||||
@click="
|
||||
handleSelectPrescription(
|
||||
item,
|
||||
commonPrescriptionData.chinese[index],
|
||||
2,
|
||||
)
|
||||
"
|
||||
>
|
||||
使用此常用方
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.name || `中药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="green"
|
||||
>{{
|
||||
getDrugCount(commonPrescriptionData.chinese[index])
|
||||
}}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleSelectPrescription(
|
||||
item,
|
||||
commonPrescriptionData.chinese[index],
|
||||
2,
|
||||
)
|
||||
"
|
||||
>
|
||||
使用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
<span>{{ formatField(item.clinical_diagnose) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
<span>{{ formatField(item.doctor_order) }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">调配方式:</span>
|
||||
<span>{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}</span>
|
||||
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
|
||||
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
|
||||
</p>
|
||||
<p v-if="item.rule_type === 2">
|
||||
<span class="font-medium text-muted-foreground">委托调剂:</span>
|
||||
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
|
||||
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
|
||||
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
<span class="text-primary">{{
|
||||
getDrugNames(
|
||||
commonPrescriptionData.chinese[index],
|
||||
'drug_name',
|
||||
true,
|
||||
)
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Empty v-else description="暂无中药常用方" />
|
||||
@@ -339,21 +340,3 @@ function getDrugCount(recipes: any[]): number {
|
||||
</Page>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-collapse-item {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -99,6 +99,18 @@ const getImageSource = (imageString) => {
|
||||
} else {
|
||||
return `data:image/jpeg;base64,${imageString}`; // 使用Base64格式
|
||||
}
|
||||
};
|
||||
|
||||
function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
if (!content) return {};
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -187,20 +199,24 @@ const getImageSource = (imageString) => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="item.prescription_type === 2 || item.prescription_type === 3 || item.prescription_type === 5 || item.prescription_type === 6 || item.prescription_type === 7">
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
JSON.parse(recipe.content).name ||
|
||||
JSON.parse(recipe.content).drug_name
|
||||
}}</span>
|
||||
<span class="drug-quantity">{{ JSON.parse(recipe.content).number
|
||||
}}{{ JSON.parse(recipe.content).unit?.name }}</span>
|
||||
<div
|
||||
v-if="JSON.parse(recipe.content).useWay"
|
||||
class="usage-info"
|
||||
>
|
||||
{{ JSON.parse(recipe.content).useWay }}
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span></span>
|
||||
<span class="drug-quantity">{{ drug?.number
|
||||
}}{{ drug?.unit?.name }}</span>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="preparation-info">
|
||||
使用方法: {{ recipe.instruction }}
|
||||
</div>
|
||||
@@ -357,6 +373,13 @@ const getImageSource = (imageString) => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drug-spec {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.preparation-info {
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -21,8 +21,6 @@ import SignatureModal from './components/SignatureModal.vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
CollapsePanel,
|
||||
Empty,
|
||||
Input,
|
||||
InputGroup,
|
||||
@@ -189,6 +187,10 @@ const getDrugNames = (recipes: any[], nameField = 'drug_name') => {
|
||||
return recipes.map((item) => item[nameField] || item.name).join('、');
|
||||
};
|
||||
|
||||
const formatField = (value?: string | null) => (value?.trim() ? value : '—');
|
||||
|
||||
const getDrugCount = (recipes: any[]) => recipes?.length || 0;
|
||||
|
||||
// ==================== 常用方编辑弹窗 ====================
|
||||
|
||||
const [EditCommonPrescriptionModals, EditCommonPrescriptionModalApi] =
|
||||
@@ -553,50 +555,61 @@ const handleViewSignature = () => {
|
||||
<h3 class="mb-4 border-l-4 border-blue-500 pl-3 text-lg font-semibold">
|
||||
西药常用方
|
||||
</h3>
|
||||
<Collapse>
|
||||
<CollapsePanel
|
||||
v-for="(item, index) in commonPrescriptionData.west_prescription"
|
||||
:key="item.id"
|
||||
:header="item.name || `西药处方 ${index + 1}`"
|
||||
<div
|
||||
v-for="(item, index) in commonPrescriptionData.west_prescription"
|
||||
:key="item.id"
|
||||
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p v-if="item.clinical_diagnose">
|
||||
<span class="font-medium">临床诊断:</span>
|
||||
{{ item.clinical_diagnose }}
|
||||
</p>
|
||||
<p v-if="item.doctor_order">
|
||||
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.west[index]) }}
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.west[index],
|
||||
'west',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'west')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.name || `西药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="blue"
|
||||
>{{ getDrugCount(commonPrescriptionData.west[index]) }}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.west[index],
|
||||
'west',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'west')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
{{ formatField(item.clinical_diagnose) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
{{ formatField(item.doctor_order) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.west[index]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中药常用方 -->
|
||||
@@ -610,62 +623,73 @@ const handleViewSignature = () => {
|
||||
<h3 class="mb-4 border-l-4 border-green-500 pl-3 text-lg font-semibold">
|
||||
中药常用方
|
||||
</h3>
|
||||
<Collapse>
|
||||
<CollapsePanel
|
||||
v-for="(item, index) in commonPrescriptionData.chin_prescription"
|
||||
:key="item.id"
|
||||
:header="item.name || `中药处方 ${index + 1}`"
|
||||
<div
|
||||
v-for="(item, index) in commonPrescriptionData.chin_prescription"
|
||||
:key="item.id"
|
||||
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p v-if="item.clinical_diagnose">
|
||||
<span class="font-medium">临床诊断:</span>
|
||||
{{ item.clinical_diagnose }}
|
||||
</p>
|
||||
<p v-if="item.doctor_order">
|
||||
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">调配方式:</span>
|
||||
{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}
|
||||
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
|
||||
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
|
||||
</p>
|
||||
<p v-if="item.rule_type === 2">
|
||||
<span class="font-medium">委托调剂配置:</span>
|
||||
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
|
||||
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
|
||||
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.chinese[index], 'drug_name') }}
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.chinese[index],
|
||||
'chinese',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'chinese')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.name || `中药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="green"
|
||||
>{{ getDrugCount(commonPrescriptionData.chinese[index]) }}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.chinese[index],
|
||||
'chinese',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'chinese')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
{{ formatField(item.clinical_diagnose) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
{{ formatField(item.doctor_order) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">调配方式:</span>
|
||||
{{ item.rule_type === 1 ? '自制剂' : '委托调剂' }}
|
||||
<span class="ml-4">剂量:{{ item.dosage || 7 }}天/剂</span>
|
||||
<span class="ml-4">频次:{{ item.day_dosage || 2 }}次/天</span>
|
||||
</p>
|
||||
<p v-if="item.rule_type === 2">
|
||||
<span class="font-medium text-muted-foreground">委托调剂配置:</span>
|
||||
<span v-if="item.process_rule_name">制剂:{{ item.process_rule_name }}</span>
|
||||
<span v-if="item.child_process_rule_name" class="ml-4">煎法:{{ item.child_process_rule_name }}</span>
|
||||
<span v-if="item.process_rule_note_name" class="ml-4">备注:{{ item.process_rule_note_name }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.chinese[index], 'drug_name') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 颗粒药常用方 -->
|
||||
@@ -679,50 +703,61 @@ const handleViewSignature = () => {
|
||||
<h3 class="mb-4 border-l-4 border-orange-500 pl-3 text-lg font-semibold">
|
||||
颗粒药常用方
|
||||
</h3>
|
||||
<Collapse>
|
||||
<CollapsePanel
|
||||
v-for="(item, index) in commonPrescriptionData.granular_prescription"
|
||||
:key="item.id"
|
||||
:header="item.name || `颗粒药处方 ${index + 1}`"
|
||||
<div
|
||||
v-for="(item, index) in commonPrescriptionData.granular_prescription"
|
||||
:key="item.id"
|
||||
class="mb-3 rounded-lg border border-border bg-muted/30 p-4"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-3 border-b border-border pb-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p v-if="item.clinical_diagnose">
|
||||
<span class="font-medium">临床诊断:</span>
|
||||
{{ item.clinical_diagnose }}
|
||||
</p>
|
||||
<p v-if="item.doctor_order">
|
||||
<span class="font-medium">医嘱:</span>{{ item.doctor_order }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.granular[index], 'name') }}
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.granular[index],
|
||||
'granular',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'granular')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate font-medium">{{
|
||||
item.name || `颗粒药处方 ${index + 1}`
|
||||
}}</span>
|
||||
<Tag color="orange"
|
||||
>{{ getDrugCount(commonPrescriptionData.granular[index]) }}种药品</Tag
|
||||
>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="
|
||||
handleEditCommonPrescription(
|
||||
item,
|
||||
commonPrescriptionData.granular[index],
|
||||
'granular',
|
||||
)
|
||||
"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个常用方吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteCommonPrescription(item.id, 'granular')"
|
||||
>
|
||||
<Button type="primary" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm leading-relaxed">
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">临床诊断:</span>
|
||||
{{ formatField(item.clinical_diagnose) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">医嘱:</span>
|
||||
{{ formatField(item.doctor_order) }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium text-muted-foreground">药品:</span>
|
||||
{{ getDrugNames(commonPrescriptionData.granular[index], 'name') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
|
||||
@@ -4,8 +4,24 @@ const prefix = 'settlement/';
|
||||
* 分页查询用户列表
|
||||
* @param data
|
||||
*/
|
||||
/** 结算记录(yii_ledger_log,正确分页) */
|
||||
export async function getSettlementLedgerLogList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}ledger-log-list`, { params: data });
|
||||
}
|
||||
|
||||
/** @deprecated 请使用 getSettlementLedgerLogList */
|
||||
export async function getSettlementList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
return getSettlementLedgerLogList(data);
|
||||
}
|
||||
|
||||
/** 结算明细(yii_ledger) */
|
||||
export async function getSettlementLedgerDetail(params: {
|
||||
ledger_log_id?: number;
|
||||
order_id?: number;
|
||||
order_type?: number;
|
||||
fee_type?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
|
||||
}
|
||||
/**
|
||||
* 分页查询用户列表
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import {getSettlementItemsList, getSettlementList} from '#/views/finance/withdrawal/api/settlement';
|
||||
import {
|
||||
getSettlementItemsList,
|
||||
getSettlementLedgerLogList,
|
||||
} from '#/views/finance/withdrawal/api/settlement';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
@@ -89,10 +92,10 @@ export const gridOptions3: VxeGridProps<RowType> = {
|
||||
// { type: 'checkbox', width: 60 },
|
||||
// { field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'order_no', title: '订单号' },
|
||||
{ field: 'user_id', title: '名称', slots: { default: 'user_id' } },
|
||||
{ field: 'money', title: '分账金额' },
|
||||
// { field: 'fee_type_txt', title: '费用类型' },
|
||||
// { field: 'status_txt', title: '结算状态' },
|
||||
{ field: 'fee_type_txt', title: '费用类型' },
|
||||
{ field: 'amount', title: '金额' },
|
||||
{ field: 'type_txt', title: '类型' },
|
||||
{ field: 'content', title: '说明' },
|
||||
{ field: 'created_at', title: '创建时间' },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' } },
|
||||
],
|
||||
@@ -102,7 +105,7 @@ export const gridOptions3: VxeGridProps<RowType> = {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSettlementList({
|
||||
return await getSettlementLedgerLogList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
|
||||
@@ -7,6 +7,10 @@ import { Descriptions } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { getIcon } from '#/util/tool';
|
||||
import {
|
||||
getApiOpLogOperatorLabel,
|
||||
getApiOpLogPlatformLabel,
|
||||
} from '../config/platform';
|
||||
|
||||
|
||||
|
||||
@@ -43,10 +47,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
>
|
||||
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人员">
|
||||
<span v-if="data.platform_type === 0">{{ data?.admin?.nick_name || '获取失败' }}</span>
|
||||
<span v-else-if="data.platform_type === 1">{{ data?.admin?.nick_name || '获取失败' }}</span>
|
||||
<span v-else-if="data.platform_type === 2">{{ data?.user?.nickname || '获取失败' }}</span>
|
||||
<span v-else>获取失败</span>
|
||||
<span>{{ getApiOpLogOperatorLabel(data) }}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="URL">{{ data.url }}</Descriptions.Item>
|
||||
<Descriptions.Item label="控制器">
|
||||
@@ -62,11 +63,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<Descriptions.Item label="返回结果代码">
|
||||
{{ data.result_code }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台类型">
|
||||
<span v-if="data.platform_type === 0">平台</span>
|
||||
<span v-else-if="data.platform_type === 1">诊所</span>
|
||||
<span v-else-if="data.platform_type === 2">小程序</span>
|
||||
<span v-else>获取失败</span>
|
||||
<Descriptions.Item label="接口来源">
|
||||
<span>{{ getApiOpLogPlatformLabel(data.platform_type) }}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="设备">
|
||||
<Icon :icon="getIcon(data.equipment)" :size="20" />
|
||||
|
||||
35
apps/web-antd/src/views/log/api-log/config/platform.ts
Normal file
35
apps/web-antd/src/views/log/api-log/config/platform.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const API_OP_LOG_PLATFORM_OPTIONS = [
|
||||
{ label: '后台', value: 0 },
|
||||
{ label: '用户移动端', value: 1 },
|
||||
{ label: '医生移动端', value: 2 },
|
||||
{ label: '互医内部接口', value: 3 },
|
||||
] as const;
|
||||
|
||||
export function getApiOpLogPlatformLabel(
|
||||
platformType: number | string | null | undefined,
|
||||
): string {
|
||||
const value = Number(platformType);
|
||||
const found = API_OP_LOG_PLATFORM_OPTIONS.find((item) => item.value === value);
|
||||
return found?.label ?? '历史/未知';
|
||||
}
|
||||
|
||||
export function getApiOpLogOperatorLabel(row: {
|
||||
platform_type?: number;
|
||||
admin?: { nick_name?: string };
|
||||
user?: { nickname?: string };
|
||||
}): string {
|
||||
const type = row.platform_type;
|
||||
if (type === 3) {
|
||||
return '互医中转';
|
||||
}
|
||||
if (type === 2) {
|
||||
return row?.user?.nickname || row?.admin?.nick_name || '-';
|
||||
}
|
||||
if (type === 1) {
|
||||
return row?.user?.nickname || '获取失败';
|
||||
}
|
||||
if (type === 0) {
|
||||
return row?.admin?.nick_name || '获取失败';
|
||||
}
|
||||
return row?.admin?.nick_name || row?.user?.nickname || '-';
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { API_OP_LOG_PLATFORM_OPTIONS } from './platform';
|
||||
|
||||
// import dayjs from 'dayjs';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
@@ -18,6 +20,16 @@ export const formOptions: VbenFormProps = {
|
||||
fieldName: 'router',
|
||||
label: 'api接口',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [...API_OP_LOG_PLATFORM_OPTIONS],
|
||||
placeholder: '全部',
|
||||
},
|
||||
fieldName: 'platform_type',
|
||||
label: '接口来源',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
|
||||
@@ -32,6 +32,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
slots: { default: 'admin' },
|
||||
},
|
||||
{ field: 'url', title: '访问路由' },
|
||||
{
|
||||
field: 'platform_type',
|
||||
title: '接口来源',
|
||||
slots: { default: 'platform_type' },
|
||||
width: 120,
|
||||
},
|
||||
{ field: 'ip', title: '用户IP地址' },
|
||||
{ field: 'ip_address', title: 'IP归属地' },
|
||||
{ field: 'controller', title: '访问控制器' },
|
||||
|
||||
@@ -11,6 +11,10 @@ import { TableAction } from '#/components/table-action';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import {
|
||||
getApiOpLogOperatorLabel,
|
||||
getApiOpLogPlatformLabel,
|
||||
} from './config/platform';
|
||||
import { getIcon } from '#/util/tool';
|
||||
import {Icon} from "#/components/icon";
|
||||
|
||||
@@ -46,10 +50,10 @@ const showModal = (data = {}, isUpdate = false) => {
|
||||
<Tag v-else color="red"> 访问失败 </Tag>
|
||||
</template>
|
||||
<template #admin="{ row }">
|
||||
<span v-if="row.platform_type === 0">{{ row?.admin?.nick_name || '获取失败' }}</span>
|
||||
<span v-else-if="row.platform_type === 1">{{ row?.admin?.nick_name || '获取失败' }}</span>
|
||||
<span v-else-if="row.platform_type === 2">{{ row?.user?.nickname || '获取失败' }}</span>
|
||||
<span v-else>获取失败</span>
|
||||
<span>{{ getApiOpLogOperatorLabel(row) }}</span>
|
||||
</template>
|
||||
<template #platform_type="{ row }">
|
||||
<span>{{ getApiOpLogPlatformLabel(row.platform_type) }}</span>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #equipment="{ row }">
|
||||
|
||||
291
apps/web-antd/src/views/system/admin/_shared/admin-page.vue
Normal file
291
apps/web-antd/src/views/system/admin/_shared/admin-page.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import {
|
||||
createAdmin,
|
||||
deleteAdmin,
|
||||
generateLoginAccount,
|
||||
resetPassword,
|
||||
updateAdmin,
|
||||
} from '#/views/system/admin/api';
|
||||
|
||||
import { normalizeAdminPayload } from './admin-payload';
|
||||
import { createAdminModalFormProps } from './form-schemas';
|
||||
import { createAdminSearchOptions } from './search-config';
|
||||
import { formatAddressDisplay } from '#/util/address-index';
|
||||
|
||||
import { createAdminGridOptions } from './table-config';
|
||||
import { getRoleMeta } from './role-meta';
|
||||
|
||||
function formatAdminRegion(row: {
|
||||
province_id?: number;
|
||||
city_id?: number;
|
||||
}): string {
|
||||
if (row.province_id && row.city_id) {
|
||||
return formatAddressDisplay([row.province_id, row.city_id]);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
const DEFAULT_AVATAR =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20251022/20251022104156946505ca86d97626c08882f31797dbcef49c.png';
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
}>();
|
||||
|
||||
const meta = computed(() => getRoleMeta(props.roleId));
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
const modalGridApi = ref();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
createAdminModalFormProps(props.roleId, meta.value.formType),
|
||||
);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
const payload = normalizeAdminPayload(
|
||||
{ ...values, role_id: props.roleId },
|
||||
meta.value.formType,
|
||||
);
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateAdmin : createAdmin;
|
||||
submitApi(payload)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
modalGridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
const data = modalApi.getData<Record<string, any>>();
|
||||
modalGridApi.value = isOpen ? data?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values = {}, update = false } = data ?? {};
|
||||
isUpdate.value = !!update;
|
||||
|
||||
if (update && values?.id) {
|
||||
const formValues = { ...values };
|
||||
if (formValues.province_id && formValues.city_id) {
|
||||
formValues.address = [formValues.province_id, formValues.city_id];
|
||||
}
|
||||
formValues.role_id = props.roleId;
|
||||
formApi.setValues(formValues);
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdate.value = false;
|
||||
formApi.resetForm();
|
||||
formApi.setValues({
|
||||
role_id: props.roleId,
|
||||
avatar: DEFAULT_AVATAR,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: createAdminSearchOptions(),
|
||||
gridOptions: createAdminGridOptions(props.roleId, meta.value.formType),
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const showModal = (data: Record<string, any> = {}, update = false) => {
|
||||
modalApi.setData({
|
||||
values: data,
|
||||
update,
|
||||
gridApi,
|
||||
});
|
||||
modalApi.open();
|
||||
};
|
||||
|
||||
const deleteApi = (row: number | false) => {
|
||||
let ids: number[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||
}
|
||||
deleteAdmin({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
const resetPasswordApi = (id: number) => {
|
||||
resetPassword(id).then(() => {
|
||||
message.success('重置成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
const generateAccountApi = async (row: { id: number }) => {
|
||||
try {
|
||||
const result = await generateLoginAccount(row.id);
|
||||
if (result.login_account) {
|
||||
message.success('生成账号成功');
|
||||
gridApi.query();
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '生成账号失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height :title="meta.title">
|
||||
<Modal
|
||||
:title="`${isUpdate ? '编辑' : '新增'}${meta.title}`"
|
||||
class="w-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #avatar="{ row }">
|
||||
<Image :src="row.avatar" height="30" width="30" />
|
||||
</template>
|
||||
<template #region="{ row }">
|
||||
{{ formatAdminRegion(row) }}
|
||||
</template>
|
||||
<template #login_account="{ row }">
|
||||
<span v-if="row.login_account">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="copyToClipboard(row.login_account)"
|
||||
>
|
||||
{{ row.login_account }}
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
v-else
|
||||
type="link"
|
||||
size="small"
|
||||
@click="generateAccountApi(row)"
|
||||
>
|
||||
生成账号
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '重置密码',
|
||||
type: 'link',
|
||||
icon: 'bitcoin-icons:refresh-filled',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定重置密码吗?',
|
||||
confirm: resetPasswordApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AdminFormType } from './role-meta';
|
||||
|
||||
const UNUSED_ID_FIELDS = [
|
||||
'store_id',
|
||||
'doctor_id',
|
||||
'pharmacist_id',
|
||||
'supplier_id',
|
||||
] as const;
|
||||
|
||||
function isEmptyId(value: unknown): boolean {
|
||||
return value === '' || value === null || value === undefined;
|
||||
}
|
||||
|
||||
function toZeroOrOmit(
|
||||
payload: Record<string, unknown>,
|
||||
field: string,
|
||||
omitWhenEmpty: boolean,
|
||||
): void {
|
||||
if (!(field in payload)) {
|
||||
return;
|
||||
}
|
||||
if (isEmptyId(payload[field])) {
|
||||
if (omitWhenEmpty) {
|
||||
delete payload[field];
|
||||
} else {
|
||||
payload[field] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前归一化:避免整型列收到 '';不削弱各角色必填校验(在 validate 之后调用)。
|
||||
*/
|
||||
export function normalizeAdminPayload(
|
||||
values: Record<string, unknown>,
|
||||
formType: AdminFormType,
|
||||
): Record<string, unknown> {
|
||||
const payload = { ...values };
|
||||
delete payload.confirmPassword;
|
||||
|
||||
switch (formType) {
|
||||
case 'basic':
|
||||
for (const field of UNUSED_ID_FIELDS) {
|
||||
toZeroOrOmit(payload, field, true);
|
||||
}
|
||||
break;
|
||||
case 'address':
|
||||
for (const field of UNUSED_ID_FIELDS) {
|
||||
toZeroOrOmit(payload, field, true);
|
||||
}
|
||||
break;
|
||||
case 'supplier':
|
||||
toZeroOrOmit(payload, 'store_id', true);
|
||||
toZeroOrOmit(payload, 'doctor_id', true);
|
||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||
break;
|
||||
case 'clinic':
|
||||
toZeroOrOmit(payload, 'doctor_id', true);
|
||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||
toZeroOrOmit(payload, 'supplier_id', true);
|
||||
break;
|
||||
case 'doctor':
|
||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||
toZeroOrOmit(payload, 'supplier_id', true);
|
||||
if (isEmptyId(payload.doctor_id)) {
|
||||
payload.doctor_id = 0;
|
||||
}
|
||||
break;
|
||||
case 'pharmacist':
|
||||
toZeroOrOmit(payload, 'doctor_id', true);
|
||||
toZeroOrOmit(payload, 'supplier_id', true);
|
||||
if (isEmptyId(payload.pharmacist_id)) {
|
||||
payload.pharmacist_id = 0;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
243
apps/web-antd/src/views/system/admin/_shared/form-schemas.ts
Normal file
243
apps/web-antd/src/views/system/admin/_shared/form-schemas.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getStoreOption } from '#/views/system/store/api';
|
||||
import { getSupplierOption } from '#/views/system/supplier/api';
|
||||
|
||||
import type { AdminFormType } from './role-meta';
|
||||
import { ROLE_CITY_MANAGER, ROLE_SALESPERSON } from './role-meta';
|
||||
|
||||
const defaultPassword = 'Xk123456@';
|
||||
const defaultAvatar =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20251022/20251022104156946505ca86d97626c08882f31797dbcef49c.png';
|
||||
|
||||
function passwordFields() {
|
||||
return [
|
||||
{
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
component: 'InputPassword',
|
||||
help: '5-18位数字、字母、特殊字符组成。',
|
||||
componentProps: {
|
||||
placeholder: '请输入密码',
|
||||
allowClear: true,
|
||||
},
|
||||
defaultValue: defaultPassword,
|
||||
rules: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?=.*[A-Z0-9])(?=.*[!@#$%^&*])[A-Z0-9!@#$%^&*]{5,18}$/i,
|
||||
'密码由5-18位数字、字母、特殊字符组成。',
|
||||
),
|
||||
dependencies: {
|
||||
if({ id }: { id?: number }) {
|
||||
return !id;
|
||||
},
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'InputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入确认密码',
|
||||
allowClear: true,
|
||||
},
|
||||
defaultValue: defaultPassword,
|
||||
rules: z
|
||||
.string()
|
||||
.regex(/[\w!@#$%^&*]{5,18}/, '密码由5-18位数字、字母、特殊字符组成。'),
|
||||
dependencies: {
|
||||
if({ id }: { id?: number }) {
|
||||
return !id;
|
||||
},
|
||||
triggerFields: ['id', 'confirmPassword'],
|
||||
rules: (values: { password?: string }) => {
|
||||
return z
|
||||
.string()
|
||||
.regex(
|
||||
/[\w!@#$%^&*]{5,18}/,
|
||||
'密码由5-18位数字、字母、特殊字符组成。',
|
||||
)
|
||||
.refine(
|
||||
(confirmPassword) => confirmPassword === values.password,
|
||||
{ message: '确认密码必须与密码一致' },
|
||||
);
|
||||
},
|
||||
},
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function shouldShowAddress(roleId: number, formType: AdminFormType): boolean {
|
||||
if (formType !== 'address') {
|
||||
return false;
|
||||
}
|
||||
const userStore = useUserStore();
|
||||
const currentRoleId = (userStore.userInfo as { roles?: { id?: number } })
|
||||
?.roles?.id;
|
||||
if (
|
||||
roleId === ROLE_SALESPERSON &&
|
||||
currentRoleId === ROLE_CITY_MANAGER
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createAdminModalFormProps(
|
||||
roleId: number,
|
||||
formType: AdminFormType,
|
||||
): VbenFormProps {
|
||||
const schema: VbenFormProps['schema'] = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
formItemClass: 'col-span-6',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'role_id',
|
||||
label: '角色ID',
|
||||
defaultValue: roleId,
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['role_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入管理员昵称',
|
||||
},
|
||||
fieldName: 'nick_name',
|
||||
label: '昵称',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'Avatar',
|
||||
fieldName: 'avatar',
|
||||
label: '头像',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: defaultAvatar,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入管理员手机号码',
|
||||
},
|
||||
fieldName: 'phone',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowAddress(roleId, formType)) {
|
||||
schema.push({
|
||||
component: 'RegionAddressPicker',
|
||||
fieldName: 'address',
|
||||
label: '省市区',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-12',
|
||||
});
|
||||
}
|
||||
|
||||
if (formType === 'supplier') {
|
||||
schema.push({
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
showSearch: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getSupplierOption,
|
||||
placeholder: '请选择',
|
||||
},
|
||||
fieldName: 'supplier_id',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '所属供应商',
|
||||
rules: 'required',
|
||||
});
|
||||
}
|
||||
|
||||
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
||||
schema.push({
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: (input: string, option: { label?: string }) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
showSearch: true,
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getStoreOption,
|
||||
placeholder: '请选择诊所',
|
||||
},
|
||||
fieldName: 'store_id',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '所属诊所',
|
||||
rules: 'required',
|
||||
});
|
||||
}
|
||||
|
||||
if (formType === 'doctor') {
|
||||
schema.push({
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '关联医生档案 ID(可选,建议在医生管理中创建)',
|
||||
},
|
||||
fieldName: 'doctor_id',
|
||||
label: '医生ID',
|
||||
formItemClass: 'col-span-6',
|
||||
});
|
||||
}
|
||||
|
||||
if (formType === 'pharmacist') {
|
||||
schema.push({
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '关联药师档案 ID(可选)',
|
||||
},
|
||||
fieldName: 'pharmacist_id',
|
||||
label: '药师ID',
|
||||
formItemClass: 'col-span-6',
|
||||
});
|
||||
}
|
||||
|
||||
schema.push(...passwordFields());
|
||||
|
||||
return {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema,
|
||||
showDefaultActions: false,
|
||||
};
|
||||
}
|
||||
40
apps/web-antd/src/views/system/admin/_shared/role-meta.ts
Normal file
40
apps/web-antd/src/views/system/admin/_shared/role-meta.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type AdminFormType =
|
||||
| 'basic'
|
||||
| 'address'
|
||||
| 'supplier'
|
||||
| 'clinic'
|
||||
| 'doctor'
|
||||
| 'pharmacist';
|
||||
|
||||
export interface AdminRoleMeta {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
formType: AdminFormType;
|
||||
}
|
||||
|
||||
/** 与后端 RoleEnum 一致 */
|
||||
export const ADMIN_ROLE_LIST: AdminRoleMeta[] = [
|
||||
{ id: 1, title: '超级管理员', slug: 'super', formType: 'basic' },
|
||||
{ id: 2, title: '系统管理员', slug: 'platform', formType: 'basic' },
|
||||
{ id: 3, title: '省级管理员', slug: 'province', formType: 'address' },
|
||||
{ id: 4, title: '市级管理员', slug: 'city', formType: 'address' },
|
||||
{ id: 5, title: '区级管理员', slug: 'district', formType: 'address' },
|
||||
{ id: 6, title: '业务员', slug: 'salesperson', formType: 'address' },
|
||||
{ id: 7, title: '供应商', slug: 'supplier', formType: 'supplier' },
|
||||
{ id: 8, title: '诊所管理员', slug: 'clinic', formType: 'clinic' },
|
||||
{ id: 9, title: '诊所员工', slug: 'clinic-staff', formType: 'clinic' },
|
||||
{ id: 10, title: '医生', slug: 'doctor', formType: 'doctor' },
|
||||
{ id: 11, title: '药师', slug: 'pharmacist', formType: 'pharmacist' },
|
||||
];
|
||||
|
||||
export const ROLE_CITY_MANAGER = 4;
|
||||
export const ROLE_SALESPERSON = 6;
|
||||
|
||||
export function getRoleMeta(roleId: number): AdminRoleMeta {
|
||||
const meta = ADMIN_ROLE_LIST.find((r) => r.id === roleId);
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown admin role id: ${roleId}`);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
export function createAdminSearchOptions(): VbenFormProps {
|
||||
return {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '输入名称',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'nick_name',
|
||||
label: '管理员名称',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '输入手机号码',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'phone',
|
||||
label: '手机号码',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
}
|
||||
137
apps/web-antd/src/views/system/admin/_shared/table-config.ts
Normal file
137
apps/web-antd/src/views/system/admin/_shared/table-config.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getAdminList } from '#/views/system/admin/api';
|
||||
|
||||
import type { AdminFormType } from './role-meta';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
nick_name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
open_id: string;
|
||||
code: string;
|
||||
phone: string;
|
||||
login_account: string;
|
||||
desc: string;
|
||||
created_at: string;
|
||||
store_id?: number;
|
||||
doctor_id?: number;
|
||||
pharmacist_id?: number;
|
||||
province_id?: number;
|
||||
city_id?: number;
|
||||
}
|
||||
|
||||
function buildColumns(formType: AdminFormType) {
|
||||
const cols: VxeGridProps<RowType>['columns'] = [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'nick_name', align: 'left', title: '名称' },
|
||||
{
|
||||
field: 'avatar',
|
||||
align: 'left',
|
||||
title: '头像',
|
||||
slots: { default: 'avatar' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'open_id', title: 'Open ID' },
|
||||
];
|
||||
|
||||
if (formType === 'address' || formType === 'supplier') {
|
||||
cols.push({ field: 'code', title: '业务推广码' });
|
||||
}
|
||||
|
||||
if (formType === 'address') {
|
||||
cols.push({
|
||||
field: 'region',
|
||||
title: '省市区',
|
||||
minWidth: 180,
|
||||
slots: { default: 'region' },
|
||||
});
|
||||
}
|
||||
|
||||
if (formType === 'supplier') {
|
||||
cols.push({ field: 'supplier.name', title: '所属供应商' });
|
||||
}
|
||||
|
||||
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
||||
cols.push({ field: 'store_id', title: '诊所ID', width: 100 });
|
||||
}
|
||||
|
||||
if (formType === 'doctor') {
|
||||
cols.push({ field: 'doctor_id', title: '医生ID', width: 100 });
|
||||
}
|
||||
|
||||
if (formType === 'pharmacist') {
|
||||
cols.push({ field: 'pharmacist_id', title: '药师ID', width: 100 });
|
||||
}
|
||||
|
||||
if (formType === 'basic') {
|
||||
cols.push({ field: 'platform.name', title: '所属平台' });
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{ field: 'phone', title: '手机号码' },
|
||||
{
|
||||
field: 'login_account',
|
||||
align: 'left',
|
||||
title: '登录账号',
|
||||
slots: { default: 'login_account' },
|
||||
width: 150,
|
||||
},
|
||||
{ field: 'email', title: '邮箱' },
|
||||
{ field: 'created_at', title: '注册时间' },
|
||||
{ type: 'html', title: '操作', width: 200, slots: { default: 'action' } },
|
||||
);
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
export function createAdminGridOptions(
|
||||
roleId: number,
|
||||
formType: AdminFormType,
|
||||
): VxeGridProps<RowType> {
|
||||
return {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: buildColumns(formType),
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getAdminList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
role_id: roleId,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
}
|
||||
6
apps/web-antd/src/views/system/admin/city/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/city/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="4" />
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="9" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/clinic/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/clinic/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="8" />
|
||||
</template>
|
||||
@@ -3,7 +3,6 @@ import type { VbenFormProps } from '#/adapter/form';
|
||||
import { z } from '#/adapter/form';
|
||||
import { getRoleOption } from '#/views/system/role/api';
|
||||
import { getSupplierOption } from '#/views/system/supplier/api';
|
||||
import {addressOption} from "#/util/address";
|
||||
|
||||
const defaultPassword = 'Xk123456@';
|
||||
|
||||
@@ -86,11 +85,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Cascader',
|
||||
componentProps: {
|
||||
placeholder: '请选省市区',
|
||||
options: addressOption,
|
||||
},
|
||||
component: 'RegionAddressPicker',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.role_id === pharmacistId || values.role_id === cityId || values.role_id === districtId;
|
||||
|
||||
6
apps/web-antd/src/views/system/admin/district/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/district/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="5" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/doctor/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/doctor/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="10" />
|
||||
</template>
|
||||
@@ -1,197 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { ref } from 'vue';
|
||||
const router = useRouter();
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deleteAdmin, resetPassword, generateLoginAccount } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
onMounted(() => {
|
||||
router.replace('/system/admin/platform');
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||
}
|
||||
deleteAdmin({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
const resetPasswordApi = (id: number) => {
|
||||
resetPassword(id).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
const generateAccountApi = async (row: any) => {
|
||||
try {
|
||||
const result = await generateLoginAccount(row.id);
|
||||
if (result.login_account) {
|
||||
message.success('生成账号成功');
|
||||
gridApi.query();
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '生成账号失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success('复制成功');
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
message.success('复制成功');
|
||||
} catch (err) {
|
||||
message.error('复制失败');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="管理员管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
// auth: ['超级管理员', 'sys:user:save'],
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
// auth: ['超级管理员', 'sys:user:save'],
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #avatar="{ row }">
|
||||
<Image :src="row.avatar" height="30" width="30" />
|
||||
</template>
|
||||
<template #login_account="{ row }">
|
||||
<span v-if="row.login_account">
|
||||
<Button type="link" size="small" @click="copyToClipboard(row.login_account)">
|
||||
{{ row.login_account }}
|
||||
</Button>
|
||||
</span>
|
||||
<Button v-else type="link" size="small" @click="generateAccountApi(row)">
|
||||
生成账号
|
||||
</Button>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '重置密码',
|
||||
type: 'link',
|
||||
icon: 'bitcoin-icons:refresh-filled',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定重置密码吗?',
|
||||
confirm: resetPasswordApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="11" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/platform/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/platform/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="2" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/province/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/province/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="3" />
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="6" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/super/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/super/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="1" />
|
||||
</template>
|
||||
6
apps/web-antd/src/views/system/admin/supplier/index.vue
Normal file
6
apps/web-antd/src/views/system/admin/supplier/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AdminPage from '../_shared/admin-page.vue';
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage :role-id="7" />
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { addressOption } from '#/util/address.ts';
|
||||
|
||||
// 药店管理表单配置
|
||||
// 注意:已移除类型选择字段,固定为type=1(药店类型)
|
||||
@@ -178,12 +177,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
// 省市区级联选择字段
|
||||
component: 'Cascader',
|
||||
componentProps: {
|
||||
placeholder: '请选省市区',
|
||||
options: addressOption,
|
||||
},
|
||||
component: 'RegionAddressPicker',
|
||||
fieldName: 'address',
|
||||
label: '省市区',
|
||||
rules: 'required',
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 菜单搜索选择组件(自定义实现)
|
||||
*
|
||||
* @description 自定义菜单搜索下拉选择器,不依赖antd的Select组件
|
||||
* - 支持模糊搜索菜单 title
|
||||
* - 支持多选
|
||||
* - 只显示叶子节点(有 path 和 component 的菜单项)
|
||||
* - 支持 roleMenuIds 过滤
|
||||
* @author 系统
|
||||
* @date 2024
|
||||
*/
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { Input, Spin, Tag } from 'ant-design-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { getMenuTreeOption, searchMenu } from '#/views/system/menu/api';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
// ==================== Props 定义 ====================
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 选中的菜单ID列表
|
||||
*/
|
||||
modelValue?: number[];
|
||||
/**
|
||||
* 是否按用户过滤
|
||||
*/
|
||||
filterByUser?: boolean;
|
||||
/**
|
||||
* 角色已授权的菜单ID列表
|
||||
*/
|
||||
roleMenuIds?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
filterByUser: true,
|
||||
roleMenuIds: undefined,
|
||||
});
|
||||
|
||||
// ==================== Emits 定义 ====================
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 选中值变化时触发
|
||||
* @param value 选中的菜单ID列表
|
||||
*/
|
||||
(e: 'update:modelValue', value: number[]): void;
|
||||
}>();
|
||||
|
||||
// ==================== 响应式数据 ====================
|
||||
|
||||
/**
|
||||
* 搜索关键词
|
||||
*/
|
||||
const searchKeyword = ref('');
|
||||
|
||||
/**
|
||||
* 是否正在搜索
|
||||
*/
|
||||
const isSearching = ref(false);
|
||||
|
||||
/**
|
||||
* 扁平化的菜单列表(搜索结果或已选中的菜单)
|
||||
*/
|
||||
const menuList = ref<any[]>([]);
|
||||
|
||||
/**
|
||||
* 已选中菜单的完整信息(用于显示标签)
|
||||
*/
|
||||
const selectedMenuMap = ref<Map<number, any>>(new Map());
|
||||
|
||||
/**
|
||||
* 过滤后的菜单列表(搜索结果直接来自后端,不需要前端过滤)
|
||||
*/
|
||||
const filteredMenuList = computed(() => {
|
||||
return menuList.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 已选中的菜单列表(从 selectedMenuMap 获取)
|
||||
*/
|
||||
const selectedMenus = computed(() => {
|
||||
if (!props.modelValue || props.modelValue.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return props.modelValue
|
||||
.map((id) => selectedMenuMap.value.get(id))
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否显示下拉列表
|
||||
*/
|
||||
const showDropdown = ref(false);
|
||||
|
||||
/**
|
||||
* 组件容器引用
|
||||
*/
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* 当前高亮的选项索引(用于键盘导航)
|
||||
*/
|
||||
const highlightIndex = ref(-1);
|
||||
|
||||
// ==================== 方法定义 ====================
|
||||
|
||||
/**
|
||||
* 从树形数据中提取所有叶子节点
|
||||
* @param nodes 树形节点数组
|
||||
* @param roleMenuIds 角色已授权的菜单ID列表
|
||||
*/
|
||||
const flattenLeafNodes = (nodes: any[], roleMenuIds?: number[]): any[] => {
|
||||
const leafNodes: any[] = [];
|
||||
|
||||
const traverse = (node: any) => {
|
||||
const hasPath = node.path && node.path.trim() !== '';
|
||||
const hasComponent = node.component && node.component.trim() !== '';
|
||||
const noChildren = !node.children || node.children.length === 0;
|
||||
|
||||
// 如果是叶子节点
|
||||
if (hasPath && hasComponent && noChildren) {
|
||||
// 如果提供了 roleMenuIds,需要检查是否在授权列表中
|
||||
if (roleMenuIds === undefined || roleMenuIds.includes(node.id)) {
|
||||
leafNodes.push({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
icon: node.icon,
|
||||
path: node.path,
|
||||
component: node.component,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 递归遍历子节点
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(traverse);
|
||||
return leafNodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索菜单
|
||||
* @param keyword 搜索关键词
|
||||
*/
|
||||
const searchMenus = debounce(async (keyword: string) => {
|
||||
if (!keyword || keyword.length < 1) {
|
||||
menuList.value = [];
|
||||
showDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
showDropdown.value = true;
|
||||
|
||||
try {
|
||||
const res = await searchMenu({
|
||||
keyword: keyword,
|
||||
filter_by_user: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
// 如果提供了 roleMenuIds,需要过滤
|
||||
let filteredRes = res;
|
||||
if (props.roleMenuIds !== undefined && props.roleMenuIds.length > 0) {
|
||||
filteredRes = res.filter((menu: any) => props.roleMenuIds!.includes(menu.id));
|
||||
}
|
||||
|
||||
// 更新已选中菜单的信息(如果搜索结果中包含)
|
||||
filteredRes.forEach((menu: any) => {
|
||||
if (props.modelValue?.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
|
||||
menuList.value = filteredRes;
|
||||
} catch (error) {
|
||||
console.error('搜索菜单失败:', error);
|
||||
menuList.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* 处理输入变化
|
||||
* @param e 输入事件
|
||||
*/
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
searchKeyword.value = target.value;
|
||||
highlightIndex.value = -1;
|
||||
|
||||
if (target.value) {
|
||||
isSearching.value = true;
|
||||
searchMenus(target.value);
|
||||
} else {
|
||||
showDropdown.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入框获得焦点
|
||||
*/
|
||||
function handleFocus() {
|
||||
if (filteredMenuList.value.length > 0 && searchKeyword.value) {
|
||||
showDropdown.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选中菜单
|
||||
* @param menu 选中的菜单数据
|
||||
*/
|
||||
function handleSelectMenu(menu: any) {
|
||||
const currentValue = props.modelValue || [];
|
||||
const isSelected = currentValue.includes(menu.id);
|
||||
|
||||
if (isSelected) {
|
||||
// 取消选中
|
||||
const newValue = currentValue.filter((id) => id !== menu.id);
|
||||
selectedMenuMap.value.delete(menu.id);
|
||||
emit('update:modelValue', newValue);
|
||||
} else {
|
||||
// 选中
|
||||
const newValue = [...currentValue, menu.id];
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
// 不清空搜索关键词,保持下拉显示
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理移除菜单
|
||||
* @param menuId 菜单ID
|
||||
*/
|
||||
function handleRemoveMenu(menuId: number) {
|
||||
const currentValue = props.modelValue || [];
|
||||
const newValue = currentValue.filter((id) => id !== menuId);
|
||||
selectedMenuMap.value.delete(menuId);
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* @param e 键盘事件
|
||||
*/
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || filteredMenuList.value.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
highlightIndex.value + 1,
|
||||
filteredMenuList.value.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightIndex.value >= 0) {
|
||||
handleSelectMenu(filteredMenuList.value[highlightIndex.value]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点击外部关闭下拉
|
||||
* @param e 点击事件
|
||||
*/
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据(用于显示已选中的菜单)
|
||||
*/
|
||||
const initData = async () => {
|
||||
try {
|
||||
// 如果需要显示已选中的菜单,加载完整列表
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
const res = await getMenuTreeOption({
|
||||
filterByUser: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
// 扁平化为叶子节点列表
|
||||
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
|
||||
|
||||
// 更新已选中菜单的完整信息
|
||||
allMenus.forEach((menu) => {
|
||||
if (props.modelValue!.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索时 menuList 会被更新,初始时为空
|
||||
menuList.value = [];
|
||||
} catch (error) {
|
||||
console.error('获取菜单列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
initData();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
// ==================== 监听 ====================
|
||||
|
||||
/**
|
||||
* 监听 roleMenuIds 变化,重新初始化数据
|
||||
*/
|
||||
watch(
|
||||
() => props.roleMenuIds,
|
||||
(newVal) => {
|
||||
if (newVal !== undefined) {
|
||||
initData();
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 filterByUser 变化,重新初始化数据
|
||||
*/
|
||||
watch(
|
||||
() => props.filterByUser,
|
||||
() => {
|
||||
initData();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 modelValue 变化,更新已选中菜单信息
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newVal) => {
|
||||
if (newVal && newVal.length > 0) {
|
||||
// 检查是否有缺失的菜单信息
|
||||
const missingIds = newVal.filter((id) => !selectedMenuMap.value.has(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
// 加载缺失的菜单信息
|
||||
try {
|
||||
const res = await getMenuTreeOption({
|
||||
filterByUser: props.filterByUser ? 1 : 0,
|
||||
});
|
||||
|
||||
const allMenus = flattenLeafNodes(res, props.roleMenuIds);
|
||||
|
||||
allMenus.forEach((menu) => {
|
||||
if (missingIds.includes(menu.id)) {
|
||||
selectedMenuMap.value.set(menu.id, menu);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取菜单信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已取消选中的菜单
|
||||
selectedMenuMap.value.forEach((menu, id) => {
|
||||
if (!newVal.includes(id)) {
|
||||
selectedMenuMap.value.delete(id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 清空已选中菜单
|
||||
selectedMenuMap.value.clear();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
|
||||
/**
|
||||
* 暴露方法供父组件调用
|
||||
*/
|
||||
defineExpose({
|
||||
refresh: initData,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="menu-search-select">
|
||||
<!-- 搜索输入框 -->
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="输入菜单名称搜索..."
|
||||
allow-clear
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="isSearching" class="text-gray-400" />
|
||||
<SearchOutlined v-else class="text-gray-400" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- 已选中的菜单标签 -->
|
||||
<div v-if="selectedMenus.length > 0" class="selected-tags">
|
||||
<Tag
|
||||
v-for="menu in selectedMenus"
|
||||
:key="menu.id"
|
||||
closable
|
||||
class="mb-2"
|
||||
@close="handleRemoveMenu(menu.id)"
|
||||
>
|
||||
<Icon v-if="menu.icon" :icon="menu.icon" class="mr-1" />
|
||||
{{ $t(menu.title) }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
已选择 {{ selectedMenus.length }} 个菜单项
|
||||
</div>
|
||||
|
||||
<!-- 下拉列表 -->
|
||||
<div v-if="showDropdown" class="menu-dropdown">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isSearching" class="menu-dropdown__loading">
|
||||
<Spin size="small" />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<div
|
||||
v-else-if="filteredMenuList.length === 0"
|
||||
class="menu-dropdown__empty"
|
||||
>
|
||||
暂无匹配菜单
|
||||
</div>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<div v-else class="menu-dropdown__list">
|
||||
<div
|
||||
v-for="(menu, index) in filteredMenuList"
|
||||
:key="menu.id"
|
||||
class="menu-item"
|
||||
:class="{
|
||||
'menu-item--active': index === highlightIndex,
|
||||
'menu-item--selected': props.modelValue?.includes(menu.id),
|
||||
}"
|
||||
@click="handleSelectMenu(menu)"
|
||||
@mouseenter="highlightIndex = index"
|
||||
>
|
||||
<div class="menu-item__content">
|
||||
<Icon v-if="menu.icon" :icon="menu.icon" class="menu-item__icon" />
|
||||
<div class="menu-item__info">
|
||||
<div class="menu-item__title">{{ $t(menu.title) }}</div>
|
||||
<div class="menu-item__path">{{ menu.path }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.modelValue?.includes(menu.id)" class="menu-item__check">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-tags {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
margin-top: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
&__loading,
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__path {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__check {
|
||||
flex-shrink: 0;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.dark {
|
||||
.menu-dropdown {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&__path {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,595 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SortableOptions } from 'sortablejs';
|
||||
import type Sortable from 'sortablejs';
|
||||
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Empty, Input } from 'ant-design-vue';
|
||||
import { SearchOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface QuickNavMenuItem {
|
||||
id: number;
|
||||
title: string;
|
||||
icon?: string;
|
||||
path: string;
|
||||
component?: string;
|
||||
}
|
||||
|
||||
export interface SavedQuickNavItem {
|
||||
menu_id: number;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: number[];
|
||||
menuTree?: any[];
|
||||
roleMenuIds?: number[];
|
||||
savedItems?: SavedQuickNavItem[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
menuTree: () => [],
|
||||
roleMenuIds: () => [],
|
||||
savedItems: () => [],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number[]): void;
|
||||
}>();
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const availableListRef = ref<HTMLElement | null>(null);
|
||||
const selectedListRef = ref<HTMLElement | null>(null);
|
||||
const availableSortable = ref<Sortable | null>(null);
|
||||
const selectedSortable = ref<Sortable | null>(null);
|
||||
|
||||
/** 从菜单树提取角色可授权的叶子页面(支持父级授权继承) */
|
||||
function flattenAuthorizedLeafNodes(
|
||||
nodes: any[],
|
||||
roleMenuIds: number[],
|
||||
): QuickNavMenuItem[] {
|
||||
const roleIdSet = new Set(roleMenuIds);
|
||||
const hasRoleFilter = roleMenuIds.length > 0;
|
||||
const leafNodes: QuickNavMenuItem[] = [];
|
||||
|
||||
const traverse = (node: any, parentAuthorized: boolean) => {
|
||||
const nodeAuthorized = roleIdSet.has(node.id);
|
||||
const isAuthorized = !hasRoleFilter || parentAuthorized || nodeAuthorized;
|
||||
const nextParentAuthorized = isAuthorized;
|
||||
|
||||
const hasPath = node.path && String(node.path).trim() !== '';
|
||||
const hasComponent =
|
||||
node.component && String(node.component).trim() !== '';
|
||||
const noChildren = !node.children || node.children.length === 0;
|
||||
|
||||
if (hasPath && hasComponent && noChildren && isAuthorized) {
|
||||
leafNodes.push({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
icon: node.icon,
|
||||
path: node.path,
|
||||
component: node.component,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
node.children.forEach((child: any) =>
|
||||
traverse(child, nextParentAuthorized),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach((node) => traverse(node, false));
|
||||
return leafNodes;
|
||||
}
|
||||
|
||||
const allPoolMenus = computed(() =>
|
||||
flattenAuthorizedLeafNodes(props.menuTree, props.roleMenuIds),
|
||||
);
|
||||
|
||||
const menuMap = computed(() => {
|
||||
const map = new Map<number, QuickNavMenuItem>();
|
||||
allPoolMenus.value.forEach((m) => map.set(m.id, m));
|
||||
return map;
|
||||
});
|
||||
|
||||
const savedItemMap = computed(() => {
|
||||
const map = new Map<number, SavedQuickNavItem>();
|
||||
(props.savedItems || []).forEach((item) => {
|
||||
if (item.menu_id) {
|
||||
map.set(item.menu_id, item);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const selectedMenus = computed(() =>
|
||||
(props.modelValue || [])
|
||||
.map((id) => {
|
||||
const fromPool = menuMap.value.get(id);
|
||||
if (fromPool) return fromPool;
|
||||
|
||||
const saved = savedItemMap.value.get(id);
|
||||
if (saved) {
|
||||
return {
|
||||
id: saved.menu_id,
|
||||
title: saved.title || '',
|
||||
icon: saved.icon || '',
|
||||
path: saved.url || '',
|
||||
} as QuickNavMenuItem;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((m): m is QuickNavMenuItem => !!m),
|
||||
);
|
||||
|
||||
const availableMenus = computed(() => {
|
||||
const selectedSet = new Set(props.modelValue || []);
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
return allPoolMenus.value.filter((menu) => {
|
||||
if (selectedSet.has(menu.id)) return false;
|
||||
if (!keyword) return true;
|
||||
const title = $t(menu.title).toLowerCase();
|
||||
return (
|
||||
title.includes(keyword) ||
|
||||
menu.path.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const hasNoRoleMenus = computed(
|
||||
() => props.roleMenuIds.length === 0 && !props.loading,
|
||||
);
|
||||
|
||||
function getIdsFromList(el: HTMLElement | null): number[] {
|
||||
if (!el) return [];
|
||||
return [...el.querySelectorAll('[data-menu-id]')].map((node) =>
|
||||
Number((node as HTMLElement).dataset.menuId),
|
||||
);
|
||||
}
|
||||
|
||||
function syncSelectedFromDom() {
|
||||
const ids = getIdsFromList(selectedListRef.value);
|
||||
emit('update:modelValue', ids);
|
||||
}
|
||||
|
||||
function destroySortables() {
|
||||
availableSortable.value?.destroy();
|
||||
selectedSortable.value?.destroy();
|
||||
availableSortable.value = null;
|
||||
selectedSortable.value = null;
|
||||
}
|
||||
|
||||
async function createSortable(
|
||||
el: HTMLElement,
|
||||
options: SortableOptions = {},
|
||||
): Promise<Sortable> {
|
||||
const mod = await import(
|
||||
// @ts-expect-error sortablejs modular esm path
|
||||
'sortablejs/modular/sortable.complete.esm.js'
|
||||
);
|
||||
return mod.default.create(el, {
|
||||
animation: 200,
|
||||
...options,
|
||||
}) as Sortable;
|
||||
}
|
||||
|
||||
async function initSortables() {
|
||||
destroySortables();
|
||||
await nextTick();
|
||||
|
||||
const groupName = 'quickNav';
|
||||
|
||||
if (availableListRef.value) {
|
||||
availableSortable.value = await createSortable(availableListRef.value, {
|
||||
group: {
|
||||
name: groupName,
|
||||
pull: 'clone',
|
||||
put: true,
|
||||
},
|
||||
sort: false,
|
||||
ghostClass: 'quick-nav-item--ghost',
|
||||
chosenClass: 'quick-nav-item--chosen',
|
||||
dragClass: 'quick-nav-item--drag',
|
||||
onAdd(evt) {
|
||||
// 从右侧拖回左侧:以右侧列表为准同步
|
||||
syncSelectedFromDom();
|
||||
evt.item?.remove();
|
||||
nextTick(() => initSortables());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedListRef.value) {
|
||||
selectedSortable.value = await createSortable(selectedListRef.value, {
|
||||
group: {
|
||||
name: groupName,
|
||||
pull: true,
|
||||
put: true,
|
||||
},
|
||||
ghostClass: 'quick-nav-item--ghost',
|
||||
chosenClass: 'quick-nav-item--chosen',
|
||||
dragClass: 'quick-nav-item--drag',
|
||||
onAdd(evt) {
|
||||
syncSelectedFromDom();
|
||||
evt.item?.remove();
|
||||
nextTick(() => initSortables());
|
||||
},
|
||||
onRemove() {
|
||||
syncSelectedFromDom();
|
||||
},
|
||||
onUpdate() {
|
||||
syncSelectedFromDom();
|
||||
},
|
||||
onEnd() {
|
||||
syncSelectedFromDom();
|
||||
nextTick(() => initSortables());
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthorize(menuId: number) {
|
||||
const current = props.modelValue || [];
|
||||
if (current.includes(menuId)) return;
|
||||
emit('update:modelValue', [...current, menuId]);
|
||||
}
|
||||
|
||||
function handleRemove(menuId: number) {
|
||||
const next = (props.modelValue || []).filter((id) => id !== menuId);
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.modelValue,
|
||||
props.menuTree,
|
||||
props.roleMenuIds,
|
||||
props.savedItems,
|
||||
props.loading,
|
||||
],
|
||||
() => {
|
||||
if (!props.loading) {
|
||||
nextTick(() => initSortables());
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
nextTick(() => initSortables());
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.loading) {
|
||||
initSortables();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroySortables();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quick-nav-transfer">
|
||||
<div
|
||||
class="mb-4 rounded-md border-l-4 border-blue-500 bg-blue-50 px-4 py-3 dark:border-blue-400 dark:bg-blue-900/20"
|
||||
>
|
||||
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">
|
||||
左侧点击或拖拽到右侧即可授权;拖回左侧或点击移除可取消;右侧可拖拽排序。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="quick-nav-transfer__panels">
|
||||
<!-- 左侧:可授权 -->
|
||||
<div class="quick-nav-transfer__panel">
|
||||
<div class="quick-nav-transfer__panel-header">
|
||||
<span class="font-medium">可授权菜单</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ availableMenus.length }} / {{ allPoolMenus.length }}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
class="mb-3"
|
||||
placeholder="搜索菜单名称或路径..."
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined class="text-gray-400" />
|
||||
</template>
|
||||
</Input>
|
||||
<div class="quick-nav-transfer__list-wrap">
|
||||
<Empty
|
||||
v-if="hasNoRoleMenus"
|
||||
class="quick-nav-transfer__empty-hint"
|
||||
description="该角色暂无已授权菜单,请先在「授权菜单」中配置"
|
||||
/>
|
||||
<Empty
|
||||
v-else-if="availableMenus.length === 0 && !loading"
|
||||
class="quick-nav-transfer__empty-hint"
|
||||
description="暂无可授权菜单(已全部添加或搜索无结果)"
|
||||
/>
|
||||
<ul ref="availableListRef" class="quick-nav-transfer__list">
|
||||
<li
|
||||
v-for="menu in availableMenus"
|
||||
:key="`avail-${menu.id}`"
|
||||
:data-menu-id="menu.id"
|
||||
class="quick-nav-item quick-nav-item--available"
|
||||
@click="handleAuthorize(menu.id)"
|
||||
>
|
||||
<div class="quick-nav-item__handle" @click.stop>
|
||||
<Icon icon="ant-design:menu-outlined" class="text-base" />
|
||||
</div>
|
||||
<Icon
|
||||
v-if="menu.icon"
|
||||
:icon="menu.icon"
|
||||
class="quick-nav-item__icon"
|
||||
/>
|
||||
<div class="quick-nav-item__info">
|
||||
<div class="quick-nav-item__title">{{ $t(menu.title) }}</div>
|
||||
<div class="quick-nav-item__path">{{ menu.path }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="quick-nav-item__add"
|
||||
type="button"
|
||||
title="添加到快捷导航"
|
||||
@click.stop="handleAuthorize(menu.id)"
|
||||
>
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已配置 -->
|
||||
<div class="quick-nav-transfer__panel">
|
||||
<div class="quick-nav-transfer__panel-header">
|
||||
<span class="font-medium">已配置快捷导航</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ selectedMenus.length }} 项
|
||||
</span>
|
||||
</div>
|
||||
<div class="quick-nav-transfer__list-wrap">
|
||||
<Empty
|
||||
v-if="selectedMenus.length === 0 && !loading"
|
||||
class="quick-nav-transfer__empty-hint"
|
||||
description="从左侧点击或拖拽菜单到此处,添加后可拖拽调整顺序"
|
||||
/>
|
||||
<ul ref="selectedListRef" class="quick-nav-transfer__list">
|
||||
<li
|
||||
v-for="(menu, index) in selectedMenus"
|
||||
:key="`sel-${menu.id}`"
|
||||
:data-menu-id="menu.id"
|
||||
class="quick-nav-item quick-nav-item--selected"
|
||||
>
|
||||
<div class="quick-nav-item__handle">
|
||||
<Icon icon="ant-design:menu-outlined" class="text-base" />
|
||||
</div>
|
||||
<Icon
|
||||
v-if="menu.icon"
|
||||
:icon="menu.icon"
|
||||
class="quick-nav-item__icon"
|
||||
/>
|
||||
<div class="quick-nav-item__info">
|
||||
<div class="quick-nav-item__title">{{ $t(menu.title) }}</div>
|
||||
<div class="quick-nav-item__path">{{ menu.path }}</div>
|
||||
</div>
|
||||
<div class="quick-nav-item__order">{{ index + 1 }}</div>
|
||||
<button
|
||||
class="quick-nav-item__remove"
|
||||
type="button"
|
||||
title="移除"
|
||||
@click.stop="handleRemove(menu.id)"
|
||||
>
|
||||
<Icon icon="ant-design:close-outlined" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quick-nav-transfer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ant-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
&__panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__list-wrap {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
|
||||
}
|
||||
|
||||
&__empty-hint {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: grab;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.12);
|
||||
}
|
||||
|
||||
&--available {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: #91caff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&--chosen {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&--drag {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
flex-shrink: 0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__path {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__order {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__add,
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__add {
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove {
|
||||
color: #999;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.quick-nav-transfer__panel {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.quick-nav-item {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
|
||||
&--selected {
|
||||
background: #1e3a5f;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,95 +3,43 @@ import { ref, computed, nextTick } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Card, Row, Col, Empty, Popconfirm } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { getMenuTreeOption } from '#/views/system/menu/api';
|
||||
import { getRoleQuickNavList, deleteRoleQuickNav, batchSaveRoleQuickNav } from '#/views/system/role-quick-nav/api';
|
||||
import { getRoleQuickNavList, batchSaveRoleQuickNav } from '#/views/system/role-quick-nav/api';
|
||||
import { getMenuIdsByRoleIds } from '../api';
|
||||
import MenuSearchSelect from './menu-search-select.vue';
|
||||
import QuickNavTransfer from './quick-nav-transfer.vue';
|
||||
|
||||
const record = ref();
|
||||
const quickNavList = ref<any[]>([]);
|
||||
const menuTreeData = ref<any[]>([]);
|
||||
const checkedMenuIds = ref<number[]>([]);
|
||||
const savedQuickNavItems = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const roleMenuIds = ref<number[]>([]); // 角色已授权的菜单ID列表
|
||||
const roleMenuIds = ref<number[]>([]);
|
||||
|
||||
// 计算标题:快捷导航管理 - 角色名称
|
||||
const drawerTitle = computed(() => {
|
||||
const roleName = record.value?.name || '';
|
||||
return roleName ? `快捷导航管理 - ${roleName}` : '快捷导航管理';
|
||||
});
|
||||
|
||||
// 从菜单树中获取菜单信息
|
||||
const getMenuById = (menuId: number) => {
|
||||
const findMenu = (nodes: any[]): any => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === menuId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findMenu(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findMenu(menuTreeData.value);
|
||||
};
|
||||
|
||||
// 合并已保存和新选择的快捷导航
|
||||
const displayQuickNavs = computed(() => {
|
||||
// 已保存的快捷导航
|
||||
const saved = quickNavList.value.map((item) => ({
|
||||
...item,
|
||||
isSaved: true,
|
||||
}));
|
||||
|
||||
// 新选择但未保存的快捷导航(从菜单树查找)
|
||||
const newSelected = checkedMenuIds.value
|
||||
.filter((id) => !quickNavList.value.some((item) => item.menu_id === id))
|
||||
.map((menuId, index) => {
|
||||
const menu = getMenuById(menuId);
|
||||
if (!menu) return null;
|
||||
return {
|
||||
id: menuId,
|
||||
menu_id: menuId,
|
||||
title: menu.title || '',
|
||||
icon: menu.icon || '',
|
||||
url: menu.path || '',
|
||||
color: ['#1fdaca', '#bf0c2c', '#e18525', '#4daf1bc9', '#00d8ff'][index % 5] || '#00d8ff',
|
||||
sort: quickNavList.value.length + index + 1,
|
||||
isSaved: false,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...saved, ...newSelected];
|
||||
});
|
||||
|
||||
// 获取角色已授权的菜单ID列表
|
||||
const fetchRoleMenuIds = async () => {
|
||||
if (!record.value?.id) {
|
||||
roleMenuIds.value = undefined; // 改为 undefined
|
||||
roleMenuIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const res = await getMenuIdsByRoleIds({
|
||||
id: record.value.id,
|
||||
});
|
||||
// 空数组也改为 undefined,表示该角色没有授权任何菜单
|
||||
roleMenuIds.value = Array.isArray(res) && res.length > 0 ? res : undefined;
|
||||
roleMenuIds.value = Array.isArray(res) ? res : [];
|
||||
} catch (error) {
|
||||
console.error('获取角色菜单ID失败:', error);
|
||||
message.error('获取角色菜单ID失败');
|
||||
roleMenuIds.value = undefined; // 改为 undefined
|
||||
roleMenuIds.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取菜单树数据
|
||||
const fetchMenuTree = async () => {
|
||||
try {
|
||||
const res = await getMenuTreeOption({
|
||||
@@ -104,58 +52,51 @@ const fetchMenuTree = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取快捷导航列表
|
||||
const fetchQuickNavList = async () => {
|
||||
if (!record.value?.id) return;
|
||||
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getRoleQuickNavList({
|
||||
role_id: record.value.id,
|
||||
page: 1,
|
||||
pageSize: 1000, // 获取所有数据
|
||||
pageSize: 1000,
|
||||
});
|
||||
// 处理返回的数据格式
|
||||
const items = res.items || res.data || res || [];
|
||||
// 按sort排序,然后按id排序
|
||||
quickNavList.value = items.sort((a: any, b: any) => {
|
||||
const sorted = [...items].sort((a: any, b: any) => {
|
||||
if (a.sort !== b.sort) {
|
||||
return (a.sort || 0) - (b.sort || 0);
|
||||
}
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
});
|
||||
|
||||
// 设置已选中的菜单ID
|
||||
checkedMenuIds.value = items.map((item: any) => item.menu_id).filter(Boolean);
|
||||
|
||||
// 使用 nextTick 确保 MenuTreeSelector 能正确响应
|
||||
|
||||
savedQuickNavItems.value = sorted;
|
||||
checkedMenuIds.value = sorted
|
||||
.map((item: any) => item.menu_id)
|
||||
.filter(Boolean);
|
||||
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
console.error('获取快捷导航列表失败:', error);
|
||||
message.error('获取快捷导航列表失败');
|
||||
quickNavList.value = [];
|
||||
checkedMenuIds.value = [];
|
||||
savedQuickNavItems.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 批量保存快捷导航
|
||||
const handleSave = async () => {
|
||||
if (!record.value?.id) {
|
||||
message.error('角色ID不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkedMenuIds.value.length === 0) {
|
||||
message.warning('请至少选择一个菜单');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
DrawerApi.setState({
|
||||
loading: true,
|
||||
confirmLoading: true,
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
await batchSaveRoleQuickNav({
|
||||
role_id: record.value.id,
|
||||
@@ -163,7 +104,6 @@ const handleSave = async () => {
|
||||
});
|
||||
message.success('保存成功');
|
||||
await fetchQuickNavList();
|
||||
DrawerApi.close();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
@@ -175,49 +115,21 @@ const handleSave = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 删除快捷导航(从已选中移除)
|
||||
const handleRemove = (menuId: number) => {
|
||||
checkedMenuIds.value = checkedMenuIds.value.filter((id) => id !== menuId);
|
||||
message.success('已移除');
|
||||
};
|
||||
|
||||
// 删除已保存的快捷导航(从数据库删除)
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 找到要删除的快捷导航
|
||||
const item = quickNavList.value.find((item) => item.id === id);
|
||||
if (item && item.menu_id) {
|
||||
// 从选中列表中移除
|
||||
checkedMenuIds.value = checkedMenuIds.value.filter((menuId) => menuId !== item.menu_id);
|
||||
}
|
||||
|
||||
await deleteRoleQuickNav({ ids: [id] });
|
||||
message.success('删除成功');
|
||||
await fetchQuickNavList();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const [Drawer, DrawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen) {
|
||||
record.value = isOpen ? DrawerApi.getData()?.record : {};
|
||||
if (isOpen) {
|
||||
checkedMenuIds.value = [];
|
||||
savedQuickNavItems.value = [];
|
||||
roleMenuIds.value = [];
|
||||
menuTreeData.value = [];
|
||||
DrawerApi.setState({
|
||||
loading: true,
|
||||
});
|
||||
// 先获取角色已授权的菜单ID,再加载菜单树,最后加载快捷导航列表
|
||||
fetchRoleMenuIds()
|
||||
.then(() => {
|
||||
// 等待 MenuTreeSelector 初始化完成,响应 roleMenuIds 的变化
|
||||
return nextTick();
|
||||
})
|
||||
.then(() => nextTick())
|
||||
.then(() => fetchMenuTree())
|
||||
.then(() => {
|
||||
// 等待菜单树准备好后再获取快捷导航列表
|
||||
return nextTick();
|
||||
})
|
||||
.then(() => nextTick())
|
||||
.then(() => fetchQuickNavList())
|
||||
.finally(() => {
|
||||
DrawerApi.setState({
|
||||
@@ -229,7 +141,6 @@ const [Drawer, DrawerApi] = useVbenDrawer({
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
// 暴露DrawerApi供父组件调用
|
||||
defineExpose({
|
||||
DrawerApi,
|
||||
});
|
||||
@@ -237,106 +148,12 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle" class="w-[95%]">
|
||||
<div class="flex h-full gap-4">
|
||||
<!-- 左侧:菜单树选择 -->
|
||||
<div class="w-1/2 border-r pr-4">
|
||||
<MenuSearchSelect
|
||||
v-model="checkedMenuIds"
|
||||
:filter-by-user="true"
|
||||
:role-menu-ids="roleMenuIds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选择的快捷导航卡片列表 -->
|
||||
<div class="w-1/2 pl-4 flex flex-col">
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
已选择 {{ displayQuickNavs.length }} 个快捷导航
|
||||
<span v-if="quickNavList.length > 0" class="ml-2 text-gray-400">
|
||||
(已保存 {{ quickNavList.length }} 个)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 flex-1">
|
||||
<Icon icon="ant-design:loading-outlined" class="text-2xl animate-spin" />
|
||||
</div>
|
||||
<Empty
|
||||
v-else-if="displayQuickNavs.length === 0"
|
||||
description="请在左侧选择菜单"
|
||||
class="flex-1"
|
||||
/>
|
||||
<div v-else class="flex-1 overflow-y-auto">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in displayQuickNavs"
|
||||
:key="`${item.menu_id}-${item.isSaved ? 'saved' : 'new'}`"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
>
|
||||
<Card
|
||||
class="quick-nav-card"
|
||||
:style="{
|
||||
borderTop: `4px solid ${item.color || '#00d8ff'}`,
|
||||
}"
|
||||
hoverable
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center p-4 min-h-[180px]">
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
class="mb-3 flex items-center justify-center"
|
||||
:style="{
|
||||
color: item.color || '#00d8ff',
|
||||
}"
|
||||
>
|
||||
<Icon :icon="item.icon" class="text-4xl" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="mb-2 text-center font-medium text-base truncate w-full">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
|
||||
<!-- 跳转地址 -->
|
||||
<div class="mb-3 text-center text-xs text-gray-500 truncate w-full px-2">
|
||||
{{ item.url }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2 mt-auto">
|
||||
<Popconfirm
|
||||
:title="item.isSaved ? '确定要删除这个快捷导航吗?' : '确定要移除这个快捷导航吗?'"
|
||||
@confirm="item.isSaved ? handleDelete(item.id) : handleRemove(item.menu_id)"
|
||||
>
|
||||
<Button type="link" size="small" danger @click.stop>
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
{{ item.isSaved ? '删除' : '移除' }}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<QuickNavTransfer
|
||||
v-model="checkedMenuIds"
|
||||
:loading="loading"
|
||||
:menu-tree="menuTreeData"
|
||||
:role-menu-ids="roleMenuIds"
|
||||
:saved-items="savedQuickNavItems"
|
||||
/>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-nav-card {
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-nav-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.quick-nav-card :deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { addressOption } from '#/util/address.ts';
|
||||
|
||||
// 诊所|药店信息录入表单配置
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
@@ -169,11 +168,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Cascader',
|
||||
componentProps: {
|
||||
placeholder: '请选省市区',
|
||||
options: addressOption,
|
||||
},
|
||||
component: 'RegionAddressPicker',
|
||||
fieldName: 'address',
|
||||
label: '省市区',
|
||||
rules: 'required',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { addressOption } from '#/util/address.ts';
|
||||
|
||||
// 诊所管理表单配置
|
||||
// 注意:已移除类型选择字段,固定为type=0(诊所类型)
|
||||
@@ -200,11 +199,7 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Cascader',
|
||||
componentProps: {
|
||||
placeholder: '请选省市区',
|
||||
options: addressOption,
|
||||
},
|
||||
component: 'RegionAddressPicker',
|
||||
fieldName: 'address',
|
||||
label: '省市区',
|
||||
rules: 'required',
|
||||
|
||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user