From c6d2b0f917aed663e13038e94c4378ad8c73a8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Mon, 17 Aug 2026 09:51:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20WidgetAiDailyBrief?= =?UTF-8?q?.vue=EF=BC=9A=E5=BF=AB=E7=85=A7=E7=BB=9F=E8=AE=A1=20pill?= =?UTF-8?q?=EF=BC=88=E5=8F=A3=E5=BE=84=E5=8F=96=E8=87=AA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=97=B6=E7=9A=84=20data=5Fsnapshot=EF=BC=8C=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E4=B8=8E=20AI=20=E6=96=87=E6=9C=AC=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=89+=20=E5=88=86=E6=AE=B5=E6=AD=A3=E6=96=87?= =?UTF-8?q?=EF=BC=88=E6=8C=89=E3=80=90=E6=A0=87=E9=A2=98=E3=80=91=E5=88=87?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E4=B8=8D=E8=A7=84=E8=8C=83=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=95=B4=E6=AE=B5=EF=BC=89+=20=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E7=94=9F=E6=88=90=E6=8C=89=E9=92=AE=EF=BC=9BAI=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=8A=AF=E7=89=87?= =?UTF-8?q?=E4=BB=8D=E5=B1=95=E7=A4=BA=E3=80=81=E6=AD=A3=E6=96=87=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E7=BB=99=E9=87=8D=E8=AF=95=E3=80=82=20=20=20=20=20=20?= =?UTF-8?q?=20=E6=AF=8F=E6=97=A5=E9=A6=96=E6=AC=A1=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=9A=E7=94=9F=E6=88=90=E6=88=90=E5=8A=9F=E4=B8=94=20localS?= =?UTF-8?q?torage=20=E5=BD=93=E5=A4=A9=E6=9C=AA=E8=AE=B0=E5=BD=95=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BC=B9=E5=85=A8=E6=96=87=EF=BC=8C=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E7=82=B9=E3=80=8C=E6=9F=A5=E7=9C=8B=E5=85=A8=E6=96=87?= =?UTF-8?q?=E3=80=8D=E9=9A=8F=E6=97=B6=E5=86=8D=E7=9C=8B=EF=BC=9B=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E5=85=A8=E9=83=A8=E8=B5=B0=E4=B8=BB=E9=A2=98=E5=8F=98?= =?UTF-8?q?=E9=87=8F=EF=BC=8C=E6=9A=97=E8=89=B2=E8=87=AA=E9=80=82=E9=85=8D?= =?UTF-8?q?=EF=BC=8C=E5=8D=A1=E7=89=87=E5=BC=BA=E8=B0=83=E7=94=A8=E8=A7=84?= =?UTF-8?q?=E5=88=99=E8=A6=81=E6=B1=82=E7=9A=84=E3=80=8C1px=20=E5=90=8C?= =?UTF-8?q?=E8=89=B2=E8=BE=B9=E6=A1=86=20+=20=E5=BE=AE=E5=85=89=E3=80=8D?= =?UTF-8?q?=E3=80=82=20=20=20=20=20=20=20=E5=B8=83=E5=B1=80=E4=B8=8A?= =?UTF-8?q?=E7=AE=80=E6=8A=A5=E4=BD=9C=E4=B8=BA=E5=85=A8=E5=AE=BD=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=8D=A1=E5=9B=BA=E5=AE=9A=E6=B8=B2=E6=9F=93=E5=9C=A8?= =?UTF-8?q?=E6=97=A5=E7=A8=8B=E8=A1=8C=E4=B8=8E=20KPI=20=E5=8C=BA=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=EF=BC=8C=E4=B8=8D=E8=BF=9B=20KPI=20=E8=8A=AF=E7=89=87?= =?UTF-8?q?=E9=9B=86=E5=90=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/dashboard/workspace/api/index.ts | 26 + .../components/WidgetAiDailyBrief.vue | 568 ++++++++++++++++++ .../dashboard/workspace/config/widgets.ts | 3 + .../src/views/dashboard/workspace/index.vue | 43 +- .../doctor/doctor-reception/api/index.ts | 7 + .../components/AiPrescriptionDrawer.vue | 234 +++++++- .../views/system/ai/generation/api/index.ts | 24 + .../generation/components/archive-modal.vue | 164 +++++ .../ai/generation/components/detail-modal.vue | 239 ++++++-- .../components/generation-card-list.vue | 256 ++++++++ .../components/generation-flow-timeline.vue | 499 +++++++++++++++ .../ai/generation/components/review-modal.vue | 141 +++++ .../system/ai/generation/config/constants.ts | 40 ++ .../system/ai/generation/config/search.ts | 101 +++- .../system/ai/generation/config/table.ts | 53 +- .../src/views/system/ai/generation/index.vue | 336 +++++++++-- .../components/ai-model-config-panel.vue | 109 +++- .../components/dict-search-config-panel.vue | 74 +++ .../components/doctor-login-config-panel.vue | 77 +++ .../components/input-audit-config-panel.vue | 69 +++ .../invoice-notice-config-panel.vue | 89 +++ .../components/logistics-config-panel.vue | 97 +++ .../medical-record-config-panel.vue | 75 +++ .../components/miniprogram-config-panel.vue | 65 ++ .../components/oa-notify-config-panel.vue | 4 +- .../components/price-adjust-config-panel.vue | 151 +++++ .../components/salesperson-config-panel.vue | 78 +++ .../src/views/system/system-config/index.vue | 538 +++++------------ 28 files changed, 3597 insertions(+), 563 deletions(-) create mode 100644 apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue create mode 100644 apps/web-antd/src/views/system/ai/generation/components/archive-modal.vue create mode 100644 apps/web-antd/src/views/system/ai/generation/components/generation-card-list.vue create mode 100644 apps/web-antd/src/views/system/ai/generation/components/generation-flow-timeline.vue create mode 100644 apps/web-antd/src/views/system/ai/generation/components/review-modal.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/dict-search-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/doctor-login-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/input-audit-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/invoice-notice-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/logistics-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/medical-record-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/miniprogram-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/price-adjust-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/salesperson-config-panel.vue diff --git a/apps/web-antd/src/views/dashboard/workspace/api/index.ts b/apps/web-antd/src/views/dashboard/workspace/api/index.ts index a43c303c..7ffde7e6 100644 --- a/apps/web-antd/src/views/dashboard/workspace/api/index.ts +++ b/apps/web-antd/src/views/dashboard/workspace/api/index.ts @@ -100,6 +100,22 @@ export type AppointmentUpcomingListResult = { total: number; }; +/** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */ +export type AiDailyBriefResult = { + brief_date: string; + /** 1=生成成功 0=生成失败(失败时 error_msg 有值,data 快照仍可展示) */ + status: number; + content: string; + error_msg: string; + /** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */ + data: Record; + provider: string; + model: string; + generated_at: string; + /** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */ + from_cache: boolean; +}; + /** * 获取当前登录角色的工作台布局 */ @@ -177,3 +193,13 @@ export async function getAppointmentUpcomingListApi(data: { { params: data }, ); } + +/** + * AI 今日简报(每天首次调用后端生成并缓存,refresh=true 强制重新生成) + * 生成可能耗时十几秒(走 AI),调用方需自行处理 loading 展示 + */ +export async function getAiDailyBriefApi(refresh: boolean = false) { + return requestClient.get(`${prefix}ai-daily-brief`, { + params: refresh ? { refresh: 1 } : {}, + }); +} diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue new file mode 100644 index 00000000..fcdc2476 --- /dev/null +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue @@ -0,0 +1,568 @@ + + + + + diff --git a/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts b/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts index 534a28a1..5e4f6f2b 100644 --- a/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts +++ b/apps/web-antd/src/views/dashboard/workspace/config/widgets.ts @@ -1,6 +1,7 @@ import type { Component } from 'vue'; import NoticeTicker from '../components/NoticeTicker.vue'; +import WidgetAiDailyBrief from '../components/WidgetAiDailyBrief.vue'; import WidgetAppointmentUpcoming from '../components/WidgetAppointmentUpcoming.vue'; import WidgetCalendarTodo from '../components/WidgetCalendarTodo.vue'; import WidgetClinicToday from '../components/WidgetClinicToday.vue'; @@ -33,6 +34,8 @@ export const WIDGET_COMPONENT_MAP: Record = { // 日程待办(快捷入口行右侧)与日程日历(快捷入口行下方全宽),布局位置固定不进 KPI 区 calendar_todo: WidgetCalendarTodo, work_calendar: WidgetWorkCalendar, + // AI 今日简报:全宽内容卡(文本主体),固定渲染在日程行之后、KPI 区之前,不进 KPI 芯片区 + ai_daily_brief: WidgetAiDailyBrief, }; /** KPI 类模块(渲染在中部 KPI 区,其余按固定位置渲染) */ diff --git a/apps/web-antd/src/views/dashboard/workspace/index.vue b/apps/web-antd/src/views/dashboard/workspace/index.vue index 8c5321cc..17467660 100644 --- a/apps/web-antd/src/views/dashboard/workspace/index.vue +++ b/apps/web-antd/src/views/dashboard/workspace/index.vue @@ -58,7 +58,12 @@ const workCalendarItem = computed(() => validItems.value.find((item) => item.widget_code === 'work_calendar'), ); -/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱,需排除) */ +/** AI 今日简报:日程行之后全宽一行(文本主体卡,含每日首弹弹窗) */ +const aiBriefItem = computed(() => + validItems.value.find((item) => item.widget_code === 'ai_daily_brief'), +); + +/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱需排除,AI 简报不在 KPI 集合天然不进来) */ const kpiItems = computed(() => validItems.value.filter( (item) => @@ -136,6 +141,28 @@ onMounted(fetchLayout); + + +
+ +
+ + +
+ +
+
- - -
- -
@@ -188,7 +202,6 @@ onMounted(fetchLayout); display: flex; flex-direction: column; gap: 16px; - max-width: 1400px; margin: 0 auto; } diff --git a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts index 050a64ef..c12bf6a8 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts +++ b/apps/web-antd/src/views/doctor/doctor-reception/api/index.ts @@ -473,6 +473,13 @@ export async function aiGenerationDetailApi(data: { id: number }) { return requestClient.get(`${prefix}ai-generation-detail`, { params: data }); } +/** 标记 AI 生成记录为已读(熄灭历史未读微光) */ +export async function aiMarkGenerationReadApi(data: { + generation_id: number; +}) { + return requestClient.post(`${prefix}ai-mark-generation-read`, data); +} + /** AI 处方药名模糊对照 */ export async function aiMatchPrescriptionDrugsApi(data: { generation_id?: number; diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue index 01447141..1b846fb4 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue @@ -7,11 +7,12 @@ import { computed, ref } from 'vue'; import { useVbenDrawer, useVbenModal } from '@vben/common-ui'; -import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tag, message } from 'ant-design-vue'; +import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag, message } from 'ant-design-vue'; import { aiGeneratePrescriptionApi, aiListGenerationsApi, + aiMarkGenerationReadApi, aiMatchPrescriptionDrugsApi, aiSavePrescriptionDrugSelectionApi, getProcessRuleList, @@ -114,6 +115,12 @@ const rxProcessRuleNoteId = ref(0); const rxProcessRuleName = ref(''); const rxChildProcessRuleName = ref(''); const rxProcessRuleNote = ref(''); +/** + * 一次多份生成结果(方案对比): + * 后端 prescriptions[];选中成功方案后右侧对照/导入跟选中份走 + */ +const multiPrescriptions = ref([]); +const multiActiveIndex = ref(0); /** 生成弹窗:中药委托调剂选项(本地记忆) */ const AI_RX_ENTRUSTED_STORAGE_KEY = 'xk_ai_rx_use_entrusted_process'; const useEntrustedProcess = ref(false); @@ -247,6 +254,8 @@ function resetPreview() { rxProcessRuleNote.value = ''; activeId.value = 0; editingKey.value = ''; + multiPrescriptions.value = []; + multiActiveIndex.value = 0; } function resetState() { @@ -608,9 +617,76 @@ async function confirmGenerateAndClose(): Promise { return true; } +/** + * 本地把某条历史标为已读(同步 multi / historyList) + */ +function patchLocalRead(generationId: number) { + const gid = Number(generationId || 0); + if (gid <= 0) return; + historyList.value = (historyList.value || []).map((r: any) => + Number(r.id) === gid ? { ...r, is_read: 1 } : r, + ); + multiPrescriptions.value = (multiPrescriptions.value || []).map((p: any) => + Number(p?.generation_id) === gid ? { ...p, is_read: 1 } : p, + ); +} + +/** + * 医生查看方案:调后端标记已读(失败不阻断预览) + */ +async function markReadIfNeeded(generationId: number, alreadyRead?: number | boolean) { + const gid = Number(generationId || 0); + if (gid <= 0 || Number(alreadyRead) === 1) return; + try { + await aiMarkGenerationReadApi({ generation_id: gid }); + patchLocalRead(gid); + } catch { + // 已读标记失败不影响对照预览 + } +} + +/** + * 应用某一份多方案结果到右侧预览(成功份可导入,失败份只展示错误) + */ +function applyMultiSlot(slot: any, index: number) { + multiActiveIndex.value = index; + if (!slot || slot.ok === false) { + matchedList.value = []; + unmatchedList.value = []; + conflictMessages.value = []; + safetyFlags.value = []; + applyRxMeta({ + dosage: 0, + day_dosage: 0, + reason: '', + basis: '', + prescription_name: '', + duration_ms: slot?.duration_ms, + }); + activeId.value = Number(slot?.generation_id || 0); + return; + } + activeId.value = Number(slot.generation_id || 0); + applyMatch(slot.match || null); + applyConflict(slot.conflict || null); + applySafetyFlags(slot.safety_flags || null); + applyRxMeta(slot); + void markReadIfNeeded(activeId.value, slot.is_read); +} + +/** 切换多方案 Tab */ +function onMultiTabChange(key: string | number) { + const idx = Number(key); + const slot = multiPrescriptions.value[idx]; + if (!slot) return; + applyMultiSlot(slot, idx); +} + async function runGenerateInDrawer(chief: string) { beginGeneratingPlaceholder(); generating.value = true; + multiPrescriptions.value = []; + multiActiveIndex.value = 0; try { const req: Record = { register_id: registerId.value, @@ -630,10 +706,14 @@ async function runGenerateInDrawer(chief: string) { } const data = await aiGeneratePrescriptionApi(req); const durationText = formatDurationMs(data?.duration_ms); + const list = Array.isArray(data?.prescriptions) ? data.prescriptions : []; // 业务软失败:HTTP 成功但 ok=false,警告提示而非 error if (data?.ok === false) { endGeneratingPlaceholder(); resetPreview(); + if (list.length > 1) { + multiPrescriptions.value = list; + } if (data?.generation_id) { await loadHistory(); activeId.value = Number(data.generation_id); @@ -642,11 +722,26 @@ async function runGenerateInDrawer(chief: string) { return; } await loadHistory(); + if (list.length > 1) { + multiPrescriptions.value = list; + const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false); + const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0; + applyMultiSlot(list[useIdx], useIdx); + const okN = list.filter((p: any) => p && p.ok !== false).length; + const failN = list.length - okN; + const baseMsg = + failN > 0 + ? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比` + : `已生成 ${okN} 套方案,可切换对比`; + message.success(durationText ? `${baseMsg}(耗时 ${durationText})` : baseMsg); + return; + } activeId.value = Number(data?.generation_id || 0); applyMatch(data?.match || null); applyConflict(data?.conflict || null); applySafetyFlags(data?.safety_flags || null); applyRxMeta(data); + void markReadIfNeeded(activeId.value, 0); message.success( durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入', ); @@ -661,6 +756,9 @@ async function runGenerateInDrawer(chief: string) { async function onSelectHistory(row: any) { if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return; + // 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰 + multiPrescriptions.value = []; + multiActiveIndex.value = 0; activeId.value = Number(row.id); matchLoading.value = true; try { @@ -672,6 +770,8 @@ async function onSelectHistory(row: any) { prescriptionType.value || row.prescription_type || 0, ), }); + // match 接口后端已标已读,本地同步熄灭微光 + patchLocalRead(Number(row.id)); applyMatch(data); applyConflict(data?.conflict || null); applySafetyFlags(data?.safety_flags || null); @@ -733,6 +833,15 @@ async function onPickCandidate(mi: number, candidate: any) { } function onConfirmImport() { + // 多方案时:失败方案不可导入 + const multi = multiPrescriptions.value; + if (multi.length > 1) { + const cur = multi[multiActiveIndex.value]; + if (!cur || cur.ok === false) { + message.warning('当前方案生成失败,请切换到成功方案后再导入'); + return; + } + } const drugs: any[] = []; for (const m of matchedList.value) { if (!m || m._import === false) continue; @@ -860,6 +969,10 @@ defineExpose({ 'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id), 'cursor-default border border-dashed border-primary/40 bg-primary/5': Number(row.id) === PENDING_GEN_ID || row._pending, + 'ai-hist-unread': + Number(row.id) !== PENDING_GEN_ID && + !row._pending && + Number(row.is_read) !== 1, }" @click="onSelectHistory(row)" > @@ -869,6 +982,14 @@ defineExpose({ ? '正在生成中…' : row.name || row.prescription_name || `方案 #${row.id}` }} + 未读