feat: 菜单AI整理与提示词管理,并优化OCR/AI加载体验

支持菜单标题自动填、AI整理预览应用,以及系统配置侧 AI 模型与提示词相关能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
李琦
2026-10-20 09:41:34 +08:00
parent cd216222d3
commit 618abca975
19 changed files with 3114 additions and 403 deletions

View File

@@ -8,7 +8,7 @@
* *
* 产物: * 产物:
* - 自动更新用 ASCII 名:xk-desktop-{version}-win-x64.exe(latest.yml 引用) * - 自动更新用 ASCII 名:xk-desktop-{version}-win-x64.exe(latest.yml 引用)
* - 人工/后台上传用:萧康医药 {version}.exe * - 人工/后台上传用:萧康云医 {version}.exe
* *
* 非交互(CI):XK_DESKTOP_VERSION=1.2.3 或 --version=1.2.3 * 非交互(CI):XK_DESKTOP_VERSION=1.2.3 或 --version=1.2.3
*/ */
@@ -140,7 +140,7 @@ function runBuilder() {
} }
/** /**
* 复制 Windows 安装包为「萧康医药 {version}.exe」方便后台/人工上传 * 复制 Windows 安装包为「萧康云医 {version}.exe」方便后台/人工上传
* 保留原 ASCII 文件名供 electron-updater 的 latest.yml 使用 * 保留原 ASCII 文件名供 electron-updater 的 latest.yml 使用
*/ */
function copyFriendlyWinExe(version) { function copyFriendlyWinExe(version) {
@@ -166,7 +166,7 @@ function copyFriendlyWinExe(version) {
console.warn('未找到 Windows 安装包,跳过友好文件名复制'); console.warn('未找到 Windows 安装包,跳过友好文件名复制');
return; return;
} }
const destName = `萧康医药 ${version}.exe`; const destName = `萧康云医 ${version}.exe`;
const from = join(releaseDir, src); const from = join(releaseDir, src);
const to = join(releaseDir, destName); const to = join(releaseDir, destName);
copyFileSync(from, to); copyFileSync(from, to);

View 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>

View File

@@ -1,24 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 识图开方/传方加载动画 * 识图开方/传方加载动画
* 视觉与交互对齐 sql/doc/ai识别图片转成处方的加载动画.html: * - 扫描原图 → 进度条 → 结构化处方行逐条揭示
* 扫描原图 → 进度条 → 结构化处方行逐条揭示 * - 暗色用根节点 is-dark(不依赖 :global(.dark) 穿透)
* 暗色跟随页面 .dark,不用 prefers-color-scheme 以免与后台主题冲突 * - 进度约 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' }); defineOptions({ name: 'OcrImageLoading' });
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 主标题 */ /** 主标题 */
title?: string; title?: string;
/** 接口已返回,进度冲刺到 100 */
complete?: boolean;
}>(), }>(),
{ {
title: 'AI 处方识别', title: 'AI 处方识别',
complete: false,
}, },
); );
const emit = defineEmits<{
finished: [];
}>();
const cardRef = ref<HTMLElement | null>(null); const cardRef = ref<HTMLElement | null>(null);
const docRef = ref<HTMLElement | null>(null); const docRef = ref<HTMLElement | null>(null);
const sharpRef = 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 fillRef = ref<HTMLElement | null>(null);
const pctRef = ref<HTMLElement | null>(null); const pctRef = ref<HTMLElement | null>(null);
const rowRefs = ref<(HTMLElement | 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 THRESHOLDS = [0.08, 0.24, 0.4, 0.54, 0.68, 0.82];
const STATUS_LINES = [
'正在深度思考中~',
'正在解析处方图像…',
'正在对照药库…',
'马上就好,请稍候…',
] as const;
/** 演示用中药行(仅动画占位,不代表真实识别结果) */ /** 演示用中药行(仅动画占位,不代表真实识别结果) */
const DEMO_ROWS = [ const DEMO_ROWS = [
@@ -43,10 +61,35 @@ const DEMO_ROWS = [
]; ];
let rafId = 0; let rafId = 0;
let unsetTimer: ReturnType<typeof setTimeout> | null = null;
let resizeObs: ResizeObserver | 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(() => { onMounted(() => {
syncDark();
const card = cardRef.value; const card = cardRef.value;
const docEl = docRef.value; const docEl = docRef.value;
const sharp = sharpRef.value; const sharp = sharpRef.value;
@@ -64,54 +107,72 @@ onMounted(() => {
resizeObs = new ResizeObserver(measure); resizeObs = new ResizeObserver(measure);
resizeObs.observe(docEl); resizeObs.observe(docEl);
} }
themeObs = new MutationObserver(syncDark);
themeObs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
let t0 = performance.now(); const t0 = performance.now();
let prevT = -1;
const frame = (now: number) => { const frame = (now: number) => {
const t = (now - t0) % CYCLE; const elapsed = now - t0;
if (t < prevT) { if (liveComplete.value && completeAtMs < 0) {
card.classList.add('no-anim'); completeAtMs = elapsed;
if (unsetTimer) clearTimeout(unsetTimer);
unsetTimer = setTimeout(() => card.classList.remove('no-anim'), 60);
} }
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(); if (!docH) measure();
scanner.style.transform = `translateY(${(p * docH).toFixed(1)}px)`; // 扫描线:完成前跟进度走;完成后停在底部并淡出
scanner.style.opacity = done ? '0' : p < 0.015 ? '0' : '1'; const scanP = done ? 1 : p;
sharp.style.clipPath = `inset(0 0 ${((1 - p) * 100).toFixed(2)}% 0)`; 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)}%`; fill.style.width = `${(p * 100).toFixed(1)}%`;
pctEl.textContent = String(Math.round(p * 100)); pctEl.textContent = String(Math.round(p * 100));
card.classList.toggle('is-done', done); card.classList.toggle('is-done', done);
const rows = rowRefs.value.filter(Boolean) as HTMLElement[]; const rows = rowRefs.value.filter(Boolean) as HTMLElement[];
for (let i = 0; i < rows.length; i++) { 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); rafId = requestAnimationFrame(frame);
}); });
onUnmounted(() => { onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
if (unsetTimer) clearTimeout(unsetTimer);
resizeObs?.disconnect(); resizeObs?.disconnect();
themeObs?.disconnect();
}); });
</script> </script>
<template> <template>
<div class="ocr-load"> <div class="ocr-load" :class="{ 'is-dark': isDark }">
<div ref="cardRef" class="ocr-load-card"> <div ref="cardRef" class="ocr-load-card" :class="{ 'is-done': isDone }">
<div class="ocr-load-head"> <div class="ocr-load-head">
<div class="ocr-load-logo"> <div class="ocr-load-logo">
<span class="ocr-load-logo-halo" aria-hidden="true" /> <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"> <svg class="ocr-load-logo-mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<defs> <defs>
<linearGradient <linearGradient
id="ocrLoadLogoCross" id="ocrLoadLogoCross2"
x1="6" x1="6"
y1="6" y1="6"
x2="18" x2="18"
@@ -125,7 +186,7 @@ onUnmounted(() => {
</defs> </defs>
<path <path
d="M12 6.6v10.8M6.6 12h10.8" d="M12 6.6v10.8M6.6 12h10.8"
stroke="url(#ocrLoadLogoCross)" stroke="url(#ocrLoadLogoCross2)"
stroke-width="2.5" stroke-width="2.5"
stroke-linecap="round" stroke-linecap="round"
/> />
@@ -135,10 +196,7 @@ onUnmounted(() => {
<div class="ocr-load-title">{{ title }}</div> <div class="ocr-load-title">{{ title }}</div>
<div class="ocr-load-sub"> <div class="ocr-load-sub">
<span class="ocr-load-pulse" /> <span class="ocr-load-pulse" />
<span class="ocr-load-swap"> <span>{{ statusText }}</span>
<span class="s1">正在解析处方图像…</span>
<span class="s2">识别完成 · 已生成电子处方</span>
</span>
</div> </div>
</div> </div>
<div class="ocr-load-pct"> <div class="ocr-load-pct">
@@ -229,19 +287,22 @@ onUnmounted(() => {
--ink: #0b3a2c; --ink: #0b3a2c;
--ink-2: #4e7d6c; --ink-2: #4e7d6c;
--ink-3: #8aab9e; --ink-3: #8aab9e;
--glass-a: rgba(255, 255, 255, 0.7); --glass-a: rgba(255, 255, 255, 0.78);
--glass-b: rgba(255, 255, 255, 0.34); --glass-b: rgba(236, 253, 245, 0.55);
--edge-a: rgba(255, 255, 255, 0.95); --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); --sheen: rgba(255, 255, 255, 0.7);
--ring: rgba(255, 255, 255, 0.6); --ring: rgba(255, 255, 255, 0.6);
--panel: rgba(255, 255, 255, 0.38); --panel: rgba(255, 255, 255, 0.5);
--hairline2: rgba(11, 58, 44, 0.06); --hairline2: rgba(11, 58, 44, 0.08);
--paper: #fff; --paper: #fff;
--paper-line: #dceae5; --paper-line: #dceae5;
--logo-c1: #5eead4; --logo-bg: linear-gradient(
--logo-c2: #10b981; 150deg,
--logo-c3: #047857; rgba(255, 255, 255, 0.85) 0%,
rgba(209, 250, 229, 0.5) 52%,
rgba(255, 255, 255, 0.4) 100%
);
--shadow: --shadow:
0 40px 80px -42px rgba(3, 55, 40, 0.42), 0 40px 80px -42px rgba(3, 55, 40, 0.42),
0 16px 36px -24px rgba(3, 55, 40, 0.26), 0 16px 36px -24px rgba(3, 55, 40, 0.26),
@@ -258,35 +319,39 @@ onUnmounted(() => {
padding: 8px 0; padding: 8px 0;
} }
:global(.dark) .ocr-load { /* 暗色:跟 is-dark 绑定,不依赖 :global(.dark) */
.ocr-load.is-dark {
--g-300: #5eead4; --g-300: #5eead4;
--g-400: #34d399; --g-400: #34d399;
--g-500: #2bd9a0; --g-500: #2bd9a0;
--g-600: #10b981; --g-600: #6ee7b7;
--g-700: #7bf0c8; --g-700: #a7f3d0;
--ink: #e9f7f2; --ink: #e8f5f0;
--ink-2: #9dc2b6; --ink-2: #a8cfc2;
--ink-3: #6f9388; --ink-3: #8eb9ab;
--glass-a: rgba(28, 43, 38, 0.58); --glass-a: rgba(18, 36, 30, 0.88);
--glass-b: rgba(10, 20, 17, 0.34); --glass-b: rgba(10, 24, 20, 0.72);
--edge-a: rgba(255, 255, 255, 0.32); --edge-a: rgba(110, 231, 183, 0.35);
--edge-b: rgba(255, 255, 255, 0.02); --edge-b: rgba(255, 255, 255, 0.04);
--sheen: rgba(255, 255, 255, 0.13); --sheen: rgba(255, 255, 255, 0.18);
--ring: rgba(255, 255, 255, 0.1); --ring: rgba(110, 231, 183, 0.12);
--panel: rgba(255, 255, 255, 0.035); --panel: rgba(255, 255, 255, 0.06);
--hairline2: rgba(255, 255, 255, 0.055); --hairline2: rgba(110, 231, 183, 0.12);
--paper: #e9f3ef; --paper: #1a2e28;
--paper-line: #c9dcd4; --paper-line: #2d4a40;
--logo-c1: #b9f6e0; --logo-bg: linear-gradient(
--logo-c2: #34d399; 150deg,
--logo-c3: #0fa97d; rgba(52, 211, 153, 0.22) 0%,
rgba(16, 185, 129, 0.1) 52%,
rgba(255, 255, 255, 0.04) 100%
);
--shadow: --shadow:
0 40px 90px -38px rgba(0, 0, 0, 0.85), 0 40px 90px -38px rgba(0, 0, 0, 0.85),
0 16px 40px -26px rgba(0, 0, 0, 0.6), 0 16px 40px -26px rgba(0, 0, 0, 0.6),
0 2px 6px -2px rgba(0, 0, 0, 0.4); 0 2px 6px -2px rgba(0, 0, 0, 0.4);
--shadow-paper: --shadow-paper:
0 12px 28px -16px rgba(0, 0, 0, 0.7), 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 { .ocr-load-card {
@@ -296,11 +361,10 @@ onUnmounted(() => {
padding: 22px; padding: 22px;
border-radius: 28px; border-radius: 28px;
background: linear-gradient(150deg, var(--glass-a), var(--glass-b)); 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: box-shadow:
var(--shadow), var(--shadow),
inset 0 1px 0 0 var(--ring), inset 0 1px 0 0 var(--ring);
inset 0 -1px 0 0 rgba(255, 255, 255, 0.06);
transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1); transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1);
} }
@@ -329,12 +393,8 @@ onUnmounted(() => {
box-shadow: box-shadow:
var(--shadow), var(--shadow),
inset 0 1px 0 0 var(--ring), inset 0 1px 0 0 var(--ring),
0 0 0 1px rgba(16, 185, 129, 0.2), 0 0 0 1px rgba(16, 185, 129, 0.25),
0 0 64px -16px rgba(16, 185, 129, 0.5); 0 0 64px -16px rgba(16, 185, 129, 0.45);
}
.ocr-load-card.no-anim .ocr-load-rrow {
transition: none !important;
} }
.ocr-load-head { .ocr-load-head {
@@ -350,19 +410,12 @@ onUnmounted(() => {
place-items: center; place-items: center;
width: 42px; width: 42px;
height: 42px; height: 42px;
background: linear-gradient( background: var(--logo-bg);
150deg,
rgba(255, 255, 255, 0.66) 0%,
rgba(255, 255, 255, 0.2) 52%,
rgba(255, 255, 255, 0.34) 100%
);
border-radius: 14px; border-radius: 14px;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92), inset 0 1px 0 rgba(255, 255, 255, 0.35),
inset 0 0 20px rgba(16, 185, 129, 0.12),
0 6px 18px -8px rgba(5, 95, 70, 0.3), 0 6px 18px -8px rgba(5, 95, 70, 0.3),
0 0 0 1px rgba(16, 185, 129, 0.14); 0 0 0 1px rgba(16, 185, 129, 0.2);
backdrop-filter: blur(16px) saturate(200%);
} }
.ocr-load-logo-halo { .ocr-load-logo-halo {
@@ -393,19 +446,6 @@ onUnmounted(() => {
z-index: 1; z-index: 1;
width: 22px; width: 22px;
height: 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 { .ocr-load-meta {
@@ -422,10 +462,12 @@ onUnmounted(() => {
} }
.ocr-load-sub { .ocr-load-sub {
position: relative;
height: 17px; height: 17px;
overflow: hidden;
font-size: 12px; font-size: 12px;
color: var(--ink-3); color: var(--ink-3);
text-overflow: ellipsis;
white-space: nowrap;
} }
.ocr-load-pulse { .ocr-load-pulse {
@@ -457,39 +499,6 @@ onUnmounted(() => {
animation: none; 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 { .ocr-load-pct {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 52px; min-width: 52px;
@@ -528,13 +537,14 @@ onUnmounted(() => {
background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600)); background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600));
border-radius: 99px; border-radius: 99px;
box-shadow: 0 0 14px rgba(16, 185, 129, 0.6); box-shadow: 0 0 14px rgba(16, 185, 129, 0.6);
transition: width 0.05s linear;
} }
.ocr-load-fill::after { .ocr-load-fill::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; 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%); transform: translateX(-100%);
animation: ocrSheen 1.7s linear infinite; animation: ocrSheen 1.7s linear infinite;
} }
@@ -836,14 +846,12 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.ocr-load-rrow .val :deep(b) { .ocr-load-card.is-done .ocr-load-rrow {
font-weight: 650; background: rgba(16, 185, 129, 0.06);
color: var(--g-700);
font-variant-numeric: tabular-nums;
} }
.ocr-load-card.is-done .ocr-load-rrow { .ocr-load.is-dark .ocr-load-card.is-done .ocr-load-rrow {
background: rgba(16, 185, 129, 0.035); background: rgba(16, 185, 129, 0.1);
} }
@media (max-width: 640px) { @media (max-width: 640px) {

View 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]!;
}

View File

@@ -67,6 +67,9 @@ const storeId = ref(0);
const imageUrls = ref<string[]>([]); const imageUrls = ref<string[]>([]);
const uploading = ref(false); const uploading = ref(false);
const loading = ref(false); const loading = ref(false);
/** 识图接口已返回,loading 冲到 100% 后再卸挂载 */
const ocrLoadComplete = ref(false);
const histLoadComplete = ref(false);
const medicalAdvice = ref(''); const medicalAdvice = ref('');
const clinicalDiagnose = ref(''); const clinicalDiagnose = ref('');
const dosage = ref(7); const dosage = ref(7);
@@ -156,7 +159,13 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerOpen.value = !!isOpen; drawerOpen.value = !!isOpen;
if (isOpen) { if (isOpen) {
void loadHistory(); 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; if (!id) return;
activeGenerationId.value = id; activeGenerationId.value = id;
histDetailLoading.value = true; histDetailLoading.value = true;
histLoadComplete.value = false;
try { try {
const detail = await aiGenerationDetailApi({ id }); const detail = await aiGenerationDetailApi({ id });
applyOcrPayload({ applyOcrPayload({
@@ -303,10 +313,11 @@ async function onSelectHistory(row: any) {
historyList.value = historyList.value.map((r) => historyList.value = historyList.value.map((r) =>
Number(r.id) === id ? { ...r, is_read: 1 } : r, Number(r.id) === id ? { ...r, is_read: 1 } : r,
); );
histLoadComplete.value = true;
} catch (error: any) { } catch (error: any) {
message.error(error?.message || '加载历史失败'); message.error(error?.message || '加载历史失败');
} finally {
histDetailLoading.value = false; histDetailLoading.value = false;
histLoadComplete.value = false;
} }
} }
@@ -478,6 +489,7 @@ function runOcr() {
*/ */
async function requestOcr() { async function requestOcr() {
loading.value = true; loading.value = true;
ocrLoadComplete.value = false;
matchedList.value = []; matchedList.value = [];
unmatchedList.value = []; unmatchedList.value = [];
unmatchedPicked.value = {}; unmatchedPicked.value = {};
@@ -493,6 +505,8 @@ async function requestOcr() {
}); });
if (!res?.ok) { if (!res?.ok) {
message.error(res?.message || '识图失败'); message.error(res?.message || '识图失败');
loading.value = false;
ocrLoadComplete.value = false;
return; return;
} }
applyOcrPayload({ applyOcrPayload({
@@ -506,13 +520,27 @@ async function requestOcr() {
message.success( message.success(
unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成', unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成',
); );
// 等进度条冲到 100% 再卸 loading(@finished)
ocrLoadComplete.value = true;
} catch (error: any) { } catch (error: any) {
message.error(error?.message || '识图失败'); message.error(error?.message || '识图失败');
} finally {
loading.value = false; 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,顶层常为空 * 门店药库行药名:product-list 药名在 drug.drug_name / matched_alias,顶层常为空
* 统一扁平化,避免 Tag/已选显示空白 * 统一扁平化,避免 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 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"> <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"> <div v-else-if="histDetailLoading" class="py-8">
<OcrImageLoading title="加载识图记录" /> <OcrImageLoading
title="加载识图记录"
:complete="histLoadComplete"
@finished="onHistLoadFinished"
/>
</div> </div>
<template v-else> <template v-else>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">

View File

@@ -75,12 +75,17 @@ const phase = ref<Phase>('upload');
const submitResult = ref<Record<string, any> | null>(null); const submitResult = ref<Record<string, any> | null>(null);
const imageUrls = ref<string[]>([]); const imageUrls = ref<string[]>([]);
const recognizing = ref(false); const recognizing = ref(false);
/** 识图接口已返回,loading 冲到 100% 后再卸挂载 */
const ocrLoadComplete = ref(false);
const confirming = ref(false); const confirming = ref(false);
/** 我的生成记录(传方识图,按医生维度) */ /** 我的生成记录(传方识图,按医生维度) */
const historyList = ref<any[]>([]); const historyList = ref<any[]>([]);
const histLoading = ref(false); const histLoading = ref(false);
const activeGenerationId = ref(0); const activeGenerationId = ref(0);
const histDetailLoading = ref(false); const histDetailLoading = ref(false);
const histLoadComplete = ref(false);
/** 历史加载完成后待切 form 的标记(等进度条到 100%) */
const pendingHistToForm = ref(false);
const medicalAdvice = ref(''); const medicalAdvice = ref('');
const clinicalDiagnose = ref(''); const clinicalDiagnose = ref('');
@@ -465,6 +470,8 @@ async function onSelectHistory(row: any) {
if (!id) return; if (!id) return;
activeGenerationId.value = id; activeGenerationId.value = id;
histDetailLoading.value = true; histDetailLoading.value = true;
histLoadComplete.value = false;
pendingHistToForm.value = false;
try { try {
const detailApi = isClinicMode.value const detailApi = isClinicMode.value
? clinicAiGenerationDetailApi ? clinicAiGenerationDetailApi
@@ -475,13 +482,14 @@ async function onSelectHistory(row: any) {
historyList.value = historyList.value.map((r) => historyList.value = historyList.value.map((r) =>
Number(r.id) === id ? { ...r, is_read: 1 } : r, Number(r.id) === id ? { ...r, is_read: 1 } : r,
); );
phase.value = 'form'; // 等进度条到 100% 再切 form,避免 overlay 被卸掉
suppressPatientMatch = false; pendingHistToForm.value = true;
void debouncedQuote(); histLoadComplete.value = true;
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载历史失败'); message.error(e?.message || '加载历史失败');
} finally {
histDetailLoading.value = false; histDetailLoading.value = false;
histLoadComplete.value = false;
suppressPatientMatch = false;
} }
} }
@@ -658,6 +666,7 @@ function handleRecognize() {
/** 带上收货人文本调用识图接口并回填表单 */ /** 带上收货人文本调用识图接口并回填表单 */
async function runRecognize() { async function runRecognize() {
recognizing.value = true; recognizing.value = true;
ocrLoadComplete.value = false;
resetFormState(); resetFormState();
try { try {
const ocrApi = isClinicMode.value const ocrApi = isClinicMode.value
@@ -671,6 +680,8 @@ async function runRecognize() {
}); });
if (!res?.ok) { if (!res?.ok) {
message.error(res?.message || '识图失败'); message.error(res?.message || '识图失败');
recognizing.value = false;
ocrLoadComplete.value = false;
return; return;
} }
applyOcrPayload({ applyOcrPayload({
@@ -680,21 +691,50 @@ async function runRecognize() {
generation_id: Number(res.generation_id || 0), generation_id: Number(res.generation_id || 0),
}); });
await loadHistory(); await loadHistory();
suppressPatientMatch = true;
phase.value = 'form';
suppressPatientMatch = false;
void debouncedQuote();
const unN = unmatchedList.value.length; const unN = unmatchedList.value.length;
message.success( message.success(
unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成', unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成',
); );
// 等进度条冲到 100% 再切 form(@finished)
ocrLoadComplete.value = true;
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '识图失败'); message.error(e?.message || '识图失败');
} finally {
recognizing.value = false; 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 <OcrImageLoading
:title="histDetailLoading ? '加载识图记录' : 'AI 处方识别'" :title="histDetailLoading ? '加载识图记录' : 'AI 处方识别'"
:complete="histDetailLoading ? histLoadComplete : ocrLoadComplete"
@finished="onTransferLoadFinished"
/> />
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '每日简报', value: 'daily_brief' }, { label: '每日简报', value: 'daily_brief' },
{ label: '识图开方', value: 'prescription_image_ocr' }, { label: '识图开方', value: 'prescription_image_ocr' },
{ label: '识图传方', value: 'transfer_image_ocr' }, { label: '识图传方', value: 'transfer_image_ocr' },
{ label: '菜单层级整理', value: 'menu_organize' },
]; ];
/** 是否命中业务缓存(简报按天缓存等) */ /** 是否命中业务缓存(简报按天缓存等) */

View 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`, {});
}

View File

@@ -132,3 +132,58 @@ export async function sortMenuSiblings(data: {
}) { }) {
return requestClient.post<any>(`${prefix}sort-siblings`, 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,
});
}

View File

@@ -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 { CardActions } from '#/components/table-action';
import { Icon } from '#/components/icon'; import { Icon } from '#/components/icon';
import { sortMenuSiblings } from '../api';
import { MENU_TYPE_MAP } from '../config/constants'; import { MENU_TYPE_MAP } from '../config/constants';
/** 下钻路径 id 列表本地记忆 */ /** 下钻路径 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 { interface TreeNode {
id: number; id: number;
pid: number; pid: number;
@@ -279,6 +293,85 @@ function childCountLabel(node: TreeNode) {
const n = node.children.length; const n = node.children.length;
return n > 0 ? `${n} 个子菜单` : '叶子菜单'; 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> </script>
<template> <template>
@@ -306,11 +399,21 @@ function childCountLabel(node: TreeNode) {
当前:{{ currentParent.title }} 当前:{{ currentParent.title }}
</span> </span>
<CardActions <CardActions
size="sm"
:actions="currentLevelActions.actions" :actions="currentLevelActions.actions"
:drop-down-actions="currentLevelActions.dropDownActions" :drop-down-actions="currentLevelActions.dropDownActions"
:max-visible="3" :max-visible="3"
/> />
</template> </template>
<Button size="small" type="default" @click="emitAiOrganize">
AI 整理
</Button>
<span
v-if="canDragSort"
class="menu-card-nav__drag-hint"
>
{{ sortSaving ? '保存排序中…' : '拖动手柄可排序' }}
</span>
<Button <Button
v-if="kw && locatingFromSearch" v-if="kw && locatingFromSearch"
type="link" type="link"
@@ -336,7 +439,7 @@ function childCountLabel(node: TreeNode) {
</div> </div>
<Empty v-if="displayCards.length === 0" description="暂无菜单" /> <Empty v-if="displayCards.length === 0" description="暂无菜单" />
<div v-else class="menu-card-grid"> <div v-else ref="cardGridEl" class="menu-card-grid">
<div <div
v-for="card in displayCards" v-for="card in displayCards"
:key="card.id" :key="card.id"
@@ -347,6 +450,14 @@ function childCountLabel(node: TreeNode) {
card.children.length > 0 || (!!kw && !locatingFromSearch), 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)"> <div class="menu-card__head" @click="onCardBodyClick(card)">
<!-- icon 右侧放大,不挤左侧标题 --> <!-- icon 右侧放大,不挤左侧标题 -->
<div v-if="card.icon" class="menu-card__icon-wrap" aria-hidden="true"> <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" /> <Icon icon="ant-design:right-outlined" class="ml-0.5" />
</Button> </Button>
<CardActions <CardActions
size="sm"
:actions="buildRowActions(card.raw).actions" :actions="buildRowActions(card.raw).actions"
:drop-down-actions="buildRowActions(card.raw).dropDownActions" :drop-down-actions="buildRowActions(card.raw).dropDownActions"
:max-visible="1" :max-visible="1"
@@ -445,6 +557,11 @@ function childCountLabel(node: TreeNode) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.menu-card-nav__drag-hint {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.menu-card-grid { .menu-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
@@ -464,6 +581,7 @@ function childCountLabel(node: TreeNode) {
} }
.menu-card { .menu-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -476,6 +594,23 @@ function childCountLabel(node: TreeNode) {
border-color 0.15s ease; 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 { .menu-card.is-clickable .menu-card__head {
cursor: pointer; cursor: pointer;
} }
@@ -497,6 +632,10 @@ function childCountLabel(node: TreeNode) {
padding: 14px 16px 10px; padding: 14px 16px 10px;
} }
.menu-card:has(.menu-card__drag) .menu-card__head {
padding-left: 36px;
}
.menu-card__icon-wrap { .menu-card__icon-wrap {
position: absolute; position: absolute;
top: 12px; top: 12px;

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 菜单新增/编辑弹窗 * 菜单新增/编辑弹窗
* - 新增:pick 选类型 → form 填表 → sort 同级排序(可跳过) * - 新增:pick 选类型 → form 填表(标题自动匹配 name/path/icon)→ sort 同级排序(可跳过)
* - 编辑:拉详情回填;保存后直接关闭 * - 编辑:拉详情回填;保存后直接关闭
* - 切换父级 pid 时自动 next-sort 填入 sort * - 切换父级 pid 时自动 next-sort 填入 sort
*/ */
@@ -20,6 +20,7 @@ import {
getMenuNextSort, getMenuNextSort,
getMenuSiblings, getMenuSiblings,
sortMenuSiblings, sortMenuSiblings,
suggestMenuFromTitle,
updateMenu, updateMenu,
} from '../api'; } from '../api';
import { import {
@@ -29,6 +30,7 @@ import {
menuKindFromType, menuKindFromType,
} from '../config/constants'; } from '../config/constants';
import { modalFormProps } from '../config/form'; import { modalFormProps } from '../config/form';
import { createDirtyTracker, debounceFn } from '../utils/auto-fill';
import MenuKindPickCard from './menu-kind-pick-card.vue'; import MenuKindPickCard from './menu-kind-pick-card.vue';
const isUpdate = ref(false); const isUpdate = ref(false);
@@ -42,6 +44,11 @@ const sortPid = ref(0);
const highlightId = ref(0); const highlightId = ref(0);
/** 避免编辑回填 / 程序化改 pid 时误触发 next-sort */ /** 避免编辑回填 / 程序化改 pid 时误触发 next-sort */
const skipPidNextSort = ref(false); const skipPidNextSort = ref(false);
/** 程序化自动填时跳过脏标记 */
const skipAutoFillDirty = ref(false);
const dirtyTracker = createDirtyTracker();
/** 图标推荐候选(默认已选第一个写入 form) */
const iconCandidates = ref<string[]>([]);
/** /**
* 按当前 pid 同步目录的 component 可编辑状态 * 按当前 pid 同步目录的 component 可编辑状态
@@ -57,13 +64,10 @@ async function syncCatalogComponentByPid(pid: number) {
link: '', link: '',
}; };
if (isTop) { if (isTop) {
// 顶级必须布局壳
patch.component = 'BasicLayout'; patch.component = 'BasicLayout';
} else if (cur.component == null) { } else if (cur.component == null) {
// 新建二级尚未设过:先给默认,用户可再手动清空
patch.component = 'BasicLayout'; patch.component = 'BasicLayout';
} }
// 二级且已是 '' / 自定义值:原样保留,不覆盖
await formApi.setValues(patch); await formApi.setValues(patch);
formApi.updateSchema([ 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({ const [Form, formApi] = useVbenForm({
...modalFormProps, ...modalFormProps,
handleValuesChange(values, fieldsChanged) { handleValuesChange(values, fieldsChanged) {
@@ -138,7 +187,6 @@ const [Form, formApi] = useVbenForm({
) { ) {
syncTypeFields('menu', String(values.open_type || 'menu')); syncTypeFields('menu', String(values.open_type || 'menu'));
} }
// 非编辑锁定场景:切父级自动填 sort = max+1
if ( if (
fieldsChanged?.includes('pid') && fieldsChanged?.includes('pid') &&
step.value === 'form' && step.value === 'form' &&
@@ -146,7 +194,6 @@ const [Form, formApi] = useVbenForm({
) { ) {
fillNextSort(Number(values.pid) || 0); fillNextSort(Number(values.pid) || 0);
} }
// 目录切换父级时:顶级锁 BasicLayout,二级解锁可清空
if ( if (
fieldsChanged?.includes('pid') && fieldsChanged?.includes('pid') &&
step.value === 'form' && step.value === 'form' &&
@@ -154,9 +201,42 @@ const [Form, formApi] = useVbenForm({
) { ) {
syncCatalogComponentByPid(Number(values.pid) || 0); 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(() => { const modalTitle = computed(() => {
if (isUpdate.value) return '编辑菜单'; if (isUpdate.value) return '编辑菜单';
if (step.value === 'pick') return '新增菜单 · 选择类型'; if (step.value === 'pick') return '新增菜单 · 选择类型';
@@ -201,6 +281,8 @@ function applyFooterForStep(next: 'pick' | 'form' | 'sort', editing: boolean) {
async function goToFormStep() { async function goToFormStep() {
step.value = 'form'; step.value = 'form';
applyFooterForStep('form', false); applyFooterForStep('form', false);
dirtyTracker.reset();
iconCandidates.value = [];
skipPidNextSort.value = true; skipPidNextSort.value = true;
await formApi.reset(); await formApi.reset();
await syncTypeFields(createKind.value, 'menu'); await syncTypeFields(createKind.value, 'menu');
@@ -310,7 +392,6 @@ const [Modal, modalApi] = useVbenModal({
if (isCatalogType(values.type) || createKind.value === 'catalog') { if (isCatalogType(values.type) || createKind.value === 'catalog') {
values.type = 'catalog'; values.type = 'catalog';
const pid = Number(values.pid) || 0; const pid = Number(values.pid) || 0;
// 顶级目录强制 BasicLayout;二级目录保留用户手动值(可空)
if (pid === 0) { if (pid === 0) {
values.component = 'BasicLayout'; values.component = 'BasicLayout';
} else { } else {
@@ -330,7 +411,6 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close(); modalApi.close();
} else { } else {
const created = await createMenu(values); const created = await createMenu(values);
// create 的 result 即 insertGetId
const newId = Number(created); const newId = Number(created);
message.success('保存成功'); message.success('保存成功');
await enterSortStep(Number(values.pid) || 0, newId); await enterSortStep(Number(values.pid) || 0, newId);
@@ -348,6 +428,9 @@ const [Modal, modalApi] = useVbenModal({
createKind.value = 'menu'; createKind.value = 'menu';
siblingItems.value = []; siblingItems.value = [];
highlightId.value = 0; highlightId.value = 0;
iconCandidates.value = [];
dirtyTracker.reset();
debouncedSuggest.cancel();
formApi.reset(); formApi.reset();
return; return;
} }
@@ -384,7 +467,6 @@ function typeLabel(type: string | undefined) {
<template> <template>
<Modal :title="modalTitle" class="w-[60%]"> <Modal :title="modalTitle" class="w-[60%]">
<!-- 选类型:粒子 + Canvas 动效卡 -->
<div v-if="step === 'pick'" class="menu-pick"> <div v-if="step === 'pick'" class="menu-pick">
<div class="menu-pick__hint"> <div class="menu-pick__hint">
单击选择类型,双击或点底部「下一步」进入填写 单击选择类型,双击或点底部「下一步」进入填写
@@ -402,7 +484,6 @@ function typeLabel(type: string | undefined) {
/> />
</div> </div>
</div> </div>
<!-- 同级排序 -->
<div v-else-if="step === 'sort'" class="menu-sort"> <div v-else-if="step === 'sort'" class="menu-sort">
<div class="menu-sort__hint"> <div class="menu-sort__hint">
可调整同级菜单顺序;「完成」写入排序,「跳过」保持当前 sort 可调整同级菜单顺序;「完成」写入排序,「跳过」保持当前 sort
@@ -442,7 +523,25 @@ function typeLabel(type: string | undefined) {
暂无同级菜单 暂无同级菜单
</div> </div>
</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> </Modal>
</template> </template>
@@ -459,6 +558,38 @@ function typeLabel(type: string | undefined) {
gap: 16px; 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 { .menu-sort__hint {
margin-bottom: 12px; margin-bottom: 12px;
font-size: 14px; font-size: 14px;

View File

@@ -1,7 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 菜单移动弹窗:把其它子菜单迁到当前父级(或指定目标父级) * 菜单移动弹窗:把其它子菜单迁到当前父级(或指定目标父级)
* 流程:选菜单 → 预览角色授权 diff → 确认执行(补新父 / 撤孤儿旧父) * 流程:树形勾选菜单 → 预览角色授权 diff → 确认执行(补新父 / 撤孤儿旧父)
* 候选用可勾选树(checkStrictly):只勾节点本身,避免父子联动误选整枝
*/ */
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
@@ -10,11 +11,11 @@ import { useVbenModal } from '@vben/common-ui';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Empty, Empty,
Form, Form,
FormItem, FormItem,
Table, Table,
Tree,
TreeSelect, TreeSelect,
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
@@ -29,13 +30,20 @@ interface RoleDiffRow {
revoke_menus: { id: number; title: string }[]; revoke_menus: { id: number; title: string }[];
} }
/** antd Tree 节点(禁用目标自身及子孙防成环) */
interface CandidateTreeNode {
key: number;
title: string;
disableCheckbox?: boolean;
children?: CandidateTreeNode[];
}
const targetPid = ref<number>(0); const targetPid = ref<number>(0);
const treeOptions = ref<any[]>([]); const treeOptions = ref<any[]>([]);
/** 可选迁入的菜单(扁平,排除目标自身与其子孙) */ /** 可勾选的候选树(已排除目标子孙为禁用或过滤) */
const candidateMenus = ref< const candidateTree = ref<CandidateTreeNode[]>([]);
{ id: number; title: string; pid: number; pathLabel: string }[] /** checkStrictly 下 checked.keys 为选中 id */
>([]); const checkedKeys = ref<number[]>([]);
const checkedIds = ref<number[]>([]);
const previewed = ref(false); const previewed = ref(false);
const previewLoading = ref(false); const previewLoading = ref(false);
const roleDiffs = ref<RoleDiffRow[]>([]); const roleDiffs = ref<RoleDiffRow[]>([]);
@@ -46,53 +54,7 @@ const onSuccess = ref<(() => void) | null>(null);
/** 从行操作带入的目标父级标题(仅展示) */ /** 从行操作带入的目标父级标题(仅展示) */
const targetTitle = ref(''); const targetTitle = ref('');
const checkedCount = computed(() => checkedIds.value.length); const checkedCount = computed(() => checkedKeys.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;
}
/** 收集某节点全部子孙 id,用于排除成环候选 */ /** 收集某节点全部子孙 id,用于排除成环候选 */
function collectDescendantIds(node: any, bag: Set<number>) { 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[] { function normalizeTreeOptions(list: any[]): any[] {
return (list || []).map((item) => ({ 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({ const [Modal, modalApi] = useVbenModal({
fullscreenButton: false, fullscreenButton: false,
draggable: true, draggable: true,
@@ -123,7 +142,7 @@ const [Modal, modalApi] = useVbenModal({
message.warning('请先预览授权变更后再确认执行'); message.warning('请先预览授权变更后再确认执行');
return; return;
} }
if (checkedIds.value.length === 0) { if (checkedKeys.value.length === 0) {
message.error('请勾选要移动的菜单'); message.error('请勾选要移动的菜单');
return; return;
} }
@@ -131,7 +150,7 @@ const [Modal, modalApi] = useVbenModal({
try { try {
await moveMenu({ await moveMenu({
target_pid: Number(targetPid.value), target_pid: Number(targetPid.value),
menu_ids: checkedIds.value, menu_ids: checkedKeys.value,
}); });
message.success('移动成功'); message.success('移动成功');
onSuccess.value?.(); onSuccess.value?.();
@@ -151,32 +170,32 @@ const [Modal, modalApi] = useVbenModal({
onSuccess.value = data?.onSuccess ?? null; onSuccess.value = data?.onSuccess ?? null;
targetPid.value = Number(data?.target?.id ?? 0); targetPid.value = Number(data?.target?.id ?? 0);
targetTitle.value = String(data?.target?.title || ''); targetTitle.value = String(data?.target?.title || '');
checkedIds.value = []; checkedKeys.value = [];
previewed.value = false; previewed.value = false;
roleDiffs.value = []; roleDiffs.value = [];
moveSummary.value = null; moveSummary.value = null;
loadTree(); loadTree();
} else { } else {
onSuccess.value = null; onSuccess.value = null;
candidateMenus.value = []; candidateTree.value = [];
checkedIds.value = []; checkedKeys.value = [];
previewed.value = false; previewed.value = false;
roleDiffs.value = []; roleDiffs.value = [];
} }
}, },
}); });
/** 拉菜单树:目标下拉 + 候选勾选列表 */ /** 拉菜单树:目标下拉 + 候选勾选树 */
async function loadTree() { async function loadTree() {
try { try {
const res = await getMenuTreeOptionSelect(); const res = await getMenuTreeOptionSelect();
const list = Array.isArray(res) ? res : res?.result || res || []; const list = Array.isArray(res) ? res : res?.result || res || [];
treeOptions.value = normalizeTreeOptions(list); treeOptions.value = normalizeTreeOptions(list);
candidateMenus.value = flattenCandidates(list, Number(targetPid.value)); refreshCandidates(list);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
treeOptions.value = []; treeOptions.value = [];
candidateMenus.value = []; candidateTree.value = [];
} }
} }
@@ -185,19 +204,33 @@ async function onTargetChange() {
previewed.value = false; previewed.value = false;
roleDiffs.value = []; roleDiffs.value = [];
moveSummary.value = null; moveSummary.value = null;
checkedIds.value = []; checkedKeys.value = [];
try { try {
const res = await getMenuTreeOptionSelect(); const res = await getMenuTreeOptionSelect();
const list = Array.isArray(res) ? res : res?.result || res || []; const list = Array.isArray(res) ? res : res?.result || res || [];
candidateMenus.value = flattenCandidates(list, Number(targetPid.value)); refreshCandidates(list);
} catch { } 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 */ /** 调用后端 preview-move,展示角色授权 diff */
async function handlePreview() { async function handlePreview() {
if (checkedIds.value.length === 0) { if (checkedKeys.value.length === 0) {
message.error('请勾选要移动的菜单'); message.error('请勾选要移动的菜单');
return; return;
} }
@@ -205,7 +238,7 @@ async function handlePreview() {
try { try {
const res = await previewMoveMenu({ const res = await previewMoveMenu({
target_pid: Number(targetPid.value), target_pid: Number(targetPid.value),
menu_ids: checkedIds.value, menu_ids: checkedKeys.value,
}); });
const data = res?.result ?? res ?? {}; const data = res?.result ?? res ?? {};
roleDiffs.value = Array.isArray(data.role_diffs) ? data.role_diffs : []; roleDiffs.value = Array.isArray(data.role_diffs) ? data.role_diffs : [];
@@ -257,26 +290,20 @@ const diffColumns = [
</div> </div>
</FormItem> </FormItem>
<FormItem :label="`迁入的子菜单(已选 ${checkedCount})`" required> <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 <Empty
v-if="candidateMenus.length === 0" v-if="candidateTree.length === 0"
description="暂无可迁入菜单" description="暂无可迁入菜单"
/> />
<Checkbox.Group <Tree
v-else v-else
v-model:value="checkedIds" checkable
class="flex w-full flex-col gap-1" check-strictly
@change="previewed = false" default-expand-all
> :tree-data="candidateTree"
<Checkbox :checked-keys="checkedKeys"
v-for="item in candidateMenus" @check="onTreeCheck"
:key="item.id" />
:value="item.id"
class="!ml-0"
>
<span class="text-sm">{{ item.pathLabel }}</span>
</Checkbox>
</Checkbox.Group>
</div> </div>
</FormItem> </FormItem>
<div class="mb-3"> <div class="mb-3">

View File

@@ -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>

View File

@@ -77,9 +77,9 @@ export const modalFormProps: VbenFormProps = {
rules: 'selectRequired', rules: 'selectRequired',
}, },
{ {
component: 'VbenInput', component: 'IconPicker',
componentProps: { componentProps: {
placeholder: '请输入菜单图标,如 lucide:layout-dashboard', placeholder: '选择或搜索图标',
}, },
fieldName: 'icon', fieldName: 'icon',
label: '菜单图标', label: '菜单图标',

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 菜单管理:树形列表 + 卡片双视图;支持移动子菜单(预览角色授权后执行) * 菜单管理:树形列表 + 卡片双视图;支持移动子菜单、同级拖拽排序、AI 整理
* 搜索区与视图切换分行;首屏按 viewMode 主动拉数 * 搜索区与视图切换分行;首屏按 viewMode 主动拉数
*/ */
import type { VxeGridListeners } from '#/adapter/vxe-table'; 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 { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { deleteMenu, getMenuList } from './api'; import { deleteMenu, getMenuList } from './api';
import AiOrganizeModal from './components/ai-organize-modal.vue';
import AuthRole from './components/auth-role.vue'; import AuthRole from './components/auth-role.vue';
import FormModalDemo from './components/modal.vue'; import FormModalDemo from './components/modal.vue';
import MenuCardList from './components/MenuCardList.vue'; import MenuCardList from './components/MenuCardList.vue';
import MoveMenuModal from './components/move-menu-modal.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 { MENU_TYPE_MAP } from './config/constants';
import { formOptions } from './config/search'; import { formOptions } from './config/search';
import { gridOptions as baseGridOptions } from './config/table'; import { gridOptions as baseGridOptions } from './config/table';
@@ -40,6 +42,7 @@ const viewMode = useViewMode('menu-view-mode');
const cardItems = ref<Record<string, any>[]>([]); const cardItems = ref<Record<string, any>[]>([]);
const cardLoading = ref(false); const cardLoading = ref(false);
const searchKeyword = ref(''); const searchKeyword = ref('');
const menuCardListRef = ref<InstanceType<typeof MenuCardList> | null>(null);
const gridEvents: VxeGridListeners<any> = { const gridEvents: VxeGridListeners<any> = {
checkboxChange() { checkboxChange() {
@@ -170,6 +173,14 @@ const [MoveModal, moveModalApi] = useVbenModal({
connectedComponent: MoveMenuModal, connectedComponent: MoveMenuModal,
}); });
const [SortModal, sortModalApi] = useVbenModal({
connectedComponent: SortSiblingsModal,
});
const [AiModal, aiModalApi] = useVbenModal({
connectedComponent: AiOrganizeModal,
});
const showModal = (data = {}, isUpdate = false) => { const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({ formModalApi.setData({
values: data, values: data,
@@ -227,6 +238,33 @@ const handleMoveMenus = (row: Record<string, any>) => {
moveModalApi.open(); 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 共用 */ /** 行操作定义:列表 TableAction / 卡片 CardActions 共用 */
function buildRowActions(row: Record<string, any>) { function buildRowActions(row: Record<string, any>) {
return { return {
@@ -254,6 +292,13 @@ function buildRowActions(row: Record<string, any>) {
}, },
], ],
dropDownActions: [ dropDownActions: [
{
label: '同级排序',
type: 'link' as const,
icon: 'lucide:arrow-up-down',
size: 'small' as const,
onClick: () => openSortSiblings(Number(row.pid) || 0),
},
{ {
label: '删除', label: '删除',
type: 'link' as const, type: 'link' as const,
@@ -275,6 +320,8 @@ const showListToolbar = computed(() => viewMode.value === 'list');
<Page auto-content-height title="菜单管理"> <Page auto-content-height title="菜单管理">
<FormModal /> <FormModal />
<MoveModal /> <MoveModal />
<SortModal />
<AiModal />
<AuthRole ref="authRoleRef" /> <AuthRole ref="authRoleRef" />
<!-- 顶部独立搜索区(与视图切换脱开) --> <!-- 顶部独立搜索区(与视图切换脱开) -->
<div <div
@@ -282,13 +329,22 @@ const showListToolbar = computed(() => viewMode.value === 'list');
> >
<SearchForm /> <SearchForm />
</div> </div>
<!-- 工具行:视图切换 + 卡片态新增(当前层级操作在下方面包屑栏) --> <!-- 工具行:视图切换 + 卡片态新增 / AI -->
<div class="mb-3 flex flex-wrap items-center justify-between gap-3"> <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<ViewModeSwitch v-model="viewMode" /> <ViewModeSwitch v-model="viewMode" />
<Button v-if="viewMode === 'card'" type="primary" @click="showModal()"> <div class="flex flex-wrap gap-2">
<Icon icon="ant-design:plus-outlined" class="mr-1" /> <Button
新增 v-if="viewMode === 'card'"
</Button> @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> </div>
<Grid v-if="viewMode === 'list'"> <Grid v-if="viewMode === 'list'">
<template #toolbar-actions> <template #toolbar-actions>
@@ -312,6 +368,21 @@ const showListToolbar = computed(() => viewMode.value === 'list');
ifShow: showListToolbar, ifShow: showListToolbar,
onClick: collapseAll.bind(null), 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="[ :drop-down-actions="[
{ {
@@ -350,7 +421,6 @@ const showListToolbar = computed(() => viewMode.value === 'list');
</template> </template>
</Grid> </Grid>
<div v-else class="menu-card-wrap"> <div v-else class="menu-card-wrap">
<!-- 保持挂载,避免 loading 卸载导致本地层级记忆丢失 -->
<div <div
v-show="cardLoading" v-show="cardLoading"
class="py-16 text-center text-[hsl(var(--muted-foreground))]" class="py-16 text-center text-[hsl(var(--muted-foreground))]"
@@ -358,10 +428,13 @@ const showListToolbar = computed(() => viewMode.value === 'list');
加载中… 加载中…
</div> </div>
<MenuCardList <MenuCardList
ref="menuCardListRef"
v-show="!cardLoading" v-show="!cardLoading"
:items="cardItems" :items="cardItems"
:keyword="searchKeyword" :keyword="searchKeyword"
:build-row-actions="buildRowActions" :build-row-actions="buildRowActions"
@sorted="refreshAll"
@ai-organize="openAiOrganize"
/> />
</div> </div>
</Page> </Page>

View 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;
}

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 系统配置 - AI:二级 Tab * 系统配置 - AI管理:二级 Tab
* 1) 模型配置 2) 病历配置 3) 提醒文案 * 1) 模型配置 2) 功能路由 3) 病历配置 4) 提醒文案
* 4) Agent 中转 5) 语音配置 6) Agent 高级 * 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 { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue';
import { import {
@@ -12,12 +12,13 @@ import {
Checkbox, Checkbox,
Input, Input,
InputNumber, InputNumber,
message, Select,
Radio,
Spin, Spin,
Switch, Switch,
Tabs, Tabs,
Tag, Tag,
message,
Radio,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { import {
@@ -33,11 +34,80 @@ import {
supportsThinkingType, supportsThinkingType,
thinkingTypeLabel, thinkingTypeLabel,
} from '#/views/system/ai/platform/config/provider-capabilities'; } from '#/views/system/ai/platform/config/provider-capabilities';
import { GlassCard } from '#/components/glass-card';
import { ensureAiPromptDefaults } from '#/views/system/ai/prompt/api';
import { import {
getSystemConfigList, getSystemConfigList,
saveSystemConfig, saveSystemConfig,
} from '#/views/system/system-config/api'; } 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 持久化 */ /** 二级 Tab 持久化 */
const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_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 aiAgentBaseUrl = ref('http://127.0.0.1:18123');
const aiAgentSecret = ref(''); const aiAgentSecret = ref('');
const aiAgentTesting = ref(false); 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 并行) */ /** 一次 AI 出方生成几份方案(1–5,默认 1;>1 需开启 Agent 中转并由 Go 并行) */
const aiPrescriptionGenerateCount = ref(1); const aiPrescriptionGenerateCount = ref(1);
/** 病历场景是否让 Go Agent 检索知识库(独立于「是否中转」) */ /** 病历场景是否让 Go Agent 检索知识库(独立于「是否中转」) */
@@ -487,6 +564,29 @@ async function loadAdvancedCfg() {
aiTokenMaxPerCall.value = Number(row.config_value) || 2048; aiTokenMaxPerCall.value = Number(row.config_value) || 2048;
break; 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: { default: {
break; 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:直连/中转开关 + 服务地址 + 密钥 + 出方份数 * 保存「Agent 中转」Tab:直连/中转开关 + 服务地址 + 密钥 + 出方份数
* 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项 * 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
@@ -851,6 +989,7 @@ onMounted(() => {
// 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级 // 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
if ( if (
stored === 'model' || stored === 'model' ||
stored === 'feature' ||
stored === 'copy' || stored === 'copy' ||
stored === 'mr' || stored === 'mr' ||
stored === 'agent' || stored === 'agent' ||
@@ -1094,6 +1233,77 @@ onMounted(() => {
</div> </div>
</Tabs.TabPane> </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="病历配置"> <Tabs.TabPane key="mr" tab="病历配置">
<div class="py-2"> <div class="py-2">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]"> <div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">

View File

@@ -54,168 +54,204 @@ interface MenuItem {
tabs?: ModuleTabItem[]; tabs?: ModuleTabItem[];
} }
const menuItems: MenuItem[] = [ /** 左侧分组:标题只展示,不参与选中 */
interface MenuGroup {
title: string;
items: MenuItem[];
}
/**
* 左侧按业务域分组(小程序 / 诊疗 / 运营 / 智能)
* 扁平 menuItems 由 groups 展开,供查找与持久化
*/
const menuGroups: MenuGroup[] = [
{ {
key: 'mp_patient', title: '小程序与展示',
title: '患者端小程序', items: [
tabs: [
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
{ {
key: 'short_link', key: 'mp_patient',
title: 'Short Link 次数', title: '患者端小程序',
component: MpShortLinkQuotaPanel, 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: '诊疗业务',
title: '医生端小程序', items: [
tabs: [
{ {
key: 'default_fav', key: 'register',
title: '默认常用入口', title: '挂号',
component: WxWorkbenchDefaultFavPanel, tabs: [
{
key: 'wait',
title: '排队预估',
component: RegisterWaitConfigPanel,
},
{
key: 'voucher_ui',
title: '凭证页风格',
component: RegisterVoucherUiConfigPanel,
},
],
}, },
{ {
key: 'card_style', key: 'order',
title: '工作台瓷片风格', title: '订单',
component: WxWorkbenchCardStylePanel, 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', key: 'image_transfer',
title: '传方瓷片图标', title: '传方',
component: WxWorkbenchTileIconPanel, 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: '运营支撑',
title: '工作台', items: [
component: WorkbenchConfigPanel,
},
{
key: 'dashboard',
title: '数据大屏',
component: DashboardConfigPanel,
},
{
key: 'dispense_sign',
title: '处方签名',
component: DispenseSignConfigPanel,
},
{
key: 'order',
title: '订单',
tabs: [
{ {
key: 'price_adjust', key: 'finance',
title: '订单调价', title: '财务',
component: PriceAdjustConfigPanel, tabs: [
{
key: 'withdrawal_rules',
title: '提现规则',
component: WithdrawalRulesConfigPanel,
},
],
}, },
{ {
key: 'excipient_price', key: 'notice',
title: '辅料定价', title: '通知',
component: ExcipientPriceConfigPanel, tabs: [
}, {
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel }, key: 'invoice',
{ title: '开票通知',
key: 'free_shipping', component: InvoiceNoticeConfigPanel,
title: '满额包邮', },
component: FreeShippingConfigPanel, { key: 'oa', title: 'OA通知', component: OaNotifyConfigPanel },
],
}, },
{ {
key: 'public_account_pay', key: 'security',
title: '公账支付', title: '权限安全',
component: PublicAccountPayConfigPanel, tabs: [
{ key: 'login', title: '登录安全', component: DoctorLoginConfigPanel },
{
key: 'salesperson',
title: '业务员权限',
component: SalespersonConfigPanel,
},
],
},
{
key: 'upload',
title: '上传',
component: ImageCompressConfigPanel,
}, },
], ],
}, },
{ {
key: 'register', title: '智能',
title: '挂号', items: [
tabs: [
{ {
key: 'wait', key: 'ai_model',
title: '排队预估', title: 'AI管理',
component: RegisterWaitConfigPanel, component: AiModelConfigPanel,
},
{
key: 'voucher_ui',
title: '凭证页风格',
component: RegisterVoucherUiConfigPanel,
}, },
], ],
}, },
{
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,避免刷新后丢选中 */ /** 旧左侧菜单 key → 新模块 + 二级 Tab,避免刷新后丢选中 */
const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = { const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = {
price_adjust: { menu: 'order', tab: 'price_adjust' }, price_adjust: { menu: 'order', tab: 'price_adjust' },
@@ -290,16 +326,23 @@ onMounted(readStoredMenu);
<Card class="sys-cfg-card" :body-style="{ padding: 0, height: '100%' }"> <Card class="sys-cfg-card" :body-style="{ padding: 0, height: '100%' }">
<div class="sys-cfg-layout"> <div class="sys-cfg-layout">
<aside class="sys-cfg-nav"> <aside class="sys-cfg-nav">
<button <div
v-for="item in menuItems" v-for="group in menuGroups"
:key="item.key" :key="group.title"
type="button" class="sys-cfg-nav-group"
class="sys-cfg-nav-item"
:class="{ active: activeKey === item.key }"
@click="selectMenu(item.key)"
> >
{{ item.title }} <div class="sys-cfg-nav-group-title">{{ group.title }}</div>
</button> <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> </aside>
<section class="sys-cfg-main"> <section class="sys-cfg-main">
<component <component
@@ -343,6 +386,20 @@ onMounted(readStoredMenu);
border-right: 1px solid hsl(var(--border)); 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 { .sys-cfg-nav-item {
display: block; display: block;
width: 100%; width: 100%;