@@ -229,19 +287,22 @@ onUnmounted(() => {
--ink: #0b3a2c;
--ink-2: #4e7d6c;
--ink-3: #8aab9e;
- --glass-a: rgba(255, 255, 255, 0.7);
- --glass-b: rgba(255, 255, 255, 0.34);
+ --glass-a: rgba(255, 255, 255, 0.78);
+ --glass-b: rgba(236, 253, 245, 0.55);
--edge-a: rgba(255, 255, 255, 0.95);
- --edge-b: rgba(255, 255, 255, 0.06);
+ --edge-b: rgba(16, 185, 129, 0.08);
--sheen: rgba(255, 255, 255, 0.7);
--ring: rgba(255, 255, 255, 0.6);
- --panel: rgba(255, 255, 255, 0.38);
- --hairline2: rgba(11, 58, 44, 0.06);
+ --panel: rgba(255, 255, 255, 0.5);
+ --hairline2: rgba(11, 58, 44, 0.08);
--paper: #fff;
--paper-line: #dceae5;
- --logo-c1: #5eead4;
- --logo-c2: #10b981;
- --logo-c3: #047857;
+ --logo-bg: linear-gradient(
+ 150deg,
+ rgba(255, 255, 255, 0.85) 0%,
+ rgba(209, 250, 229, 0.5) 52%,
+ rgba(255, 255, 255, 0.4) 100%
+ );
--shadow:
0 40px 80px -42px rgba(3, 55, 40, 0.42),
0 16px 36px -24px rgba(3, 55, 40, 0.26),
@@ -258,35 +319,39 @@ onUnmounted(() => {
padding: 8px 0;
}
-:global(.dark) .ocr-load {
+/* 暗色:跟 is-dark 绑定,不依赖 :global(.dark) */
+.ocr-load.is-dark {
--g-300: #5eead4;
--g-400: #34d399;
--g-500: #2bd9a0;
- --g-600: #10b981;
- --g-700: #7bf0c8;
- --ink: #e9f7f2;
- --ink-2: #9dc2b6;
- --ink-3: #6f9388;
- --glass-a: rgba(28, 43, 38, 0.58);
- --glass-b: rgba(10, 20, 17, 0.34);
- --edge-a: rgba(255, 255, 255, 0.32);
- --edge-b: rgba(255, 255, 255, 0.02);
- --sheen: rgba(255, 255, 255, 0.13);
- --ring: rgba(255, 255, 255, 0.1);
- --panel: rgba(255, 255, 255, 0.035);
- --hairline2: rgba(255, 255, 255, 0.055);
- --paper: #e9f3ef;
- --paper-line: #c9dcd4;
- --logo-c1: #b9f6e0;
- --logo-c2: #34d399;
- --logo-c3: #0fa97d;
+ --g-600: #6ee7b7;
+ --g-700: #a7f3d0;
+ --ink: #e8f5f0;
+ --ink-2: #a8cfc2;
+ --ink-3: #8eb9ab;
+ --glass-a: rgba(18, 36, 30, 0.88);
+ --glass-b: rgba(10, 24, 20, 0.72);
+ --edge-a: rgba(110, 231, 183, 0.35);
+ --edge-b: rgba(255, 255, 255, 0.04);
+ --sheen: rgba(255, 255, 255, 0.18);
+ --ring: rgba(110, 231, 183, 0.12);
+ --panel: rgba(255, 255, 255, 0.06);
+ --hairline2: rgba(110, 231, 183, 0.12);
+ --paper: #1a2e28;
+ --paper-line: #2d4a40;
+ --logo-bg: linear-gradient(
+ 150deg,
+ rgba(52, 211, 153, 0.22) 0%,
+ rgba(16, 185, 129, 0.1) 52%,
+ rgba(255, 255, 255, 0.04) 100%
+ );
--shadow:
0 40px 90px -38px rgba(0, 0, 0, 0.85),
0 16px 40px -26px rgba(0, 0, 0, 0.6),
0 2px 6px -2px rgba(0, 0, 0, 0.4);
--shadow-paper:
0 12px 28px -16px rgba(0, 0, 0, 0.7),
- 0 0 0 1px rgba(255, 255, 255, 0.06);
+ 0 0 0 1px rgba(110, 231, 183, 0.12);
}
.ocr-load-card {
@@ -296,11 +361,10 @@ onUnmounted(() => {
padding: 22px;
border-radius: 28px;
background: linear-gradient(150deg, var(--glass-a), var(--glass-b));
- backdrop-filter: blur(34px) saturate(190%) brightness(1.05);
+ backdrop-filter: blur(34px) saturate(190%);
box-shadow:
var(--shadow),
- inset 0 1px 0 0 var(--ring),
- inset 0 -1px 0 0 rgba(255, 255, 255, 0.06);
+ inset 0 1px 0 0 var(--ring);
transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
@@ -329,12 +393,8 @@ onUnmounted(() => {
box-shadow:
var(--shadow),
inset 0 1px 0 0 var(--ring),
- 0 0 0 1px rgba(16, 185, 129, 0.2),
- 0 0 64px -16px rgba(16, 185, 129, 0.5);
-}
-
-.ocr-load-card.no-anim .ocr-load-rrow {
- transition: none !important;
+ 0 0 0 1px rgba(16, 185, 129, 0.25),
+ 0 0 64px -16px rgba(16, 185, 129, 0.45);
}
.ocr-load-head {
@@ -350,19 +410,12 @@ onUnmounted(() => {
place-items: center;
width: 42px;
height: 42px;
- background: linear-gradient(
- 150deg,
- rgba(255, 255, 255, 0.66) 0%,
- rgba(255, 255, 255, 0.2) 52%,
- rgba(255, 255, 255, 0.34) 100%
- );
+ background: var(--logo-bg);
border-radius: 14px;
box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.92),
- inset 0 0 20px rgba(16, 185, 129, 0.12),
+ inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 6px 18px -8px rgba(5, 95, 70, 0.3),
- 0 0 0 1px rgba(16, 185, 129, 0.14);
- backdrop-filter: blur(16px) saturate(200%);
+ 0 0 0 1px rgba(16, 185, 129, 0.2);
}
.ocr-load-logo-halo {
@@ -393,19 +446,6 @@ onUnmounted(() => {
z-index: 1;
width: 22px;
height: 22px;
- filter: drop-shadow(0 1px 3px rgba(16, 185, 129, 0.4));
-}
-
-.ocr-load-logo-mark :deep(.s1) {
- stop-color: var(--logo-c1);
-}
-
-.ocr-load-logo-mark :deep(.s2) {
- stop-color: var(--logo-c2);
-}
-
-.ocr-load-logo-mark :deep(.s3) {
- stop-color: var(--logo-c3);
}
.ocr-load-meta {
@@ -422,10 +462,12 @@ onUnmounted(() => {
}
.ocr-load-sub {
- position: relative;
height: 17px;
+ overflow: hidden;
font-size: 12px;
color: var(--ink-3);
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.ocr-load-pulse {
@@ -457,39 +499,6 @@ onUnmounted(() => {
animation: none;
}
-.ocr-load-swap {
- position: relative;
- display: inline-block;
-}
-
-.ocr-load-swap .s1,
-.ocr-load-swap .s2 {
- position: absolute;
- top: 0;
- left: 0;
- white-space: nowrap;
- transition:
- opacity 0.38s ease,
- transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
-}
-
-.ocr-load-swap .s2 {
- font-weight: 600;
- color: var(--g-700);
- opacity: 0;
- transform: translateY(5px);
-}
-
-.ocr-load-card.is-done .ocr-load-swap .s1 {
- opacity: 0;
- transform: translateY(-5px);
-}
-
-.ocr-load-card.is-done .ocr-load-swap .s2 {
- opacity: 1;
- transform: translateY(0);
-}
-
.ocr-load-pct {
flex: 0 0 auto;
min-width: 52px;
@@ -528,13 +537,14 @@ onUnmounted(() => {
background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600));
border-radius: 99px;
box-shadow: 0 0 14px rgba(16, 185, 129, 0.6);
+ transition: width 0.05s linear;
}
.ocr-load-fill::after {
content: '';
position: absolute;
inset: 0;
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent);
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
transform: translateX(-100%);
animation: ocrSheen 1.7s linear infinite;
}
@@ -836,14 +846,12 @@ onUnmounted(() => {
white-space: nowrap;
}
-.ocr-load-rrow .val :deep(b) {
- font-weight: 650;
- color: var(--g-700);
- font-variant-numeric: tabular-nums;
+.ocr-load-card.is-done .ocr-load-rrow {
+ background: rgba(16, 185, 129, 0.06);
}
-.ocr-load-card.is-done .ocr-load-rrow {
- background: rgba(16, 185, 129, 0.035);
+.ocr-load.is-dark .ocr-load-card.is-done .ocr-load-rrow {
+ background: rgba(16, 185, 129, 0.1);
}
@media (max-width: 640px) {
diff --git a/apps/web-antd/src/components/loading/ai-loading-progress.ts b/apps/web-antd/src/components/loading/ai-loading-progress.ts
new file mode 100644
index 00000000..a364b1d7
--- /dev/null
+++ b/apps/web-antd/src/components/loading/ai-loading-progress.ts
@@ -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]!;
+}
diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/ImageOcrDrawer.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/ImageOcrDrawer.vue
index 21b6512a..d57ef740 100644
--- a/apps/web-antd/src/views/doctor/doctor-reception/components/ImageOcrDrawer.vue
+++ b/apps/web-antd/src/views/doctor/doctor-reception/components/ImageOcrDrawer.vue
@@ -67,6 +67,9 @@ const storeId = ref(0);
const imageUrls = ref
([]);
const uploading = ref(false);
const loading = ref(false);
+/** 识图接口已返回,loading 冲到 100% 后再卸挂载 */
+const ocrLoadComplete = ref(false);
+const histLoadComplete = ref(false);
const medicalAdvice = ref('');
const clinicalDiagnose = ref('');
const dosage = ref(7);
@@ -156,7 +159,13 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerOpen.value = !!isOpen;
if (isOpen) {
void loadHistory();
+ return;
}
+ // 关抽屉时清掉 loading,避免下次打开卡在半完成态
+ loading.value = false;
+ ocrLoadComplete.value = false;
+ histDetailLoading.value = false;
+ histLoadComplete.value = false;
},
});
@@ -293,6 +302,7 @@ async function onSelectHistory(row: any) {
if (!id) return;
activeGenerationId.value = id;
histDetailLoading.value = true;
+ histLoadComplete.value = false;
try {
const detail = await aiGenerationDetailApi({ id });
applyOcrPayload({
@@ -303,10 +313,11 @@ async function onSelectHistory(row: any) {
historyList.value = historyList.value.map((r) =>
Number(r.id) === id ? { ...r, is_read: 1 } : r,
);
+ histLoadComplete.value = true;
} catch (error: any) {
message.error(error?.message || '加载历史失败');
- } finally {
histDetailLoading.value = false;
+ histLoadComplete.value = false;
}
}
@@ -478,6 +489,7 @@ function runOcr() {
*/
async function requestOcr() {
loading.value = true;
+ ocrLoadComplete.value = false;
matchedList.value = [];
unmatchedList.value = [];
unmatchedPicked.value = {};
@@ -493,6 +505,8 @@ async function requestOcr() {
});
if (!res?.ok) {
message.error(res?.message || '识图失败');
+ loading.value = false;
+ ocrLoadComplete.value = false;
return;
}
applyOcrPayload({
@@ -506,13 +520,27 @@ async function requestOcr() {
message.success(
unN > 0 ? `识别完成;${unN} 味未匹配,可手动搜索` : '识别完成',
);
+ // 等进度条冲到 100% 再卸 loading(@finished)
+ ocrLoadComplete.value = true;
} catch (error: any) {
message.error(error?.message || '识图失败');
- } finally {
loading.value = false;
+ ocrLoadComplete.value = false;
}
}
+/** 识图 Loading 到 100% 后卸挂载 */
+function onOcrLoadFinished() {
+ loading.value = false;
+ ocrLoadComplete.value = false;
+}
+
+/** 历史详情 Loading 到 100% 后卸挂载 */
+function onHistLoadFinished() {
+ histDetailLoading.value = false;
+ histLoadComplete.value = false;
+}
+
/**
* 门店药库行药名:product-list 药名在 drug.drug_name / matched_alias,顶层常为空
* 统一扁平化,避免 Tag/已选显示空白
@@ -886,9 +914,18 @@ defineExpose({
diff --git a/apps/web-antd/src/views/system/ai/generation/config/constants.ts b/apps/web-antd/src/views/system/ai/generation/config/constants.ts
index 0b29b207..940febd9 100644
--- a/apps/web-antd/src/views/system/ai/generation/config/constants.ts
+++ b/apps/web-antd/src/views/system/ai/generation/config/constants.ts
@@ -14,6 +14,7 @@ export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '每日简报', value: 'daily_brief' },
{ label: '识图开方', value: 'prescription_image_ocr' },
{ label: '识图传方', value: 'transfer_image_ocr' },
+ { label: '菜单层级整理', value: 'menu_organize' },
];
/** 是否命中业务缓存(简报按天缓存等) */
diff --git a/apps/web-antd/src/views/system/ai/prompt/api/index.ts b/apps/web-antd/src/views/system/ai/prompt/api/index.ts
new file mode 100644
index 00000000..1edca896
--- /dev/null
+++ b/apps/web-antd/src/views/system/ai/prompt/api/index.ts
@@ -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
(`${prefix}list`, { params });
+}
+
+/** 详情 xk-api GET /ai-prompt/detail */
+export async function getAiPromptDetail(id: number) {
+ return requestClient.get(`${prefix}detail`, { params: { id } });
+}
+
+/** 更新 xk-api POST /ai-prompt/update */
+export async function updateAiPrompt(data: Record) {
+ return requestClient.post(`${prefix}update`, data);
+}
+
+/** 同步代码种子 xk-api POST /ai-prompt/ensure-defaults */
+export async function ensureAiPromptDefaults() {
+ return requestClient.post(`${prefix}ensure-defaults`, {});
+}
diff --git a/apps/web-antd/src/views/system/menu/api/index.ts b/apps/web-antd/src/views/system/menu/api/index.ts
index 299ab537..2aa279ce 100644
--- a/apps/web-antd/src/views/system/menu/api/index.ts
+++ b/apps/web-antd/src/views/system/menu/api/index.ts
@@ -132,3 +132,58 @@ export async function sortMenuSiblings(data: {
}) {
return requestClient.post(`${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(`${prefix}ai-organize-suggest`, data);
+}
+
+/** AI 整理预览 xk-api GET /menu/ai-organize-preview */
+export async function aiOrganizePreview(generationId: number) {
+ return requestClient.get(`${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(`${prefix}ai-organize-apply`, {
+ generation_id: generationId,
+ });
+}
+
+/** AI 整理取消 xk-api POST /menu/ai-organize-cancel */
+export async function aiOrganizeCancel(generationId: number) {
+ return requestClient.post(`${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(`${prefix}ai-organize-history`, {
+ params: data,
+ });
+}
diff --git a/apps/web-antd/src/views/system/menu/components/MenuCardList.vue b/apps/web-antd/src/views/system/menu/components/MenuCardList.vue
index e3c19dba..45b16a40 100644
--- a/apps/web-antd/src/views/system/menu/components/MenuCardList.vue
+++ b/apps/web-antd/src/views/system/menu/components/MenuCardList.vue
@@ -6,13 +6,16 @@
* - 当前层级(栈顶父级)在导航工具栏可编辑/授权/移动/删除
* - 搜索:扁平列出命中项并高亮路径;可「定位到所在层级」切入浏览
*/
-import { computed, nextTick, ref, watch } from 'vue';
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
-import { Breadcrumb, Button, Empty, Tag } from 'ant-design-vue';
+import { useSortable } from '@vben/hooks';
+
+import { Breadcrumb, Button, Empty, Tag, message } from 'ant-design-vue';
import { CardActions } from '#/components/table-action';
import { Icon } from '#/components/icon';
+import { sortMenuSiblings } from '../api';
import { MENU_TYPE_MAP } from '../config/constants';
/** 下钻路径 id 列表本地记忆 */
@@ -31,6 +34,17 @@ const props = defineProps<{
};
}>();
+const emit = defineEmits<{
+ /** 当前层拖拽排序成功后通知父级刷新 */
+ sorted: [];
+ /** 打开 AI 整理(带当前父级) */
+ 'ai-organize': [payload: { pid: number; parentTitle: string }];
+}>();
+
+const cardGridEl = ref(null);
+let sortableInst: { destroy?: () => void } | null = null;
+const sortSaving = ref(false);
+
interface TreeNode {
id: number;
pid: number;
@@ -279,6 +293,85 @@ function childCountLabel(node: TreeNode) {
const n = node.children.length;
return n > 0 ? `${n} 个子菜单` : '叶子菜单';
}
+
+/** 当前层是否允许拖拽排序(浏览态且非搜索扁平列表) */
+const canDragSort = computed(
+ () => showBrowseNav.value && displayCards.value.length > 1,
+);
+
+async function destroySortable() {
+ sortableInst?.destroy?.();
+ sortableInst = null;
+}
+
+/**
+ * 初始化当前层卡片拖拽;拖完写 sort-siblings
+ * 为什么销毁再建:下钻/搜索切换后 DOM 节点会换
+ */
+async function setupCardSortable() {
+ await destroySortable();
+ await nextTick();
+ if (!canDragSort.value || !cardGridEl.value) return;
+ const { initializeSortable } = useSortable(cardGridEl.value, {
+ animation: 200,
+ handle: '.menu-card__drag',
+ draggable: '.menu-card',
+ onEnd: async (evt: { oldIndex?: number; newIndex?: number }) => {
+ const { oldIndex, newIndex } = evt;
+ if (
+ oldIndex === undefined ||
+ newIndex === undefined ||
+ oldIndex === newIndex
+ ) {
+ return;
+ }
+ const list = [...displayCards.value];
+ const [moved] = list.splice(oldIndex, 1);
+ if (!moved) return;
+ list.splice(newIndex, 0, moved);
+ const pid = currentParent.value?.id ?? 0;
+ sortSaving.value = true;
+ try {
+ await sortMenuSiblings({
+ pid,
+ items: list.map((n, i) => ({ id: n.id, sort: i })),
+ });
+ message.success('排序已保存');
+ emit('sorted');
+ } catch (err) {
+ console.error(err);
+ emit('sorted');
+ } finally {
+ sortSaving.value = false;
+ }
+ },
+ });
+ sortableInst = await initializeSortable();
+}
+
+watch(
+ [displayCards, canDragSort],
+ () => {
+ nextTick(() => setupCardSortable());
+ },
+ { flush: 'post' },
+);
+
+onBeforeUnmount(() => {
+ destroySortable();
+});
+
+function emitAiOrganize() {
+ emit('ai-organize', {
+ pid: currentParent.value?.id ?? 0,
+ parentTitle: currentParent.value?.title || '根目录',
+ });
+}
+
+defineExpose({
+ getCurrentPid: () => currentParent.value?.id ?? 0,
+ getCurrentTitle: () => currentParent.value?.title || '根目录',
+});
@@ -306,11 +399,21 @@ function childCountLabel(node: TreeNode) {
当前:{{ currentParent.title }}
+
+