feat: 菜单AI整理与提示词管理,并优化OCR/AI加载体验
支持菜单标题自动填、AI整理预览应用,以及系统配置侧 AI 模型与提示词相关能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
702
apps/web-antd/src/components/loading/MenuOrganizeLoading.vue
Normal file
702
apps/web-antd/src/components/loading/MenuOrganizeLoading.vue
Normal file
@@ -0,0 +1,702 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 菜单 AI 整理加载动画(Canvas)
|
||||
* - 玻璃卡风格对齐识图;暗色用根节点 is-dark(不依赖 :global(.dark) 穿透)
|
||||
* - 进度约 10s+ 缓进,complete 后冲到 100% 并 emit finished
|
||||
*/
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
AI_LOAD_STATUS_LINES,
|
||||
resolveAiLoadProgress,
|
||||
statusLineAt,
|
||||
} from './ai-loading-progress';
|
||||
|
||||
defineOptions({ name: 'MenuOrganizeLoading' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
/** 接口已返回,进度冲刺到 100 */
|
||||
complete?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: 'AI 菜单整理',
|
||||
complete: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
finished: [];
|
||||
}>();
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const fillRef = ref<HTMLElement | null>(null);
|
||||
const pctRef = ref<HTMLElement | null>(null);
|
||||
const statusText = ref(AI_LOAD_STATUS_LINES[0]);
|
||||
const isDone = ref(false);
|
||||
const isDark = ref(false);
|
||||
|
||||
const FLAT_ITEMS = [
|
||||
'用户管理',
|
||||
'角色权限',
|
||||
'操作日志',
|
||||
'商品订单',
|
||||
'药品目录',
|
||||
'门店配置',
|
||||
'财务流水',
|
||||
'消息通知',
|
||||
];
|
||||
const FOLDERS = [
|
||||
{ title: '系统设置', color: '#10b981' },
|
||||
{ title: '业务经营', color: '#34d399' },
|
||||
{ title: '数据财务', color: '#059669' },
|
||||
];
|
||||
const STATUS_LINES = [
|
||||
'正在深度思考中~',
|
||||
'正在分析菜单结构…',
|
||||
'正在规划目录归类…',
|
||||
'马上就好,请稍候…',
|
||||
] as const;
|
||||
|
||||
let rafId = 0;
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
let themeObs: MutationObserver | null = null;
|
||||
let completeAtMs = -1;
|
||||
let finishedEmitted = false;
|
||||
const liveComplete = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.complete,
|
||||
(v) => {
|
||||
liveComplete.value = !!v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectDark(): boolean {
|
||||
const html = document.documentElement;
|
||||
return (
|
||||
html.classList.contains('dark') ||
|
||||
html.getAttribute('data-theme') === 'dark' ||
|
||||
html.getAttribute('class')?.split(/\s+/).includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncDark() {
|
||||
isDark.value = detectDark();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncDark();
|
||||
const canvas = canvasRef.value;
|
||||
const card = cardRef.value;
|
||||
const fill = fillRef.value;
|
||||
const pctEl = pctRef.value;
|
||||
if (!canvas || !card || !fill || !pctEl) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssW = 0;
|
||||
let cssH = 0;
|
||||
const t0 = performance.now();
|
||||
|
||||
function measure() {
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
cssW = Math.max(280, rect?.width || 640);
|
||||
cssH = Math.max(200, rect?.height || 240);
|
||||
const pw = Math.round(cssW * dpr);
|
||||
const ph = Math.round(cssH * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
}
|
||||
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined' && canvas.parentElement) {
|
||||
resizeObs = new ResizeObserver(measure);
|
||||
resizeObs.observe(canvas.parentElement);
|
||||
}
|
||||
themeObs = new MutationObserver(syncDark);
|
||||
themeObs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
|
||||
function roundRect(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
) {
|
||||
const rr = Math.min(r, w / 2, h / 2);
|
||||
c.beginPath();
|
||||
c.moveTo(x + rr, y);
|
||||
c.arcTo(x + w, y, x + w, y + h, rr);
|
||||
c.arcTo(x + w, y + h, x, y + h, rr);
|
||||
c.arcTo(x, y + h, x, y, rr);
|
||||
c.arcTo(x, y, x + w, y, rr);
|
||||
c.closePath();
|
||||
}
|
||||
|
||||
function theme() {
|
||||
if (isDark.value) {
|
||||
return {
|
||||
ink: '#e8f5f0',
|
||||
ink2: '#a8cfc2',
|
||||
ink3: '#7aa394',
|
||||
panel: 'rgba(255,255,255,0.06)',
|
||||
panelBorder: 'rgba(110,231,183,0.22)',
|
||||
chip: 'rgba(16,185,129,0.18)',
|
||||
chipBorder: 'rgba(52,211,153,0.4)',
|
||||
folderBg: 'rgba(16,185,129,0.14)',
|
||||
g400: '#34d399',
|
||||
g500: '#10b981',
|
||||
g600: '#6ee7b7',
|
||||
scan: 'rgba(110,231,183,0.95)',
|
||||
scanGlow: 'rgba(16,185,129,0.4)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ink: '#0b3a2c',
|
||||
ink2: '#4e7d6c',
|
||||
ink3: '#8aab9e',
|
||||
panel: 'rgba(255,255,255,0.5)',
|
||||
panelBorder: 'rgba(11,58,44,0.1)',
|
||||
chip: 'rgba(16,185,129,0.08)',
|
||||
chipBorder: 'rgba(16,185,129,0.28)',
|
||||
folderBg: 'rgba(16,185,129,0.07)',
|
||||
g400: '#34d399',
|
||||
g500: '#10b981',
|
||||
g600: '#059669',
|
||||
scan: 'rgba(16,185,129,0.9)',
|
||||
scanGlow: 'rgba(16,185,129,0.28)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(now: number) {
|
||||
measure();
|
||||
const elapsed = now - t0;
|
||||
if (liveComplete.value && completeAtMs < 0) {
|
||||
completeAtMs = elapsed;
|
||||
}
|
||||
const { progress: p, done } = resolveAiLoadProgress(
|
||||
elapsed,
|
||||
liveComplete.value,
|
||||
completeAtMs,
|
||||
);
|
||||
isDone.value = done;
|
||||
statusText.value = done
|
||||
? '整理方案已生成'
|
||||
: statusLineAt(elapsed, STATUS_LINES);
|
||||
fill.style.width = `${(p * 100).toFixed(1)}%`;
|
||||
pctEl.textContent = String(Math.round(p * 100));
|
||||
card.classList.toggle('is-done', done);
|
||||
|
||||
if (done && !finishedEmitted) {
|
||||
finishedEmitted = true;
|
||||
emit('finished');
|
||||
}
|
||||
|
||||
const c = ctx!;
|
||||
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
c.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const th = theme();
|
||||
const pad = 8;
|
||||
const gap = 14;
|
||||
const arrowW = 36;
|
||||
const leftW = Math.min(220, cssW * 0.34);
|
||||
const rightX = pad + leftW + gap + arrowW + gap;
|
||||
const rightW = cssW - rightX - pad;
|
||||
const top = 4;
|
||||
const stageH = cssH - top - 4;
|
||||
|
||||
c.fillStyle = th.panel;
|
||||
c.strokeStyle = th.panelBorder;
|
||||
c.lineWidth = 1;
|
||||
roundRect(c, pad, top, leftW, stageH, 14);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
|
||||
c.fillStyle = th.ink3;
|
||||
c.font = '600 10px system-ui,sans-serif';
|
||||
c.fillText('平铺菜单', pad + 12, top + 18);
|
||||
|
||||
const listTop = top + 30;
|
||||
const chipH = 22;
|
||||
const chipGap = 6;
|
||||
const usable = stageH - 38;
|
||||
const maxShow = Math.min(
|
||||
FLAT_ITEMS.length,
|
||||
Math.floor(usable / (chipH + chipGap)),
|
||||
);
|
||||
|
||||
for (let i = 0; i < maxShow; i++) {
|
||||
const y = listTop + i * (chipH + chipGap);
|
||||
const itemP = (i + 0.5) / maxShow;
|
||||
const absorb = Math.min(1, Math.max(0, (p - itemP * 0.75) / 0.2));
|
||||
const alpha = 1 - absorb * 0.85;
|
||||
const xOff = absorb * (leftW * 0.55 + gap + arrowW);
|
||||
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
const cx = pad + 10 + xOff;
|
||||
const chipW = leftW - 20;
|
||||
c.fillStyle = th.chip;
|
||||
c.strokeStyle = th.chipBorder;
|
||||
roundRect(c, cx, y, chipW, chipH, 8);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
c.fillStyle = th.ink;
|
||||
c.font = '500 11px system-ui,sans-serif';
|
||||
c.fillText(FLAT_ITEMS[i]!, cx + 10, y + 15);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
const scanY = listTop + p * (maxShow * (chipH + chipGap));
|
||||
const grad = c.createLinearGradient(pad, scanY - 40, pad, scanY + 4);
|
||||
grad.addColorStop(0, 'rgba(16,185,129,0)');
|
||||
grad.addColorStop(1, th.scanGlow);
|
||||
c.fillStyle = grad;
|
||||
c.fillRect(pad + 2, scanY - 40, leftW - 4, 40);
|
||||
c.strokeStyle = th.scan;
|
||||
c.lineWidth = 2;
|
||||
c.shadowColor = th.scan;
|
||||
c.shadowBlur = 10;
|
||||
c.beginPath();
|
||||
c.moveTo(pad + 4, scanY);
|
||||
c.lineTo(pad + leftW - 4, scanY);
|
||||
c.stroke();
|
||||
c.shadowBlur = 0;
|
||||
}
|
||||
|
||||
const ax = pad + leftW + gap + arrowW / 2;
|
||||
const ay = top + stageH / 2;
|
||||
c.save();
|
||||
c.translate(ax, ay + (done ? 2 : Math.sin(now / 400) * 2));
|
||||
c.strokeStyle = done ? th.g600 : th.g400;
|
||||
c.lineWidth = 2;
|
||||
c.lineCap = 'round';
|
||||
c.lineJoin = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-10, 0);
|
||||
c.lineTo(8, 0);
|
||||
c.moveTo(2, -7);
|
||||
c.lineTo(10, 0);
|
||||
c.lineTo(2, 7);
|
||||
c.stroke();
|
||||
c.restore();
|
||||
|
||||
c.fillStyle = th.panel;
|
||||
c.strokeStyle = th.panelBorder;
|
||||
roundRect(c, rightX, top, rightW, stageH, 14);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
|
||||
c.fillStyle = th.ink3;
|
||||
c.font = '600 10px system-ui,sans-serif';
|
||||
c.fillText('归入目录', rightX + 12, top + 18);
|
||||
|
||||
const folderGap = 8;
|
||||
const folderH =
|
||||
(stageH - 34 - folderGap * (FOLDERS.length - 1)) / FOLDERS.length;
|
||||
const folderReveal = [0.18, 0.4, 0.62];
|
||||
|
||||
for (let fi = 0; fi < FOLDERS.length; fi++) {
|
||||
const folder = FOLDERS[fi]!;
|
||||
const fy = top + 28 + fi * (folderH + folderGap);
|
||||
const show = Math.min(1, Math.max(0, (p - folderReveal[fi]!) / 0.18));
|
||||
|
||||
c.save();
|
||||
c.globalAlpha = 0.3 + show * 0.7;
|
||||
c.fillStyle = th.folderBg;
|
||||
c.strokeStyle = folder.color + (isDark.value ? 'aa' : '66');
|
||||
roundRect(c, rightX + 10, fy, rightW - 20, folderH, 10);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
|
||||
c.fillStyle = folder.color;
|
||||
roundRect(c, rightX + 18, fy + 8, 14, 11, 3);
|
||||
c.fill();
|
||||
c.fillStyle = th.ink;
|
||||
c.font = '600 12px system-ui,sans-serif';
|
||||
c.fillText(folder.title, rightX + 38, fy + 18);
|
||||
|
||||
const assigned = FLAT_ITEMS.filter((_, i) => i % FOLDERS.length === fi);
|
||||
const chipY = fy + 28;
|
||||
let cx = rightX + 18;
|
||||
for (let j = 0; j < assigned.length; j++) {
|
||||
const itemShow = Math.min(
|
||||
1,
|
||||
Math.max(0, (p - (folderReveal[fi]! + 0.08 + j * 0.05)) / 0.12),
|
||||
);
|
||||
if (itemShow <= 0) continue;
|
||||
const label = assigned[j]!;
|
||||
c.font = '500 10px system-ui,sans-serif';
|
||||
const tw = c.measureText(label).width + 12;
|
||||
if (cx + tw > rightX + rightW - 16) break;
|
||||
c.globalAlpha = itemShow;
|
||||
c.fillStyle = th.chip;
|
||||
c.strokeStyle = th.chipBorder;
|
||||
roundRect(c, cx, chipY, tw, 18, 9);
|
||||
c.fill();
|
||||
c.stroke();
|
||||
c.fillStyle = th.ink2;
|
||||
c.fillText(label, cx + 6, chipY + 13);
|
||||
cx += tw + 6;
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
if (!finishedEmitted) {
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
resizeObs?.disconnect();
|
||||
themeObs?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mol-load" :class="{ 'is-dark': isDark }">
|
||||
<div ref="cardRef" class="mol-load-card" :class="{ 'is-done': isDone }">
|
||||
<div class="mol-load-head">
|
||||
<div class="mol-load-logo" aria-hidden="true">
|
||||
<span class="mol-load-logo-halo" />
|
||||
<svg class="mol-load-logo-mark" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="molLogoGrad2"
|
||||
x1="4"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stop-color="#5EEAD4" />
|
||||
<stop offset=".5" stop-color="#10B981" />
|
||||
<stop offset="1" stop-color="#047857" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M3.5 8.5h6l1.5 1.5H20.5v8.5a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18.5V8.5z"
|
||||
stroke="url(#molLogoGrad2)"
|
||||
stroke-width="1.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 14h6M13.5 11.5 16 14l-2.5 2.5"
|
||||
stroke="url(#molLogoGrad2)"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mol-load-meta">
|
||||
<div class="mol-load-title">{{ title }}</div>
|
||||
<div class="mol-load-sub">
|
||||
<span class="mol-load-pulse" />
|
||||
<span>{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mol-load-pct">
|
||||
<span ref="pctRef">0</span><i>%</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mol-load-track">
|
||||
<div ref="fillRef" class="mol-load-fill" />
|
||||
</div>
|
||||
|
||||
<div class="mol-load-canvas-wrap">
|
||||
<canvas ref="canvasRef" class="mol-load-canvas" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mol-load {
|
||||
--g-400: #34d399;
|
||||
--g-500: #10b981;
|
||||
--g-600: #059669;
|
||||
--g-700: #047857;
|
||||
--ink: #0b3a2c;
|
||||
--ink-3: #6b9080;
|
||||
--glass-a: rgba(255, 255, 255, 0.78);
|
||||
--glass-b: rgba(236, 253, 245, 0.55);
|
||||
--edge-a: rgba(255, 255, 255, 0.95);
|
||||
--edge-b: rgba(16, 185, 129, 0.08);
|
||||
--ring: rgba(255, 255, 255, 0.7);
|
||||
--hairline2: rgba(11, 58, 44, 0.08);
|
||||
--logo-bg: linear-gradient(
|
||||
150deg,
|
||||
rgba(255, 255, 255, 0.85) 0%,
|
||||
rgba(209, 250, 229, 0.5) 52%,
|
||||
rgba(255, 255, 255, 0.4) 100%
|
||||
);
|
||||
--shadow:
|
||||
0 40px 80px -42px rgba(3, 55, 40, 0.42),
|
||||
0 16px 36px -24px rgba(3, 55, 40, 0.26);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 暗色:跟 is-dark 绑定,不依赖 :global(.dark) 是否穿透到 scoped */
|
||||
.mol-load.is-dark {
|
||||
--g-400: #34d399;
|
||||
--g-500: #2bd9a0;
|
||||
--g-600: #6ee7b7;
|
||||
--g-700: #a7f3d0;
|
||||
--ink: #e8f5f0;
|
||||
--ink-3: #8eb9ab;
|
||||
--glass-a: rgba(18, 36, 30, 0.88);
|
||||
--glass-b: rgba(10, 24, 20, 0.72);
|
||||
--edge-a: rgba(110, 231, 183, 0.35);
|
||||
--edge-b: rgba(255, 255, 255, 0.04);
|
||||
--ring: rgba(110, 231, 183, 0.12);
|
||||
--hairline2: rgba(110, 231, 183, 0.12);
|
||||
--logo-bg: linear-gradient(
|
||||
150deg,
|
||||
rgba(52, 211, 153, 0.22) 0%,
|
||||
rgba(16, 185, 129, 0.1) 52%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
--shadow:
|
||||
0 40px 90px -38px rgba(0, 0, 0, 0.85),
|
||||
0 16px 40px -26px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.mol-load-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: min(720px, 100%);
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(150deg, var(--glass-a), var(--glass-b));
|
||||
backdrop-filter: blur(34px) saturate(190%);
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
inset 0 1px 0 0 var(--ring);
|
||||
transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mol-load-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
padding: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
150deg,
|
||||
var(--edge-a) 0%,
|
||||
var(--edge-b) 35%,
|
||||
var(--edge-b) 60%,
|
||||
var(--edge-a) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.mol-load-card.is-done {
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
inset 0 1px 0 0 var(--ring),
|
||||
0 0 0 1px rgba(16, 185, 129, 0.25),
|
||||
0 0 64px -16px rgba(16, 185, 129, 0.45);
|
||||
}
|
||||
|
||||
.mol-load-head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mol-load-logo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--logo-bg);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 6px 18px -8px rgba(5, 95, 70, 0.35),
|
||||
0 0 0 1px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.mol-load-logo-halo {
|
||||
position: absolute;
|
||||
inset: -9px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.35), transparent 68%);
|
||||
border-radius: 24px;
|
||||
filter: blur(9px);
|
||||
animation: molBreath 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes molBreath {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.mol-load-logo-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.mol-load-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mol-load-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mol-load-sub {
|
||||
height: 17px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mol-load-pulse {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 7px;
|
||||
vertical-align: 1.5px;
|
||||
background: var(--g-500);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55);
|
||||
animation: molBeat 1.7s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes molBeat {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 7px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.mol-load-card.is-done .mol-load-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.mol-load-pct {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
color: var(--g-700);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mol-load-pct i {
|
||||
margin-left: 1px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 550;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mol-load-track {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
margin: 18px 0 16px;
|
||||
overflow: hidden;
|
||||
background: var(--hairline2);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.mol-load-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600));
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 0 14px rgba(16, 185, 129, 0.55);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
.mol-load-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.55),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: molSheen 1.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes molSheen {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.mol-load-canvas-wrap {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.mol-load-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 识图开方/传方加载动画
|
||||
* 视觉与交互对齐 sql/doc/ai识别图片转成处方的加载动画.html:
|
||||
* 扫描原图 → 进度条 → 结构化处方行逐条揭示
|
||||
* 暗色跟随页面 .dark,不用 prefers-color-scheme 以免与后台主题冲突
|
||||
* - 扫描原图 → 进度条 → 结构化处方行逐条揭示
|
||||
* - 暗色用根节点 is-dark(不依赖 :global(.dark) 穿透)
|
||||
* - 进度约 10s+ 缓进,complete 后冲到 100% 并 emit finished
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
resolveAiLoadProgress,
|
||||
statusLineAt,
|
||||
} from './ai-loading-progress';
|
||||
|
||||
defineOptions({ name: 'OcrImageLoading' });
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 主标题 */
|
||||
title?: string;
|
||||
/** 接口已返回,进度冲刺到 100 */
|
||||
complete?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: 'AI 处方识别',
|
||||
complete: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
finished: [];
|
||||
}>();
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null);
|
||||
const docRef = ref<HTMLElement | null>(null);
|
||||
const sharpRef = ref<HTMLElement | null>(null);
|
||||
@@ -26,11 +38,17 @@ const scannerRef = ref<HTMLElement | null>(null);
|
||||
const fillRef = ref<HTMLElement | null>(null);
|
||||
const pctRef = ref<HTMLElement | null>(null);
|
||||
const rowRefs = ref<(HTMLElement | null)[]>([]);
|
||||
const statusText = ref('正在深度思考中~');
|
||||
const isDone = ref(false);
|
||||
const isDark = ref(false);
|
||||
|
||||
const SCAN_MS = 3000;
|
||||
const HOLD_MS = 1600;
|
||||
const CYCLE = SCAN_MS + HOLD_MS;
|
||||
const THRESHOLDS = [0.08, 0.24, 0.4, 0.54, 0.68, 0.82];
|
||||
const STATUS_LINES = [
|
||||
'正在深度思考中~',
|
||||
'正在解析处方图像…',
|
||||
'正在对照药库…',
|
||||
'马上就好,请稍候…',
|
||||
] as const;
|
||||
|
||||
/** 演示用中药行(仅动画占位,不代表真实识别结果) */
|
||||
const DEMO_ROWS = [
|
||||
@@ -43,10 +61,35 @@ const DEMO_ROWS = [
|
||||
];
|
||||
|
||||
let rafId = 0;
|
||||
let unsetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
let themeObs: MutationObserver | null = null;
|
||||
let completeAtMs = -1;
|
||||
let finishedEmitted = false;
|
||||
const liveComplete = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.complete,
|
||||
(v) => {
|
||||
liveComplete.value = !!v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectDark(): boolean {
|
||||
const html = document.documentElement;
|
||||
return (
|
||||
html.classList.contains('dark') ||
|
||||
html.getAttribute('data-theme') === 'dark' ||
|
||||
html.getAttribute('class')?.split(/\s+/).includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncDark() {
|
||||
isDark.value = detectDark();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncDark();
|
||||
const card = cardRef.value;
|
||||
const docEl = docRef.value;
|
||||
const sharp = sharpRef.value;
|
||||
@@ -64,54 +107,72 @@ onMounted(() => {
|
||||
resizeObs = new ResizeObserver(measure);
|
||||
resizeObs.observe(docEl);
|
||||
}
|
||||
themeObs = new MutationObserver(syncDark);
|
||||
themeObs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
|
||||
let t0 = performance.now();
|
||||
let prevT = -1;
|
||||
const t0 = performance.now();
|
||||
|
||||
const frame = (now: number) => {
|
||||
const t = (now - t0) % CYCLE;
|
||||
if (t < prevT) {
|
||||
card.classList.add('no-anim');
|
||||
if (unsetTimer) clearTimeout(unsetTimer);
|
||||
unsetTimer = setTimeout(() => card.classList.remove('no-anim'), 60);
|
||||
const elapsed = now - t0;
|
||||
if (liveComplete.value && completeAtMs < 0) {
|
||||
completeAtMs = elapsed;
|
||||
}
|
||||
prevT = t;
|
||||
const { progress: p, done } = resolveAiLoadProgress(
|
||||
elapsed,
|
||||
liveComplete.value,
|
||||
completeAtMs,
|
||||
);
|
||||
isDone.value = done;
|
||||
statusText.value = done
|
||||
? '识别完成 · 已生成电子处方'
|
||||
: statusLineAt(elapsed, STATUS_LINES);
|
||||
|
||||
const p = Math.min(t / SCAN_MS, 1);
|
||||
const done = t >= SCAN_MS;
|
||||
if (!docH) measure();
|
||||
scanner.style.transform = `translateY(${(p * docH).toFixed(1)}px)`;
|
||||
scanner.style.opacity = done ? '0' : p < 0.015 ? '0' : '1';
|
||||
sharp.style.clipPath = `inset(0 0 ${((1 - p) * 100).toFixed(2)}% 0)`;
|
||||
// 扫描线:完成前跟进度走;完成后停在底部并淡出
|
||||
const scanP = done ? 1 : p;
|
||||
scanner.style.transform = `translateY(${(scanP * docH).toFixed(1)}px)`;
|
||||
scanner.style.opacity = done ? '0' : p < 0.01 ? '0' : '1';
|
||||
sharp.style.clipPath = `inset(0 0 ${((1 - scanP) * 100).toFixed(2)}% 0)`;
|
||||
fill.style.width = `${(p * 100).toFixed(1)}%`;
|
||||
pctEl.textContent = String(Math.round(p * 100));
|
||||
card.classList.toggle('is-done', done);
|
||||
|
||||
const rows = rowRefs.value.filter(Boolean) as HTMLElement[];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
rows[i].classList.toggle('show', p >= THRESHOLDS[i]);
|
||||
rows[i]!.classList.toggle('show', p >= THRESHOLDS[i]!);
|
||||
}
|
||||
|
||||
if (done && !finishedEmitted) {
|
||||
finishedEmitted = true;
|
||||
emit('finished');
|
||||
}
|
||||
if (!finishedEmitted) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
rafId = requestAnimationFrame(frame);
|
||||
};
|
||||
rafId = requestAnimationFrame(frame);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
if (unsetTimer) clearTimeout(unsetTimer);
|
||||
resizeObs?.disconnect();
|
||||
themeObs?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ocr-load">
|
||||
<div ref="cardRef" class="ocr-load-card">
|
||||
<div class="ocr-load" :class="{ 'is-dark': isDark }">
|
||||
<div ref="cardRef" class="ocr-load-card" :class="{ 'is-done': isDone }">
|
||||
<div class="ocr-load-head">
|
||||
<div class="ocr-load-logo">
|
||||
<span class="ocr-load-logo-halo" aria-hidden="true" />
|
||||
<svg class="ocr-load-logo-mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ocrLoadLogoCross"
|
||||
id="ocrLoadLogoCross2"
|
||||
x1="6"
|
||||
y1="6"
|
||||
x2="18"
|
||||
@@ -125,7 +186,7 @@ onUnmounted(() => {
|
||||
</defs>
|
||||
<path
|
||||
d="M12 6.6v10.8M6.6 12h10.8"
|
||||
stroke="url(#ocrLoadLogoCross)"
|
||||
stroke="url(#ocrLoadLogoCross2)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
@@ -135,10 +196,7 @@ onUnmounted(() => {
|
||||
<div class="ocr-load-title">{{ title }}</div>
|
||||
<div class="ocr-load-sub">
|
||||
<span class="ocr-load-pulse" />
|
||||
<span class="ocr-load-swap">
|
||||
<span class="s1">正在解析处方图像…</span>
|
||||
<span class="s2">识别完成 · 已生成电子处方</span>
|
||||
</span>
|
||||
<span>{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ocr-load-pct">
|
||||
@@ -229,19 +287,22 @@ onUnmounted(() => {
|
||||
--ink: #0b3a2c;
|
||||
--ink-2: #4e7d6c;
|
||||
--ink-3: #8aab9e;
|
||||
--glass-a: rgba(255, 255, 255, 0.7);
|
||||
--glass-b: rgba(255, 255, 255, 0.34);
|
||||
--glass-a: rgba(255, 255, 255, 0.78);
|
||||
--glass-b: rgba(236, 253, 245, 0.55);
|
||||
--edge-a: rgba(255, 255, 255, 0.95);
|
||||
--edge-b: rgba(255, 255, 255, 0.06);
|
||||
--edge-b: rgba(16, 185, 129, 0.08);
|
||||
--sheen: rgba(255, 255, 255, 0.7);
|
||||
--ring: rgba(255, 255, 255, 0.6);
|
||||
--panel: rgba(255, 255, 255, 0.38);
|
||||
--hairline2: rgba(11, 58, 44, 0.06);
|
||||
--panel: rgba(255, 255, 255, 0.5);
|
||||
--hairline2: rgba(11, 58, 44, 0.08);
|
||||
--paper: #fff;
|
||||
--paper-line: #dceae5;
|
||||
--logo-c1: #5eead4;
|
||||
--logo-c2: #10b981;
|
||||
--logo-c3: #047857;
|
||||
--logo-bg: linear-gradient(
|
||||
150deg,
|
||||
rgba(255, 255, 255, 0.85) 0%,
|
||||
rgba(209, 250, 229, 0.5) 52%,
|
||||
rgba(255, 255, 255, 0.4) 100%
|
||||
);
|
||||
--shadow:
|
||||
0 40px 80px -42px rgba(3, 55, 40, 0.42),
|
||||
0 16px 36px -24px rgba(3, 55, 40, 0.26),
|
||||
@@ -258,35 +319,39 @@ onUnmounted(() => {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:global(.dark) .ocr-load {
|
||||
/* 暗色:跟 is-dark 绑定,不依赖 :global(.dark) */
|
||||
.ocr-load.is-dark {
|
||||
--g-300: #5eead4;
|
||||
--g-400: #34d399;
|
||||
--g-500: #2bd9a0;
|
||||
--g-600: #10b981;
|
||||
--g-700: #7bf0c8;
|
||||
--ink: #e9f7f2;
|
||||
--ink-2: #9dc2b6;
|
||||
--ink-3: #6f9388;
|
||||
--glass-a: rgba(28, 43, 38, 0.58);
|
||||
--glass-b: rgba(10, 20, 17, 0.34);
|
||||
--edge-a: rgba(255, 255, 255, 0.32);
|
||||
--edge-b: rgba(255, 255, 255, 0.02);
|
||||
--sheen: rgba(255, 255, 255, 0.13);
|
||||
--ring: rgba(255, 255, 255, 0.1);
|
||||
--panel: rgba(255, 255, 255, 0.035);
|
||||
--hairline2: rgba(255, 255, 255, 0.055);
|
||||
--paper: #e9f3ef;
|
||||
--paper-line: #c9dcd4;
|
||||
--logo-c1: #b9f6e0;
|
||||
--logo-c2: #34d399;
|
||||
--logo-c3: #0fa97d;
|
||||
--g-600: #6ee7b7;
|
||||
--g-700: #a7f3d0;
|
||||
--ink: #e8f5f0;
|
||||
--ink-2: #a8cfc2;
|
||||
--ink-3: #8eb9ab;
|
||||
--glass-a: rgba(18, 36, 30, 0.88);
|
||||
--glass-b: rgba(10, 24, 20, 0.72);
|
||||
--edge-a: rgba(110, 231, 183, 0.35);
|
||||
--edge-b: rgba(255, 255, 255, 0.04);
|
||||
--sheen: rgba(255, 255, 255, 0.18);
|
||||
--ring: rgba(110, 231, 183, 0.12);
|
||||
--panel: rgba(255, 255, 255, 0.06);
|
||||
--hairline2: rgba(110, 231, 183, 0.12);
|
||||
--paper: #1a2e28;
|
||||
--paper-line: #2d4a40;
|
||||
--logo-bg: linear-gradient(
|
||||
150deg,
|
||||
rgba(52, 211, 153, 0.22) 0%,
|
||||
rgba(16, 185, 129, 0.1) 52%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
--shadow:
|
||||
0 40px 90px -38px rgba(0, 0, 0, 0.85),
|
||||
0 16px 40px -26px rgba(0, 0, 0, 0.6),
|
||||
0 2px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-paper:
|
||||
0 12px 28px -16px rgba(0, 0, 0, 0.7),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
0 0 0 1px rgba(110, 231, 183, 0.12);
|
||||
}
|
||||
|
||||
.ocr-load-card {
|
||||
@@ -296,11 +361,10 @@ onUnmounted(() => {
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(150deg, var(--glass-a), var(--glass-b));
|
||||
backdrop-filter: blur(34px) saturate(190%) brightness(1.05);
|
||||
backdrop-filter: blur(34px) saturate(190%);
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
inset 0 1px 0 0 var(--ring),
|
||||
inset 0 -1px 0 0 rgba(255, 255, 255, 0.06);
|
||||
inset 0 1px 0 0 var(--ring);
|
||||
transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@@ -329,12 +393,8 @@ onUnmounted(() => {
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
inset 0 1px 0 0 var(--ring),
|
||||
0 0 0 1px rgba(16, 185, 129, 0.2),
|
||||
0 0 64px -16px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.ocr-load-card.no-anim .ocr-load-rrow {
|
||||
transition: none !important;
|
||||
0 0 0 1px rgba(16, 185, 129, 0.25),
|
||||
0 0 64px -16px rgba(16, 185, 129, 0.45);
|
||||
}
|
||||
|
||||
.ocr-load-head {
|
||||
@@ -350,19 +410,12 @@ onUnmounted(() => {
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: linear-gradient(
|
||||
150deg,
|
||||
rgba(255, 255, 255, 0.66) 0%,
|
||||
rgba(255, 255, 255, 0.2) 52%,
|
||||
rgba(255, 255, 255, 0.34) 100%
|
||||
);
|
||||
background: var(--logo-bg);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
inset 0 0 20px rgba(16, 185, 129, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 6px 18px -8px rgba(5, 95, 70, 0.3),
|
||||
0 0 0 1px rgba(16, 185, 129, 0.14);
|
||||
backdrop-filter: blur(16px) saturate(200%);
|
||||
0 0 0 1px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.ocr-load-logo-halo {
|
||||
@@ -393,19 +446,6 @@ onUnmounted(() => {
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
filter: drop-shadow(0 1px 3px rgba(16, 185, 129, 0.4));
|
||||
}
|
||||
|
||||
.ocr-load-logo-mark :deep(.s1) {
|
||||
stop-color: var(--logo-c1);
|
||||
}
|
||||
|
||||
.ocr-load-logo-mark :deep(.s2) {
|
||||
stop-color: var(--logo-c2);
|
||||
}
|
||||
|
||||
.ocr-load-logo-mark :deep(.s3) {
|
||||
stop-color: var(--logo-c3);
|
||||
}
|
||||
|
||||
.ocr-load-meta {
|
||||
@@ -422,10 +462,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.ocr-load-sub {
|
||||
position: relative;
|
||||
height: 17px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ocr-load-pulse {
|
||||
@@ -457,39 +499,6 @@ onUnmounted(() => {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.ocr-load-swap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ocr-load-swap .s1,
|
||||
.ocr-load-swap .s2 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
opacity 0.38s ease,
|
||||
transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.ocr-load-swap .s2 {
|
||||
font-weight: 600;
|
||||
color: var(--g-700);
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.ocr-load-card.is-done .ocr-load-swap .s1 {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.ocr-load-card.is-done .ocr-load-swap .s2 {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ocr-load-pct {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
@@ -528,13 +537,14 @@ onUnmounted(() => {
|
||||
background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600));
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 0 14px rgba(16, 185, 129, 0.6);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
.ocr-load-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: ocrSheen 1.7s linear infinite;
|
||||
}
|
||||
@@ -836,14 +846,12 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ocr-load-rrow .val :deep(b) {
|
||||
font-weight: 650;
|
||||
color: var(--g-700);
|
||||
font-variant-numeric: tabular-nums;
|
||||
.ocr-load-card.is-done .ocr-load-rrow {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.ocr-load-card.is-done .ocr-load-rrow {
|
||||
background: rgba(16, 185, 129, 0.035);
|
||||
.ocr-load.is-dark .ocr-load-card.is-done .ocr-load-rrow {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
64
apps/web-antd/src/components/loading/ai-loading-progress.ts
Normal file
64
apps/web-antd/src/components/loading/ai-loading-progress.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* AI Loading 共用进度:约 10s+ 缓进到上限,接口返回后再冲到 100%
|
||||
* 为什么不用线性:长请求前期显得卡住,指数逼近更像「还在想」
|
||||
*/
|
||||
|
||||
/** 缓进阶段时长参考(毫秒),约 11s 贴近上限 */
|
||||
export const AI_LOAD_ASYMPTOTE_MS = 11_000;
|
||||
|
||||
/** 接口未返回前进度上限(0~1),留出冲刺空间 */
|
||||
export const AI_LOAD_CAP = 0.92;
|
||||
|
||||
/** 收到 complete 后冲到 100% 的时长 */
|
||||
export const AI_LOAD_FINISH_MS = 520;
|
||||
|
||||
/**
|
||||
* 时间常数:约 11s 时达到上限的 ~90%
|
||||
* p = CAP * (1 - exp(-t / TAU))
|
||||
*/
|
||||
const TAU_MS = 4_800;
|
||||
|
||||
/** 未完成时的渐近进度 */
|
||||
export function asymptoticProgress(elapsedMs: number): number {
|
||||
const t = Math.max(0, elapsedMs);
|
||||
return AI_LOAD_CAP * (1 - Math.exp(-t / TAU_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成最终展示进度
|
||||
* @param elapsedMs 自展示起经过的毫秒
|
||||
* @param complete 接口是否已返回
|
||||
* @param completeAtMs complete 变为 true 时的 elapsed(未完成传 -1)
|
||||
*/
|
||||
export function resolveAiLoadProgress(
|
||||
elapsedMs: number,
|
||||
complete: boolean,
|
||||
completeAtMs: number,
|
||||
): { progress: number; done: boolean } {
|
||||
if (!complete || completeAtMs < 0) {
|
||||
return { progress: asymptoticProgress(elapsedMs), done: false };
|
||||
}
|
||||
const base = asymptoticProgress(completeAtMs);
|
||||
const finElapsed = elapsedMs - completeAtMs;
|
||||
if (finElapsed >= AI_LOAD_FINISH_MS) {
|
||||
return { progress: 1, done: true };
|
||||
}
|
||||
const k = Math.min(1, Math.max(0, finElapsed / AI_LOAD_FINISH_MS));
|
||||
// easeOutCubic
|
||||
const eased = 1 - (1 - k) ** 3;
|
||||
return { progress: base + (1 - base) * eased, done: false };
|
||||
}
|
||||
|
||||
/** 轮换文案(含「正在深度思考中~」) */
|
||||
export const AI_LOAD_STATUS_LINES = [
|
||||
'正在深度思考中~',
|
||||
'正在分析结构与归类…',
|
||||
'正在梳理层级关系…',
|
||||
'马上就好,请稍候…',
|
||||
] as const;
|
||||
|
||||
export function statusLineAt(elapsedMs: number, lines: readonly string[] = AI_LOAD_STATUS_LINES): string {
|
||||
if (!lines.length) return '正在深度思考中~';
|
||||
const idx = Math.floor(elapsedMs / 2800) % lines.length;
|
||||
return lines[idx] || lines[0]!;
|
||||
}
|
||||
@@ -67,6 +67,9 @@ const storeId = ref(0);
|
||||
const imageUrls = ref<string[]>([]);
|
||||
const uploading = ref(false);
|
||||
const loading = ref(false);
|
||||
/** 识图接口已返回,loading 冲到 100% 后再卸挂载 */
|
||||
const ocrLoadComplete = ref(false);
|
||||
const histLoadComplete = ref(false);
|
||||
const medicalAdvice = ref('');
|
||||
const clinicalDiagnose = ref('');
|
||||
const dosage = ref(7);
|
||||
@@ -156,7 +159,13 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
drawerOpen.value = !!isOpen;
|
||||
if (isOpen) {
|
||||
void loadHistory();
|
||||
return;
|
||||
}
|
||||
// 关抽屉时清掉 loading,避免下次打开卡在半完成态
|
||||
loading.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
histDetailLoading.value = false;
|
||||
histLoadComplete.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -293,6 +302,7 @@ async function onSelectHistory(row: any) {
|
||||
if (!id) return;
|
||||
activeGenerationId.value = id;
|
||||
histDetailLoading.value = true;
|
||||
histLoadComplete.value = false;
|
||||
try {
|
||||
const detail = await aiGenerationDetailApi({ id });
|
||||
applyOcrPayload({
|
||||
@@ -303,10 +313,11 @@ async function onSelectHistory(row: any) {
|
||||
historyList.value = historyList.value.map((r) =>
|
||||
Number(r.id) === id ? { ...r, is_read: 1 } : r,
|
||||
);
|
||||
histLoadComplete.value = true;
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史失败');
|
||||
} finally {
|
||||
histDetailLoading.value = false;
|
||||
histLoadComplete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +489,7 @@ function runOcr() {
|
||||
*/
|
||||
async function requestOcr() {
|
||||
loading.value = true;
|
||||
ocrLoadComplete.value = false;
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
unmatchedPicked.value = {};
|
||||
@@ -493,6 +505,8 @@ async function requestOcr() {
|
||||
});
|
||||
if (!res?.ok) {
|
||||
message.error(res?.message || '识图失败');
|
||||
loading.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
return;
|
||||
}
|
||||
applyOcrPayload({
|
||||
@@ -506,13 +520,27 @@ async function requestOcr() {
|
||||
message.success(
|
||||
unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成',
|
||||
);
|
||||
// 等进度条冲到 100% 再卸 loading(@finished)
|
||||
ocrLoadComplete.value = true;
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '识图失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 识图 Loading 到 100% 后卸挂载 */
|
||||
function onOcrLoadFinished() {
|
||||
loading.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
}
|
||||
|
||||
/** 历史详情 Loading 到 100% 后卸挂载 */
|
||||
function onHistLoadFinished() {
|
||||
histDetailLoading.value = false;
|
||||
histLoadComplete.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店药库行药名:product-list 药名在 drug.drug_name / matched_alias,顶层常为空
|
||||
* 统一扁平化,避免 Tag/已选显示空白
|
||||
@@ -886,9 +914,18 @@ defineExpose({
|
||||
<!-- 右栏:内容区可滚,上传态操作条钉在底部不随内容滚走 -->
|
||||
<div class="ocr-right flex min-h-0 min-w-[420px] flex-1 flex-col">
|
||||
<div class="ocr-right-scroll min-h-0 flex-1 space-y-3 overflow-y-auto">
|
||||
<OcrImageLoading v-if="loading" title="AI 处方识别" />
|
||||
<OcrImageLoading
|
||||
v-if="loading"
|
||||
title="AI 处方识别"
|
||||
:complete="ocrLoadComplete"
|
||||
@finished="onOcrLoadFinished"
|
||||
/>
|
||||
<div v-else-if="histDetailLoading" class="py-8">
|
||||
<OcrImageLoading title="加载识图记录" />
|
||||
<OcrImageLoading
|
||||
title="加载识图记录"
|
||||
:complete="histLoadComplete"
|
||||
@finished="onHistLoadFinished"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -75,12 +75,17 @@ const phase = ref<Phase>('upload');
|
||||
const submitResult = ref<Record<string, any> | null>(null);
|
||||
const imageUrls = ref<string[]>([]);
|
||||
const recognizing = ref(false);
|
||||
/** 识图接口已返回,loading 冲到 100% 后再卸挂载 */
|
||||
const ocrLoadComplete = ref(false);
|
||||
const confirming = ref(false);
|
||||
/** 我的生成记录(传方识图,按医生维度) */
|
||||
const historyList = ref<any[]>([]);
|
||||
const histLoading = ref(false);
|
||||
const activeGenerationId = ref(0);
|
||||
const histDetailLoading = ref(false);
|
||||
const histLoadComplete = ref(false);
|
||||
/** 历史加载完成后待切 form 的标记(等进度条到 100%) */
|
||||
const pendingHistToForm = ref(false);
|
||||
|
||||
const medicalAdvice = ref('');
|
||||
const clinicalDiagnose = ref('');
|
||||
@@ -465,6 +470,8 @@ async function onSelectHistory(row: any) {
|
||||
if (!id) return;
|
||||
activeGenerationId.value = id;
|
||||
histDetailLoading.value = true;
|
||||
histLoadComplete.value = false;
|
||||
pendingHistToForm.value = false;
|
||||
try {
|
||||
const detailApi = isClinicMode.value
|
||||
? clinicAiGenerationDetailApi
|
||||
@@ -475,13 +482,14 @@ async function onSelectHistory(row: any) {
|
||||
historyList.value = historyList.value.map((r) =>
|
||||
Number(r.id) === id ? { ...r, is_read: 1 } : r,
|
||||
);
|
||||
phase.value = 'form';
|
||||
suppressPatientMatch = false;
|
||||
void debouncedQuote();
|
||||
// 等进度条到 100% 再切 form,避免 overlay 被卸掉
|
||||
pendingHistToForm.value = true;
|
||||
histLoadComplete.value = true;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载历史失败');
|
||||
} finally {
|
||||
histDetailLoading.value = false;
|
||||
histLoadComplete.value = false;
|
||||
suppressPatientMatch = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,6 +666,7 @@ function handleRecognize() {
|
||||
/** 带上收货人文本调用识图接口并回填表单 */
|
||||
async function runRecognize() {
|
||||
recognizing.value = true;
|
||||
ocrLoadComplete.value = false;
|
||||
resetFormState();
|
||||
try {
|
||||
const ocrApi = isClinicMode.value
|
||||
@@ -671,6 +680,8 @@ async function runRecognize() {
|
||||
});
|
||||
if (!res?.ok) {
|
||||
message.error(res?.message || '识图失败');
|
||||
recognizing.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
return;
|
||||
}
|
||||
applyOcrPayload({
|
||||
@@ -680,21 +691,50 @@ async function runRecognize() {
|
||||
generation_id: Number(res.generation_id || 0),
|
||||
});
|
||||
await loadHistory();
|
||||
suppressPatientMatch = true;
|
||||
phase.value = 'form';
|
||||
suppressPatientMatch = false;
|
||||
void debouncedQuote();
|
||||
const unN = unmatchedList.value.length;
|
||||
message.success(
|
||||
unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成',
|
||||
);
|
||||
// 等进度条冲到 100% 再切 form(@finished)
|
||||
ocrLoadComplete.value = true;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '识图失败');
|
||||
} finally {
|
||||
recognizing.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 识图 Loading 到 100% 后卸挂载并进入表单 */
|
||||
function onOcrLoadFinished() {
|
||||
recognizing.value = false;
|
||||
ocrLoadComplete.value = false;
|
||||
suppressPatientMatch = true;
|
||||
phase.value = 'form';
|
||||
suppressPatientMatch = false;
|
||||
void debouncedQuote();
|
||||
}
|
||||
|
||||
/** 历史详情 Loading 到 100% 后卸挂载并进入表单 */
|
||||
function onHistLoadFinished() {
|
||||
histDetailLoading.value = false;
|
||||
histLoadComplete.value = false;
|
||||
if (pendingHistToForm.value) {
|
||||
pendingHistToForm.value = false;
|
||||
phase.value = 'form';
|
||||
suppressPatientMatch = false;
|
||||
void debouncedQuote();
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传区 overlay 的 finished 分流:历史加载 vs 识图 */
|
||||
function onTransferLoadFinished() {
|
||||
if (histDetailLoading.value) {
|
||||
onHistLoadFinished();
|
||||
return;
|
||||
}
|
||||
onOcrLoadFinished();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未对照药:接诊同款药名气泡选中后落库
|
||||
*/
|
||||
@@ -969,6 +1009,8 @@ async function handleConfirm() {
|
||||
>
|
||||
<OcrImageLoading
|
||||
:title="histDetailLoading ? '加载识图记录' : 'AI 处方识别'"
|
||||
:complete="histDetailLoading ? histLoadComplete : ocrLoadComplete"
|
||||
@finished="onTransferLoadFinished"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export const AI_GENERATION_SCENE_OPTIONS = [
|
||||
{ label: '每日简报', value: 'daily_brief' },
|
||||
{ label: '识图开方', value: 'prescription_image_ocr' },
|
||||
{ label: '识图传方', value: 'transfer_image_ocr' },
|
||||
{ label: '菜单层级整理', value: 'menu_organize' },
|
||||
];
|
||||
|
||||
/** 是否命中业务缓存(简报按天缓存等) */
|
||||
|
||||
31
apps/web-antd/src/views/system/ai/prompt/api/index.ts
Normal file
31
apps/web-antd/src/views/system/ai/prompt/api/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* AI 场景提示词(直连/Agent)
|
||||
* xk-api:ai-prompt/*
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'ai-prompt/';
|
||||
|
||||
/** 列表 xk-api GET /ai-prompt/list */
|
||||
export async function getAiPromptList(params?: {
|
||||
scene?: string;
|
||||
channel?: string;
|
||||
status?: number | string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
/** 详情 xk-api GET /ai-prompt/detail */
|
||||
export async function getAiPromptDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/** 更新 xk-api POST /ai-prompt/update */
|
||||
export async function updateAiPrompt(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/** 同步代码种子 xk-api POST /ai-prompt/ensure-defaults */
|
||||
export async function ensureAiPromptDefaults() {
|
||||
return requestClient.post<any>(`${prefix}ensure-defaults`, {});
|
||||
}
|
||||
@@ -132,3 +132,58 @@ export async function sortMenuSiblings(data: {
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}sort-siblings`, data);
|
||||
}
|
||||
|
||||
/** 按标题推荐 name/path/icon xk-api GET /menu/suggest-from-title */
|
||||
export async function suggestMenuFromTitle(data: {
|
||||
title: string;
|
||||
pid?: number;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
name: string;
|
||||
path: string;
|
||||
path_segment: string;
|
||||
component_hint: string;
|
||||
icons: string[];
|
||||
icon: string;
|
||||
}>(`${prefix}suggest-from-title`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 整理建议 xk-api POST /menu/ai-organize-suggest(instruction 可选一句话跨父级整理) */
|
||||
export async function aiOrganizeSuggest(data: {
|
||||
pid: number;
|
||||
instruction?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-organize-suggest`, data);
|
||||
}
|
||||
|
||||
/** AI 整理预览 xk-api GET /menu/ai-organize-preview */
|
||||
export async function aiOrganizePreview(generationId: number) {
|
||||
return requestClient.get<any>(`${prefix}ai-organize-preview`, {
|
||||
params: { generation_id: generationId },
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 整理应用 xk-api POST /menu/ai-organize-apply */
|
||||
export async function aiOrganizeApply(generationId: number) {
|
||||
return requestClient.post<any>(`${prefix}ai-organize-apply`, {
|
||||
generation_id: generationId,
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 整理取消 xk-api POST /menu/ai-organize-cancel */
|
||||
export async function aiOrganizeCancel(generationId: number) {
|
||||
return requestClient.post<any>(`${prefix}ai-organize-cancel`, {
|
||||
generation_id: generationId,
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 整理历史 xk-api GET /menu/ai-organize-history */
|
||||
export async function aiOrganizeHistory(data: {
|
||||
pid?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ai-organize-history`, {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
* - 当前层级(栈顶父级)在导航工具栏可编辑/授权/移动/删除
|
||||
* - 搜索:扁平列出命中项并高亮路径;可「定位到所在层级」切入浏览
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Breadcrumb, Button, Empty, Tag } from 'ant-design-vue';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
|
||||
import { Breadcrumb, Button, Empty, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { CardActions } from '#/components/table-action';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { sortMenuSiblings } from '../api';
|
||||
import { MENU_TYPE_MAP } from '../config/constants';
|
||||
|
||||
/** 下钻路径 id 列表本地记忆 */
|
||||
@@ -31,6 +34,17 @@ const props = defineProps<{
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 当前层拖拽排序成功后通知父级刷新 */
|
||||
sorted: [];
|
||||
/** 打开 AI 整理(带当前父级) */
|
||||
'ai-organize': [payload: { pid: number; parentTitle: string }];
|
||||
}>();
|
||||
|
||||
const cardGridEl = ref<HTMLElement | null>(null);
|
||||
let sortableInst: { destroy?: () => void } | null = null;
|
||||
const sortSaving = ref(false);
|
||||
|
||||
interface TreeNode {
|
||||
id: number;
|
||||
pid: number;
|
||||
@@ -279,6 +293,85 @@ function childCountLabel(node: TreeNode) {
|
||||
const n = node.children.length;
|
||||
return n > 0 ? `${n} 个子菜单` : '叶子菜单';
|
||||
}
|
||||
|
||||
/** 当前层是否允许拖拽排序(浏览态且非搜索扁平列表) */
|
||||
const canDragSort = computed(
|
||||
() => showBrowseNav.value && displayCards.value.length > 1,
|
||||
);
|
||||
|
||||
async function destroySortable() {
|
||||
sortableInst?.destroy?.();
|
||||
sortableInst = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化当前层卡片拖拽;拖完写 sort-siblings
|
||||
* 为什么销毁再建:下钻/搜索切换后 DOM 节点会换
|
||||
*/
|
||||
async function setupCardSortable() {
|
||||
await destroySortable();
|
||||
await nextTick();
|
||||
if (!canDragSort.value || !cardGridEl.value) return;
|
||||
const { initializeSortable } = useSortable(cardGridEl.value, {
|
||||
animation: 200,
|
||||
handle: '.menu-card__drag',
|
||||
draggable: '.menu-card',
|
||||
onEnd: async (evt: { oldIndex?: number; newIndex?: number }) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const list = [...displayCards.value];
|
||||
const [moved] = list.splice(oldIndex, 1);
|
||||
if (!moved) return;
|
||||
list.splice(newIndex, 0, moved);
|
||||
const pid = currentParent.value?.id ?? 0;
|
||||
sortSaving.value = true;
|
||||
try {
|
||||
await sortMenuSiblings({
|
||||
pid,
|
||||
items: list.map((n, i) => ({ id: n.id, sort: i })),
|
||||
});
|
||||
message.success('排序已保存');
|
||||
emit('sorted');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
emit('sorted');
|
||||
} finally {
|
||||
sortSaving.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
sortableInst = await initializeSortable();
|
||||
}
|
||||
|
||||
watch(
|
||||
[displayCards, canDragSort],
|
||||
() => {
|
||||
nextTick(() => setupCardSortable());
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroySortable();
|
||||
});
|
||||
|
||||
function emitAiOrganize() {
|
||||
emit('ai-organize', {
|
||||
pid: currentParent.value?.id ?? 0,
|
||||
parentTitle: currentParent.value?.title || '根目录',
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getCurrentPid: () => currentParent.value?.id ?? 0,
|
||||
getCurrentTitle: () => currentParent.value?.title || '根目录',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -306,11 +399,21 @@ function childCountLabel(node: TreeNode) {
|
||||
当前:{{ currentParent.title }}
|
||||
</span>
|
||||
<CardActions
|
||||
size="sm"
|
||||
:actions="currentLevelActions.actions"
|
||||
:drop-down-actions="currentLevelActions.dropDownActions"
|
||||
:max-visible="3"
|
||||
/>
|
||||
</template>
|
||||
<Button size="small" type="default" @click="emitAiOrganize">
|
||||
AI 整理
|
||||
</Button>
|
||||
<span
|
||||
v-if="canDragSort"
|
||||
class="menu-card-nav__drag-hint"
|
||||
>
|
||||
{{ sortSaving ? '保存排序中…' : '拖动手柄可排序' }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="kw && locatingFromSearch"
|
||||
type="link"
|
||||
@@ -336,7 +439,7 @@ function childCountLabel(node: TreeNode) {
|
||||
</div>
|
||||
|
||||
<Empty v-if="displayCards.length === 0" description="暂无菜单" />
|
||||
<div v-else class="menu-card-grid">
|
||||
<div v-else ref="cardGridEl" class="menu-card-grid">
|
||||
<div
|
||||
v-for="card in displayCards"
|
||||
:key="card.id"
|
||||
@@ -347,6 +450,14 @@ function childCountLabel(node: TreeNode) {
|
||||
card.children.length > 0 || (!!kw && !locatingFromSearch),
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="canDragSort"
|
||||
class="menu-card__drag"
|
||||
title="拖拽排序"
|
||||
@click.stop
|
||||
>
|
||||
<Icon icon="lucide:grip-vertical" />
|
||||
</span>
|
||||
<div class="menu-card__head" @click="onCardBodyClick(card)">
|
||||
<!-- icon 右侧放大,不挤左侧标题 -->
|
||||
<div v-if="card.icon" class="menu-card__icon-wrap" aria-hidden="true">
|
||||
@@ -391,6 +502,7 @@ function childCountLabel(node: TreeNode) {
|
||||
<Icon icon="ant-design:right-outlined" class="ml-0.5" />
|
||||
</Button>
|
||||
<CardActions
|
||||
size="sm"
|
||||
:actions="buildRowActions(card.raw).actions"
|
||||
:drop-down-actions="buildRowActions(card.raw).dropDownActions"
|
||||
:max-visible="1"
|
||||
@@ -445,6 +557,11 @@ function childCountLabel(node: TreeNode) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.menu-card-nav__drag-hint {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.menu-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
@@ -464,6 +581,7 @@ function childCountLabel(node: TreeNode) {
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -476,6 +594,23 @@ function childCountLabel(node: TreeNode) {
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-card__drag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: grab;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-card__drag:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 40%);
|
||||
}
|
||||
|
||||
.menu-card.is-clickable .menu-card__head {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -497,6 +632,10 @@ function childCountLabel(node: TreeNode) {
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.menu-card:has(.menu-card__drag) .menu-card__head {
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.menu-card__icon-wrap {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
|
||||
@@ -0,0 +1,915 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 整理:建议 → 可读预览 → 应用/取消 + 历史
|
||||
* 支持可选一句话指令(跨父级新建目录并迁入);输入与生成按钮固定在顶栏,无需滚动
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Modal as AModal,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import MenuOrganizeLoading from '#/components/loading/MenuOrganizeLoading.vue';
|
||||
|
||||
import {
|
||||
aiOrganizeApply,
|
||||
aiOrganizeCancel,
|
||||
aiOrganizeHistory,
|
||||
aiOrganizePreview,
|
||||
aiOrganizeSuggest,
|
||||
} from '../api';
|
||||
|
||||
const TabsTabPane = Tabs.TabPane;
|
||||
const TextArea = Input.TextArea;
|
||||
|
||||
const pid = ref(0);
|
||||
const parentTitle = ref('');
|
||||
/** 用户一句话要求(可空;有内容时可跨父级迁菜单) */
|
||||
const instruction = ref('');
|
||||
const activeTab = ref<'preview' | 'history'>('preview');
|
||||
const generationId = ref(0);
|
||||
const preview = ref<Record<string, any> | null>(null);
|
||||
const suggesting = ref(false);
|
||||
/** 接口已返回,loading 冲到 100% 后再卸挂载 */
|
||||
const suggestComplete = ref(false);
|
||||
const applying = ref(false);
|
||||
const historyItems = ref<any[]>([]);
|
||||
const historyLoading = ref(false);
|
||||
const onSuccess = ref<(() => void) | null>(null);
|
||||
|
||||
const suggestBtnText = computed(() => {
|
||||
if (suggesting.value) return '正在生成…';
|
||||
return preview.value ? '重新生成' : '生成建议';
|
||||
});
|
||||
/** 一句话要求弹窗(草稿,确认后再写入 instruction 并生成) */
|
||||
const instructOpen = ref(false);
|
||||
const instructDraft = ref('');
|
||||
|
||||
const readonly = computed(() => !!preview.value?.readonly);
|
||||
const applyStatus = computed(
|
||||
() => String(preview.value?.apply_status || 'pending'),
|
||||
);
|
||||
|
||||
/** 把「迁入列表」归到各新建分组下,方便按文件夹读 */
|
||||
const groupCards = computed(() => {
|
||||
const groups = Array.isArray(preview.value?.create_groups)
|
||||
? preview.value!.create_groups
|
||||
: [];
|
||||
const moves = Array.isArray(preview.value?.moves) ? preview.value!.moves : [];
|
||||
return groups.map((g: any) => {
|
||||
const tempKey = String(g.temp_key || '');
|
||||
const title = String(g.title || '');
|
||||
const children = moves.filter(
|
||||
(m: any) =>
|
||||
String(m.temp_key || '') === tempKey ||
|
||||
String(m.to_group || '') === title,
|
||||
);
|
||||
return {
|
||||
temp_key: tempKey,
|
||||
title,
|
||||
icon: String(g.icon || 'lucide:folder'),
|
||||
reason: String(g.reason || ''),
|
||||
parent_label: String(g.parent_label || ''),
|
||||
children,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const ungroupedItems = computed(() =>
|
||||
Array.isArray(preview.value?.ungrouped) ? preview.value!.ungrouped : [],
|
||||
);
|
||||
|
||||
/** AI 返回的可读总结(移动了什么 / 为什么 / 好处) */
|
||||
const aiSummary = computed(() => {
|
||||
const s = preview.value?.summary;
|
||||
if (!s || typeof s !== 'object') {
|
||||
return {
|
||||
overview: '',
|
||||
changes: [] as string[],
|
||||
reasons: [] as string[],
|
||||
benefits: [] as string[],
|
||||
};
|
||||
}
|
||||
return {
|
||||
overview: String(s.overview || ''),
|
||||
changes: Array.isArray(s.changes) ? s.changes.map(String) : [],
|
||||
reasons: Array.isArray(s.reasons) ? s.reasons.map(String) : [],
|
||||
benefits: Array.isArray(s.benefits) ? s.benefits.map(String) : [],
|
||||
};
|
||||
});
|
||||
|
||||
const summaryMetaText = computed(() => {
|
||||
const g = groupCards.value.length;
|
||||
const m = (preview.value?.moves || []).length;
|
||||
const u = ungroupedItems.value.length;
|
||||
if (!preview.value) return '';
|
||||
const parts: string[] = [];
|
||||
if (g > 0) parts.push(`新建 ${g} 个目录`);
|
||||
if (m > 0) parts.push(`迁入 ${m} 项`);
|
||||
if (u > 0) parts.push(`未分组 ${u} 项`);
|
||||
return parts.length ? parts.join(' · ') : '无结构变更';
|
||||
});
|
||||
|
||||
const historyColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '状态', key: 'apply_status', width: 90 },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const res = await aiOrganizeHistory({
|
||||
pid: pid.value,
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
});
|
||||
historyItems.value = Array.isArray(res?.items) ? res.items : [];
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
historyItems.value = [];
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开「生成/重新生成」弹窗:可选填一句话要求,确认后再请求
|
||||
* 为什么用弹窗:长 TextArea 占顶栏空间,且生成前才需要填
|
||||
*/
|
||||
function openInstructModal() {
|
||||
if (suggesting.value) return;
|
||||
instructDraft.value = instruction.value;
|
||||
instructOpen.value = true;
|
||||
}
|
||||
|
||||
/** 弹窗确认:写入要求并开始生成 */
|
||||
function confirmInstructAndSuggest() {
|
||||
instruction.value = String(instructDraft.value || '').trim();
|
||||
instructOpen.value = false;
|
||||
void runSuggest();
|
||||
}
|
||||
|
||||
async function runSuggest() {
|
||||
suggesting.value = true;
|
||||
suggestComplete.value = false;
|
||||
preview.value = null;
|
||||
activeTab.value = 'preview';
|
||||
try {
|
||||
const res = await aiOrganizeSuggest({
|
||||
pid: pid.value,
|
||||
instruction: instruction.value.trim() || undefined,
|
||||
});
|
||||
generationId.value = Number(res?.generation_id || 0);
|
||||
preview.value = res?.preview || null;
|
||||
if (res?.preview?.instruction) {
|
||||
instruction.value = String(res.preview.instruction);
|
||||
}
|
||||
message.success('已生成整理建议');
|
||||
await loadHistory();
|
||||
// 等进度条冲到 100% 再卸 loading(@finished)
|
||||
suggestComplete.value = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
suggesting.value = false;
|
||||
suggestComplete.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Loading 动画到 100% 后卸挂载,露出预览 */
|
||||
function onOrganizeLoadFinished() {
|
||||
suggesting.value = false;
|
||||
suggestComplete.value = false;
|
||||
}
|
||||
|
||||
async function openHistoryItem(id: number) {
|
||||
try {
|
||||
const res = await aiOrganizePreview(id);
|
||||
generationId.value = id;
|
||||
preview.value = res || null;
|
||||
if (res?.instruction) {
|
||||
instruction.value = String(res.instruction);
|
||||
}
|
||||
activeTab.value = 'preview';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!generationId.value) {
|
||||
message.warning('请先生成建议');
|
||||
return;
|
||||
}
|
||||
applying.value = true;
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await aiOrganizeApply(generationId.value);
|
||||
message.success('已应用整理建议');
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
applying.value = false;
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelSuggest() {
|
||||
if (!generationId.value) {
|
||||
modalApi.close();
|
||||
return;
|
||||
}
|
||||
if (readonly.value) {
|
||||
modalApi.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aiOrganizeCancel(generationId.value);
|
||||
message.success('已取消建议');
|
||||
preview.value = null;
|
||||
generationId.value = 0;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
confirmText: '应用建议',
|
||||
cancelText: '关闭',
|
||||
// 内容区拉高,与模板 class w-[80%] h-[70%] 配套
|
||||
contentClass: 'ai-organize-modal-body',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (suggesting.value) {
|
||||
message.info('正在生成建议,请稍候');
|
||||
return;
|
||||
}
|
||||
if (readonly.value) {
|
||||
message.info('该记录已应用或已取消,仅可查看');
|
||||
return;
|
||||
}
|
||||
if (!preview.value) {
|
||||
message.warning('请先生成建议');
|
||||
return;
|
||||
}
|
||||
await handleApply();
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
preview.value = null;
|
||||
generationId.value = 0;
|
||||
historyItems.value = [];
|
||||
onSuccess.value = null;
|
||||
suggesting.value = false;
|
||||
suggestComplete.value = false;
|
||||
instruction.value = '';
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
pid?: number;
|
||||
parentTitle?: string;
|
||||
onSuccess?: () => void;
|
||||
}>();
|
||||
pid.value = Number(data?.pid ?? 0);
|
||||
parentTitle.value = String(
|
||||
data?.parentTitle || (pid.value ? '' : '根目录'),
|
||||
);
|
||||
instruction.value = '';
|
||||
onSuccess.value = data?.onSuccess ?? null;
|
||||
activeTab.value = 'preview';
|
||||
await loadHistory();
|
||||
},
|
||||
});
|
||||
|
||||
function statusTag(status: string) {
|
||||
if (status === 'applied') return { color: 'success', text: '已应用' };
|
||||
if (status === 'cancelled') return { color: 'default', text: '已取消' };
|
||||
return { color: 'processing', text: '待应用' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="AI 整理子菜单" class="ai-organize-modal w-[80%] h-[70%]">
|
||||
<div class="ai-org">
|
||||
<!-- 顶栏紧凑:范围 + 生成入口(要求在弹窗里填,不占版面) -->
|
||||
<div class="ai-org__toolbar">
|
||||
<div class="ai-org__scope">
|
||||
<Icon icon="lucide:folder-tree" class="ai-org__scope-icon" />
|
||||
<div>
|
||||
<div class="ai-org__scope-title">
|
||||
锚点:{{ parentTitle || `ID ${pid}` }}
|
||||
</div>
|
||||
<div class="ai-org__scope-desc">
|
||||
可不填要求(只整理本层);也可在弹窗里写一句话跨父级归类迁入
|
||||
</div>
|
||||
<div
|
||||
v-if="instruction"
|
||||
class="ai-org__instruction-chip"
|
||||
:title="instruction"
|
||||
>
|
||||
当前要求:{{ instruction }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="suggesting"
|
||||
:disabled="suggesting"
|
||||
@click="openInstructModal"
|
||||
>
|
||||
<Icon v-if="!suggesting" icon="lucide:sparkles" class="mr-1" />
|
||||
{{ suggestBtnText }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs v-model:active-key="activeTab" class="ai-org__tabs">
|
||||
<TabsTabPane key="preview" tab="整理预览">
|
||||
<div v-if="suggesting" class="ai-org__loading">
|
||||
<MenuOrganizeLoading
|
||||
title="AI 菜单整理"
|
||||
:complete="suggestComplete"
|
||||
class="w-full max-w-[720px]"
|
||||
@finished="onOrganizeLoadFinished"
|
||||
/>
|
||||
</div>
|
||||
<Empty
|
||||
v-else-if="!preview"
|
||||
class="ai-org__empty"
|
||||
description="点右上角「生成建议」,弹窗里可填一句话要求;不填则只整理本层"
|
||||
/>
|
||||
<div v-else class="ai-org__preview">
|
||||
<div class="ai-org__summary">
|
||||
<div class="ai-org__summary-head">
|
||||
<Tag :color="statusTag(applyStatus).color">
|
||||
{{ statusTag(applyStatus).text }}
|
||||
</Tag>
|
||||
<span class="ai-org__meta">{{ summaryMetaText }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="preview.instruction"
|
||||
class="ai-org__instruction-echo"
|
||||
>
|
||||
<Icon icon="lucide:message-square-quote" />
|
||||
<span>本次要求:{{ preview.instruction }}</span>
|
||||
</div>
|
||||
<div v-if="aiSummary.overview" class="ai-org__overview">
|
||||
{{ aiSummary.overview }}
|
||||
</div>
|
||||
<div class="ai-org__explain">
|
||||
<div
|
||||
v-if="aiSummary.changes.length"
|
||||
class="ai-org__explain-block"
|
||||
>
|
||||
<div class="ai-org__explain-title">
|
||||
<Icon icon="lucide:move" />
|
||||
移动了什么
|
||||
</div>
|
||||
<ul class="ai-org__explain-list">
|
||||
<li
|
||||
v-for="(line, i) in aiSummary.changes"
|
||||
:key="`c-${i}`"
|
||||
>
|
||||
{{ line }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="aiSummary.reasons.length"
|
||||
class="ai-org__explain-block"
|
||||
>
|
||||
<div class="ai-org__explain-title">
|
||||
<Icon icon="lucide:lightbulb" />
|
||||
为什么这样移动
|
||||
</div>
|
||||
<ul class="ai-org__explain-list">
|
||||
<li
|
||||
v-for="(line, i) in aiSummary.reasons"
|
||||
:key="`r-${i}`"
|
||||
>
|
||||
{{ line }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="aiSummary.benefits.length"
|
||||
class="ai-org__explain-block"
|
||||
>
|
||||
<div class="ai-org__explain-title">
|
||||
<Icon icon="lucide:sparkles" />
|
||||
好处是什么
|
||||
</div>
|
||||
<ul class="ai-org__explain-list">
|
||||
<li
|
||||
v-for="(line, i) in aiSummary.benefits"
|
||||
:key="`b-${i}`"
|
||||
>
|
||||
{{ line }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-org__section-label">
|
||||
<Icon icon="lucide:folder-plus" />
|
||||
将新建这些目录(点应用后才会真正创建)
|
||||
</div>
|
||||
<Empty
|
||||
v-if="groupCards.length === 0"
|
||||
description="本次不建议新建目录"
|
||||
:image-style="{ height: '48px' }"
|
||||
/>
|
||||
<div v-else class="ai-org__folders">
|
||||
<div
|
||||
v-for="card in groupCards"
|
||||
:key="card.temp_key"
|
||||
class="ai-org__folder"
|
||||
>
|
||||
<div class="ai-org__folder-head">
|
||||
<Icon :icon="card.icon" class="ai-org__folder-icon" />
|
||||
<div class="ai-org__folder-title">{{ card.title }}</div>
|
||||
<Tag class="!m-0">{{ card.children.length }} 项</Tag>
|
||||
</div>
|
||||
<div class="ai-org__folder-body">
|
||||
<div
|
||||
v-if="card.parent_label"
|
||||
class="ai-org__folder-parent"
|
||||
>
|
||||
挂在:{{ card.parent_label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="card.reason"
|
||||
class="ai-org__folder-reason"
|
||||
>
|
||||
为何建此目录:{{ card.reason }}
|
||||
</div>
|
||||
<div class="ai-org__folder-hint">将迁入:</div>
|
||||
<div
|
||||
v-if="card.children.length === 0"
|
||||
class="ai-org__muted"
|
||||
>
|
||||
(空目录,可挂子目录或后续迁入)
|
||||
</div>
|
||||
<div v-else class="ai-org__chips">
|
||||
<span
|
||||
v-for="child in card.children"
|
||||
:key="child.menu_id"
|
||||
class="ai-org__chip"
|
||||
:title="
|
||||
child.from_parent
|
||||
? `来自「${child.from_parent}」`
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<Icon icon="lucide:file-text" class="ai-org__chip-icon" />
|
||||
<span class="ai-org__chip-text">
|
||||
{{ child.title }}
|
||||
<i v-if="child.from_parent">
|
||||
← {{ child.from_parent }}
|
||||
</i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="ungroupedItems.length > 0">
|
||||
<div class="ai-org__section-label">
|
||||
<Icon icon="lucide:layout-list" />
|
||||
仍留在「{{ parentTitle || '当前层' }}」下(不进新目录)
|
||||
</div>
|
||||
<div class="ai-org__chips ai-org__pad">
|
||||
<span
|
||||
v-for="item in ungroupedItems"
|
||||
:key="item.id"
|
||||
class="ai-org__chip ai-org__chip--stay"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!readonly" class="ai-org__actions">
|
||||
<Button danger :disabled="applying" @click="handleCancelSuggest">
|
||||
取消建议
|
||||
</Button>
|
||||
<span class="ai-org__muted">
|
||||
取消不会改菜单;应用才会创建目录并移动子项。改要求请点「重新生成」
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsTabPane>
|
||||
<TabsTabPane key="history" tab="历史记录">
|
||||
<Table
|
||||
size="small"
|
||||
:loading="historyLoading"
|
||||
:pagination="false"
|
||||
:columns="historyColumns"
|
||||
:data-source="historyItems"
|
||||
row-key="id"
|
||||
:scroll="{ y: 420 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'apply_status'">
|
||||
<Tag :color="statusTag(record.apply_status).color">
|
||||
{{ statusTag(record.apply_status).text }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openHistoryItem(record.id)"
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</TabsTabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
<!-- 底栏「重新生成」:同样打开要求弹窗,无需滚到顶 -->
|
||||
<template #prepend-footer>
|
||||
<Button
|
||||
:loading="suggesting"
|
||||
:disabled="suggesting || applying"
|
||||
@click="openInstructModal"
|
||||
>
|
||||
<Icon v-if="!suggesting" icon="lucide:refresh-cw" class="mr-1" />
|
||||
{{ preview ? '重新生成' : '生成建议' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 一句话要求:模态输入,确认后再生成 -->
|
||||
<AModal
|
||||
v-model:open="instructOpen"
|
||||
title="生成整理建议"
|
||||
:confirm-loading="suggesting"
|
||||
ok-text="开始生成"
|
||||
cancel-text="取消"
|
||||
destroy-on-close
|
||||
@ok="confirmInstructAndSuggest"
|
||||
>
|
||||
<div class="ai-org__instruct-modal">
|
||||
<div class="ai-org__instruct-hint">
|
||||
可选:用一句话描述目标结构。留空则只整理当前锚点下的平铺子菜单。
|
||||
</div>
|
||||
<TextArea
|
||||
v-model:value="instructDraft"
|
||||
:rows="5"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
placeholder="例如:把监管开放应用和 MES 开放应用放在基础管理下面的开放应用,分别建目录,并把其他目录中的相关菜单迁过来"
|
||||
/>
|
||||
</div>
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-org {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(70vh - 120px);
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.ai-org__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ai-org__instruction-chip {
|
||||
max-width: 520px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--primary));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-org__instruct-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-org__instruct-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__instruction-echo {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__folder-parent {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-org__chip-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-org__chip-text i {
|
||||
margin-left: 4px;
|
||||
font-style: normal;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__scope {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-org__scope-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
font-size: 20px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-org__scope-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-org__scope-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-org__tabs :deep(.ant-tabs-content),
|
||||
.ai-org__tabs :deep(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ai-org__tabs :deep(.ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ai-org__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 340px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.ai-org__empty {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.ai-org__preview {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.ai-org__summary {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
border: 1px solid hsl(var(--primary) / 28%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 14%);
|
||||
}
|
||||
|
||||
.ai-org__summary-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ai-org__meta {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__overview {
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ai-org__explain {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-org__explain-block {
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--card, var(--background)) / 80%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ai-org__explain-title {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-org__explain-list {
|
||||
padding-left: 18px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__explain-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-org__folder-reason {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__section-label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ai-org__folders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ai-org__folder {
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--primary) / 30%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 16%);
|
||||
}
|
||||
|
||||
.ai-org__folder-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--muted) / 25%);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ai-org__folder-icon {
|
||||
font-size: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ai-org__folder-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-org__folder-body {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.ai-org__folder-hint {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-org__chip {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 35%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ai-org__chip--stay {
|
||||
background: hsl(var(--muted) / 20%);
|
||||
}
|
||||
|
||||
.ai-org__chip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__muted {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ai-org__pad {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ai-org__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 模态外壳拉宽拉高(class 挂在 Vben Modal 根上,需非 scoped) */
|
||||
.ai-organize-modal {
|
||||
width: 80% !important;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.ai-organize-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(70vh - 110px);
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 菜单新增/编辑弹窗
|
||||
* - 新增:pick 选类型 → form 填表 → sort 同级排序(可跳过)
|
||||
* - 新增:pick 选类型 → form 填表(标题自动匹配 name/path/icon)→ sort 同级排序(可跳过)
|
||||
* - 编辑:拉详情回填;保存后直接关闭
|
||||
* - 切换父级 pid 时自动 next-sort 填入 sort
|
||||
*/
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getMenuNextSort,
|
||||
getMenuSiblings,
|
||||
sortMenuSiblings,
|
||||
suggestMenuFromTitle,
|
||||
updateMenu,
|
||||
} from '../api';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
menuKindFromType,
|
||||
} from '../config/constants';
|
||||
import { modalFormProps } from '../config/form';
|
||||
import { createDirtyTracker, debounceFn } from '../utils/auto-fill';
|
||||
import MenuKindPickCard from './menu-kind-pick-card.vue';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
@@ -42,6 +44,11 @@ const sortPid = ref(0);
|
||||
const highlightId = ref(0);
|
||||
/** 避免编辑回填 / 程序化改 pid 时误触发 next-sort */
|
||||
const skipPidNextSort = ref(false);
|
||||
/** 程序化自动填时跳过脏标记 */
|
||||
const skipAutoFillDirty = ref(false);
|
||||
const dirtyTracker = createDirtyTracker();
|
||||
/** 图标推荐候选(默认已选第一个写入 form) */
|
||||
const iconCandidates = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* 按当前 pid 同步目录的 component 可编辑状态
|
||||
@@ -57,13 +64,10 @@ async function syncCatalogComponentByPid(pid: number) {
|
||||
link: '',
|
||||
};
|
||||
if (isTop) {
|
||||
// 顶级必须布局壳
|
||||
patch.component = 'BasicLayout';
|
||||
} else if (cur.component == null) {
|
||||
// 新建二级尚未设过:先给默认,用户可再手动清空
|
||||
patch.component = 'BasicLayout';
|
||||
}
|
||||
// 二级且已是 '' / 自定义值:原样保留,不覆盖
|
||||
await formApi.setValues(patch);
|
||||
formApi.updateSchema([
|
||||
{
|
||||
@@ -128,6 +132,51 @@ async function fillNextSort(pid: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按标题请求后端建议,仅覆盖未脏字段
|
||||
*/
|
||||
async function applyTitleSuggest(title: string, pid: number) {
|
||||
const t = String(title || '').trim();
|
||||
if (!t || isUpdate.value || step.value !== 'form') return;
|
||||
try {
|
||||
const res = await suggestMenuFromTitle({ title: t, pid });
|
||||
const data = res || {};
|
||||
iconCandidates.value = Array.isArray(data.icons) ? data.icons : [];
|
||||
const patch: Record<string, any> = {};
|
||||
if (!dirtyTracker.isDirty('name') && data.name) {
|
||||
patch.name = data.name;
|
||||
}
|
||||
if (!dirtyTracker.isDirty('path') && data.path) {
|
||||
patch.path = data.path;
|
||||
}
|
||||
if (!dirtyTracker.isDirty('icon') && data.icon) {
|
||||
patch.icon = data.icon;
|
||||
}
|
||||
// 组件菜单:未脏时填 component_hint;目录由 syncCatalog 管
|
||||
if (
|
||||
createKind.value === 'menu' &&
|
||||
!dirtyTracker.isDirty('component') &&
|
||||
data.component_hint
|
||||
) {
|
||||
const cur = await formApi.getValues();
|
||||
const ot = String(cur.open_type || cur.type || 'menu');
|
||||
if (ot === 'menu') {
|
||||
patch.component = data.component_hint;
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
skipAutoFillDirty.value = true;
|
||||
await formApi.setValues(patch);
|
||||
skipAutoFillDirty.value = false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSuggest = debounceFn((title: string, pid: number) => {
|
||||
applyTitleSuggest(title, pid);
|
||||
}, 400);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
...modalFormProps,
|
||||
handleValuesChange(values, fieldsChanged) {
|
||||
@@ -138,7 +187,6 @@ const [Form, formApi] = useVbenForm({
|
||||
) {
|
||||
syncTypeFields('menu', String(values.open_type || 'menu'));
|
||||
}
|
||||
// 非编辑锁定场景:切父级自动填 sort = max+1
|
||||
if (
|
||||
fieldsChanged?.includes('pid') &&
|
||||
step.value === 'form' &&
|
||||
@@ -146,7 +194,6 @@ const [Form, formApi] = useVbenForm({
|
||||
) {
|
||||
fillNextSort(Number(values.pid) || 0);
|
||||
}
|
||||
// 目录切换父级时:顶级锁 BasicLayout,二级解锁可清空
|
||||
if (
|
||||
fieldsChanged?.includes('pid') &&
|
||||
step.value === 'form' &&
|
||||
@@ -154,9 +201,42 @@ const [Form, formApi] = useVbenForm({
|
||||
) {
|
||||
syncCatalogComponentByPid(Number(values.pid) || 0);
|
||||
}
|
||||
// 脏标记:用户手改后不再被标题建议覆盖
|
||||
if (!skipAutoFillDirty.value && !isUpdate.value && step.value === 'form') {
|
||||
if (fieldsChanged?.includes('name')) dirtyTracker.mark('name');
|
||||
if (fieldsChanged?.includes('path')) dirtyTracker.mark('path');
|
||||
if (fieldsChanged?.includes('icon')) dirtyTracker.mark('icon');
|
||||
if (fieldsChanged?.includes('component')) dirtyTracker.mark('component');
|
||||
}
|
||||
// 标题变化 → 自动匹配(防抖)
|
||||
if (
|
||||
fieldsChanged?.includes('title') &&
|
||||
!isUpdate.value &&
|
||||
step.value === 'form'
|
||||
) {
|
||||
debouncedSuggest(String(values.title || ''), Number(values.pid) || 0);
|
||||
}
|
||||
// 切父级后若标题已有且 path 未脏,再建议一次以拼接父 path
|
||||
if (
|
||||
fieldsChanged?.includes('pid') &&
|
||||
!isUpdate.value &&
|
||||
step.value === 'form' &&
|
||||
String(values.title || '').trim()
|
||||
) {
|
||||
debouncedSuggest(String(values.title || ''), Number(values.pid) || 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 点选推荐图标 */
|
||||
async function pickIconCandidate(icon: string) {
|
||||
skipAutoFillDirty.value = true;
|
||||
await formApi.setValues({ icon });
|
||||
skipAutoFillDirty.value = false;
|
||||
// 用户主动选了推荐,视为已确认,仍允许后续改
|
||||
dirtyTracker.mark('icon');
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (isUpdate.value) return '编辑菜单';
|
||||
if (step.value === 'pick') return '新增菜单 · 选择类型';
|
||||
@@ -201,6 +281,8 @@ function applyFooterForStep(next: 'pick' | 'form' | 'sort', editing: boolean) {
|
||||
async function goToFormStep() {
|
||||
step.value = 'form';
|
||||
applyFooterForStep('form', false);
|
||||
dirtyTracker.reset();
|
||||
iconCandidates.value = [];
|
||||
skipPidNextSort.value = true;
|
||||
await formApi.reset();
|
||||
await syncTypeFields(createKind.value, 'menu');
|
||||
@@ -310,7 +392,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (isCatalogType(values.type) || createKind.value === 'catalog') {
|
||||
values.type = 'catalog';
|
||||
const pid = Number(values.pid) || 0;
|
||||
// 顶级目录强制 BasicLayout;二级目录保留用户手动值(可空)
|
||||
if (pid === 0) {
|
||||
values.component = 'BasicLayout';
|
||||
} else {
|
||||
@@ -330,7 +411,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
} else {
|
||||
const created = await createMenu(values);
|
||||
// create 的 result 即 insertGetId
|
||||
const newId = Number(created);
|
||||
message.success('保存成功');
|
||||
await enterSortStep(Number(values.pid) || 0, newId);
|
||||
@@ -348,6 +428,9 @@ const [Modal, modalApi] = useVbenModal({
|
||||
createKind.value = 'menu';
|
||||
siblingItems.value = [];
|
||||
highlightId.value = 0;
|
||||
iconCandidates.value = [];
|
||||
dirtyTracker.reset();
|
||||
debouncedSuggest.cancel();
|
||||
formApi.reset();
|
||||
return;
|
||||
}
|
||||
@@ -384,7 +467,6 @@ function typeLabel(type: string | undefined) {
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-[60%]">
|
||||
<!-- 选类型:粒子 + Canvas 动效卡 -->
|
||||
<div v-if="step === 'pick'" class="menu-pick">
|
||||
<div class="menu-pick__hint">
|
||||
单击选择类型,双击或点底部「下一步」进入填写
|
||||
@@ -402,7 +484,6 @@ function typeLabel(type: string | undefined) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 同级排序 -->
|
||||
<div v-else-if="step === 'sort'" class="menu-sort">
|
||||
<div class="menu-sort__hint">
|
||||
可调整同级菜单顺序;「完成」写入排序,「跳过」保持当前 sort
|
||||
@@ -442,7 +523,25 @@ function typeLabel(type: string | undefined) {
|
||||
暂无同级菜单
|
||||
</div>
|
||||
</div>
|
||||
<Form v-else />
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!isUpdate && iconCandidates.length"
|
||||
class="menu-icon-suggest"
|
||||
>
|
||||
<span class="menu-icon-suggest__label">推荐图标</span>
|
||||
<button
|
||||
v-for="ic in iconCandidates"
|
||||
:key="ic"
|
||||
type="button"
|
||||
class="menu-icon-suggest__btn"
|
||||
:title="ic"
|
||||
@click="pickIconCandidate(ic)"
|
||||
>
|
||||
<Icon :icon="ic" />
|
||||
</button>
|
||||
</div>
|
||||
<Form />
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -459,6 +558,38 @@ function typeLabel(type: string | undefined) {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.menu-icon-suggest {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.menu-icon-suggest__label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.menu-icon-suggest__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted) / 30%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.menu-icon-suggest__btn:hover {
|
||||
border-color: hsl(var(--primary) / 45%);
|
||||
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.menu-sort__hint {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 菜单移动弹窗:把其它子菜单迁到当前父级(或指定目标父级)
|
||||
* 流程:选菜单 → 预览角色授权 diff → 确认执行(补新父 / 撤孤儿旧父)
|
||||
* 流程:树形勾选菜单 → 预览角色授权 diff → 确认执行(补新父 / 撤孤儿旧父)
|
||||
* 候选用可勾选树(checkStrictly):只勾节点本身,避免父子联动误选整枝
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
@@ -10,11 +11,11 @@ import { useVbenModal } from '@vben/common-ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Empty,
|
||||
Form,
|
||||
FormItem,
|
||||
Table,
|
||||
Tree,
|
||||
TreeSelect,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
@@ -29,13 +30,20 @@ interface RoleDiffRow {
|
||||
revoke_menus: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
/** antd Tree 节点(禁用目标自身及子孙防成环) */
|
||||
interface CandidateTreeNode {
|
||||
key: number;
|
||||
title: string;
|
||||
disableCheckbox?: boolean;
|
||||
children?: CandidateTreeNode[];
|
||||
}
|
||||
|
||||
const targetPid = ref<number>(0);
|
||||
const treeOptions = ref<any[]>([]);
|
||||
/** 可选迁入的菜单(扁平,排除目标自身与其子孙) */
|
||||
const candidateMenus = ref<
|
||||
{ id: number; title: string; pid: number; pathLabel: string }[]
|
||||
>([]);
|
||||
const checkedIds = ref<number[]>([]);
|
||||
/** 可勾选的候选树(已排除目标子孙为禁用或过滤) */
|
||||
const candidateTree = ref<CandidateTreeNode[]>([]);
|
||||
/** checkStrictly 下 checked.keys 为选中 id */
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
const previewed = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const roleDiffs = ref<RoleDiffRow[]>([]);
|
||||
@@ -46,53 +54,7 @@ const onSuccess = ref<(() => void) | null>(null);
|
||||
/** 从行操作带入的目标父级标题(仅展示) */
|
||||
const targetTitle = ref('');
|
||||
|
||||
const checkedCount = computed(() => checkedIds.value.length);
|
||||
|
||||
/**
|
||||
* 把树压平为候选列表,并拼路径文案;排除 target 及其子孙(迁到自身/子孙会成环)
|
||||
*/
|
||||
function flattenCandidates(
|
||||
nodes: any[],
|
||||
targetId: number,
|
||||
ancestors: string[] = [],
|
||||
excludeIds: Set<number> = new Set(),
|
||||
): { id: number; title: string; pid: number; pathLabel: string }[] {
|
||||
const result: {
|
||||
id: number;
|
||||
title: string;
|
||||
pid: number;
|
||||
pathLabel: string;
|
||||
}[] = [];
|
||||
for (const node of nodes || []) {
|
||||
const id = Number(node.id);
|
||||
const title = String(node.title || '');
|
||||
const pathLabel = [...ancestors, title].filter(Boolean).join(' → ');
|
||||
const nextExclude = new Set(excludeIds);
|
||||
if (id === targetId) {
|
||||
nextExclude.add(id);
|
||||
collectDescendantIds(node, nextExclude);
|
||||
}
|
||||
if (!nextExclude.has(id) && id !== 0) {
|
||||
result.push({
|
||||
id,
|
||||
title,
|
||||
pid: Number(node.pid ?? 0),
|
||||
pathLabel,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
result.push(
|
||||
...flattenCandidates(
|
||||
node.children,
|
||||
targetId,
|
||||
[...ancestors, title],
|
||||
nextExclude,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
|
||||
/** 收集某节点全部子孙 id,用于排除成环候选 */
|
||||
function collectDescendantIds(node: any, bag: Set<number>) {
|
||||
@@ -102,6 +64,57 @@ function collectDescendantIds(node: any, bag: Set<number>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可勾选树:目标自身及子孙 disableCheckbox(仍展示结构,不可选)
|
||||
* 根 id=0 的占位节点不加入勾选
|
||||
*/
|
||||
function buildCandidateTree(
|
||||
nodes: any[],
|
||||
excludeIds: Set<number>,
|
||||
): CandidateTreeNode[] {
|
||||
const result: CandidateTreeNode[] = [];
|
||||
for (const node of nodes || []) {
|
||||
const id = Number(node.id);
|
||||
if (id === 0) {
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
result.push(...buildCandidateTree(node.children, excludeIds));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const disabled = excludeIds.has(id);
|
||||
const children = Array.isArray(node.children)
|
||||
? buildCandidateTree(node.children, excludeIds)
|
||||
: undefined;
|
||||
result.push({
|
||||
key: id,
|
||||
title: String(node.title || ''),
|
||||
disableCheckbox: disabled,
|
||||
children: children && children.length > 0 ? children : undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 根据目标父级算出需排除的 id 集合(自身 + 子孙) */
|
||||
function buildExcludeIds(nodes: any[], targetId: number): Set<number> {
|
||||
const bag = new Set<number>();
|
||||
if (targetId <= 0) return bag;
|
||||
const walk = (list: any[]) => {
|
||||
for (const node of list || []) {
|
||||
const id = Number(node.id);
|
||||
if (id === targetId) {
|
||||
bag.add(id);
|
||||
collectDescendantIds(node, bag);
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(node.children) && walk(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
walk(nodes);
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** 把树下拉里的「根目录」保留,供目标父级选择 */
|
||||
function normalizeTreeOptions(list: any[]): any[] {
|
||||
return (list || []).map((item) => ({
|
||||
@@ -112,6 +125,12 @@ function normalizeTreeOptions(list: any[]): any[] {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 用原始树刷新候选勾选树 */
|
||||
function refreshCandidates(list: any[]) {
|
||||
const exclude = buildExcludeIds(list, Number(targetPid.value));
|
||||
candidateTree.value = buildCandidateTree(list, exclude);
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -123,7 +142,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
message.warning('请先预览授权变更后再确认执行');
|
||||
return;
|
||||
}
|
||||
if (checkedIds.value.length === 0) {
|
||||
if (checkedKeys.value.length === 0) {
|
||||
message.error('请勾选要移动的菜单');
|
||||
return;
|
||||
}
|
||||
@@ -131,7 +150,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
try {
|
||||
await moveMenu({
|
||||
target_pid: Number(targetPid.value),
|
||||
menu_ids: checkedIds.value,
|
||||
menu_ids: checkedKeys.value,
|
||||
});
|
||||
message.success('移动成功');
|
||||
onSuccess.value?.();
|
||||
@@ -151,32 +170,32 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onSuccess.value = data?.onSuccess ?? null;
|
||||
targetPid.value = Number(data?.target?.id ?? 0);
|
||||
targetTitle.value = String(data?.target?.title || '');
|
||||
checkedIds.value = [];
|
||||
checkedKeys.value = [];
|
||||
previewed.value = false;
|
||||
roleDiffs.value = [];
|
||||
moveSummary.value = null;
|
||||
loadTree();
|
||||
} else {
|
||||
onSuccess.value = null;
|
||||
candidateMenus.value = [];
|
||||
checkedIds.value = [];
|
||||
candidateTree.value = [];
|
||||
checkedKeys.value = [];
|
||||
previewed.value = false;
|
||||
roleDiffs.value = [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 拉菜单树:目标下拉 + 候选勾选列表 */
|
||||
/** 拉菜单树:目标下拉 + 候选勾选树 */
|
||||
async function loadTree() {
|
||||
try {
|
||||
const res = await getMenuTreeOptionSelect();
|
||||
const list = Array.isArray(res) ? res : res?.result || res || [];
|
||||
treeOptions.value = normalizeTreeOptions(list);
|
||||
candidateMenus.value = flattenCandidates(list, Number(targetPid.value));
|
||||
refreshCandidates(list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
treeOptions.value = [];
|
||||
candidateMenus.value = [];
|
||||
candidateTree.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,19 +204,33 @@ async function onTargetChange() {
|
||||
previewed.value = false;
|
||||
roleDiffs.value = [];
|
||||
moveSummary.value = null;
|
||||
checkedIds.value = [];
|
||||
checkedKeys.value = [];
|
||||
try {
|
||||
const res = await getMenuTreeOptionSelect();
|
||||
const list = Array.isArray(res) ? res : res?.result || res || [];
|
||||
candidateMenus.value = flattenCandidates(list, Number(targetPid.value));
|
||||
refreshCandidates(list);
|
||||
} catch {
|
||||
candidateMenus.value = [];
|
||||
candidateTree.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 勾选变化:checkStrictly 时 info.checked 为 boolean */
|
||||
function onTreeCheck(
|
||||
keys: number[] | { checked: number[]; halfChecked: number[] },
|
||||
) {
|
||||
previewed.value = false;
|
||||
if (Array.isArray(keys)) {
|
||||
checkedKeys.value = keys.map(Number).filter((id) => id > 0);
|
||||
} else {
|
||||
checkedKeys.value = (keys.checked || [])
|
||||
.map(Number)
|
||||
.filter((id) => id > 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 调用后端 preview-move,展示角色授权 diff */
|
||||
async function handlePreview() {
|
||||
if (checkedIds.value.length === 0) {
|
||||
if (checkedKeys.value.length === 0) {
|
||||
message.error('请勾选要移动的菜单');
|
||||
return;
|
||||
}
|
||||
@@ -205,7 +238,7 @@ async function handlePreview() {
|
||||
try {
|
||||
const res = await previewMoveMenu({
|
||||
target_pid: Number(targetPid.value),
|
||||
menu_ids: checkedIds.value,
|
||||
menu_ids: checkedKeys.value,
|
||||
});
|
||||
const data = res?.result ?? res ?? {};
|
||||
roleDiffs.value = Array.isArray(data.role_diffs) ? data.role_diffs : [];
|
||||
@@ -257,26 +290,20 @@ const diffColumns = [
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem :label="`迁入的子菜单(已选 ${checkedCount})`" required>
|
||||
<div class="max-h-52 overflow-y-auto rounded border border-border p-2">
|
||||
<div class="max-h-64 overflow-y-auto rounded border border-border p-2">
|
||||
<Empty
|
||||
v-if="candidateMenus.length === 0"
|
||||
v-if="candidateTree.length === 0"
|
||||
description="暂无可迁入菜单"
|
||||
/>
|
||||
<Checkbox.Group
|
||||
<Tree
|
||||
v-else
|
||||
v-model:value="checkedIds"
|
||||
class="flex w-full flex-col gap-1"
|
||||
@change="previewed = false"
|
||||
>
|
||||
<Checkbox
|
||||
v-for="item in candidateMenus"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
class="!ml-0"
|
||||
>
|
||||
<span class="text-sm">{{ item.pathLabel }}</span>
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
checkable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:tree-data="candidateTree"
|
||||
:checked-keys="checkedKeys"
|
||||
@check="onTreeCheck"
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 同级菜单拖拽排序弹窗(列表工具栏入口;与卡片层拖拽共用 sort-siblings API)
|
||||
*/
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
|
||||
import { Empty, message } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import { getMenuSiblings, sortMenuSiblings } from '../api';
|
||||
import { MENU_TYPE_MAP } from '../config/constants';
|
||||
|
||||
const pid = ref(0);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
let sortableInst: { destroy?: () => void } | null = null;
|
||||
const onSuccess = ref<(() => void) | null>(null);
|
||||
|
||||
function typeLabel(type: string | undefined) {
|
||||
return (MENU_TYPE_MAP[String(type || 'menu')] || MENU_TYPE_MAP.menu).label;
|
||||
}
|
||||
|
||||
async function loadSiblings() {
|
||||
const res = await getMenuSiblings(pid.value);
|
||||
items.value = Array.isArray(res?.items) ? [...res.items] : [];
|
||||
}
|
||||
|
||||
async function initSortable() {
|
||||
sortableInst?.destroy?.();
|
||||
sortableInst = null;
|
||||
await nextTick();
|
||||
if (!listEl.value || items.value.length < 2) return;
|
||||
const { initializeSortable } = useSortable(listEl.value, {
|
||||
animation: 200,
|
||||
handle: '.menu-sibling-sort__handle',
|
||||
onEnd: (evt: { oldIndex?: number; newIndex?: number }) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const list = [...items.value];
|
||||
const [moved] = list.splice(oldIndex, 1);
|
||||
if (moved) {
|
||||
list.splice(newIndex, 0, moved);
|
||||
items.value = list;
|
||||
}
|
||||
},
|
||||
});
|
||||
sortableInst = await initializeSortable();
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await sortMenuSiblings({
|
||||
pid: pid.value,
|
||||
items: items.value.map((row, i) => ({
|
||||
id: Number(row.id),
|
||||
sort: i,
|
||||
})),
|
||||
});
|
||||
message.success('排序已保存');
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
sortableInst?.destroy?.();
|
||||
sortableInst = null;
|
||||
items.value = [];
|
||||
onSuccess.value = null;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
pid?: number;
|
||||
onSuccess?: () => void;
|
||||
}>();
|
||||
pid.value = Number(data?.pid ?? 0);
|
||||
onSuccess.value = data?.onSuccess ?? null;
|
||||
modalApi.setState({ loading: true });
|
||||
try {
|
||||
await loadSiblings();
|
||||
await initSortable();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
items.value = [];
|
||||
} finally {
|
||||
modalApi.setState({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="拖拽排序(同级)" class="w-[520px]">
|
||||
<p class="mb-3 text-sm text-muted-foreground">
|
||||
拖动手柄调整顺序,确定后写入当前父级下的 sort
|
||||
</p>
|
||||
<Empty v-if="items.length === 0" description="暂无同级菜单" />
|
||||
<ul v-else ref="listEl" class="menu-sibling-sort">
|
||||
<li v-for="row in items" :key="row.id" class="menu-sibling-sort__item">
|
||||
<span class="menu-sibling-sort__handle" title="拖拽">
|
||||
<Icon icon="lucide:grip-vertical" />
|
||||
</span>
|
||||
<Icon v-if="row.icon" :icon="row.icon" class="menu-sibling-sort__icon" />
|
||||
<span class="menu-sibling-sort__title">{{ row.title }}</span>
|
||||
<span class="menu-sibling-sort__type">{{ typeLabel(row.type) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-sibling-sort {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.menu-sibling-sort__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.menu-sibling-sort__handle {
|
||||
display: inline-flex;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.menu-sibling-sort__icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.menu-sibling-sort__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-sibling-sort__type {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -77,9 +77,9 @@ export const modalFormProps: VbenFormProps = {
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
component: 'IconPicker',
|
||||
componentProps: {
|
||||
placeholder: '请输入菜单图标,如 lucide:layout-dashboard',
|
||||
placeholder: '选择或搜索图标',
|
||||
},
|
||||
fieldName: 'icon',
|
||||
label: '菜单图标',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 菜单管理:树形列表 + 卡片双视图;支持移动子菜单(预览角色授权后执行)
|
||||
* 菜单管理:树形列表 + 卡片双视图;支持移动子菜单、同级拖拽排序、AI 整理
|
||||
* 搜索区与视图切换分行;首屏按 viewMode 主动拉数
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
@@ -18,10 +18,12 @@ import { TableAction } from '#/components/table-action';
|
||||
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
|
||||
|
||||
import { deleteMenu, getMenuList } from './api';
|
||||
import AiOrganizeModal from './components/ai-organize-modal.vue';
|
||||
import AuthRole from './components/auth-role.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import MenuCardList from './components/MenuCardList.vue';
|
||||
import MoveMenuModal from './components/move-menu-modal.vue';
|
||||
import SortSiblingsModal from './components/sort-siblings-modal.vue';
|
||||
import { MENU_TYPE_MAP } from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions as baseGridOptions } from './config/table';
|
||||
@@ -40,6 +42,7 @@ const viewMode = useViewMode('menu-view-mode');
|
||||
const cardItems = ref<Record<string, any>[]>([]);
|
||||
const cardLoading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const menuCardListRef = ref<InstanceType<typeof MenuCardList> | null>(null);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -170,6 +173,14 @@ const [MoveModal, moveModalApi] = useVbenModal({
|
||||
connectedComponent: MoveMenuModal,
|
||||
});
|
||||
|
||||
const [SortModal, sortModalApi] = useVbenModal({
|
||||
connectedComponent: SortSiblingsModal,
|
||||
});
|
||||
|
||||
const [AiModal, aiModalApi] = useVbenModal({
|
||||
connectedComponent: AiOrganizeModal,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
@@ -227,6 +238,33 @@ const handleMoveMenus = (row: Record<string, any>) => {
|
||||
moveModalApi.open();
|
||||
};
|
||||
|
||||
/** 列表工具栏:对根级(pid=0)拖拽排序;行上也可对子级打开 */
|
||||
function openSortSiblings(pid = 0) {
|
||||
sortModalApi.setData({
|
||||
pid,
|
||||
onSuccess: () => refreshAll(),
|
||||
});
|
||||
sortModalApi.open();
|
||||
}
|
||||
|
||||
function openAiOrganize(payload?: { pid: number; parentTitle: string }) {
|
||||
const fromCard = payload;
|
||||
const pid =
|
||||
fromCard?.pid ??
|
||||
menuCardListRef.value?.getCurrentPid?.() ??
|
||||
0;
|
||||
const parentTitle =
|
||||
fromCard?.parentTitle ??
|
||||
menuCardListRef.value?.getCurrentTitle?.() ??
|
||||
'根目录';
|
||||
aiModalApi.setData({
|
||||
pid,
|
||||
parentTitle,
|
||||
onSuccess: () => refreshAll(),
|
||||
});
|
||||
aiModalApi.open();
|
||||
}
|
||||
|
||||
/** 行操作定义:列表 TableAction / 卡片 CardActions 共用 */
|
||||
function buildRowActions(row: Record<string, any>) {
|
||||
return {
|
||||
@@ -254,6 +292,13 @@ function buildRowActions(row: Record<string, any>) {
|
||||
},
|
||||
],
|
||||
dropDownActions: [
|
||||
{
|
||||
label: '同级排序',
|
||||
type: 'link' as const,
|
||||
icon: 'lucide:arrow-up-down',
|
||||
size: 'small' as const,
|
||||
onClick: () => openSortSiblings(Number(row.pid) || 0),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link' as const,
|
||||
@@ -275,6 +320,8 @@ const showListToolbar = computed(() => viewMode.value === 'list');
|
||||
<Page auto-content-height title="菜单管理">
|
||||
<FormModal />
|
||||
<MoveModal />
|
||||
<SortModal />
|
||||
<AiModal />
|
||||
<AuthRole ref="authRoleRef" />
|
||||
<!-- 顶部独立搜索区(与视图切换脱开) -->
|
||||
<div
|
||||
@@ -282,13 +329,22 @@ const showListToolbar = computed(() => viewMode.value === 'list');
|
||||
>
|
||||
<SearchForm />
|
||||
</div>
|
||||
<!-- 工具行:视图切换 + 卡片态新增(当前层级操作在下方面包屑栏) -->
|
||||
<!-- 工具行:视图切换 + 卡片态新增 / AI -->
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<ViewModeSwitch v-model="viewMode" />
|
||||
<Button v-if="viewMode === 'card'" type="primary" @click="showModal()">
|
||||
<Icon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</Button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="viewMode === 'card'"
|
||||
@click="openAiOrganize()"
|
||||
>
|
||||
<Icon icon="lucide:sparkles" class="mr-1" />
|
||||
AI 整理
|
||||
</Button>
|
||||
<Button v-if="viewMode === 'card'" type="primary" @click="showModal()">
|
||||
<Icon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Grid v-if="viewMode === 'list'">
|
||||
<template #toolbar-actions>
|
||||
@@ -312,6 +368,21 @@ const showListToolbar = computed(() => viewMode.value === 'list');
|
||||
ifShow: showListToolbar,
|
||||
onClick: collapseAll.bind(null),
|
||||
},
|
||||
{
|
||||
label: '根级排序',
|
||||
type: 'default',
|
||||
icon: 'lucide:arrow-up-down',
|
||||
ifShow: showListToolbar,
|
||||
onClick: () => openSortSiblings(0),
|
||||
},
|
||||
{
|
||||
label: 'AI 整理根级',
|
||||
type: 'default',
|
||||
icon: 'lucide:sparkles',
|
||||
ifShow: showListToolbar,
|
||||
onClick: () =>
|
||||
openAiOrganize({ pid: 0, parentTitle: '根目录' }),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
@@ -350,7 +421,6 @@ const showListToolbar = computed(() => viewMode.value === 'list');
|
||||
</template>
|
||||
</Grid>
|
||||
<div v-else class="menu-card-wrap">
|
||||
<!-- 保持挂载,避免 loading 卸载导致本地层级记忆丢失 -->
|
||||
<div
|
||||
v-show="cardLoading"
|
||||
class="py-16 text-center text-[hsl(var(--muted-foreground))]"
|
||||
@@ -358,10 +428,13 @@ const showListToolbar = computed(() => viewMode.value === 'list');
|
||||
加载中…
|
||||
</div>
|
||||
<MenuCardList
|
||||
ref="menuCardListRef"
|
||||
v-show="!cardLoading"
|
||||
:items="cardItems"
|
||||
:keyword="searchKeyword"
|
||||
:build-row-actions="buildRowActions"
|
||||
@sorted="refreshAll"
|
||||
@ai-organize="openAiOrganize"
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
42
apps/web-antd/src/views/system/menu/utils/auto-fill.ts
Normal file
42
apps/web-antd/src/views/system/menu/utils/auto-fill.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 菜单新建自动填:脏标记 + 防抖建议结果合并
|
||||
* 真正的拼音/图标候选由后端 suggest-from-title 产出,这里只管理「用户是否手改过」
|
||||
*/
|
||||
|
||||
export type AutoFillField = 'name' | 'path' | 'icon' | 'component';
|
||||
|
||||
/** 用户手动改过的字段,自动填不再覆盖 */
|
||||
export function createDirtyTracker() {
|
||||
const dirty = new Set<AutoFillField>();
|
||||
return {
|
||||
mark(field: AutoFillField) {
|
||||
dirty.add(field);
|
||||
},
|
||||
isDirty(field: AutoFillField) {
|
||||
return dirty.has(field);
|
||||
},
|
||||
reset() {
|
||||
dirty.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 简单防抖 */
|
||||
export function debounceFn<T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
wait = 400,
|
||||
): T & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const wrapped = ((...args: any[]) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn(...args);
|
||||
}, wait);
|
||||
}) as T & { cancel: () => void };
|
||||
wrapped.cancel = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - AI:二级 Tab
|
||||
* 1) 模型配置 2) 病历配置 3) 提醒文案
|
||||
* 4) Agent 中转 5) 语音配置 6) Agent 高级
|
||||
* 系统配置 - AI管理:二级 Tab
|
||||
* 1) 模型配置 2) 功能路由 3) 病历配置 4) 提醒文案
|
||||
* 5) Agent 中转 6) 语音配置 7) Agent 高级
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
Checkbox,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Radio,
|
||||
Select,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
Radio,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
@@ -33,11 +34,80 @@ import {
|
||||
supportsThinkingType,
|
||||
thinkingTypeLabel,
|
||||
} from '#/views/system/ai/platform/config/provider-capabilities';
|
||||
import { GlassCard } from '#/components/glass-card';
|
||||
import { ensureAiPromptDefaults } from '#/views/system/ai/prompt/api';
|
||||
import {
|
||||
getSystemConfigList,
|
||||
saveSystemConfig,
|
||||
} from '#/views/system/system-config/api';
|
||||
|
||||
/** 功能路由可选值 */
|
||||
const VIA_MODE_OPTIONS = [
|
||||
{ label: '跟随全局', value: 'inherit' },
|
||||
{ label: '强制 Agent', value: 'agent' },
|
||||
{ label: '强制直连', value: 'direct' },
|
||||
];
|
||||
|
||||
/** 各 AI 功能(与 AiGenerationSceneEnum 对齐);icon/tone 供液态玻璃卡展示 */
|
||||
const FEATURE_VIA_ROWS = [
|
||||
{
|
||||
scene: 'medical_record',
|
||||
label: '写病历',
|
||||
tip: '',
|
||||
icon: 'lucide:file-heart',
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
scene: 'prescription',
|
||||
label: '出方',
|
||||
tip: '',
|
||||
icon: 'lucide:pill',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
scene: 'voice_prescription',
|
||||
label: '语音口述开方',
|
||||
tip: '可覆盖语音 Tab 旧键',
|
||||
icon: 'lucide:mic',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
scene: 'daily_brief',
|
||||
label: '每日简报',
|
||||
tip: '',
|
||||
icon: 'lucide:newspaper',
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
scene: 'prescription_image_ocr',
|
||||
label: '识图开方',
|
||||
tip: '多模态暂强制直连,改 Agent 不生效',
|
||||
icon: 'lucide:scan-line',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
scene: 'transfer_image_ocr',
|
||||
label: '识图传方',
|
||||
tip: '多模态暂强制直连,改 Agent 不生效',
|
||||
icon: 'lucide:image',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
scene: 'menu_organize',
|
||||
label: '菜单层级整理',
|
||||
tip: '可走 Agent',
|
||||
icon: 'lucide:folder-tree',
|
||||
tone: 'success',
|
||||
},
|
||||
];
|
||||
|
||||
/** 路由取值 → 卡片角标文案 */
|
||||
function viaModeLabel(mode: string) {
|
||||
if (mode === 'agent') return 'Agent';
|
||||
if (mode === 'direct') return '直连';
|
||||
return '跟随全局';
|
||||
}
|
||||
|
||||
/** 二级 Tab 持久化 */
|
||||
const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_tab';
|
||||
|
||||
@@ -110,6 +180,13 @@ const aiAgentViaAgent = ref(false);
|
||||
const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
|
||||
const aiAgentSecret = ref('');
|
||||
const aiAgentTesting = ref(false);
|
||||
/** 各功能 via:scene -> inherit|agent|direct */
|
||||
const featureViaModes = reactive<Record<string, string>>({});
|
||||
const featureViaSaving = ref(false);
|
||||
const promptSyncing = ref(false);
|
||||
for (const row of FEATURE_VIA_ROWS) {
|
||||
featureViaModes[row.scene] = 'inherit';
|
||||
}
|
||||
/** 一次 AI 出方生成几份方案(1–5,默认 1;>1 需开启 Agent 中转并由 Go 并行) */
|
||||
const aiPrescriptionGenerateCount = ref(1);
|
||||
/** 病历场景是否让 Go Agent 检索知识库(独立于「是否中转」) */
|
||||
@@ -487,6 +564,29 @@ async function loadAdvancedCfg() {
|
||||
aiTokenMaxPerCall.value = Number(row.config_value) || 2048;
|
||||
break;
|
||||
}
|
||||
case 'ai_feature_via_modes': {
|
||||
let parsed: Record<string, string> = {};
|
||||
try {
|
||||
const raw = row.config_value;
|
||||
parsed =
|
||||
typeof raw === 'string'
|
||||
? JSON.parse(raw || '{}')
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, string>)
|
||||
: {};
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
for (const fr of FEATURE_VIA_ROWS) {
|
||||
const m = String(parsed[fr.scene] || 'inherit').toLowerCase();
|
||||
featureViaModes[fr.scene] = ['inherit', 'agent', 'direct'].includes(
|
||||
m,
|
||||
)
|
||||
? m
|
||||
: 'inherit';
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
@@ -498,6 +598,44 @@ async function loadAdvancedCfg() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存各 AI 功能直连/Agent 路由 */
|
||||
async function handleSaveFeatureVia() {
|
||||
featureViaSaving.value = true;
|
||||
try {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const row of FEATURE_VIA_ROWS) {
|
||||
payload[row.scene] = featureViaModes[row.scene] || 'inherit';
|
||||
}
|
||||
await saveSystemConfig([
|
||||
{
|
||||
config_key: 'ai_feature_via_modes',
|
||||
config_value: JSON.stringify(payload),
|
||||
value_type: 'json',
|
||||
config_group: 'ai',
|
||||
description: '各 AI 功能调用路径 inherit|agent|direct',
|
||||
},
|
||||
]);
|
||||
message.success('功能路由已保存');
|
||||
} finally {
|
||||
featureViaSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把代码种子写入 xk_ai_prompt(不覆盖已有);Agent 后台即可看到 */
|
||||
async function handleSyncPrompts() {
|
||||
promptSyncing.value = true;
|
||||
try {
|
||||
const res = await ensureAiPromptDefaults();
|
||||
const inserted = Number(res?.inserted ?? 0);
|
||||
const skipped = Number(res?.skipped ?? 0);
|
||||
message.success(`提示词同步完成:新增 ${inserted},跳过 ${skipped}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
promptSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存「Agent 中转」Tab:直连/中转开关 + 服务地址 + 密钥 + 出方份数
|
||||
* 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
|
||||
@@ -851,6 +989,7 @@ onMounted(() => {
|
||||
// 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
|
||||
if (
|
||||
stored === 'model' ||
|
||||
stored === 'feature' ||
|
||||
stored === 'copy' ||
|
||||
stored === 'mr' ||
|
||||
stored === 'agent' ||
|
||||
@@ -1094,6 +1233,77 @@ onMounted(() => {
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<!-- 功能路由:各场景一张液态玻璃卡 -->
|
||||
<Tabs.TabPane key="feature" tab="功能路由">
|
||||
<div class="space-y-4 py-4">
|
||||
<GlassCard
|
||||
title="AI 功能调用路径"
|
||||
subtitle="跟随全局 = Agent 中转总开关;强制 Agent/直连覆盖该功能。识图多模态暂强制直连。提示词在 Agent 后台「提示词管理」编辑。"
|
||||
icon="lucide:route"
|
||||
tone="primary"
|
||||
padding="md"
|
||||
>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="featureViaSaving"
|
||||
@click="handleSaveFeatureVia"
|
||||
>
|
||||
保存功能路由
|
||||
</Button>
|
||||
<Button
|
||||
:loading="promptSyncing"
|
||||
@click="handleSyncPrompts"
|
||||
>
|
||||
同步默认提示词到库
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<GlassCard
|
||||
v-for="row in FEATURE_VIA_ROWS"
|
||||
:key="row.scene"
|
||||
:title="row.label"
|
||||
:subtitle="row.scene"
|
||||
:icon="row.icon"
|
||||
:tone="row.tone"
|
||||
hoverable
|
||||
padding="md"
|
||||
>
|
||||
<template #extra>
|
||||
<Tag
|
||||
:color="
|
||||
featureViaModes[row.scene] === 'agent'
|
||||
? 'processing'
|
||||
: featureViaModes[row.scene] === 'direct'
|
||||
? 'default'
|
||||
: 'success'
|
||||
"
|
||||
>
|
||||
{{ viaModeLabel(featureViaModes[row.scene]) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<div
|
||||
v-if="row.tip"
|
||||
class="mb-3 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{{ row.tip }}
|
||||
</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))] mb-1.5">
|
||||
调用路径
|
||||
</div>
|
||||
<Select
|
||||
v-model:value="featureViaModes[row.scene]"
|
||||
:options="VIA_MODE_OPTIONS"
|
||||
class="w-full"
|
||||
/>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="mr" tab="病历配置">
|
||||
<div class="py-2">
|
||||
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
|
||||
|
||||
@@ -54,168 +54,204 @@ interface MenuItem {
|
||||
tabs?: ModuleTabItem[];
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
/** 左侧分组:标题只展示,不参与选中 */
|
||||
interface MenuGroup {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧按业务域分组(小程序 / 诊疗 / 运营 / 智能)
|
||||
* 扁平 menuItems 由 groups 展开,供查找与持久化
|
||||
*/
|
||||
const menuGroups: MenuGroup[] = [
|
||||
{
|
||||
key: 'mp_patient',
|
||||
title: '患者端小程序',
|
||||
tabs: [
|
||||
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
|
||||
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
|
||||
title: '小程序与展示',
|
||||
items: [
|
||||
{
|
||||
key: 'short_link',
|
||||
title: 'Short Link 次数',
|
||||
component: MpShortLinkQuotaPanel,
|
||||
key: 'mp_patient',
|
||||
title: '患者端小程序',
|
||||
tabs: [
|
||||
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
|
||||
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
|
||||
{
|
||||
key: 'short_link',
|
||||
title: 'Short Link 次数',
|
||||
component: MpShortLinkQuotaPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'mp_doctor',
|
||||
title: '医生端小程序',
|
||||
tabs: [
|
||||
{
|
||||
key: 'default_fav',
|
||||
title: '默认常用入口',
|
||||
component: WxWorkbenchDefaultFavPanel,
|
||||
},
|
||||
{
|
||||
key: 'card_style',
|
||||
title: '工作台瓷片风格',
|
||||
component: WxWorkbenchCardStylePanel,
|
||||
},
|
||||
{
|
||||
key: 'tile_icons',
|
||||
title: '传方瓷片图标',
|
||||
component: WxWorkbenchTileIconPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workbench',
|
||||
title: '工作台',
|
||||
component: WorkbenchConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'dashboard',
|
||||
title: '数据大屏',
|
||||
component: DashboardConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'mp_doctor',
|
||||
title: '医生端小程序',
|
||||
tabs: [
|
||||
title: '诊疗业务',
|
||||
items: [
|
||||
{
|
||||
key: 'default_fav',
|
||||
title: '默认常用入口',
|
||||
component: WxWorkbenchDefaultFavPanel,
|
||||
key: 'register',
|
||||
title: '挂号',
|
||||
tabs: [
|
||||
{
|
||||
key: 'wait',
|
||||
title: '排队预估',
|
||||
component: RegisterWaitConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'voucher_ui',
|
||||
title: '凭证页风格',
|
||||
component: RegisterVoucherUiConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'card_style',
|
||||
title: '工作台瓷片风格',
|
||||
component: WxWorkbenchCardStylePanel,
|
||||
key: 'order',
|
||||
title: '订单',
|
||||
tabs: [
|
||||
{
|
||||
key: 'price_adjust',
|
||||
title: '订单调价',
|
||||
component: PriceAdjustConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'excipient_price',
|
||||
title: '辅料定价',
|
||||
component: ExcipientPriceConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'logistics',
|
||||
title: '物流展示',
|
||||
component: LogisticsConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'free_shipping',
|
||||
title: '满额包邮',
|
||||
component: FreeShippingConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'public_account_pay',
|
||||
title: '公账支付',
|
||||
component: PublicAccountPayConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tile_icons',
|
||||
title: '传方瓷片图标',
|
||||
component: WxWorkbenchTileIconPanel,
|
||||
key: 'image_transfer',
|
||||
title: '传方',
|
||||
component: ImageTransferConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'medical',
|
||||
title: '病历词条',
|
||||
tabs: [
|
||||
{ key: 'entry', title: '词条', component: MedicalRecordConfigPanel },
|
||||
{
|
||||
key: 'dict_search',
|
||||
title: '字典搜索',
|
||||
component: DictSearchConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dispense_sign',
|
||||
title: '处方签名',
|
||||
component: DispenseSignConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
title: '审核',
|
||||
component: InputAuditConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workbench',
|
||||
title: '工作台',
|
||||
component: WorkbenchConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'dashboard',
|
||||
title: '数据大屏',
|
||||
component: DashboardConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'dispense_sign',
|
||||
title: '处方签名',
|
||||
component: DispenseSignConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
title: '订单',
|
||||
tabs: [
|
||||
title: '运营支撑',
|
||||
items: [
|
||||
{
|
||||
key: 'price_adjust',
|
||||
title: '订单调价',
|
||||
component: PriceAdjustConfigPanel,
|
||||
key: 'finance',
|
||||
title: '财务',
|
||||
tabs: [
|
||||
{
|
||||
key: 'withdrawal_rules',
|
||||
title: '提现规则',
|
||||
component: WithdrawalRulesConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'excipient_price',
|
||||
title: '辅料定价',
|
||||
component: ExcipientPriceConfigPanel,
|
||||
},
|
||||
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
|
||||
{
|
||||
key: 'free_shipping',
|
||||
title: '满额包邮',
|
||||
component: FreeShippingConfigPanel,
|
||||
key: 'notice',
|
||||
title: '通知',
|
||||
tabs: [
|
||||
{
|
||||
key: 'invoice',
|
||||
title: '开票通知',
|
||||
component: InvoiceNoticeConfigPanel,
|
||||
},
|
||||
{ key: 'oa', title: 'OA通知', component: OaNotifyConfigPanel },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'public_account_pay',
|
||||
title: '公账支付',
|
||||
component: PublicAccountPayConfigPanel,
|
||||
key: 'security',
|
||||
title: '权限安全',
|
||||
tabs: [
|
||||
{ key: 'login', title: '登录安全', component: DoctorLoginConfigPanel },
|
||||
{
|
||||
key: 'salesperson',
|
||||
title: '业务员权限',
|
||||
component: SalespersonConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
title: '上传',
|
||||
component: ImageCompressConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
title: '挂号',
|
||||
tabs: [
|
||||
title: '智能',
|
||||
items: [
|
||||
{
|
||||
key: 'wait',
|
||||
title: '排队预估',
|
||||
component: RegisterWaitConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'voucher_ui',
|
||||
title: '凭证页风格',
|
||||
component: RegisterVoucherUiConfigPanel,
|
||||
key: 'ai_model',
|
||||
title: 'AI管理',
|
||||
component: AiModelConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
title: '上传',
|
||||
component: ImageCompressConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
title: '权限安全',
|
||||
tabs: [
|
||||
{ key: 'login', title: '登录安全', component: DoctorLoginConfigPanel },
|
||||
{
|
||||
key: 'salesperson',
|
||||
title: '业务员权限',
|
||||
component: SalespersonConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'medical',
|
||||
title: '病历词条',
|
||||
tabs: [
|
||||
{ key: 'entry', title: '词条', component: MedicalRecordConfigPanel },
|
||||
{
|
||||
key: 'dict_search',
|
||||
title: '字典搜索',
|
||||
component: DictSearchConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: '通知',
|
||||
tabs: [
|
||||
{
|
||||
key: 'invoice',
|
||||
title: '开票通知',
|
||||
component: InvoiceNoticeConfigPanel,
|
||||
},
|
||||
{ key: 'oa', title: 'OA通知', component: OaNotifyConfigPanel },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
title: '审核',
|
||||
component: InputAuditConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'image_transfer',
|
||||
title: '传方',
|
||||
component: ImageTransferConfigPanel,
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
title: '财务',
|
||||
tabs: [
|
||||
{
|
||||
key: 'withdrawal_rules',
|
||||
title: '提现规则',
|
||||
component: WithdrawalRulesConfigPanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ai_model',
|
||||
title: 'AI模型',
|
||||
component: AiModelConfigPanel,
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems = menuGroups.flatMap((g) => g.items);
|
||||
|
||||
/** 旧左侧菜单 key → 新模块 + 二级 Tab,避免刷新后丢选中 */
|
||||
const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = {
|
||||
price_adjust: { menu: 'order', tab: 'price_adjust' },
|
||||
@@ -290,16 +326,23 @@ onMounted(readStoredMenu);
|
||||
<Card class="sys-cfg-card" :body-style="{ padding: 0, height: '100%' }">
|
||||
<div class="sys-cfg-layout">
|
||||
<aside class="sys-cfg-nav">
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="sys-cfg-nav-item"
|
||||
:class="{ active: activeKey === item.key }"
|
||||
@click="selectMenu(item.key)"
|
||||
<div
|
||||
v-for="group in menuGroups"
|
||||
:key="group.title"
|
||||
class="sys-cfg-nav-group"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
<div class="sys-cfg-nav-group-title">{{ group.title }}</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="sys-cfg-nav-item"
|
||||
:class="{ active: activeKey === item.key }"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="sys-cfg-main">
|
||||
<component
|
||||
@@ -343,6 +386,20 @@ onMounted(readStoredMenu);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.sys-cfg-nav-group + .sys-cfg-nav-group {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid hsl(var(--border) / 80%);
|
||||
}
|
||||
|
||||
.sys-cfg-nav-group-title {
|
||||
padding: 4px 12px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sys-cfg-nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user