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}` }} + 未读