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 522605c1..01447141 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 @@ -68,6 +68,8 @@ const emit = defineEmits<{ child_process_rule_id?: number; process_rule_note_id?: number; rule_type?: number; + /** P4:本次导入来源的 AI 生成记录 ID,随开方提交回传做采纳率统计 */ + generation_id?: number; }, ): void; (e: 'search-drug', keyword: string): void; @@ -91,6 +93,13 @@ const activeId = ref(0); const matchedList = ref([]); const unmatchedList = ref([]); const conflictMessages = ref([]); +/** + * P2 安全校验提示(后端 safety_flags): + * 十八反/孕妇禁忌/剂量超限逐条标红展示,医生自行决断(只提示不拦截) + */ +const safetyFlags = ref< + Array<{ drug?: string; level: string; message: string; ref?: string; type: string }> +>([]); const rxDosage = ref(0); const rxDayDosage = ref(0); const rxReason = ref(''); @@ -223,6 +232,7 @@ function resetPreview() { matchedList.value = []; unmatchedList.value = []; conflictMessages.value = []; + safetyFlags.value = []; rxDosage.value = 0; rxDayDosage.value = 0; rxReason.value = ''; @@ -416,6 +426,37 @@ function applyConflict(conflict: any) { .filter(Boolean); } +/** + * 解析后端 safety_flags 结构 { has_risk, flags: [{type, level, drug, message, ref}] } + * 有 safety_flags 时优先展示统一安全块(含十八反),老记录没有该字段则回落旧配伍提示条 + */ +function applySafetyFlags(safety: any) { + const flags = Array.isArray(safety?.flags) ? safety.flags : []; + safetyFlags.value = flags + .map((f: any) => ({ + type: String(f?.type || ''), + level: String(f?.level || 'info'), + drug: String(f?.drug || ''), + message: String(f?.message || '').trim(), + ref: String(f?.ref || '').trim(), + })) + .filter((f: any) => f.message); +} + +/** 安全提示分级样式(error红/warning橙/info灰,主题变量适配暗色) */ +function safetyLevelClass(level: string) { + if (level === 'error') return 'text-red-500'; + if (level === 'warning') return 'text-orange-500'; + return 'text-muted-foreground'; +} + +/** 安全提示分级标签文本 */ +function safetyLevelLabel(level: string) { + if (level === 'error') return '禁忌'; + if (level === 'warning') return '警示'; + return '参考'; +} + function applyMatch(match: any) { const matched = Array.isArray(match?.matched) ? match.matched : []; matchedList.value = matched.map((m: any) => ({ @@ -604,6 +645,7 @@ async function runGenerateInDrawer(chief: string) { activeId.value = Number(data?.generation_id || 0); applyMatch(data?.match || null); applyConflict(data?.conflict || null); + applySafetyFlags(data?.safety_flags || null); applyRxMeta(data); message.success( durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入', @@ -632,6 +674,7 @@ async function onSelectHistory(row: any) { }); applyMatch(data); applyConflict(data?.conflict || null); + applySafetyFlags(data?.safety_flags || null); applyRxMeta({ ...data, duration_ms: row.duration_ms, @@ -642,6 +685,7 @@ async function onSelectHistory(row: any) { matchedList.value = []; unmatchedList.value = []; conflictMessages.value = []; + safetyFlags.value = []; message.error(e?.message || e?.msg || '对照失败'); } finally { matchLoading.value = false; @@ -718,9 +762,14 @@ function onConfirmImport() { process_rule_id?: number; child_process_rule_id?: number; process_rule_note_id?: number; + generation_id?: number; } = { rows: drugs, }; + // P4:带上生成记录 ID,父页开方提交时透传给后端做采纳率/修改率统计 + if (activeId.value > 0) { + importPayload.generation_id = activeId.value; + } // 中药导入必须带剂数/每日次数,缺省用处方页默认 7/2 if (isTcmRx.value) { importPayload.dosage = rxDosage.value > 0 ? rxDosage.value : 7; @@ -769,8 +818,26 @@ defineExpose({ {{ typeLabel }} +
+
安全校验提示(请人工核对)
+
+ [{{ safetyLevelLabel(f.level) }}] + + {{ f.message }} + (依据:{{ f.ref }}) + +
+
+ +
配伍禁忌提示
diff --git a/apps/web-antd/src/views/doctor/doctor-reception/index.vue b/apps/web-antd/src/views/doctor/doctor-reception/index.vue index a37c0f1d..481bfaca 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/index.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/index.vue @@ -871,6 +871,8 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => { // 存纯数字字符串,避免 JSON.stringify 造成读取歧义 localStorage.setItem(`doctorReception-id`, String(patient.id)); + // 切换患者后 AI 采纳关联失效,重置避免跨患者误关联 + aiRxGenerationId.value = 0; getCurrentDrugs(); }; @@ -1082,6 +1084,8 @@ const updateLocalStorage = () => { }; const currentDrugs = ref([]); +/** P4:最近一次 AI 导入的生成记录 ID,发处方时回传后端做采纳率/修改率统计(清空/切换/发送后重置) */ +const aiRxGenerationId = ref(0); /** * 获取缓存的处方信息(按类型分开存储) */ @@ -1205,8 +1209,12 @@ const sendPrescription = () => { salesperson_transfer_prescription_id: salespersonTransferPrescriptionId.value || undefined, price_discount: priceDiscount.value, special_prescription_id: appliedSpecialPrescriptionId.value || 0, + // P4:AI 导入来源生成 ID,后端据此统计采纳率/修改率(非 AI 导入时为 undefined 不传) + ai_generation_id: aiRxGenerationId.value || undefined, }).then(async (res) => { message.success('处方已发送'); + // 处方已提交,重置 AI 关联,避免下一张处方误关联 + aiRxGenerationId.value = 0; // 有 VIP 时同步保存病历(诊断/医嘱已与处方同源) if (canUseMedicalRecord.value) { try { @@ -1551,6 +1559,7 @@ async function handleImportAiPrescription(payload: { process_rule_id?: number; child_process_rule_id?: number; process_rule_note_id?: number; + generation_id?: number; }) { if (guardSpecialPrescriptionCartEdit()) return; const list = Array.isArray(payload?.rows) ? payload.rows : []; @@ -1661,6 +1670,8 @@ async function handleImportAiPrescription(payload: { return; } currentDrugs.value = next; + // P4:记录 AI 生成 ID(金方导入等无 ID 场景会重置为 0,避免误关联) + aiRxGenerationId.value = Number(payload.generation_id || 0); if (activeCategory.value === 1) { // 中药剂数/每日次数:有值用返回值,否则保留处方页默认 7/2 dosage.value = Number(payload.dosage) > 0 ? Number(payload.dosage) : 7; @@ -2644,6 +2655,8 @@ watch( */ function clearPrescriptionCart(showToast = true) { currentDrugs.value = []; + // 处方清空后 AI 导入内容已不存在,解除采纳关联 + aiRxGenerationId.value = 0; diagnosis.value = ''; medicalAdvice.value = ''; packageMethodId.value = 2; diff --git a/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue b/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue index 6c62ce45..1f50d6b1 100644 --- a/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue +++ b/apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue @@ -143,6 +143,8 @@ const form = reactive(emptyMedicalRecord()); const loading = ref(false); /** 加载中不写本地,避免接口回填触发草稿覆盖 */ const hydrating = ref(false); +/** P4:最近一次导入的 AI 生成记录 ID,保存病历时回传后端做采纳率/修改率统计(换号/清空重置) */ +const aiMrGenerationId = ref(0); /** 系统配置:常用|我的展示位置,默认标签 */ const commonDisplayMode = ref('tags'); const showCommonTags = computed( @@ -471,6 +473,8 @@ async function loadRecord() { watch( () => props.registerId, (id) => { + // 换号后 AI 采纳关联失效,重置避免误关联到别的挂号 + aiMrGenerationId.value = 0; if (id) loadRecord(); }, { immediate: true }, @@ -725,6 +729,8 @@ async function handleSave() { medicalAdvice: medicalAdviceModel.value, // 后端按此开关做主诉必填拦截 validate_required: 1, + // P4:AI 导入来源生成 ID,后端据此统计采纳率/修改率 + ai_generation_id: aiMrGenerationId.value || undefined, }); persistLocalDraft(); message.success('病历已保存'); @@ -767,7 +773,10 @@ function handleImportAiMedicalRecord(payload: { method_id?: number | null; syndrome_id?: number | null; } | null; + generation_id?: number; }) { + // P4:记录本次导入来源的生成 ID,保存病历时回传做采纳率统计 + aiMrGenerationId.value = Number(payload?.generation_id || 0); const fields = payload?.fields || {}; Object.keys(fields).forEach((k) => { if (k === 'chief_complaint' || k === 'name') return; @@ -845,6 +854,8 @@ async function handleClear() { const keepPatient = effectivePatientId.value; Object.assign(form, emptyMedicalRecord(props.registerId, props.storeId)); form.user_patient_id = keepPatient; + // 病历清空后 AI 内容已不存在,解除采纳关联 + aiMrGenerationId.value = 0; emit('update:diagnosis', ''); emit('update:medicalAdvice', ''); clearMedicalRecordDraft(`${props.storagePrefix || ''}${props.registerId}`); @@ -902,6 +913,8 @@ defineExpose({ user_patient_id: effectivePatientId.value, diagnosis: diagnosisModel.value, doctor_order: medicalAdviceModel.value, + // P4:随发处方同步保存病历的链路也带上 AI 生成 ID + ai_generation_id: aiMrGenerationId.value || undefined, }), /** 外部局部回写(如 AI 出方预览点改) */ patchFields, diff --git a/apps/web-antd/src/views/doctor/medical-record/components/AiMedicalRecordModal.vue b/apps/web-antd/src/views/doctor/medical-record/components/AiMedicalRecordModal.vue index ffa1f29e..09acb2f7 100644 --- a/apps/web-antd/src/views/doctor/medical-record/components/AiMedicalRecordModal.vue +++ b/apps/web-antd/src/views/doctor/medical-record/components/AiMedicalRecordModal.vue @@ -96,6 +96,8 @@ const emit = defineEmits<{ method_id?: number | null; syndrome_id?: number | null; } | null; + /** P4:本次导入来源的 AI 生成记录 ID,保存病历时回传做采纳率统计 */ + generation_id?: number; }, ): void; /** 生成弹窗里改字段时即时回写病历面板(与 AI 出方 sync-medical-record 同协议) */ @@ -127,6 +129,33 @@ const previewChief = ref(''); const lastDurationMs = ref(0); /** 醒目提示文案(系统配置 / 门闸下发) */ const disclaimerText = ref(''); +/** + * P2 病历质量提示(后端 quality_flags): + * 必填空缺/主诉-诊断不自洽/辨证链路不完整,规则级提示医生人工核对 + */ +const qualityFlags = ref< + Array<{ field?: string; level: string; message: string; type: string }> +>([]); + +/** 解析后端 quality_flags 结构(结构异常时静默为空,不影响预览) */ +function applyQualityFlags(quality: any) { + const flags = Array.isArray(quality?.flags) ? quality.flags : []; + qualityFlags.value = flags + .map((f: any) => ({ + type: String(f?.type || ''), + level: String(f?.level || 'info'), + field: String(f?.field || ''), + message: String(f?.message || '').trim(), + })) + .filter((f: any) => f.message); +} + +/** 质量提示分级样式(warning橙/info灰,主题变量适配暗色) */ +function qualityLevelClass(level: string) { + if (level === 'error') return 'text-red-500'; + if (level === 'warning') return 'text-orange-500'; + return 'text-muted-foreground'; +} const sexLabel = computed(() => { const s = Number(patientSex.value); @@ -419,6 +448,7 @@ async function onSelectHistory(row: any) { const fields = (data?.fields || {}) as Record; previewFields.value = { ...fields }; previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value; + applyQualityFlags(data?.quality_flags || null); const snap = data?.input_snapshot || {}; previewChief.value = String( snap.chief_complaint || chiefComplaint.value || '', @@ -428,6 +458,7 @@ async function onSelectHistory(row: any) { previewFields.value = {}; previewTcmIds.value = null; previewChief.value = ''; + qualityFlags.value = []; message.error(e?.message || e?.msg || '加载详情失败'); } finally { detailLoading.value = false; @@ -458,6 +489,7 @@ function beginGeneratingPlaceholder() { activeId.value = PENDING_GEN_ID; previewFields.value = {}; previewTcmIds.value = null; + qualityFlags.value = []; lastDurationMs.value = 0; const rest = (historyList.value || []).filter( (r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending, @@ -509,6 +541,7 @@ async function runGenerateInDrawer(chief: string) { } previewFields.value = (data?.fields || {}) as Record; previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value; + applyQualityFlags(data?.quality_flags || null); await loadHistory(); activeId.value = Number(data?.generation_id || 0); message.success( @@ -518,6 +551,7 @@ async function runGenerateInDrawer(chief: string) { endGeneratingPlaceholder(); previewFields.value = {}; previewTcmIds.value = null; + qualityFlags.value = []; message.error(e?.message || e?.msg || 'AI写病历失败'); } finally { generating.value = false; @@ -533,6 +567,8 @@ function onConfirmImport() { chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(), fields: { ...previewFields.value }, tcm_ids: previewTcmIds.value || null, + // P4:带上生成记录 ID,病历保存时透传后端做采纳率/修改率统计 + generation_id: activeId.value > 0 ? activeId.value : undefined, }); drawerApi.close(); } @@ -560,6 +596,19 @@ defineExpose({ {{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁
+ +
+
质量提示(请人工核对)
+
+ · + + {{ f.message }} + +
+
历史记录
diff --git a/apps/web-antd/src/views/system/ai-shadow/api/index.ts b/apps/web-antd/src/views/system/ai-shadow/api/index.ts new file mode 100644 index 00000000..40a3d894 --- /dev/null +++ b/apps/web-antd/src/views/system/ai-shadow/api/index.ts @@ -0,0 +1,70 @@ +import { requestClient } from '#/api/request'; + +const prefix = 'ai-generation/'; + +export interface ShadowCompareRow { + id: number; + store_id: number; + register_id: number; + doctor_id: number; + scene: string; + prescription_type: number; + primary_generation_id: number; + primary_provider: string; + primary_model: string; + primary_tokens: number; + primary_duration_ms: number; + primary_status: number; + shadow_generation_id: number; + shadow_provider: string; + shadow_model: string; + shadow_tokens: number; + shadow_duration_ms: number; + shadow_status: number; + content_diff_hash: string; + content_similarity: number; + quality_score: number; + remark: string; + created_at: number; +} + +export interface ShadowListResult { + list: ShadowCompareRow[]; + total: number; + page: number; + size: number; +} + +export interface ShadowStats { + count: number; + avg_primary_tokens: number; + avg_shadow_tokens: number; + avg_primary_duration_ms: number; + avg_shadow_duration_ms: number; + primary_success_rate: number; + shadow_success_rate: number; + avg_content_similarity: number; +} + +export interface ShadowListParams { + store_id?: number; + scene?: string; + primary_provider?: string; + shadow_provider?: string; + date_start?: number; + date_end?: number; + page?: number; + size?: number; +} + +export async function getShadowList(params: ShadowListParams) { + return requestClient.get(`${prefix}shadow-list`, { params }); +} + +export async function getShadowDetail(id: number) { + return requestClient.get(`${prefix}shadow-detail`, { params: { id } }); +} + +export async function getShadowStats(params: Partial) { + return requestClient.get(`${prefix}shadow-stats`, { params }); +} diff --git a/apps/web-antd/src/views/system/ai-shadow/config/constants.ts b/apps/web-antd/src/views/system/ai-shadow/config/constants.ts new file mode 100644 index 00000000..113567a2 --- /dev/null +++ b/apps/web-antd/src/views/system/ai-shadow/config/constants.ts @@ -0,0 +1,23 @@ +/** + * 影子流量对比模块常量 + * 场景 / 供应商选项集中维护,避免在 schema 与列配置中散落字面量 + */ + +/** 场景选项(与后端 xk_ai_shadow_compare.scene 取值对齐) */ +export const SHADOW_SCENE_OPTIONS = [ + { label: '病历', value: 'medical_record' }, + { label: '处方', value: 'prescription' }, +]; + +/** 供应商选项(主链路 / 影子链路共用) */ +export const SHADOW_PROVIDER_OPTIONS = [ + { label: 'DeepSeek', value: 'deepseek' }, + { label: '讯飞星火', value: 'spark' }, + { label: 'Go Agent', value: 'agent' }, +]; + +/** 场景 code 转中文展示名(列表列用) */ +export function shadowSceneLabel(scene: string): string { + const hit = SHADOW_SCENE_OPTIONS.find((o) => o.value === scene); + return hit ? hit.label : scene || '-'; +} diff --git a/apps/web-antd/src/views/system/ai-shadow/config/search.ts b/apps/web-antd/src/views/system/ai-shadow/config/search.ts new file mode 100644 index 00000000..9cfd1600 --- /dev/null +++ b/apps/web-antd/src/views/system/ai-shadow/config/search.ts @@ -0,0 +1,105 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import dayjs from 'dayjs'; + +import { getStoreOption } from '#/views/system/store/api'; + +import { SHADOW_PROVIDER_OPTIONS, SHADOW_SCENE_OPTIONS } from './constants'; + +/** + * 影子流量对比搜索表单 + * 诊所用 ApiSelect(复用 getStoreOption),时间范围映射为 date_start/date_end 时间戳 + */ +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + showSearch: true, + filterOption: true, + afterFetch: (data: { id: number; name: string }[]) => { + return (data || []).map((item: any) => ({ + label: `${item.name}【${item.id}】`, + value: item.id, + })); + }, + api: getStoreOption, + placeholder: '请选择诊所', + }, + defaultValue: undefined, + fieldName: 'store_id', + label: '诊所', + }, + { + component: 'VbenSelect', + componentProps: { + placeholder: '全部场景', + allowClear: true, + options: SHADOW_SCENE_OPTIONS, + }, + defaultValue: undefined, + fieldName: 'scene', + label: '场景', + }, + { + component: 'VbenSelect', + componentProps: { + placeholder: '全部', + allowClear: true, + options: SHADOW_PROVIDER_OPTIONS, + }, + defaultValue: undefined, + fieldName: 'primary_provider', + label: '主链路', + }, + { + component: 'VbenSelect', + componentProps: { + placeholder: '全部', + allowClear: true, + options: SHADOW_PROVIDER_OPTIONS, + }, + defaultValue: undefined, + fieldName: 'shadow_provider', + label: '影子链路', + }, + { + component: 'RangePicker', + componentProps: { + format: 'YYYY-MM-DD', + valueFormat: 'YYYY-MM-DD', + }, + defaultValue: undefined, + fieldName: 'search_time', + label: '时间范围', + }, + ], + showCollapseButton: true, + submitButtonOptions: { + content: '查询', + }, + submitOnChange: true, + submitOnEnter: false, +}; + +/** + * 将表单值转为接口参数:search_time → date_start/date_end(当天起止秒级时间戳) + * 后端 AiShadowCompareService::listCompare 按 created_at 时间戳过滤 + */ +export function normalizeShadowFilters(formValues: Record = {}) { + const params: Record = { ...formValues }; + const range = params.search_time; + delete params.search_time; + if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) { + params.date_start = dayjs(range[0]).startOf('day').unix(); + params.date_end = dayjs(range[1]).endOf('day').unix(); + } + Object.keys(params).forEach((k) => { + if (params[k] === '' || params[k] === undefined || params[k] === null) { + delete params[k]; + } + }); + return params; +} diff --git a/apps/web-antd/src/views/system/ai-shadow/config/table.ts b/apps/web-antd/src/views/system/ai-shadow/config/table.ts new file mode 100644 index 00000000..85244575 --- /dev/null +++ b/apps/web-antd/src/views/system/ai-shadow/config/table.ts @@ -0,0 +1,114 @@ +import type { VxeGridProps } from '#/adapter/vxe-table'; + +import type { ShadowCompareRow } from '../api'; + +import { getShadowList } from '../api'; +import { shadowSceneLabel } from './constants'; +import { normalizeShadowFilters } from './search'; + +/** + * 影子流量对比表格配置(只读,无操作列) + * + * 用工厂函数而不是直接导出 gridOptions: + * 顶部统计看板要与列表用同一套筛选条件(诊所/场景/时间范围), + * 每次 Grid 发起查询时通过 onQueried 回调把归一化后的参数抛给页面, + * 页面据此同步刷新 getShadowStats——单一数据源,避免两套筛选状态漂移 + */ +export function createGridOptions( + onQueried?: (params: Record) => void, +): VxeGridProps { + return { + columnConfig: { + useKey: true, + }, + rowConfig: { + useKey: true, + }, + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { + field: 'scene', + align: 'left', + title: '场景', + width: 90, + formatter: ({ cellValue }) => shadowSceneLabel(String(cellValue)), + }, + { field: 'store_id', align: 'left', title: '门店ID', width: 90 }, + { + title: '主链路(PHP 直连)', + align: 'center', + children: [ + { field: 'primary_provider', align: 'left', title: 'Provider', width: 100 }, + { field: 'primary_tokens', align: 'right', title: 'Token', width: 90 }, + { field: 'primary_duration_ms', align: 'right', title: '耗时(ms)', width: 100 }, + { + field: 'primary_status', + align: 'left', + title: '状态', + width: 90, + slots: { default: 'primary_status' }, + }, + ], + }, + { + title: '影子链路(Go Agent)', + align: 'center', + children: [ + { field: 'shadow_provider', align: 'left', title: 'Provider', width: 100 }, + { field: 'shadow_tokens', align: 'right', title: 'Token', width: 90 }, + { field: 'shadow_duration_ms', align: 'right', title: '耗时(ms)', width: 100 }, + { + field: 'shadow_status', + align: 'left', + title: '状态', + width: 90, + slots: { default: 'shadow_status' }, + }, + ], + }, + { + field: 'content_similarity', + align: 'right', + title: '内容相似度', + width: 110, + slots: { default: 'similarity' }, + }, + { field: 'remark', align: 'left', title: '备注', minWidth: 160, showOverflow: true }, + { field: 'created_at', align: 'left', title: '对比时间', width: 170 }, + ], + keepSource: true, + pagerConfig: {}, + proxyConfig: { + ajax: { + query: async ({ page }, formValues) => { + const filters = normalizeShadowFilters(formValues || {}); + // 统计看板与列表同步刷新(同一套筛选条件) + onQueried?.(filters); + const res = await getShadowList({ + page: page.currentPage, + size: page.pageSize, + ...filters, + }); + // 后端返回 {list,total,page,size},适配器约定 {items,total} + return { + items: (res as any)?.list ?? [], + total: (res as any)?.total ?? 0, + }; + }, + }, + }, + height: 'auto', + border: false, + toolbarConfig: { + search: true, + refresh: true, + print: false, + export: false, + zoom: true, + custom: { + icon: 'vxe-icon-menu', + }, + }, + showOverflow: true, + }; +} diff --git a/apps/web-antd/src/views/system/ai-shadow/index.vue b/apps/web-antd/src/views/system/ai-shadow/index.vue new file mode 100644 index 00000000..d0cfaf49 --- /dev/null +++ b/apps/web-antd/src/views/system/ai-shadow/index.vue @@ -0,0 +1,150 @@ + + + diff --git a/apps/web-antd/src/views/system/ai/generation/config/table.ts b/apps/web-antd/src/views/system/ai/generation/config/table.ts index 1edf1593..1ab8e712 100644 --- a/apps/web-antd/src/views/system/ai/generation/config/table.ts +++ b/apps/web-antd/src/views/system/ai/generation/config/table.ts @@ -17,6 +17,10 @@ interface RowType { duration_ms: number; error_msg: string; created_at: string; + /** P4:采纳状态(0未采纳/1已采纳)与文案 */ + is_adopted: number; + is_modified_final: number; + adopted_txt: string; } /** @@ -60,6 +64,14 @@ export const gridOptions: VxeGridProps = { width: 90, slots: { default: 'duration' }, }, + { + // P4:医生采纳情况(未采纳/直接采纳/改后采纳),直接采纳是质量最好的信号 + field: 'adopted_txt', + align: 'left', + title: '采纳', + width: 96, + slots: { default: 'adopted' }, + }, { field: 'error_msg', align: 'left', diff --git a/apps/web-antd/src/views/system/ai/generation/index.vue b/apps/web-antd/src/views/system/ai/generation/index.vue index de95ebf9..54c4ed5e 100644 --- a/apps/web-antd/src/views/system/ai/generation/index.vue +++ b/apps/web-antd/src/views/system/ai/generation/index.vue @@ -32,6 +32,13 @@ const stats = reactive({ total_count: 0, success_count: 0, fail_count: 0, + // P4 质量指标:采纳率/直接采纳率/修改率/药名未匹配率/人均重生成 + adopted_count: 0, + adoption_rate: 0, + direct_rate: 0, + modified_rate: 0, + unmatched_rate: 0, + avg_regen: 0, }); /** 全量模型 option,选平台时按 platform_id 过滤 */ @@ -56,6 +63,12 @@ async function refreshStats(formValues?: Record) { stats.total_count = Number(data.total_count || 0); stats.success_count = Number(data.success_count || 0); stats.fail_count = Number(data.fail_count || 0); + stats.adopted_count = Number(data.adopted_count || 0); + stats.adoption_rate = Number(data.adoption_rate || 0); + stats.direct_rate = Number(data.direct_rate || 0); + stats.modified_rate = Number(data.modified_rate || 0); + stats.unmatched_rate = Number(data.unmatched_rate || 0); + stats.avg_regen = Number(data.avg_regen || 0); } catch { // 摘要失败不阻断列表 } @@ -221,6 +234,61 @@ onMounted(async () => { + + + + +
+ 采纳率 + 已采纳/成功数 +
+
+ {{ stats.adoption_rate }}% + + {{ stats.adopted_count }}/{{ stats.success_count }} + +
+
+ + + +
+ 直接采纳率 + 未改原稿直接用 +
+
+ {{ stats.direct_rate }}% +
+
+ + + +
+ 修改率 + 采纳但改过原稿 +
+
{{ stats.modified_rate }}%
+
+ + + +
+ 药名未匹配率 + 越低越好 +
+
{{ stats.unmatched_rate }}%
+
+ + + +
+ 人均重生成 + >1 需关注 +
+
{{ stats.avg_regen }}
+
+ +