Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e5411b6a | ||
|
|
c06d293424 | ||
|
|
6c444a4a04 | ||
|
|
cc6173e5b0 | ||
|
|
5794f60c5d | ||
|
|
35f91ee37a | ||
|
|
bc1228a310 | ||
|
|
8b9df1154c | ||
|
|
5f53aa0bbd |
@@ -1,33 +1,34 @@
|
||||
# Build and Release Folders
|
||||
bin-debug/
|
||||
bin-release/
|
||||
[Oo]bj/
|
||||
[Bb]in/
|
||||
|
||||
# Other files and folders
|
||||
.settings/
|
||||
|
||||
# Executables
|
||||
*.swf
|
||||
*.air
|
||||
*.ipa
|
||||
*.apk
|
||||
|
||||
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
|
||||
# should NOT be excluded as they contain compiler settings and other important
|
||||
# information for Eclipse / Flash Builder.
|
||||
|
||||
/.idea
|
||||
/.codex-tasks
|
||||
/.trellis
|
||||
/.claude
|
||||
/.agent
|
||||
/.shared
|
||||
/.cursor
|
||||
/.codex
|
||||
/.agents
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
# Build and Release Folders
|
||||
bin-debug/
|
||||
bin-release/
|
||||
[Oo]bj/
|
||||
[Bb]in/
|
||||
|
||||
# Other files and folders
|
||||
.settings/
|
||||
|
||||
# Executables
|
||||
*.swf
|
||||
*.air
|
||||
*.ipa
|
||||
*.apk
|
||||
|
||||
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
|
||||
# should NOT be excluded as they contain compiler settings and other important
|
||||
# information for Eclipse / Flash Builder.
|
||||
# 测试临时目录
|
||||
app/.test-tmp-stream/
|
||||
/.idea
|
||||
/.codex-tasks
|
||||
/.trellis
|
||||
/.claude
|
||||
/.agent
|
||||
/.shared
|
||||
/.cursor
|
||||
/.codex
|
||||
/.agents
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 2026-08-20 — 医生工作站 AI 问诊对话框布局优化
|
||||
|
||||
## 改动
|
||||
- 文件: `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
|
||||
- 模块: `AiConsultDialog` 右侧「智能分析」卡片 + 问诊对话卡片密度优化
|
||||
- 新增 helpers: `_InsightItem`, `_split_insight_segments`, `_split_numbered_list`, `_split_plain_paragraph`, `_split_label_or_sentence`, `_escape_html`
|
||||
- `_set_insights` 改成将稠密段落拆成 lead / list / plain 三类, 每类单独 QLabel 显示
|
||||
- CSS 新增 `AiConsultInsightMarker` / `AiConsultInsightText` / `AiConsultInsightPlaceholder`
|
||||
- 卡片 padding `10,8,10,8` → `14,12,14,14`; 卡间距 `8` → `10`
|
||||
- 聊天气泡垂直 margin `4` → `6`, chat_layout spacing `10` → `12`
|
||||
|
||||
## 拆分策略
|
||||
- 文本以 `1./2、/3)/4)` 起头 → 视为完整编号列表
|
||||
- 文本中嵌入 `…:1.…2.…` → 拆出序言 + 编号列表
|
||||
- 短中文标签 `XX:YY` 开头 → 视为 lead, 标签部分加粗
|
||||
- 其余按 `。/;/;/` 分句 → 单独成行
|
||||
|
||||
## 测试
|
||||
- 现有 `tests/test_ai_consult_ui.py` 通过 `widget.objectName() == "AiConsultRecordTitle"` 校验标题仍在, 未受影响
|
||||
- 暂未运行 UI 测试 (开发环境未装 PySide6); 解析逻辑单独脚本验证通过
|
||||
|
||||
## 注意
|
||||
- PySide6 QSS 对 `QLabel` 的 `line-height` 支持有限; 主要靠 item 间距 `8px` 与 `padding:1px 0` 提供节奏
|
||||
- 如果后续接入 `AiConsultInsightText` 还嫌密集, 可再调 `body_host.setSpacing`
|
||||
|
||||
---
|
||||
|
||||
# 接诊台 AI 智能分析详情对话框 (`ReceptionAiAnalysisDialog`) 优化
|
||||
用户截图(`clipboard-2026-08-20T08-42-40`...)反馈: 「问诊详情 → AI 智能分析」卡片里的诊断建议 / 风险评估 / 治疗建议三段文字太稠密, 不利于阅读。
|
||||
|
||||
## 关键差异
|
||||
这次的目标文件和上次不同 — 用户截图里出现 "诊断建议 / 风险评估 / 治疗建议" 标题, 而 `ai_consult.py` 用的是 "血糖控制评估 / 并发症风险评估 / 用药合理性评估"。实际是 `app/src/doctor_workstation/ui/pages/reception.py` 中的 `_ReceptionAiAnalysisDialog` 类。
|
||||
|
||||
## 改动
|
||||
- 文件: `app/src/doctor_workstation/ui/pages/reception.py`
|
||||
- 新增 helpers (与 ai_consult.py 类似但导出名加 `_ai_` 前缀):
|
||||
- `_ai_split_into_segments` / `_ai_split_numbered_list` / `_ai_split_plain_paragraph`
|
||||
- `_ai_segments_to_html` / `_ai_narrative_structured_html`
|
||||
- `_ai_split_label_or_sentence` / `_ai_escape_html`
|
||||
- 正则: `_AI_INSIGHT_LIST_RE`, `_AI_INSIGHT_LIST_LOOKAHEAD_RE`, `_AI_INSIGHT_LABEL_RE`
|
||||
- `add_text_section` 闭包内多挂一个 RichText `_structured_labels[name]` 标签 (`.setObjectName(name + "Structured")`, `property="dialogAiStructured"`)
|
||||
- 原 PlainText `value_label` 仍存在 (`hide()` + `setMaximumHeight(0)`) 用于兼容既有 findChild 测试
|
||||
- `_render_model` 同步刷新两个结构化标签; 空 HTML 时回退显示原 label 文本
|
||||
- 风险评估 FlowLayout spacing `7×7` → `9×9`
|
||||
- `body_layout.setSpacing` `12` → `14`
|
||||
- 卡片 contentsMargins `14,13,16,15` → `16,14,18,16`
|
||||
- QSS: `dialogAiBody` 加 `line-height: 1.85`, 新增 `dialogAiStructured` 选择器 (line-height 175%); 风险 pill `min-height` `28` → `30`, padding `4 10` → `5 12`, font 12 → 12.5, border-radius `7` → `8`
|
||||
|
||||
## 兼容约束
|
||||
- `tests/test_reception_parity_ui.py` 严格校验:
|
||||
- `findChild(QLabel, "ReceptionAiAnalysisDialogDiagnosisText")` 必须能找到
|
||||
- `.text()` 必须等于 `_ai_narrative_text` 归一化结果
|
||||
- `.textFormat() == Qt.TextFormat.PlainText`
|
||||
- 因此 PlainText 标签必须原样保留, 仅做 `.hide()` + `setMaximumHeight(0)` (避免 QSS `>` 子选择器不被 Qt 支持)
|
||||
|
||||
## 验证
|
||||
- `py_compile` 通过, 无 `SyntaxWarning`
|
||||
- 单独脚本跑过 4 组样本文本 (稠密段落 / 嵌入式编号列表 / 短句 / 纯编号列表), 拆分结果符合预期
|
||||
- 未在本机跑 UI 测试 (缺 PySide6)
|
||||
|
||||
---
|
||||
|
||||
# 患者列表 AI 分析: 发送完整患者资料给 AI
|
||||
|
||||
用户需求: 患者列表 AI 分析时, 每日血糖报告、舌苔、AI 报告、视频录制回放的文字对话都要发给 AI。
|
||||
|
||||
## 改动
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
|
||||
- `_load_workspace` 新增 `list_call_records` section (视频问诊记录, 含 `transcript_text`)
|
||||
- 模块级纯函数 (无 Qt 依赖, 便于测试):
|
||||
- `AI_CONTEXT_MAX_CHARS=320`, `AI_PROMPT_LIMIT=500`, `AI_CONTEXT_SEPARATOR="\n\n— 医生提问 —\n"`
|
||||
- `_truncate_for_context` / `_patient_context_blood_sugar` / `_patient_context_tongue` / `_patient_context_reports` / `_patient_context_prescriptions` / `_patient_context_videos` / `build_patient_ai_context` / `_compose_ai_prompt`
|
||||
- `build_patient_ai_context` 返回 `(text, labels)`, 按 每日血糖→舌苔/脉象→视频问诊文字→历史AI报告→处方记录 顺序装入 320 字预算的信封
|
||||
- `_patient_context_videos` 按 `diagnosis_id` 过滤归属 (`_exact_positive_id`), 只取最近一条有文字的
|
||||
- `_compose_ai_prompt` 本地强制 500 字上限: 上下文 + "— 医生提问 —" + 问题, 超长时截断问题加 `…` (服务端 `tcm.diagnosis/aiAssistant` prompt 上限 500)
|
||||
- `_ChatBubble.attach_extra()` 支持气泡下方挂载控件
|
||||
- `_render_patient_context`: 聊天区系统气泡 "AI 已加载本诊单上下文(N 项…)", `_attach_context_detail` 挂 "查看本次发送给 AI 的资料全文" 展开按钮 (QSS: `AiConsultContextToggle`/`AiConsultContextReveal`)
|
||||
- `_ask` 用 `_compose_ai_prompt(text, self._patient_ai_context)` 组装后传给 `_AiStreamWorker`; 医生气泡仍显示原始问题
|
||||
- `open_for` / `_clear_chat` 重置 `_patient_ai_context*` 三个状态, 防止跨患者残留
|
||||
|
||||
## 测试
|
||||
- `app/tests/test_ai_consult_ui.py` 新增 5 个测试 (总 15 个全通过):
|
||||
纯函数: 全 section 信封+视频按诊单过滤 / 仅基本病历返回空 / 长问题截断≤500
|
||||
UI: 上下文气泡+展开按钮+视频记录计数 / `_ask` 注入上下文 (FakeWorker 捕获 prompt, monkeypatch `QThreadPool`)
|
||||
- `app/scripts/check_ai_context.py`: AST 提取纯函数的冒烟脚本 (无需 Qt)
|
||||
- `tests/test_ai_consult_workspace_ui.py` 18 个测试无回归
|
||||
|
||||
## 环境要点 (重要)
|
||||
- **PySide6 + pytest 在 `app/.venv` 里可用**: 用 `D:/web/zyt/app/.venv/Scripts/python.exe -m pytest` 跑 UI 测试 (QT_QPA_PLATFORM=offscreen)。之前"本机缺 PySide6"的结论只对系统 Python 成立。
|
||||
- 测试 FakeWorker 需实现 `signals.event/error/finished.connect()` 和 `cancel()` (closeEvent 会调 cancel)
|
||||
@@ -1,6 +1,6 @@
|
||||
import App from './App'
|
||||
|
||||
var baseUrl = 'https://admin.zhenyangtang.com.cn/'
|
||||
var baseUrl = 'https://xt.zhenyangtang.com.cn/'
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
{
|
||||
"name" : "tongji-uniapp",
|
||||
"appid" : "__UNI__TONGJI01",
|
||||
"description" : "血糖管理 / 日常护理",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx0c2a9d5ebad26a3f",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["uni"],"mappings":";;AAgCA,MAAM,QAAQ;AACd,MAAM,cAAc;;;;;;;;;;;AATpB,UAAM,QAAQ;AAWd,aAAS,SAAS;AACd,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,mBAAkB,CAAE;AAAA,IAC9C;AAEA,aAAS,WAAW;AAChB,UAAI,MAAM,WAAW;AAAG;AACxBA,oBAAAA,MAAI,WAAW,EAAE,KAAK,uBAAsB,CAAE;AAAA,IAClD;;;;;;;;;;;;;;AC1CA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"tab-bar-dock.js","sources":["components/app-tab-bar/tab-bar-dock.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvY29tcG9uZW50cy9hcHAtdGFiLWJhci90YWItYmFyLWRvY2sudnVl"],"sourcesContent":["<template>\r\n <view class=\"tab-dock\">\r\n <view class=\"tab-dock__inner\">\r\n <view class=\"tab-dock__item tab-dock__item--center\" @click=\"goWeekly\">\r\n <view class=\"tab-dock__fab\" :class=\"{ 'tab-dock__fab--active': active === 0 }\">\r\n <text class=\"tab-dock__fab-text\">糖</text>\r\n </view>\r\n <text class=\"tab-dock__label tab-dock__label--center\" :style=\"{ color: active === 0 ? activeColor : color }\">血糖</text>\r\n </view>\r\n\r\n <view class=\"tab-dock__item\" @click=\"goUser\">\r\n <image\r\n class=\"tab-dock__icon\"\r\n :src=\"active === 1 ? '/static/user/user_no.png' : '/static/user/user.png'\"\r\n mode=\"aspectFit\"\r\n />\r\n <text class=\"tab-dock__label\" :style=\"{ color: active === 1 ? activeColor : color }\">我的</text>\r\n </view>\r\n </view>\r\n <view class=\"tab-dock__safe\" />\r\n </view>\r\n</template>\r\n\r\n<script setup>\r\nconst props = defineProps({\r\n /** 0 血糖 1 我的 */\r\n active: {\r\n type: Number,\r\n default: 0\r\n }\r\n})\r\n\r\nconst color = '#8a8a8a'\r\nconst activeColor = '#204e2b'\r\n\r\nfunction goUser() {\r\n if (props.active === 1) return\r\n uni.redirectTo({ url: '/pages/user/user' })\r\n}\r\n\r\nfunction goWeekly() {\r\n if (props.active === 0) return\r\n uni.redirectTo({ url: '/tongji/pages/weekly' })\r\n}\r\n</script>\r\n\r\n<style scoped lang=\"scss\">\r\n.tab-dock {\r\n position: fixed;\r\n left: 0;\r\n right: 0;\r\n bottom: 0;\r\n z-index: 999;\r\n background: #ffffff;\r\n border-top: 1rpx solid rgba(0, 0, 0, 0.06);\r\n box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);\r\n}\r\n\r\n.tab-dock__inner {\r\n display: flex;\r\n align-items: flex-end;\r\n height: 100rpx;\r\n}\r\n\r\n.tab-dock__item {\r\n flex: 1;\r\n height: 100rpx;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__item--center {\r\n justify-content: flex-end;\r\n padding-bottom: 6rpx;\r\n}\r\n\r\n.tab-dock__icon {\r\n width: 46rpx;\r\n height: 46rpx;\r\n}\r\n\r\n.tab-dock__label {\r\n margin-top: 6rpx;\r\n font-size: 22rpx;\r\n line-height: 1.2;\r\n}\r\n\r\n.tab-dock__label--center {\r\n margin-top: 8rpx;\r\n}\r\n\r\n.tab-dock__fab {\r\n width: 96rpx;\r\n height: 96rpx;\r\n margin-top: -48rpx;\r\n border-radius: 50%;\r\n background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);\r\n border: 6rpx solid #ffffff;\r\n box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.tab-dock__fab--active {\r\n transform: scale(1.04);\r\n box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);\r\n}\r\n\r\n.tab-dock__fab-text {\r\n color: #ffffff;\r\n font-size: 38rpx;\r\n font-weight: 700;\r\n line-height: 1;\r\n}\r\n\r\n.tab-dock__safe {\r\n height: env(safe-area-inset-bottom);\r\n background: #ffffff;\r\n}\r\n</style>\r\n","import Component from 'D:/web/zyt/TongjiUniApp/components/app-tab-bar/tab-bar-dock.vue'\nwx.createComponent(Component)"],"names":["color","activeColor","props","__props","goUser","uni","goWeekly","Component"],"mappings":"uDAgCMA,EAAQ,UACRC,EAAc,mFATpB,MAAMC,EAAQC,EAWd,SAASC,GAAS,CACVF,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,kBAAkB,CAAE,CAC9C,CAEA,SAASC,GAAW,CACZJ,EAAM,SAAW,GACrBG,EAAAA,MAAI,WAAW,CAAE,IAAK,sBAAsB,CAAE,CAClD,iOC1CA,GAAG,gBAAgBE,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AACA,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"VoiceSpeakMascot.js","sources":["../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdG9uZ2ppL2NvbXBvbmVudHMvVm9pY2VTcGVha01hc2NvdC52dWU"],"sourcesContent":["import Component from 'D:/web/zyt/TongjiUniApp/tongji/components/VoiceSpeakMascot.vue'\nwx.createComponent(Component)"],"names":["Component"],"mappings":"8XACA,GAAG,gBAAgBA,CAAS"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["uni"],"mappings":";;AAGO,SAAS,YAAY,OAAO;AAC/B,MAAI,eAAe;AAEnB,WAAS,WAAW;AAChB,WAAO,CAAC,CAAC,OAAOA,oBAAI,eAAe,OAAO,KAAK,EAAE,EAAE,KAAM;AAAA,EAC5D;AAED,WAAS,aAAa;AAClBA,kBAAG,MAAC,kBAAkB,OAAO;AAAA,EAChC;AAED,WAAS,cAAc;AACnB,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACpCA,oBAAAA,MAAI,MAAM;AAAA,QACN,UAAU;AAAA,QACV,SAAS,CAAC,SAAQ,2BAAK,QAAO,QAAQ,IAAI,IAAI,IAAI,OAAO,IAAI,MAAM,cAAc,CAAC;AAAA,QAClF,MAAM;AAAA,MACtB,CAAa;AAAA,IACb,CAAS;AAAA,EACJ;AAED,iBAAe,cAAc,MAAM;;AAC/B,UAAM,MAAM,MAAM,MAAM,OAAO;AAAA,MAC3B,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,MAAM,EAAE,KAAM;AAAA,IACjB,GAAE,KAAK;AACR,SAAI,2BAAK,UAAS,KAAK,GAAC,SAAI,SAAJ,mBAAU,QAAO;AACrC,iBAAY;AACZ,YAAM,IAAI,OAAM,2BAAK,QAAO,MAAM;AAAA,IACrC;AACDA,kBAAAA,MAAI,eAAe,SAAS,IAAI,KAAK,KAAK;AAC1CA,kBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,WAAO;AAAA,EACV;AAED,iBAAe,gBAAgB;AAC3B,QAAI,SAAQ,GAAI;AACZ,UAAI;AACA,cAAM,MAAM,MAAM,MAAM,OAAO,EAAE,KAAK,kBAAkB,QAAQ,OAAQ,GAAE,KAAK;AAC/E,aAAI,2BAAK,UAAS,KAAK,IAAI,MAAM;AAC7BA,wBAAAA,MAAI,eAAe,YAAY,IAAI,IAAI;AACvC,iBAAO;AAAA,QACV;AAED,aAAI,2BAAK,UAAS;AAAI,iBAAO;AAAA,MAChC,SAAQ,GAAG;AAER,eAAO;AAAA,MACV;AACD,iBAAY;AAAA,IACf;AACD,UAAM,OAAO,MAAM,YAAa;AAChC,WAAO,cAAc,IAAI;AAAA,EAC5B;AAED,iBAAe,iBAAiB;AAC5B,QAAI;AAAc,aAAO;AACzB,mBAAe,cAAe,EACzB,KAAK,QAAM,CAAC,CAAC,EAAE,EACf,MAAM,MAAM,KAAK,EAEjB,QAAQ,MAAM;AAAE,qBAAe;AAAA,KAAM;AAC1C,WAAO;AAAA,EACV;AAED,SAAO,EAAE,eAAgB;AAC7B;;"}
|
||||
{"version":3,"file":"useGameAuth.js","sources":["tongji/endless-game/composables/useGameAuth.js"],"sourcesContent":["/**\r\n * 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。\r\n */\r\nexport function useGameAuth(proxy) {\r\n let loginPromise = null\r\n\r\n function hasToken() {\r\n return !!String(uni.getStorageSync('token') || '').trim()\r\n }\r\n\r\n function clearToken() {\r\n uni.removeStorageSync('token')\r\n }\r\n\r\n function wxLoginCode() {\r\n return new Promise((resolve, reject) => {\r\n uni.login({\r\n provider: 'weixin',\r\n success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),\r\n fail: reject\r\n })\r\n })\r\n }\r\n\r\n async function loginWithCode(code) {\r\n const res = await proxy.apiUrl({\r\n url: '/api/login/mnpLogin',\r\n method: 'POST',\r\n data: { code }\r\n }, false)\r\n if (res?.code !== 1 || !res.data?.token) {\r\n clearToken()\r\n throw new Error(res?.msg || '登录失败')\r\n }\r\n uni.setStorageSync('token', res.data.token)\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n\r\n async function verifyOrLogin() {\r\n if (hasToken()) {\r\n try {\r\n const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)\r\n if (res?.code === 1 && res.data) {\r\n uni.setStorageSync('userData', res.data)\r\n return true\r\n }\r\n // 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。\r\n if (res?.code !== -1) return true\r\n } catch (_) {\r\n // 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。\r\n return true\r\n }\r\n clearToken()\r\n }\r\n const code = await wxLoginCode()\r\n return loginWithCode(code)\r\n }\r\n\r\n async function ensureLoggedIn() {\r\n if (loginPromise) return loginPromise\r\n loginPromise = verifyOrLogin()\r\n .then(ok => !!ok)\r\n .catch(() => false)\r\n // 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。\r\n .finally(() => { loginPromise = null })\r\n return loginPromise\r\n }\r\n\r\n return { ensureLoggedIn }\r\n}\r\n"],"names":["useGameAuth","proxy","loginPromise","hasToken","uni","clearToken","wxLoginCode","resolve","reject","res","loginWithCode","code","_a","verifyOrLogin","ensureLoggedIn","ok"],"mappings":"0DAGO,SAASA,EAAYC,EAAO,CAC/B,IAAIC,EAAe,KAEnB,SAASC,GAAW,CAChB,MAAO,CAAC,CAAC,OAAOC,QAAI,eAAe,OAAO,GAAK,EAAE,EAAE,KAAM,CAC5D,CAED,SAASC,GAAa,CAClBD,EAAG,MAAC,kBAAkB,OAAO,CAChC,CAED,SAASE,GAAc,CACnB,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACpCJ,EAAAA,MAAI,MAAM,CACN,SAAU,SACV,QAAUK,GAAQA,GAAA,MAAAA,EAAK,KAAOF,EAAQE,EAAI,IAAI,EAAID,EAAO,IAAI,MAAM,cAAc,CAAC,EAClF,KAAMA,CACtB,CAAa,CACb,CAAS,CACJ,CAED,eAAeE,EAAcC,EAAM,OAC/B,MAAMF,EAAM,MAAMR,EAAM,OAAO,CAC3B,IAAK,sBACL,OAAQ,OACR,KAAM,CAAE,KAAAU,CAAM,CACjB,EAAE,EAAK,EACR,IAAIF,GAAA,YAAAA,EAAK,QAAS,GAAK,GAACG,EAAAH,EAAI,OAAJ,MAAAG,EAAU,OAC9B,MAAAP,EAAY,EACN,IAAI,OAAMI,GAAA,YAAAA,EAAK,MAAO,MAAM,EAEtCL,OAAAA,EAAAA,MAAI,eAAe,QAASK,EAAI,KAAK,KAAK,EAC1CL,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,EACV,CAED,eAAeI,GAAgB,CAC3B,GAAIV,EAAQ,EAAI,CACZ,GAAI,CACA,MAAMM,EAAM,MAAMR,EAAM,OAAO,CAAE,IAAK,iBAAkB,OAAQ,MAAQ,EAAE,EAAK,EAC/E,IAAIQ,GAAA,YAAAA,EAAK,QAAS,GAAKA,EAAI,KACvBL,OAAAA,EAAAA,MAAI,eAAe,WAAYK,EAAI,IAAI,EAChC,GAGX,IAAIA,GAAA,YAAAA,EAAK,QAAS,GAAI,MAAO,EAChC,MAAW,CAER,MAAO,EACV,CACDJ,EAAY,CACf,CACD,MAAMM,EAAO,MAAML,EAAa,EAChC,OAAOI,EAAcC,CAAI,CAC5B,CAED,eAAeG,GAAiB,CAC5B,OAAIZ,IACJA,EAAeW,EAAe,EACzB,KAAKE,GAAM,CAAC,CAACA,CAAE,EACf,MAAM,IAAM,EAAK,EAEjB,QAAQ,IAAM,CAAEb,EAAe,KAAM,EACnCA,EACV,CAED,MAAO,CAAE,eAAAY,CAAgB,CAC7B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["uni"],"mappings":";;AAIA,SAAS,YAAY,KAAK;AACtB,QAAM,UAAU,mBAAmB,GAAG;AACtC,QAAM,QAAQ,CAAE;AAChB,WAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;AACrC,QAAI,QAAQ,WAAW,CAAC,MAAM,IAAI;AAC9B,YAAM,KAAK,SAAS,QAAQ,UAAU,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;AACxD,WAAK;AAAA,IACjB,OAAe;AACH,YAAM,KAAK,QAAQ,WAAW,CAAC,CAAC;AAAA,IACnC;AAAA,EACJ;AACD,SAAO,IAAI,WAAW,KAAK;AAC/B;AAEA,SAAS,cAAc,OAAO;AAC1B,MAAI,OAAOA,cAAG,UAAK,eAAe,OAAOA,cAAAA,MAAI,wBAAwB,YAAY;AAC7E,WAAOA,oBAAI,oBAAoB,MAAM,MAAM;AAAA,EAC9C;AACD,MAAI,OAAO,SAAS,aAAa;AAC7B,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACnC,gBAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,IACzC;AACD,WAAO,KAAK,MAAM;AAAA,EACrB;AACD,QAAM,QAAQ;AACd,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;AACtC,UAAM,IAAI,MAAM,CAAC;AACjB,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,UAAM,IAAI,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,CAAC,IAAI;AAChD,cAAU,MAAM,KAAK,CAAC;AACtB,cAAU,OAAQ,IAAI,MAAM,IAAM,KAAK,CAAE;AACzC,cAAU,IAAI,IAAI,MAAM,SAAS,OAAQ,IAAI,OAAO,IAAM,KAAK,CAAE,IAAI;AACrE,cAAU,IAAI,IAAI,MAAM,SAAS,MAAM,IAAI,EAAE,IAAI;AAAA,EACpD;AACD,SAAO;AACX;AAEO,SAAS,aAAa,KAAK;AAC9B,QAAM,aAAa,OAAO,OAAO,EAAE,EAAE,KAAM;AAC3C,MAAI,CAAC;AAAY,WAAO;AACxB,QAAM,SAAS,cAAc,YAAY,UAAU,CAAC;AACpD,SAAO,6BAA6B,MAAM;AAC9C;;"}
|
||||
{"version":3,"file":"svgDataUrl.js","sources":["tongji/utils/svgDataUrl.js"],"sourcesContent":["/**\r\n * 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)\r\n */\r\n\r\nfunction utf8ToBytes(str) {\r\n const encoded = encodeURIComponent(str)\r\n const bytes = []\r\n for (let i = 0; i < encoded.length; i++) {\r\n if (encoded.charCodeAt(i) === 37) {\r\n bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))\r\n i += 2\r\n } else {\r\n bytes.push(encoded.charCodeAt(i))\r\n }\r\n }\r\n return new Uint8Array(bytes)\r\n}\r\n\r\nfunction bytesToBase64(bytes) {\r\n if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {\r\n return uni.arrayBufferToBase64(bytes.buffer)\r\n }\r\n if (typeof btoa !== 'undefined') {\r\n let binary = ''\r\n for (let i = 0; i < bytes.length; i++) {\r\n binary += String.fromCharCode(bytes[i])\r\n }\r\n return btoa(binary)\r\n }\r\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\r\n let result = ''\r\n for (let i = 0; i < bytes.length; i += 3) {\r\n const a = bytes[i]\r\n const b = i + 1 < bytes.length ? bytes[i + 1] : 0\r\n const c = i + 2 < bytes.length ? bytes[i + 2] : 0\r\n result += chars[a >> 2]\r\n result += chars[((a & 3) << 4) | (b >> 4)]\r\n result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='\r\n result += i + 2 < bytes.length ? chars[c & 63] : '='\r\n }\r\n return result\r\n}\r\n\r\nexport function svgToDataUrl(svg) {\r\n const normalized = String(svg || '').trim()\r\n if (!normalized) return ''\r\n const base64 = bytesToBase64(utf8ToBytes(normalized))\r\n return `data:image/svg+xml;base64,${base64}`\r\n}\r\n"],"names":["utf8ToBytes","str","encoded","bytes","i","bytesToBase64","uni","binary","chars","result","a","b","c","svgToDataUrl","svg","normalized"],"mappings":"uDAIA,SAASA,EAAYC,EAAK,CACtB,MAAMC,EAAU,mBAAmBD,CAAG,EAChCE,EAAQ,CAAE,EAChB,QAASC,EAAI,EAAGA,EAAIF,EAAQ,OAAQE,IAC5BF,EAAQ,WAAWE,CAAC,IAAM,IAC1BD,EAAM,KAAK,SAASD,EAAQ,UAAUE,EAAI,EAAGA,EAAI,CAAC,EAAG,EAAE,CAAC,EACxDA,GAAK,GAELD,EAAM,KAAKD,EAAQ,WAAWE,CAAC,CAAC,EAGxC,OAAO,IAAI,WAAWD,CAAK,CAC/B,CAEA,SAASE,EAAcF,EAAO,CAC1B,GAAI,OAAOG,EAAG,MAAK,KAAe,OAAOA,EAAAA,MAAI,qBAAwB,WACjE,OAAOA,QAAI,oBAAoBH,EAAM,MAAM,EAE/C,GAAI,OAAO,KAAS,IAAa,CAC7B,IAAII,EAAS,GACb,QAASH,EAAI,EAAGA,EAAID,EAAM,OAAQC,IAC9BG,GAAU,OAAO,aAAaJ,EAAMC,CAAC,CAAC,EAE1C,OAAO,KAAKG,CAAM,CACrB,CACD,MAAMC,EAAQ,mEACd,IAAIC,EAAS,GACb,QAASL,EAAI,EAAGA,EAAID,EAAM,OAAQC,GAAK,EAAG,CACtC,MAAMM,EAAIP,EAAMC,CAAC,EACXO,EAAIP,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAC1CQ,EAAIR,EAAI,EAAID,EAAM,OAASA,EAAMC,EAAI,CAAC,EAAI,EAChDK,GAAUD,EAAME,GAAK,CAAC,EACtBD,GAAUD,GAAQE,EAAI,IAAM,EAAMC,GAAK,CAAE,EACzCF,GAAUL,EAAI,EAAID,EAAM,OAASK,GAAQG,EAAI,KAAO,EAAMC,GAAK,CAAE,EAAI,IACrEH,GAAUL,EAAI,EAAID,EAAM,OAASK,EAAMI,EAAI,EAAE,EAAI,GACpD,CACD,OAAOH,CACX,CAEO,SAASI,EAAaC,EAAK,CAC9B,MAAMC,EAAa,OAAOD,GAAO,EAAE,EAAE,KAAM,EAC3C,OAAKC,EAEE,6BADQV,EAAcL,EAAYe,CAAU,CAAC,CACV,GAFlB,EAG5B"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_LEVELS","TREE_MAX_LEVEL","TREE_XP_PER_LEVEL"],"mappings":";;AAGY,MAAC,oBAAoB;AAAA,EAC7B;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQA,wBAAW,YAAC,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA,EACzC;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AAAA,EACvE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AAAA,EACD;AAAA,IACI,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,SAAS;AAAA,IACT,QAAQ,CAAC,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,IAAI;AAAA,EACtE;AACL;AAKO,MAAM,wBAAwB;AAE9B,SAAS,eAAe,IAAI;AAC/B,SAAO,kBAAkB,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,kBAAkB,CAAC;AAC5E;AAEO,SAAS,oBAAoB,WAAW,OAAO;AAClD,QAAM,KAAK,eAAe,SAAS;AACnC,QAAM,KAAK,KAAK,IAAIC,wBAAc,gBAAE,KAAK,IAAI,GAAG,OAAO,KAAK,KAAK,CAAC,CAAC;AACnE,SAAO,GAAG,OAAO,EAAE,KAAK,GAAG,OAAO,CAAC,KAAK;AAC5C;AAGO,SAAS,qBAAqB,WAAW;AAC5C,QAAM,KAAK,eAAe,SAAS;AACnC,SAAOD,oCAAY,IAAI,CAAC,MAAM,MAAM;AAChC,UAAM,UAAU,IAAIE,wBAAiB;AACrC,UAAM,WAAW,MAAM,IACjB,IACA,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AAC5D,WAAO;AAAA,MACH,OAAO;AAAA,MACP,MAAM,KAAK;AAAA,MACX,OAAO,GAAG,OAAO,CAAC,KAAK,KAAK;AAAA,MAC5B,MAAM,KAAK;AAAA,MACX;AAAA,MACA;AAAA,IACH;AAAA,EACT,CAAK;AACL;AAGO,SAAS,yBAAyB;AACrC,QAAM,UAAUD,wBAAAA,iBAAiBC,wBAAiB;AAClD,SAAO,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,qBAAqB,CAAC;AACjE;;;;;;"}
|
||||
{"version":3,"file":"treeSpecies.js","sources":["tongji/utils/treeSpecies.js"],"sourcesContent":["import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'\r\n\r\n/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */\r\nexport const TREE_SPECIES_LIST = [\r\n {\r\n id: 'classic',\r\n name: '稳糖灵树',\r\n tagline: '经典路线,从种子到圆满',\r\n stages: TREE_LEVELS.map((l) => l.emoji)\r\n },\r\n {\r\n id: 'bonsai',\r\n name: '雅韵盆景',\r\n tagline: '小巧精致,书桌旁的温柔陪伴',\r\n stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']\r\n },\r\n {\r\n id: 'pine',\r\n name: '苍劲青松',\r\n tagline: '挺拔向上,越记越稳',\r\n stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']\r\n },\r\n {\r\n id: 'sakura',\r\n name: '樱花小树',\r\n tagline: '坚持打卡,枝头渐开',\r\n stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']\r\n },\r\n {\r\n id: 'ginkgo',\r\n name: '金秋银杏',\r\n tagline: '叶色渐变,见证每一天',\r\n stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']\r\n }\r\n]\r\n\r\nexport const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'\r\n\r\n/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */\r\nexport const DAILY_MAX_TASK_POINTS = 40\r\n\r\nexport function getTreeSpecies(id) {\r\n return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]\r\n}\r\n\r\nexport function speciesEmojiAtLevel(speciesId, level) {\r\n const sp = getTreeSpecies(speciesId)\r\n const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))\r\n return sp.stages[lv] || sp.stages[0] || '🌱'\r\n}\r\n\r\n/** 当前树种成长路线图(供选择面板展示) */\r\nexport function speciesGrowthRoadmap(speciesId) {\r\n const sp = getTreeSpecies(speciesId)\r\n return TREE_LEVELS.map((meta, i) => {\r\n const xpTotal = i * TREE_XP_PER_LEVEL\r\n const daysHint = i === 0\r\n ? 0\r\n : Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))\r\n return {\r\n level: i,\r\n name: meta.name,\r\n emoji: sp.stages[i] || meta.emoji,\r\n desc: meta.desc,\r\n xpTotal,\r\n daysHint\r\n }\r\n })\r\n}\r\n\r\n/** 全勤打卡大约多少天满级 */\r\nexport function estimateDaysToMaxLevel() {\r\n const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL\r\n return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))\r\n}\r\n"],"names":["TREE_SPECIES_LIST","TREE_LEVELS","l","DAILY_MAX_TASK_POINTS","getTreeSpecies","id","s","speciesEmojiAtLevel","speciesId","level","sp","lv","TREE_MAX_LEVEL","speciesGrowthRoadmap","meta","i","xpTotal","TREE_XP_PER_LEVEL","daysHint","estimateDaysToMaxLevel","totalXp"],"mappings":"gDAGaA,EAAoB,CAC7B,CACI,GAAI,UACJ,KAAM,OACN,QAAS,cACT,OAAQC,EAAW,YAAC,IAAKC,GAAMA,EAAE,KAAK,CACzC,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,gBACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,OACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,MAAO,IAAI,CACvE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,YACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,EACD,CACI,GAAI,SACJ,KAAM,OACN,QAAS,aACT,OAAQ,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,CACtE,CACL,EAKaC,EAAwB,GAE9B,SAASC,EAAeC,EAAI,CAC/B,OAAOL,EAAkB,KAAMM,GAAMA,EAAE,KAAOD,CAAE,GAAKL,EAAkB,CAAC,CAC5E,CAEO,SAASO,EAAoBC,EAAWC,EAAO,CAClD,MAAMC,EAAKN,EAAeI,CAAS,EAC7BG,EAAK,KAAK,IAAIC,EAAc,eAAE,KAAK,IAAI,EAAG,OAAOH,CAAK,GAAK,CAAC,CAAC,EACnE,OAAOC,EAAG,OAAOC,CAAE,GAAKD,EAAG,OAAO,CAAC,GAAK,IAC5C,CAGO,SAASG,EAAqBL,EAAW,CAC5C,MAAME,EAAKN,EAAeI,CAAS,EACnC,OAAOP,cAAY,IAAI,CAACa,EAAMC,IAAM,CAChC,MAAMC,EAAUD,EAAIE,EAAiB,kBAC/BC,EAAWH,IAAM,EACjB,EACA,KAAK,IAAI,EAAG,KAAK,KAAKC,EAAUb,CAAqB,CAAC,EAC5D,MAAO,CACH,MAAOY,EACP,KAAMD,EAAK,KACX,MAAOJ,EAAG,OAAOK,CAAC,GAAKD,EAAK,MAC5B,KAAMA,EAAK,KACX,QAAAE,EACA,SAAAE,CACH,CACT,CAAK,CACL,CAGO,SAASC,GAAyB,CACrC,MAAMC,EAAUR,EAAAA,eAAiBK,EAAiB,kBAClD,OAAO,KAAK,IAAI,EAAG,KAAK,KAAKG,EAAUjB,CAAqB,CAAC,CACjE"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["fontData"],"mappings":";;;AAcC,MAAM,SAAS,CAAC,QAAQ;AACvB,QAAM,MAAM;AACZ,SAAQ,OAAO,QAAQ,YAAY,IAAI,KAAK,GAAG,IAAK,MAAM,OAAO;AAClE;AAqBA,MAAK,YAAU;AAAA,EACd,MAAM;AAAA,EACN,OAAO,CAAC,OAAO;AAAA,EACf,OAAO;AAAA,IACN,MAAM;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,OAAO;AAAA,MACN,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,MAAM;AAAA,MACL,MAAM,CAAC,QAAQ,MAAM;AAAA,MACrB,SAAS;AAAA,IACT;AAAA,IACD,cAAc;AAAA,MACb,MAAM;AAAA,MACN,SAAS;AAAA,IACT;AAAA,IACD,YAAY;AAAA,MACX,MAAM;AAAA,MACN,SAAS;AAAA,IACV;AAAA,EACA;AAAA,EACD,OAAO;AACN,WAAO;AAAA,MACN,OAAOA,2DAAO;AAAA,IACf;AAAA,EACA;AAAA,EACD,UAAU;AAAA,IACT,UAAU;AACT,UAAI,OAAO,KAAK,MAAM,KAAK,OAAK,EAAE,eAAe,KAAK,IAAI;AAC1D,UAAI,MAAM;AACT,eAAO,KAAK;AAAA,MACb;AACA,aAAO;AAAA,IACP;AAAA,IACD,WAAW;AACV,aAAO,OAAO,KAAK,IAAI;AAAA,IACvB;AAAA,IACD,WAAW;AACV,UAAI,KAAK,eAAe,IAAI;AAC3B,eAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU;AAAA,MAC1F;AACA,aAAO,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ;AAAA,IACzD;AAAA,EACA;AAAA,EACD,SAAS;AAAA,IACR,WAAW;AACV,WAAK,MAAM,OAAO;AAAA,IACnB;AAAA,EACD;AACD;;;;;;;;;;;AC1FD,GAAG,gBAAgB,SAAS;"}
|
||||
{"version":3,"file":"uni-icons.js","sources":["uni_modules/uni-icons/components/uni-icons/uni-icons.vue","../../../soft/HBuilderX/plugins/uniapp-cli-vite/uniComponent:/RDovd2ViL3p5dC9Ub25namlVbmlBcHAvdW5pX21vZHVsZXMvdW5pLWljb25zL2NvbXBvbmVudHMvdW5pLWljb25zL3VuaS1pY29ucy52dWU"],"sourcesContent":["<template>\r\n\t<!-- #ifdef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" @click=\"_onClick\">{{unicode}}</text>\r\n\t<!-- #endif -->\r\n\t<!-- #ifndef APP-NVUE -->\r\n\t<text :style=\"styleObj\" class=\"uni-icons\" :class=\"['uniui-'+type,customPrefix,customPrefix?type:'']\" @click=\"_onClick\">\r\n\t\t<slot></slot>\r\n\t</text>\r\n\t<!-- #endif -->\r\n</template>\r\n\r\n<script>\r\n\timport { fontData } from './uniicons_file_vue.js';\r\n\r\n\tconst getVal = (val) => {\r\n\t\tconst reg = /^[0-9]*$/g\r\n\t\treturn (typeof val === 'number' || reg.test(val)) ? val + 'px' : val;\r\n\t}\r\n\r\n\t// #ifdef APP-NVUE\r\n\tvar domModule = weex.requireModule('dom');\r\n\timport iconUrl from './uniicons.ttf'\r\n\tdomModule.addRule('fontFace', {\r\n\t\t'fontFamily': \"uniicons\",\r\n\t\t'src': \"url('\" + iconUrl + \"')\"\r\n\t});\r\n\t// #endif\r\n\r\n\t/**\r\n\t * Icons 图标\r\n\t * @description 用于展示 icons 图标\r\n\t * @tutorial https://ext.dcloud.net.cn/plugin?id=28\r\n\t * @property {Number} size 图标大小\r\n\t * @property {String} type 图标图案,参考示例\r\n\t * @property {String} color 图标颜色\r\n\t * @property {String} customPrefix 自定义图标\r\n\t * @event {Function} click 点击 Icon 触发事件\r\n\t */\r\n\texport default {\r\n\t\tname: 'UniIcons',\r\n\t\temits: ['click'],\r\n\t\tprops: {\r\n\t\t\ttype: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tcolor: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: '#333333'\r\n\t\t\t},\r\n\t\t\tsize: {\r\n\t\t\t\ttype: [Number, String],\r\n\t\t\t\tdefault: 16\r\n\t\t\t},\r\n\t\t\tcustomPrefix: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t},\r\n\t\t\tfontFamily: {\r\n\t\t\t\ttype: String,\r\n\t\t\t\tdefault: ''\r\n\t\t\t}\r\n\t\t},\r\n\t\tdata() {\r\n\t\t\treturn {\r\n\t\t\t\ticons: fontData\r\n\t\t\t}\r\n\t\t},\r\n\t\tcomputed: {\r\n\t\t\tunicode() {\r\n\t\t\t\tlet code = this.icons.find(v => v.font_class === this.type)\r\n\t\t\t\tif (code) {\r\n\t\t\t\t\treturn code.unicode\r\n\t\t\t\t}\r\n\t\t\t\treturn ''\r\n\t\t\t},\r\n\t\t\ticonSize() {\r\n\t\t\t\treturn getVal(this.size)\r\n\t\t\t},\r\n\t\t\tstyleObj() {\r\n\t\t\t\tif (this.fontFamily !== '') {\r\n\t\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize}; font-family: ${this.fontFamily};`\r\n\t\t\t\t}\r\n\t\t\t\treturn `color: ${this.color}; font-size: ${this.iconSize};`\r\n\t\t\t}\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\t_onClick() {\r\n\t\t\t\tthis.$emit('click')\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style lang=\"scss\">\r\n\t/* #ifndef APP-NVUE */\r\n\t@import './uniicons.css';\r\n\r\n\t@font-face {\r\n\t\tfont-family: uniicons;\r\n\t\tsrc: url('./uniicons.ttf');\r\n\t}\r\n\r\n\t/* #endif */\r\n\t.uni-icons {\r\n\t\tfont-family: uniicons;\r\n\t\ttext-decoration: none;\r\n\t\ttext-align: center;\r\n\t}\r\n</style>\n","import Component from 'D:/web/zyt/TongjiUniApp/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'\nwx.createComponent(Component)"],"names":["getVal","val","_sfc_main","fontData","code","v","Component"],"mappings":"iGAcOA,EAAUC,GAEP,OAAOA,GAAQ,UADX,YAC2B,KAAKA,CAAG,EAAKA,EAAM,KAAOA,EAsB7DC,EAAU,CACd,KAAM,WACN,MAAO,CAAC,OAAO,EACf,MAAO,CACN,KAAM,CACL,KAAM,OACN,QAAS,EACT,EACD,MAAO,CACN,KAAM,OACN,QAAS,SACT,EACD,KAAM,CACL,KAAM,CAAC,OAAQ,MAAM,EACrB,QAAS,EACT,EACD,aAAc,CACb,KAAM,OACN,QAAS,EACT,EACD,WAAY,CACX,KAAM,OACN,QAAS,EACV,CACA,EACD,MAAO,CACN,MAAO,CACN,MAAOC,EAAO,QACf,CACA,EACD,SAAU,CACT,SAAU,CACT,IAAIC,EAAO,KAAK,MAAM,KAAKC,GAAKA,EAAE,aAAe,KAAK,IAAI,EAC1D,OAAID,EACIA,EAAK,QAEN,EACP,EACD,UAAW,CACV,OAAOJ,EAAO,KAAK,IAAI,CACvB,EACD,UAAW,CACV,OAAI,KAAK,aAAe,GAChB,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,kBAAkB,KAAK,UAAU,IAEnF,UAAU,KAAK,KAAK,gBAAgB,KAAK,QAAQ,GACzD,CACA,EACD,QAAS,CACR,UAAW,CACV,KAAK,MAAM,OAAO,CACnB,CACD,CACD,0NC1FD,GAAG,gBAAgBM,CAAS"}
|
||||
@@ -1,2 +1,2 @@
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?i(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},i=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://admin.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),i=String(o||"").replace(/^\/+/,"");return i?`${a}/${i}`:a}function m(e,o=!0){const i={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:i,sslVerify:!1,success:s=>{const c=s.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:s=>{u(s)},complete:s=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(i=>{const[r,t]=i.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("./common/vendor.js"),l={__name:"App",setup(e){const{proxy:o}=n.getCurrentInstance();n.onLaunch(()=>{try{n.index.setInnerAudioOption({obeyMuteSwitch:!1,mixWithOther:!0})}catch(r){n.index.__f__("warn","at App.vue:16","setInnerAudioOption failed:",r)}n.index.login({provider:"weixin",success:function(r){n.index.getStorageSync("token")?s(r.code):a(r.code)}})});const a=async r=>{try{const t=await o.apiUrl({url:"/api/login/mnpLogin",method:"POST",data:{code:r}},!1);t&&t.data&&t.data.token&&(n.index.setStorageSync("token",t.data.token),n.index.setStorageSync("userData",t.data))}catch{}},s=async r=>{try{const t=await o.apiUrl({url:"/api/user/info",method:"POST"},!1);if(t.code==-1){a(r);return}t&&t.data&&n.index.setStorageSync("userData",t.data)}catch{}};return()=>{}}};var d="https://xt.zhenyangtang.com.cn/";function f(e,o){const a=String(e||"").replace(/\/+$/,""),s=String(o||"").replace(/^\/+/,"");return s?`${a}/${s}`:a}function m(e,o=!0){const s={token:n.index.getStorageSync("token"),"content-type":"application/x-www-form-urlencoded",...e.header||{}},r=e.data||{},t=f(d,e.url||"");return new Promise((g,u)=>{o&&n.index.showLoading({title:"加载中...",mask:!0}),n.index.request({url:t,data:r,timeout:e.timeout!=null?e.timeout:15e3,method:e.method||"POST",header:s,sslVerify:!1,success:i=>{const c=i.data;c.code===-1&&n.index.removeStorageSync("token"),g(c)},fail:i=>{u(i)},complete:i=>{o&&n.index.hideLoading()}})})}function S(e){const o={};if(!e)return o;if(e.scene)try{decodeURIComponent(e.scene).split("&").forEach(s=>{const[r,t]=s.split("=");r&&t&&(o[r]=t)})}catch(a){n.index.__f__("error","at main.js:126","解析scene参数失败:",a)}return Object.keys(e).forEach(a=>{a!=="scene"&&e[a]&&(o[a]=e[a])}),o}function p(){const e=n.createSSRApp(l);return e.config.globalProperties.$url=d,e.config.globalProperties.apiUrl=m,e.config.globalProperties.$parsePageParams=S,{app:e}}p().app.mount("#app");exports.createApp=p;
|
||||
//# sourceMappingURL=../.sourcemap/mp-weixin/app.js.map
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "",
|
||||
"appid": "wx79b9a0bfbfe7cbcd",
|
||||
"appid": "wx0c2a9d5ebad26a3f",
|
||||
"projectname": "tongji-uniapp",
|
||||
"condition": {
|
||||
"search": {
|
||||
|
||||
@@ -230,27 +230,27 @@ export function wecomPromotionDeleteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
|
||||
}
|
||||
|
||||
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
|
||||
|
||||
export interface WecomPromotionCustomerStatsParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
promotion_link_id?: number
|
||||
userid?: string
|
||||
chat_status?: 0 | 1 | 2
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsSummary {
|
||||
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
|
||||
|
||||
export interface WecomPromotionCustomerStatsParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
promotion_link_id?: number
|
||||
userid?: string
|
||||
chat_status?: 0 | 1 | 2
|
||||
}
|
||||
export interface WecomPromotionCustomerStatsSummary {
|
||||
customer_count: number
|
||||
messaged_customer_count: number
|
||||
message_customer_rate: number
|
||||
received_message_count: number
|
||||
message_count_known_count: number
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatRow {
|
||||
id?: number
|
||||
external_userid_masked?: string
|
||||
received_message_count: number
|
||||
message_count_known_count: number
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatRow {
|
||||
id?: number
|
||||
userid?: string
|
||||
external_userid_masked?: string
|
||||
customer_id_masked?: string
|
||||
customer_name_masked?: string
|
||||
link_id?: number | string
|
||||
@@ -260,21 +260,21 @@ export interface WecomPromotionCustomerStatRow {
|
||||
department_name?: string
|
||||
dept_name?: string
|
||||
has_messaged?: boolean | number
|
||||
chat_status?: 0 | 1 | 2
|
||||
message_count_known?: boolean | number
|
||||
received_message_count?: number
|
||||
last_synced_at?: string
|
||||
last_message_at?: string
|
||||
}
|
||||
chat_status?: 0 | 1 | 2
|
||||
message_count_known?: boolean | number
|
||||
received_message_count?: number
|
||||
last_synced_at?: string
|
||||
last_message_at?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsResult {
|
||||
summary?: Partial<WecomPromotionCustomerStatsSummary>
|
||||
lists?: WecomPromotionCustomerStatRow[]
|
||||
total?: number
|
||||
link_options?: Array<{ id: number | string; name: string }>
|
||||
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
|
||||
meta?: { last_synced_at?: string }
|
||||
}
|
||||
member_options?: Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>
|
||||
meta?: { last_synced_at?: string }
|
||||
}
|
||||
|
||||
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
|
||||
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export type DesktopPackageType = 'archive' | 'inno_setup'
|
||||
|
||||
export type DesktopPackage = {
|
||||
url: string
|
||||
sha256: string
|
||||
size: number
|
||||
filename: string
|
||||
type: DesktopPackageType
|
||||
}
|
||||
|
||||
export type DesktopWorkstationConfig = {
|
||||
enabled: number
|
||||
latest_version: string
|
||||
min_version: string
|
||||
force_update: number
|
||||
title: string
|
||||
notes: string
|
||||
packages: {
|
||||
windows_x64: DesktopPackage
|
||||
macos_arm64: DesktopPackage
|
||||
macos_x64: DesktopPackage
|
||||
}
|
||||
}
|
||||
|
||||
export function getDesktopWorkstationConfig() {
|
||||
return request.get({
|
||||
url: '/setting.desktop_workstation/getConfig'
|
||||
}) as Promise<DesktopWorkstationConfig>
|
||||
}
|
||||
|
||||
export function setDesktopWorkstationConfig(params: DesktopWorkstationConfig) {
|
||||
return request.post({ url: '/setting.desktop_workstation/setConfig', params })
|
||||
}
|
||||
@@ -331,9 +331,8 @@ function onSelectionChange(rows: Record<string, unknown>[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
|
||||
/** 与诊单预约弹窗一致:自媒体4H/4Q 无需补充,仅以下字典 name 需填「自媒体补充」 */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
|
||||
@@ -81,10 +81,10 @@
|
||||
<el-option
|
||||
v-for="item in group.channels"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:label="item.search_label || item.name"
|
||||
:value="item.code"
|
||||
>
|
||||
<span class="channel-option">
|
||||
<span class="channel-option" :class="{ 'is-group': item.kind === 'group' }">
|
||||
<span>{{ item.name }}</span>
|
||||
<span>{{ item.customer_count }} 人</span>
|
||||
</span>
|
||||
@@ -96,47 +96,47 @@
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
|
||||
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="showRankings" class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}订单量占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
|
||||
<strong><span>{{ formatNumber(item.value) }} 单</span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
<section v-if="showRankings" class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}订单量占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
|
||||
<strong><span>{{ formatNumber(item.value) }} 单</span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}金额占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
|
||||
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>{{ rankingSubject }}金额占比排名</h2>
|
||||
<p>按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
|
||||
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
@@ -145,7 +145,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
|
||||
{{ row.name }}
|
||||
@@ -178,34 +178,34 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="104" align="right">
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="104" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开口率" min-width="82" align="right">
|
||||
<el-table-column label="开口率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号率" min-width="82" align="right">
|
||||
<el-table-column label="挂号率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="82" align="right">
|
||||
<el-table-column label="面诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约率" min-width="82" align="right">
|
||||
<el-table-column label="预约率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="100" align="right">
|
||||
<el-table-column label="面诊接诊率" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="82" align="right">
|
||||
<el-table-column label="接诊率" min-width="82" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="72" align="right">
|
||||
<el-table-column v-if="canViewFinance" label="ROI" min-width="72" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
@@ -287,13 +287,18 @@ type MediaChannelOption = {
|
||||
tag_id?: string
|
||||
group_name?: string
|
||||
customer_count?: number
|
||||
kind?: 'channel' | 'group'
|
||||
search_label?: string
|
||||
}
|
||||
|
||||
const FINANCE_METRIC_KEYS = new Set(['account_cost', 'roi'])
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
||||
can_view_finance: false
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
@@ -330,7 +335,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
@@ -347,30 +352,58 @@ const scopeDescription = computed(() => {
|
||||
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
||||
const visibleMetricCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? metricCards
|
||||
: metricCards.filter((card) => !FINANCE_METRIC_KEYS.has(card.key))
|
||||
)
|
||||
const mediaChannelGroups = computed(() => {
|
||||
const groups = new Map<string, {
|
||||
const groups: Array<{
|
||||
group_name: string
|
||||
customer_count: number
|
||||
channels: MediaChannelOption[]
|
||||
}>()
|
||||
}> = []
|
||||
const indexByName = new Map<string, number>()
|
||||
|
||||
for (const channel of dashboard.filters.media_channels) {
|
||||
const groupName = channel.group_name || ''
|
||||
if (!groups.has(groupName)) {
|
||||
groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] })
|
||||
if (channel.kind === 'group') continue
|
||||
const groupName = String(channel.group_name || '').trim()
|
||||
let groupIndex = indexByName.get(groupName)
|
||||
if (groupIndex === undefined) {
|
||||
groupIndex = groups.length
|
||||
indexByName.set(groupName, groupIndex)
|
||||
groups.push({ group_name: groupName, customer_count: 0, channels: [] })
|
||||
}
|
||||
const group = groups.get(groupName)!
|
||||
group.channels.push(channel)
|
||||
const group = groups[groupIndex]
|
||||
const searchLabel = groupName !== '' && !channel.name.includes(groupName)
|
||||
? `${channel.name} ${groupName}`
|
||||
: ''
|
||||
group.channels.push(searchLabel === '' ? channel : { ...channel, search_label: searchLabel })
|
||||
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.group_name === '') continue
|
||||
const hasSameNameLeaf = group.channels.some((item) => item.name === group.group_name)
|
||||
if (group.channels.length < 2 && hasSameNameLeaf) continue
|
||||
group.channels.unshift({
|
||||
code: `group:${group.group_name}`,
|
||||
name: `${group.group_name}(全部)`,
|
||||
group_name: group.group_name,
|
||||
customer_count: group.customer_count,
|
||||
kind: 'group'
|
||||
})
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
@@ -452,14 +485,14 @@ function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatShare(value: any, total: number) {
|
||||
if (total <= 0) return '0.0%'
|
||||
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
|
||||
}
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatShare(value: any, total: number) {
|
||||
if (total <= 0) return '0.0%'
|
||||
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
@@ -474,11 +507,11 @@ function compactNumber(value: number) {
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, total: number) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (total <= 0 || numericValue <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
|
||||
}
|
||||
function barWidth(value: any, total: number) {
|
||||
const numericValue = Number(value || 0)
|
||||
if (total <= 0 || numericValue <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
@@ -488,29 +521,29 @@ onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
@@ -553,7 +586,7 @@ onMounted(loadDashboard)
|
||||
.date-range-picker { width: 260px; }
|
||||
.employee-select { width: 190px; }
|
||||
.dept-select { width: 220px; }
|
||||
.channel-select { width: 180px; }
|
||||
.channel-select { width: 220px; }
|
||||
.channel-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -565,6 +598,11 @@ onMounted(loadDashboard)
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.is-group span:first-child {
|
||||
font-weight: 650;
|
||||
color: #172033;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -584,7 +622,7 @@ onMounted(loadDashboard)
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
@@ -598,26 +636,26 @@ onMounted(loadDashboard)
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
|
||||
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
|
||||
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
|
||||
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
|
||||
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
|
||||
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
|
||||
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
|
||||
.bar-track i.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; overflow: hidden; }
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-table__inner-wrapper),
|
||||
:deep(.el-scrollbar) { max-width: 100%; }
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
.detail-panel { padding-bottom: 10px; overflow: hidden; }
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-table__inner-wrapper),
|
||||
:deep(.el-scrollbar) { max-width: 100%; }
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
|
||||
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
@@ -658,7 +696,7 @@ onMounted(loadDashboard)
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,11 +187,11 @@
|
||||
<div>
|
||||
<h2>获客客户消息统计</h2>
|
||||
<p>统计当前权限范围内由获客助手链接添加的客户,以及客户向承接成员发送消息的情况。</p>
|
||||
</div>
|
||||
<div class="customer-heading-actions">
|
||||
<span v-if="customerStats.meta.last_synced_at" class="update-time">最近同步 {{ customerStats.meta.last_synced_at }}</span>
|
||||
<el-button type="primary" :icon="Refresh" :loading="syncingCustomers" @click="syncCustomers">同步客户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customer-heading-actions">
|
||||
<span v-if="customerStats.meta.last_synced_at" class="update-time">最近同步 {{ customerStats.meta.last_synced_at }}</span>
|
||||
<el-button type="primary" :icon="Refresh" :loading="syncingCustomers" @click="syncCustomers">同步客户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="customer-metric-grid" aria-label="获客客户消息汇总">
|
||||
@@ -207,11 +207,11 @@
|
||||
<span class="metric-icon is-green"><el-icon><DataAnalysis /></el-icon></span>
|
||||
<div><small>消息客户率</small><strong>{{ formatCustomerRate(customerStats.summary.message_customer_rate) }}</strong><p>已发消息客户占获客客户比例</p></div>
|
||||
</article>
|
||||
<article class="customer-metric-card">
|
||||
<span class="metric-icon is-orange"><el-icon><Connection /></el-icon></span>
|
||||
<div><small>累计接收消息数</small><strong>{{ formatNumber(customerStats.summary.received_message_count) }}</strong><p>已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户</p></div>
|
||||
</article>
|
||||
</section>
|
||||
<article class="customer-metric-card">
|
||||
<span class="metric-icon is-orange"><el-icon><Connection /></el-icon></span>
|
||||
<div><small>累计接收消息数</small><strong>{{ formatNumber(customerStats.summary.received_message_count) }}</strong><p>已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户</p></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="!overview.config.callback_ready"
|
||||
@@ -220,18 +220,18 @@
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="消息次数回调尚未配置完成"
|
||||
description="请在企业微信获客助手的 API 接入场景中配置本页“应用配置”展示的消息回调地址,并在服务器配置 contact_callback_token 与 contact_callback_aes_key。未完成前只能同步客户及聊天状态,无法得到精确接收消息次数。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
class="chatkey-alert"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="消息统计依赖企业微信 ChatKey"
|
||||
description="ChatKey 仅在事件产生后的 30 分钟内有效。服务端收到企业微信回调后会立即拉取会话详情;手动同步用于刷新客户清单,无法补取已过期的会话消息。"
|
||||
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
class="chatkey-alert"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="获客助手通过事件与 ChatKey 返回收消息次数"
|
||||
description="本功能复用现有回调并按事件类型处理 customer_acquisition,不会占用第二个回调。客户首次发消息会推送 customer_start_chat;累计第 3、5、10 次时推送 message_from_customer,服务端需在 30 分钟内用 ChatKey 查询成员、客户、获客链接和累计收消息次数。"
|
||||
/>
|
||||
|
||||
<div class="customer-filter-bar">
|
||||
<el-form :model="customerFilters" inline label-position="top">
|
||||
<el-form-item label="来源链接">
|
||||
@@ -244,13 +244,13 @@
|
||||
<el-option v-for="option in customerMemberOptions" :key="option.userid || String(option.id)" :label="memberFilterLabel(option)" :value="option.userid || String(option.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="聊天状态">
|
||||
<el-form-item label="聊天状态">
|
||||
<el-select v-model="customerFilters.chat_status" clearable placeholder="全部状态">
|
||||
<el-option label="已发过消息" value="messaged" />
|
||||
<el-option label="未发过消息" value="silent" />
|
||||
<el-option label="状态未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" :loading="customerStatsLoading" @click="applyCustomerFilters">查询</el-button>
|
||||
<el-button @click="resetCustomerFilters">重置</el-button>
|
||||
@@ -269,23 +269,26 @@
|
||||
<div class="member-cell"><strong>{{ customerLinkName(row) }}</strong><small>{{ customerLinkId(row) }}</small></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="承接成员 / 部门" min-width="175">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell"><strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong><small>{{ row.department_name || row.dept_name || '未分部门' }}</small></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否发过消息" width="125" align="center">
|
||||
<el-table-column label="承接成员 / 部门" min-width="175">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell">
|
||||
<strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong>
|
||||
<small>{{ row.department_name || row.dept_name || '未分部门' }} · {{ row.userid || '未绑定企微账号' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户开口" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="message-state" :class="`is-${customerChatState(row)}`">{{ customerChatStateLabel(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接收消息数" width="120" align="right">
|
||||
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计消息" width="100" align="right">
|
||||
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近同步时间" min-width="165">
|
||||
<template #default="{ row }">{{ formatCustomerTime(row.last_synced_at || row.last_sync_time || row.synced_at || row.sync_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近消息时间" min-width="165">
|
||||
<el-table-column label="最近收消息事件" min-width="165">
|
||||
<template #default="{ row }"><span :class="{ muted: !customerLastMessageTime(row) }">{{ formatCustomerTime(customerLastMessageTime(row)) }}</span></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="当前筛选范围暂无获客客户数据" /></template>
|
||||
@@ -429,7 +432,7 @@ import {
|
||||
wecomPromotionSyncRemoteLinks,
|
||||
wecomPromotionToggleLink
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
|
||||
@@ -458,19 +461,19 @@ const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分
|
||||
const customerStatsLoading = ref(false)
|
||||
const customerStatsLoaded = ref(false)
|
||||
const syncingCustomers = ref(false)
|
||||
const customerFilters = reactive<{
|
||||
link_id: number | string
|
||||
userid: string
|
||||
chat_status: WecomPromotionCustomerChatStatus
|
||||
}>({ link_id: '', userid: '', chat_status: '' })
|
||||
const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 })
|
||||
const customerStats = reactive({
|
||||
summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 },
|
||||
const customerFilters = reactive<{
|
||||
link_id: number | string
|
||||
userid: string
|
||||
chat_status: WecomPromotionCustomerChatStatus
|
||||
}>({ link_id: '', userid: '', chat_status: '' })
|
||||
const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 })
|
||||
const customerStats = reactive({
|
||||
summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 },
|
||||
rows: [] as any[],
|
||||
link_options: [] as Array<{ id: number | string; name: string }>,
|
||||
member_options: [] as Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>,
|
||||
meta: { last_synced_at: '' }
|
||||
})
|
||||
meta: { last_synced_at: '' }
|
||||
})
|
||||
|
||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
|
||||
@@ -630,10 +633,10 @@ async function loadCustomerStats() {
|
||||
page_no: customerPager.page_no,
|
||||
page_size: customerPager.page_size,
|
||||
promotion_link_id: customerFilters.link_id ? Number(customerFilters.link_id) : undefined,
|
||||
userid: customerFilters.userid || undefined,
|
||||
chat_status: customerFilters.chat_status === ''
|
||||
? undefined
|
||||
: (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0))
|
||||
userid: customerFilters.userid || undefined,
|
||||
chat_status: customerFilters.chat_status === ''
|
||||
? undefined
|
||||
: (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0))
|
||||
})
|
||||
const summary = result?.summary || result?.stats || {}
|
||||
const customerCount = numberFrom(summary, ['customer_count', 'total_customers', 'customer_total', 'total'])
|
||||
@@ -643,9 +646,9 @@ async function loadCustomerStats() {
|
||||
Object.assign(customerStats.summary, {
|
||||
customer_count: customerCount,
|
||||
messaged_customer_count: messagedCount,
|
||||
message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0),
|
||||
received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']),
|
||||
message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count'])
|
||||
message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0),
|
||||
received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']),
|
||||
message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count'])
|
||||
})
|
||||
customerStats.rows = Array.isArray(result?.lists)
|
||||
? result.lists
|
||||
@@ -661,9 +664,9 @@ async function loadCustomerStats() {
|
||||
return Number(current || 0) > Number(latest || 0) ? current : latest
|
||||
}, '')
|
||||
const syncedAt = result?.meta?.last_synced_at || result?.last_synced_at || latestSyncTime || customerStats.meta.last_synced_at || ''
|
||||
Object.assign(customerStats.meta, result?.meta || {}, {
|
||||
last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : ''
|
||||
})
|
||||
Object.assign(customerStats.meta, result?.meta || {}, {
|
||||
last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : ''
|
||||
})
|
||||
customerStatsLoaded.value = true
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获客客户统计加载失败')
|
||||
@@ -677,8 +680,8 @@ function applyCustomerFilters() {
|
||||
loadCustomerStats()
|
||||
}
|
||||
|
||||
function resetCustomerFilters() {
|
||||
Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' })
|
||||
function resetCustomerFilters() {
|
||||
Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' })
|
||||
customerPager.page_no = 1
|
||||
loadCustomerStats()
|
||||
}
|
||||
@@ -821,10 +824,11 @@ function remoteMemberLabel(row: any) {
|
||||
return row.is_official ? '未返回成员范围' : '手工配置'
|
||||
}
|
||||
|
||||
function memberFilterLabel(member: any) {
|
||||
const department = member.department_name || member.dept_name || ''
|
||||
return department ? `${member.name} · ${department}` : member.name
|
||||
}
|
||||
function memberFilterLabel(member: any) {
|
||||
const department = member.department_name || member.dept_name || ''
|
||||
const userid = member.userid ? ` · ${member.userid}` : ''
|
||||
return department ? `${member.name} · ${department}${userid}` : `${member.name}${userid}`
|
||||
}
|
||||
|
||||
function customerIdentifier(row: any) {
|
||||
const masked = row.external_userid_masked || row.customer_id_masked || row.customer_masked || row.customer_name_masked
|
||||
@@ -879,16 +883,16 @@ function customerChatStateLabel(row: any) {
|
||||
return state === 'messaged' ? '已发消息' : (state === 'unknown' ? '状态未知' : '未发消息')
|
||||
}
|
||||
|
||||
function customerMessageCountLabel(row: any) {
|
||||
if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row))
|
||||
const state = customerChatState(row)
|
||||
if (state === 'silent') return '0'
|
||||
return state === 'unknown' ? '未知' : '待回调'
|
||||
}
|
||||
|
||||
function customerLastMessageTime(row: any) {
|
||||
return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || ''
|
||||
}
|
||||
function customerMessageCountLabel(row: any) {
|
||||
if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row))
|
||||
const state = customerChatState(row)
|
||||
if (state === 'silent') return '0'
|
||||
return state === 'unknown' ? '未知' : '待回调'
|
||||
}
|
||||
|
||||
function customerLastMessageTime(row: any) {
|
||||
return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || ''
|
||||
}
|
||||
|
||||
function formatCustomerTime(value: unknown) {
|
||||
if (value === undefined || value === null || value === '' || value === 0 || value === '0') return '-'
|
||||
@@ -998,7 +1002,7 @@ h1, h2, h3, p { margin: 0; }
|
||||
.chatkey-alert { margin-top: 14px; }.chatkey-alert :deep(.el-alert__title) { font-size: 12px; }.chatkey-alert :deep(.el-alert__description) { line-height: 1.6; }
|
||||
.customer-filter-bar { margin-top: 14px; padding: 12px 14px 0; border: 1px solid var(--line); border-radius: 9px; background: #f8fafb; }
|
||||
.customer-filter-bar :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }.customer-filter-bar :deep(.el-form-item__label) { height: 22px; padding: 0; color: #67768a; font-size: 11px; line-height: 22px; }.customer-filter-bar .el-select { width: 220px; }.customer-filter-bar .filter-actions { align-self: flex-end; margin-right: 0; }
|
||||
.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; }
|
||||
.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; }
|
||||
.customer-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; color: #8491a2; font-size: 11px; }
|
||||
.configuration-panel { margin-bottom: 16px; padding: 16px; border: 1px solid; border-radius: 10px; }.configuration-panel.is-ready { border-color: #cce8e3; background: #f5fbfa; }.configuration-panel.is-pending { border-color: #f0d8b6; background: #fffaf2; }
|
||||
.configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; }
|
||||
|
||||
@@ -215,6 +215,7 @@ const PAGE_SIZE = 15
|
||||
interface QueueRow {
|
||||
id: number
|
||||
patient_id: number
|
||||
source_patient_id?: number
|
||||
patient_name: string
|
||||
patient_phone?: string
|
||||
diagnosis_id?: number
|
||||
@@ -436,19 +437,25 @@ const handleSearch = async () => {
|
||||
}
|
||||
|
||||
const handleCall = async (row: QueueRow) => {
|
||||
if (!row.patient_id) {
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: row.patient_id,
|
||||
diagnosis_id: row.diagnosis_id || row.id
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
diagnosisId,
|
||||
signatureData: res
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div class="desktop-workstation">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
class="ls-form"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
scroll-to-error
|
||||
>
|
||||
<el-card shadow="never" class="!border-none">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-5">
|
||||
<template #title>医生工作站升级包</template>
|
||||
<div class="leading-6">
|
||||
桌面端启动后会检测本页发布的版本。请上传或填写与
|
||||
<code>一键打包</code>
|
||||
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
||||
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
||||
安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。
|
||||
</div>
|
||||
</el-alert>
|
||||
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
||||
<el-form-item label="自动检测" prop="enabled">
|
||||
<div>
|
||||
<el-switch
|
||||
v-model="formData.enabled"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<span class="ml-2">{{ formData.enabled ? '开启' : '关闭' }}</span>
|
||||
<div class="form-tips">关闭后,桌面端不会提示或强制升级。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最新版本号" prop="latest_version">
|
||||
<div class="w-80">
|
||||
<el-input
|
||||
v-model.trim="formData.latest_version"
|
||||
placeholder="须与 pyproject.toml / 客户端版本一致,例如 0.2.0"
|
||||
maxlength="20"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="强制升级" prop="force_update">
|
||||
<div>
|
||||
<el-switch
|
||||
v-model="formData.force_update"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<span class="ml-2">{{ formData.force_update ? '开启' : '关闭' }}</span>
|
||||
<div class="form-tips">
|
||||
开启后,低于最新版本且已配置对应平台安装包的客户端必须升级后才能继续使用。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低可用版本" prop="min_version">
|
||||
<div class="w-80">
|
||||
<el-input
|
||||
v-model.trim="formData.min_version"
|
||||
placeholder="可选,例如 0.1.0;低于该版本也会强制升级"
|
||||
maxlength="20"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新标题" prop="title">
|
||||
<div class="w-[480px]">
|
||||
<el-input
|
||||
v-model.trim="formData.title"
|
||||
placeholder="例如:医生工作站 0.2.0"
|
||||
maxlength="80"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="更新说明" prop="notes">
|
||||
<div class="w-[640px]">
|
||||
<el-input
|
||||
v-model="formData.notes"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="4000"
|
||||
show-word-limit
|
||||
placeholder="医生端弹窗中展示的更新说明"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card
|
||||
v-for="item in platforms"
|
||||
:key="item.key"
|
||||
shadow="never"
|
||||
class="!border-none mt-4"
|
||||
>
|
||||
<div class="text-xl font-medium mb-[20px]">{{ item.label }}</div>
|
||||
<el-form-item label="安装包类型">
|
||||
<el-select v-model="formData.packages[item.key].type" class="w-80">
|
||||
<el-option label="便携压缩包(ZIP)" value="archive" />
|
||||
<el-option
|
||||
v-if="item.key === 'windows_x64'"
|
||||
label="Windows 安装程序(Inno Setup EXE)"
|
||||
value="inno_setup"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tips ml-3">
|
||||
{{
|
||||
formData.packages[item.key].type === 'inno_setup'
|
||||
? '推荐:退出客户端后静默安装,必要时显示 Windows 权限确认。'
|
||||
: '兼容旧版客户端的 ZIP 覆盖更新。'
|
||||
}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="安装包地址">
|
||||
<div class="w-[640px]">
|
||||
<el-input
|
||||
v-model.trim="formData.packages[item.key].url"
|
||||
:placeholder="
|
||||
formData.packages[item.key].type === 'inno_setup'
|
||||
? 'https://.../DoctorWorkstation-Setup-....exe'
|
||||
: 'https://...zip 或站内 uploads/file/... 路径'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传安装包">
|
||||
<div>
|
||||
<upload
|
||||
type="file"
|
||||
:limit="1"
|
||||
:multiple="false"
|
||||
:show-progress="true"
|
||||
:data="{ cid: 0 }"
|
||||
@success="(response: any) => handleUploadSuccess(item.key, response)"
|
||||
@change="(file: any) => handleUploadFile(item.key, file)"
|
||||
>
|
||||
<el-button type="primary" plain>选择安装包并上传</el-button>
|
||||
</upload>
|
||||
<div class="form-tips">
|
||||
仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256
|
||||
填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="SHA-256">
|
||||
<div class="w-[640px]">
|
||||
<el-input
|
||||
v-model.trim="formData.packages[item.key].sha256"
|
||||
placeholder="64 位十六进制,来自 SHA256SUMS.txt 或打包生成的 .sha256"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="文件名">
|
||||
<div class="w-[480px]">
|
||||
<el-input
|
||||
v-model.trim="formData.packages[item.key].filename"
|
||||
:placeholder="
|
||||
formData.packages[item.key].type === 'inno_setup'
|
||||
? 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe'
|
||||
: 'DoctorWorkstation-Windows-x64-0.2.0.zip'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="文件大小">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-input
|
||||
v-model.number="formData.packages[item.key].size"
|
||||
class="w-48"
|
||||
placeholder="字节"
|
||||
/>
|
||||
<span class="text-tx-secondary text-sm">{{
|
||||
formatSize(formData.packages[item.key].size)
|
||||
}}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
</el-form>
|
||||
<footer-btns v-perms="['setting.desktop_workstation/setConfig']">
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</footer-btns>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="desktopWorkstation">
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import {
|
||||
getDesktopWorkstationConfig,
|
||||
setDesktopWorkstationConfig,
|
||||
type DesktopPackage,
|
||||
type DesktopPackageType
|
||||
} from '@/api/setting/desktop_workstation'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
type PlatformKey = 'windows_x64' | 'macos_arm64' | 'macos_x64'
|
||||
|
||||
const emptyPackage = (type: DesktopPackageType = 'archive'): DesktopPackage => ({
|
||||
url: '',
|
||||
sha256: '',
|
||||
size: 0,
|
||||
filename: '',
|
||||
type
|
||||
})
|
||||
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const formData = reactive({
|
||||
enabled: 1,
|
||||
latest_version: '',
|
||||
min_version: '',
|
||||
force_update: 0,
|
||||
title: '',
|
||||
notes: '',
|
||||
packages: {
|
||||
windows_x64: emptyPackage('inno_setup'),
|
||||
macos_arm64: emptyPackage(),
|
||||
macos_x64: emptyPackage()
|
||||
}
|
||||
})
|
||||
|
||||
const platforms: { key: PlatformKey; label: string }[] = [
|
||||
{ key: 'windows_x64', label: 'Windows 64 位安装包' },
|
||||
{ key: 'macos_arm64', label: 'macOS Apple 芯片安装包' },
|
||||
{ key: 'macos_x64', label: 'macOS Intel 安装包' }
|
||||
]
|
||||
|
||||
const rules = {
|
||||
latest_version: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (formData.enabled === 1 && !String(value || '').trim()) {
|
||||
callback(new Error('启用自动检测时请填写最新版本号'))
|
||||
return
|
||||
}
|
||||
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
|
||||
callback(new Error('版本号须为 x.y.z 数字格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
],
|
||||
min_version: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
|
||||
callback(new Error('版本号须为 x.y.z 数字格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: ['blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
const bytes = Number(size) || 0
|
||||
if (bytes <= 0) return '未填写'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
const assignPackage = (key: PlatformKey, row: Partial<DesktopPackage> | undefined) => {
|
||||
const current = formData.packages[key]
|
||||
current.url = String(row?.url || '')
|
||||
current.sha256 = String(row?.sha256 || '')
|
||||
current.size = Number(row?.size || 0)
|
||||
current.filename = String(row?.filename || '')
|
||||
current.type =
|
||||
row?.type === 'inno_setup' || (key === 'windows_x64' && !row?.url && !row?.filename)
|
||||
? 'inno_setup'
|
||||
: 'archive'
|
||||
}
|
||||
|
||||
const getData = async () => {
|
||||
const data = await getDesktopWorkstationConfig()
|
||||
formData.enabled = Number(data.enabled ?? 0) ? 1 : 0
|
||||
formData.latest_version = data.latest_version || ''
|
||||
formData.min_version = data.min_version || ''
|
||||
formData.force_update = Number(data.force_update ?? 0) ? 1 : 0
|
||||
formData.title = data.title || ''
|
||||
formData.notes = data.notes || ''
|
||||
assignPackage('windows_x64', data.packages?.windows_x64)
|
||||
assignPackage('macos_arm64', data.packages?.macos_arm64)
|
||||
assignPackage('macos_x64', data.packages?.macos_x64)
|
||||
}
|
||||
|
||||
const sha256File = async (file: File) => {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const handleUploadFile = async (key: PlatformKey, file: any) => {
|
||||
const raw = file?.raw as File | undefined
|
||||
if (!raw) return
|
||||
formData.packages[key].filename = raw.name || formData.packages[key].filename
|
||||
formData.packages[key].size = Number(raw.size || 0)
|
||||
if (key === 'windows_x64' && raw.name?.toLowerCase().endsWith('.exe')) {
|
||||
formData.packages[key].type = 'inno_setup'
|
||||
} else if (raw.name?.toLowerCase().endsWith('.zip')) {
|
||||
formData.packages[key].type = 'archive'
|
||||
}
|
||||
try {
|
||||
formData.packages[key].sha256 = await sha256File(raw)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
feedback.msgWarning('浏览器计算 SHA-256 失败,请手动填写校验值')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (key: PlatformKey, response: any) => {
|
||||
const payload = response?.data || {}
|
||||
if (payload.uri) {
|
||||
formData.packages[key].url = payload.uri
|
||||
} else if (payload.url) {
|
||||
formData.packages[key].url = payload.url
|
||||
}
|
||||
if (payload.name && !formData.packages[key].filename) {
|
||||
formData.packages[key].filename = payload.name
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
await setDesktopWorkstationConfig({
|
||||
enabled: formData.enabled,
|
||||
latest_version: formData.latest_version,
|
||||
min_version: formData.min_version,
|
||||
force_update: formData.force_update,
|
||||
title: formData.title,
|
||||
notes: formData.notes,
|
||||
packages: {
|
||||
windows_x64: { ...formData.packages.windows_x64 },
|
||||
macos_arm64: { ...formData.packages.macos_arm64 },
|
||||
macos_x64: { ...formData.packages.macos_x64 }
|
||||
}
|
||||
})
|
||||
await getData()
|
||||
}
|
||||
|
||||
getData()
|
||||
</script>
|
||||
@@ -996,34 +996,34 @@ const handleEdit = (row: any) => {
|
||||
}
|
||||
|
||||
// 聊天
|
||||
const handleChat = async (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有诊单ID
|
||||
if (!row.diagnosis_id && !row.id) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: row.patient_id,
|
||||
diagnosis_id: row.diagnosis_id || row.id
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
|
||||
// 直接打开聊天对话框,传入必要的参数
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
const handleChat = async (row: any) => {
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
|
||||
// 直接打开聊天对话框,传入必要的参数
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('获取聊天签名失败:', error)
|
||||
feedback.msgError(error.message || '获取聊天签名失败')
|
||||
|
||||
@@ -642,22 +642,28 @@ const handleEdit = (row: any) => {
|
||||
}
|
||||
editRef.value?.open('edit', row.patient_id)
|
||||
}
|
||||
const handleChat = async (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: row.patient_id,
|
||||
diagnosis_id: row.diagnosis_id || row.id
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
signatureData: res
|
||||
})
|
||||
const handleChat = async (row: any) => {
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res
|
||||
})
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
|
||||
}
|
||||
|
||||
@@ -262,9 +262,8 @@ const form = reactive({
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
/** 自媒体4H/4Q 无需补充;仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
.build-tools/
|
||||
.uv-cache/
|
||||
.uv-python/
|
||||
__pycache__/
|
||||
|
||||
@@ -6,12 +6,13 @@ set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
|
||||
set "RESULT=%ERRORLEVEL%"
|
||||
|
||||
if "%RESULT%"=="0" (
|
||||
if "%RESULT%"=="0" if /I "%~1"=="-ValidateOnly" (
|
||||
echo.
|
||||
echo Package ready in: %PROJECT_ROOT%dist
|
||||
if /I not "%~1"=="-ValidateOnly" (
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
)
|
||||
echo Validation complete. No installer was generated.
|
||||
) else if "%RESULT%"=="0" (
|
||||
echo.
|
||||
echo Installer and portable package ready in: %PROJECT_ROOT%dist
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
) else (
|
||||
echo.
|
||||
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
Windows 直接在项目根目录双击:
|
||||
|
||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||
|
||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||
|
||||
发布新版本时,可在管理后台的“医生工作站升级包”中为 Windows 选择 `Inno Setup EXE`,填入一键打包生成的 Setup 地址、SHA-256、文件大小和文件名。客户端启动时自动检测;发现新版本后,用户点击“立即更新”即可完成下载、校验、退出、静默安装和自动重启。生产地址必须使用 HTTPS 并开启证书校验,正式 Setup 还应配置组织的 Authenticode 代码签名。
|
||||
|
||||
macOS 在 Finder 中双击:
|
||||
|
||||
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
|
||||
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
|
||||
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境、锁定依赖和安装器编译器。首次打包需要联网下载依赖,之后会复用本机缓存。当前 Python 3.11+/PySide6/Qt 6 成品的真实最低系统是 Windows 10 1809;Qt 6 不支持 Windows 7,不能只降低安装器版本门槛来伪装兼容。Windows 7 SP1 必须另行维护 Python 3.8.10 + PySide2/Qt 5.15 的遗留构建,并在干净 Win7 虚拟机完成视频、媒体、安装、升级和卸载验收。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
|
||||
## 已实现范围
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,39 @@
|
||||
# AiConsultDialog 诊单工作区最终验收
|
||||
|
||||
日期:2026-08-18
|
||||
范围:诊单 `501`;复用 `tests/test_ai_consult_workspace_ui.py` 的 `WorkspaceRepository` 与即时异步执行方式;只读验收,未修改业务源码或测试。
|
||||
|
||||
## 结论
|
||||
|
||||
内容、控件数量、安全过滤与横向几何均通过。四个页签均有非空中文内容,没有出现 `['...']`、`{'...'}` 等 Python 集合 repr。默认窗口 `1280 × 820` 和最小窗口 `1080 × 680` 下,四页签横向滚动最大值均为 `0`,内容宽度等于视口宽度,未发现后代控件越界。
|
||||
|
||||
存在 1 类状态提示问题:测试仓储故意返回外诊单数据时,检查检验页和健康档案页已正确过滤这些数据,但把“已拒绝 1 条归属其他诊单的数据”呈现为红色 `error` 状态并显示“重新加载”。有效内容仍完整可见,安全边界也生效;建议后续改成 warning/info 提示,避免被误解为数据加载失败。
|
||||
|
||||
## 截图
|
||||
|
||||
- `01_case_records.png`:病历资料
|
||||
- `02_exam_tests.png`:检查检验
|
||||
- `03_prescriptions.png`:处方记录
|
||||
- `04_health_profile.png`:健康档案
|
||||
|
||||
截图均为 `1280 × 820`,按真实应用启动路径调用全局主题,字体为 `Microsoft YaHei UI`。
|
||||
|
||||
## 内容与控件核验
|
||||
|
||||
| 页签 | 核验结果 |
|
||||
| --- | --- |
|
||||
| 病历资料 | 文本 969 字符;`AiConsultCaseGrid` 1 个;中文集合已人类可读化;Python repr 标记 0 个。 |
|
||||
| 检查检验 | 文本 173 字符;时间线 1 个;舌苔缩略图 1 个;附件按钮 3 个,其中 PDF 报告 2 个;安全 HTTP(S) 按钮可用,本地 `file:` 附件按钮禁用。 |
|
||||
| 处方记录 | 文本 316 字符;处方卡 3 张;详情按钮 3 个;处方 ID 为 `5011 / 5012 / 5013`。 |
|
||||
| 健康档案 | 文本 443 字符;患者信息网格 1 个;本诊单健康概览 1 个;血糖/血压、饮食、运动三类跟踪记录均存在;手机号和身份证号已脱敏。 |
|
||||
|
||||
## 几何
|
||||
|
||||
- `1280 × 820`:记录面板约 `876 × 586`;有纵向滚动条时视口/内容宽度均为 `824`,检查检验页为 `834`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- `1080 × 680`:记录面板 `676 × 446`;有纵向滚动条时视口/内容宽度均为 `624`,检查检验页为 `634`;所有页签 `hbar maximum = 0`、后代越界量 `0`。
|
||||
- 长内容通过纵向滚动呈现:病历、处方、健康档案的默认窗口纵向最大值分别为 `1064 / 215 / 455`;未发现横向裁切。
|
||||
|
||||
## Smoke
|
||||
|
||||
执行:`uv run --offline pytest -q tests/test_ai_consult_workspace_ui.py`
|
||||
结果:`11 passed`。仅有 pytest 缓存目录无写权限警告,不影响测试结果。
|
||||
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -8,6 +8,21 @@ Both build scripts launch the frozen executable twice. `--media-smoke-test` is h
|
||||
|
||||
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
|
||||
|
||||
## Brand assets
|
||||
|
||||
The approved source artwork is kept byte-for-byte at `resources/branding/brand-master.png`.
|
||||
`app-icon.png`, the multi-size Windows `app-icon.ico`, the macOS `app-icon.icns`,
|
||||
the login-page `brand-lockup.png`, and the video companion favicon are deterministic
|
||||
derivatives of that master. Regenerate them after replacing the approved master:
|
||||
|
||||
```powershell
|
||||
uv run --no-project --with pillow==11.3.0 python scripts/generate_brand_assets.py
|
||||
```
|
||||
|
||||
The full lockup is reserved for large brand placements. Window, taskbar, shortcut,
|
||||
installer, uninstaller, Dock, and browser icons use the text-free pictorial mark so
|
||||
the identity remains legible at 16–64 pixels.
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
@@ -16,7 +31,15 @@ Run the build on the target operating system. PyInstaller cannot cross-build Win
|
||||
|
||||
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
|
||||
|
||||
For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass.
|
||||
For the one-click Windows installer, portable ZIP, and SHA-256 manifest, run `Build_DoctorWorkstation.bat` (or double-click `一键打包_医生工作站.bat`). It prepares locked dependencies, invokes the build/file/smoke gates, then compiles `DoctorWorkstation-Setup-Windows-x64-<version>.exe` with a pinned Inno Setup compiler. The compiler is downloaded from the official release on first use and accepted only after both its pinned SHA-256 and Authenticode signer pass validation. The portable ZIP is retained as a secondary artifact.
|
||||
|
||||
The current Python 3.11+/PySide6/Qt 6 runtime requires Windows 10 version 1809 or newer, so the installer declares `MinVersion=10.0.17763`. Do not lower that installer value to claim Windows 7 compatibility: Qt 6 does not support Windows 7. A real Windows 7 SP1 build requires a separately maintained legacy runtime (Python 3.8.10, PySide2/Qt 5.15, and a compatible freezer), plus clean Windows 7 SP1 VM validation for QtWebEngine, multimedia, installation, upgrade, and uninstall behavior.
|
||||
|
||||
After packaging, `scripts/smoke_windows_installer.ps1` silently installs the newest Setup artifact for the current user into an isolated temporary directory, runs the installed executable's smoke gate, silently uninstalls it, and verifies that the executable was removed. It intentionally retains only its small logs and isolated user-data directory under `%TEMP%` for diagnosis.
|
||||
|
||||
To publish an automatic Windows update, open **System settings → Doctor workstation update** in the admin site and select `Windows installer (Inno Setup EXE)`. Upload the generated `DoctorWorkstation-Setup-Windows-x64-<version>.exe` (or use an HTTPS CDN URL), then copy its SHA-256, byte size, and filename from the packaging output. New clients download and verify the installer, close themselves, run Inno Setup silently, and restart only after the installer succeeds. Existing ZIP metadata remains supported for older releases.
|
||||
|
||||
Automatic installer execution requires an HTTPS download with certificate verification (loopback development URLs are the only exception). Production Setup artifacts should also be Authenticode-signed before publication; the current local build can create an unsigned installer when no organization signing certificate is configured.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
|
||||
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
|
||||
RESOURCES = PROJECT_ROOT / "resources"
|
||||
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
|
||||
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
|
||||
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
|
||||
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
|
||||
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||
@@ -30,6 +32,10 @@ if not (VIDEO_DIST / "index.html").is_file():
|
||||
raise SystemExit("Build video_companion before running PyInstaller")
|
||||
if not MEDIA_SMOKE_HOOK.is_file():
|
||||
raise SystemExit(f"Frozen multimedia smoke hook is missing: {MEDIA_SMOKE_HOOK}")
|
||||
if sys.platform == "win32" and not WINDOWS_ICON.is_file():
|
||||
raise SystemExit(f"Windows application icon is missing: {WINDOWS_ICON}")
|
||||
if sys.platform == "darwin" and not MACOS_ICON.is_file():
|
||||
raise SystemExit(f"macOS application icon is missing: {MACOS_ICON}")
|
||||
|
||||
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
|
||||
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
|
||||
@@ -98,6 +104,7 @@ exe = EXE(
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
|
||||
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
|
||||
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
|
||||
version=str(VERSION_FILE) if sys.platform == "win32" else None,
|
||||
@@ -116,7 +123,7 @@ if is_macos:
|
||||
app = BUNDLE(
|
||||
collection,
|
||||
name="DoctorWorkstation.app",
|
||||
icon=None,
|
||||
icon=str(MACOS_ICON),
|
||||
bundle_identifier="com.zyt.doctor-workstation",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "甄养堂医生工作站",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef AppVersion
|
||||
#error AppVersion must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#error SourceDir must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#error OutputDir must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef SetupBaseName
|
||||
#error SetupBaseName must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef ChineseMessagesFile
|
||||
#error ChineseMessagesFile must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef AppIconFile
|
||||
#error AppIconFile must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
|
||||
#define AppName "甄养堂医生工作站"
|
||||
#define AppPublisher "ZYT"
|
||||
#define AppExecutableName "DoctorWorkstation.exe"
|
||||
#define AppIdValue "{{07D97DE8-3DF5-492D-AB2A-BE58FC1A040D}"
|
||||
|
||||
[Setup]
|
||||
AppId={#AppIdValue}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
DefaultDirName={autopf}\ZYT\DoctorWorkstation
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename={#SetupBaseName}
|
||||
SetupIconFile={#AppIconFile}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
PrivilegesRequiredOverridesAllowed=dialog commandline
|
||||
UsePreviousAppDir=yes
|
||||
UsePreviousPrivileges=yes
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
MinVersion=10.0.17763
|
||||
UninstallDisplayIcon={app}\{#AppExecutableName},0
|
||||
CloseApplications=yes
|
||||
RestartApplications=no
|
||||
SetupLogging=yes
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "{#ChineseMessagesFile}"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加快捷方式:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExecutableName}"; WorkingDir: "{app}"; IconFilename: "{app}\{#AppExecutableName}"; IconIndex: 0
|
||||
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExecutableName}"; WorkingDir: "{app}"; IconFilename: "{app}\{#AppExecutableName}"; IconIndex: 0; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExecutableName}"; Description: "启动 {#AppName}"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,173 @@
|
||||
# app AI 入口与患者上下文绑定审计(2026-08-21)
|
||||
|
||||
## 1. 范围与结论
|
||||
|
||||
本次只读审计覆盖当前工作树中的 `app/src/doctor_workstation`,重点追踪所有 AI 对话、结构化分析、诊断报告、患者纵向报告和处方库 AI 解释入口,向下核对到 `DoctorRepository` / `RemoteDoctorRepository` 的实际 HTTP 请求。为判断“服务端全量上下文”是否真实存在,额外只读核对了相应 `server/app/adminapi` 实现;没有修改生产代码或测试。
|
||||
|
||||
结论:
|
||||
|
||||
1. **没有发现生产环境下只携带 `prompt`、不携带任何资源 ID 的 HTTP 请求,也没有发现桌面端直连 OpenAI、千问、Dify 或携带 provider key/base URL 的路径。** 所有患者相关生成请求至少携带 `diagnosis_id`(线上字段名 `id`)或 `patient_id`;处方库解释携带 `template_id`(线上字段名 `id`)。
|
||||
2. **ID 绑定总体正确,但强度不一致。** 患者级报告链路对当前选择、请求和响应中的 `patient_id` 做了最严格的精确校验;AI 完整对话工作区也会用服务端诊单详情反查 `diagnosis_id`/`patient_id` 并过滤错归属数据。诊单报告和诊单结构化分析主要依赖“请求关联 + 服务端授权/DataScope”,桌面端不能从响应再次核对 `diagnosis_id`。
|
||||
3. **并非所有患者 AI 入口都走服务端“患者纵向全量上下文”。** 只有 `patientAiReports` / `generatePatientAiReport` 是服务端按 `patient_id` 聚合历次诊单、医生备注、跟踪、血糖、饮食、运动、IM/微信、通话和转写的纵向链路。`aiAssistant(Stream)`、`aiAnalysis`、诊单 `generateAiReports` 都是按单个 `diagnosis_id` 构造诊单表字段摘要。
|
||||
4. **存在明确的本地拼 prompt 路径。** `AiConsultDialog` 从多个桌面端请求结果中摘取最多 320 字的血糖、舌脉、视频转写、历史 AI 摘要、处方标题,拼到医生问题前,再受 500 字总限制截断。工作区未完成或加载失败时仍可发送,此时退化为“`diagnosis_id` + 原始问题”。该请求仍会进入第一方服务端并由服务端补入单诊单摘要,因此不是无权限的裸模型调用;但它不满足“患者上下文只能由服务端统一、全量组装”的要求。
|
||||
|
||||
## 2. 入口清单
|
||||
|
||||
| # | 可见入口 | 入口代码 | 最终 repository 方法 | 绑定键 | 上下文结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 主壳全局“AI 助手” | `ui/shell.py:1551-1565` → 全局患者诊单选择器 `ui/dialogs/ai_consult_picker.py:390-421` | `list_ai_patient_options` 后进入 `stream_diagnosis_ai` / `analyze_diagnosis_ai` | 选择器独立保存 `diagnosis_id`、`source_patient_id`,不允许 patient ID 回退为 diagnosis ID(`ai_consult_picker.py:72-119`) | 诊单级服务端摘要 + 桌面端局部上下文 |
|
||||
| 2 | “问诊列表/预约”工具栏“AI 分析” | `ui/pages/appointments.py:1578-1598` | 同上 | `diagnosis_id`;展示用 `patient_id` 只取 `source_patient_id` | 同上;存在旧字段兼容回退风险,见缺口 G3 |
|
||||
| 3 | “问诊列表/诊单”行操作“AI 分析” | `ui/pages/consultations.py:2980-3002` | 同上 | `diagnosis_id` 取 `diagnosis_id/id`,`patient_id` 取 `source_patient_id/patient_id` | 同上 |
|
||||
| 4 | “我的患者”行操作/按钮“AI 分析” | `ui/pages/patients.py:2706-2708,2732-2759` | 同上 | 明确禁止从 `patient_id` 回退为诊单;诊单取 `diagnosis_id/id` | 同上 |
|
||||
| 5 | 接诊台“AI 分析”对话工作区 | `ui/pages/reception.py:8650-8668` | 同上 | 从已加载详情的选择上下文取 `diagnosis_id`、`patient_id` | 同上;发送前不要求本地上下文已完成 |
|
||||
| 6 | 接诊台 AI 问诊助手快捷问题/输入框 | `ui/pages/reception.py:8620-8648` → `DiagnosisAiAssistantDialog` | `analyze_diagnosis_ai` | 仅 `diagnosis_id` + `prompt` + `task`;无 `patient_id` | 服务端单诊单摘要;无桌面端患者纵向上下文 |
|
||||
| 7 | 接诊台“AI 智能分析”自动加载、模型切换、重试、重新分析 | `ui/pages/reception.py:5265-6142,6954-6991` | 优先 `list_patient_ai_reports` / `generate_patient_ai_report`;权限/能力不足时回退 `get_diagnosis_ai_analysis` | 优先链路只传 `patient_id`;回退链路只传 `diagnosis_id` | 优先链路是服务端患者纵向全量;回退链路是单诊单摘要 |
|
||||
| 8 | 接诊台“AI 报告” | `ui/pages/reception.py:8680-8721` | `list_diagnosis_ai_reports` / `generate_diagnosis_ai_reports` / `edit_diagnosis_ai_report` | `diagnosis_id` | 服务端单诊单报告,不是患者纵向报告 |
|
||||
| 9 | 预约页“AI 报告” | `ui/pages/appointments.py:1559-1576` | 同上 | 页面先把解析出的诊单号覆盖写入 payload 的 `id` 和 `diagnosis_id` | 服务端单诊单报告 |
|
||||
| 10 | 诊单详情内“AI 报告” | `ui/dialogs/diagnosis.py:1897-1912` | 同上 | 当前详情的 `_diagnosis_id` 同时写入 `id`、`diagnosis_id` | 服务端单诊单报告 |
|
||||
| 11 | 处方库“AI解释” | `ui/pages/prescription_library.py:734-740` | `list_prescription_template_ai_reports` / `generate_prescription_template_ai_reports` / `edit_prescription_template_ai_report` | `template_id`,线上字段 `id` | 非患者入口;服务端只分析模板药材组合 |
|
||||
| 12 | AI 分析历史详情弹窗 | `ui/pages/reception.py:6140-6153` | 不发请求 | 使用已校验缓存 | 纯展示,无新增上下文风险 |
|
||||
|
||||
补充:AI 对话中“开个处方”等明确指令会被 `_handle_local_action` 拦截,重新读取当前诊单详情并打开处方编辑器,不会发 AI 请求(`ui/dialogs/ai_consult.py:4881-4935`)。
|
||||
|
||||
## 3. Repository 请求矩阵
|
||||
|
||||
生产实现集中在 `services/repository.py`,`services/remote_repository.py` 只是兼容导出;未发现其他 AI HTTP 实现。
|
||||
|
||||
| Repository 方法 | HTTP | 请求体/查询 | 患者标识 | 本地校验 |
|
||||
|---|---|---|---|---|
|
||||
| `list_ai_patient_options` | GET `tcm.diagnosis/aiPatientOptions` | `page_no,page_size,keyword` | 返回独立 `diagnosis_id`、`source_patient_id` | DTO 清洗;入口再分离两种 ID(`repository.py:2029-2044`) |
|
||||
| `list_prescription_template_ai_reports` | GET `tcm.prescriptionLibrary/aiReports` | `id=template_id` | 不适用 | repository 未显式正数校验(`repository.py:1267-1277`) |
|
||||
| `generate_prescription_template_ai_reports` | POST `tcm.prescriptionLibrary/generateAiReports` | `id=template_id` | 不适用 | 同上(`repository.py:1279-1289`) |
|
||||
| `edit_prescription_template_ai_report` | POST `tcm.prescriptionLibrary/editAiReport` | `id,report_id,content` | 不适用 | 同上(`repository.py:1291-1307`) |
|
||||
| `list_diagnosis_ai_reports` | GET `tcm.diagnosis/aiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1309-1319`) |
|
||||
| `generate_diagnosis_ai_reports` | POST `tcm.diagnosis/generateAiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1321-1331`) |
|
||||
| `edit_diagnosis_ai_report` | POST `tcm.diagnosis/editAiReport` | `id,report_id,content` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1333-1349`) |
|
||||
| `analyze_diagnosis_ai` | POST `tcm.diagnosis/aiAssistant` | `id, prompt, task` | `diagnosis_id` | 要求正数诊单、非空且 ≤500 字问题、任务白名单(`repository.py:1351-1371,2803-2823`) |
|
||||
| `stream_diagnosis_ai` | SSE POST `tcm.diagnosis/aiAssistantStream` | `id, prompt, task` | `diagnosis_id` | 同上;首个 delta 前失败时最多回退一次非流式助手(`repository.py:1373-1428`) |
|
||||
| `get_diagnosis_ai_analysis` | POST `tcm.diagnosis/aiAnalysis` | `id,model` | `diagnosis_id` | 正数诊单、模型白名单(`repository.py:1430-1450`) |
|
||||
| `list_patient_ai_reports` | GET `tcm.diagnosis/patientAiReports` | `patient_id` | `patient_id` | 正数患者 ID(`repository.py:1452-1464`) |
|
||||
| `generate_patient_ai_report` | POST `tcm.diagnosis/generatePatientAiReport` | `patient_id,model` | `patient_id` | 正数患者 ID、模型白名单(`repository.py:1466-1488`) |
|
||||
|
||||
安全边界:助手请求体只有 `id/prompt/task`,结构化分析只有 `id/model`,患者报告只有 `patient_id/model`;未携带 `key/api_key/base_url/provider/model` 等上游配置(模型键仅出现在固定白名单分析/报告接口)。
|
||||
|
||||
## 4. ID 绑定与归属校验
|
||||
|
||||
### 4.1 AI 完整对话工作区
|
||||
|
||||
- `present_ai_consult` 拒绝非正数 `diagnosis_id`(`ai_consult.py:5353-5378`)。
|
||||
- 打开后先按该诊单请求只读详情,再从详情中解析权威 `patient_id`。如果详情返回的诊单 ID 不完全等于当前诊单,整个详情及关联备注、处方、跟踪资料被过滤;如果入口传入的 patient ID 与详情不一致,停止患者报告请求(`ai_consult.py:3703-3802,3833-3907`)。
|
||||
- IM 消息、备注、处方要求每行明确携带当前 `diagnosis_id`;跟踪记录也必须声明当前诊单归属(`ai_consult.py:1514-1543,3866-3907`)。
|
||||
- 患者报告响应会递归检查所有已声明的 `patient_id`(`ai_consult.py:1546-1564,3769-3781`)。
|
||||
- 每次发送最终都把当前 `self.diagnosis_id` 交给 `_AiStreamWorker`(`ai_consult.py:5171-5230`)。因此即使本地患者上下文为空,也不是 prompt-only 请求。
|
||||
|
||||
### 4.2 接诊台患者级报告
|
||||
|
||||
这是 app 中最强的绑定实现:
|
||||
|
||||
- 请求前同时锁定 generation、appointment 和当前选择的 `patient_id`(`reception.py:5062-5073,5265-5352`)。
|
||||
- GET 响应要求顶层和每条 report 的 `patient_id` 都是精确正数且等于请求值;POST 还要求 `generated_report.id` 为正数、`model_key` 与请求一致(`reception.py:1865-1993`)。
|
||||
- A→B、A→B→A、迟到响应、旧 GET 覆盖新 POST 等并发情况都有单飞、取消和 mutation epoch 保护(`reception.py:5135-5164,5376-5452,5503-5731`)。
|
||||
- 该接口按患者聚合,因此请求不带单一 `diagnosis_id` 是正确契约,不是遗漏。服务端会记录全部来源诊单集合和最新诊单 ID。
|
||||
|
||||
### 4.3 接诊台诊单级回退分析
|
||||
|
||||
- 只有在患者报告权限/方法/患者 ID 条件不满足时才走 `get_diagnosis_ai_analysis`(`reception.py:5788-5853`)。
|
||||
- 请求前后都校验 generation、appointment、当前选择的 diagnosis,且响应模型必须与请求模型相同(`reception.py:5049-5060,5855-6028`)。
|
||||
- 响应契约没有返回 `diagnosis_id`,因此桌面端只能依赖异步请求关联,无法做响应所有者复核。
|
||||
|
||||
### 4.4 报告弹窗
|
||||
|
||||
`PrescriptionAiReportDialog` 通过 `AiReportKind` 把诊单、模板分别路由到正确 repository 方法,生成和读取只使用实体 ID(`prescription_ai.py:453-510,780-828,909-929`)。但 `_apply_reports` 直接接受报告数组和 capabilities,不核对响应中的 `diagnosis_id`/`prescription_id`(`prescription_ai.py:856-891`);该层完全信任服务端返回与请求 ID 对应。
|
||||
|
||||
## 5. 上下文路径判定
|
||||
|
||||
### 5.1 真正的服务端患者纵向全量路径
|
||||
|
||||
接诊台优先使用患者级报告。服务端 `PatientAiReportLogic` 明确按 `patient_id` 查询当前数据域内的全部有效诊单,并聚合诊单、doctor notes、tracking notes、blood、diet、exercise、IM、微信、通话和 transcript segments(`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:17-22,254-297,335-395`)。生成请求只接受 `patient_id` 和固定模型,完整来源快照留在服务端(同文件 `126-218`)。
|
||||
|
||||
### 5.2 诊单级服务端上下文
|
||||
|
||||
`aiAssistant(Stream)`、`aiAnalysis`、诊单报告都先按 `diagnosis_id` 做权限和 DataScope 校验,再由服务端构建脱敏病例摘要;不是裸 prompt 调模型(`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:320-378,470-515,551-590`)。但是其 `buildCaseContext` 只整理当前诊单表中的生命体征和 `CASE_FIELDS`,不会查询 doctor notes、tracking、血糖历史、饮食、运动、IM/微信、通话/转写等关联表(同文件 `211-253,881-953`)。因此它是“单诊单完整字段”,不是患者纵向全量。
|
||||
|
||||
### 5.3 桌面端本地拼 prompt
|
||||
|
||||
`AiConsultDialog` 的本地 envelope 明确存在:
|
||||
|
||||
- 上限 320 字,来源只有每日血糖摘要、舌苔/脉象、第一条视频转写、历史 AI 报告摘要和最多三条处方标题(`ai_consult.py:2687-2694,2703-2898`)。
|
||||
- envelope 与医生问题拼成最多 500 字的 `prompt`,超限时优先保留上下文、截断医生问题(`ai_consult.py:2901-2928`)。
|
||||
- 工作区加载失败时 UI 明示“可先根据已有信息提问”,此时 `_patient_ai_context` 可能为空(`ai_consult.py:3810-3831`);`_compose_ai_prompt` 在上下文为空时直接返回问题(`ai_consult.py:2915-2919`)。
|
||||
- 每次发送仍携带当前 `diagnosis_id`,并由服务端再次加入单诊单摘要(`ai_consult.py:5171-5230`)。
|
||||
|
||||
判定:这不是绕过第一方服务端或无 ID 调用,但**确实绕过了“由服务端作为唯一来源统一组装患者纵向上下文”的架构要求**。本地 envelope 是不完整、截断且可能暂时为空的第二套上下文实现;它还把本可用于医生问题的 500 字预算占掉。
|
||||
|
||||
### 5.4 仅原始问题的 UI 路径
|
||||
|
||||
接诊台轻量 `DiagnosisAiAssistantDialog` 直接发送 `diagnosis_id + prompt + task`,不拼患者报告或桌面工作区上下文(`prescription_ai.py:1427-1454`)。这是“仅原始问题 + diagnosis ID”,不是“仅 prompt”。其安全性依赖服务端按 diagnosis ID 补入单诊单摘要;如果产品要求患者纵向资料,则该路径不达标。
|
||||
|
||||
## 6. 缺口与风险
|
||||
|
||||
### G1 — 高:患者对话上下文存在第二套桌面端拼装,且不是全量
|
||||
|
||||
AI 完整对话把最多 320 字的局部资料拼入问题;快速发送、加载失败或最小病历时可退化为空。本地实现与服务端 `DiagnosisAiLogic::buildCaseContext` 并存,两者字段、更新时机和截断规则不同,容易产生遗漏或矛盾。若目标是“每次患者发消息均由服务端使用完整、权威上下文”,当前实现不满足。
|
||||
|
||||
建议:服务端提供唯一的 diagnosis/patient-scoped assistant context 聚合器;桌面只发送 `diagnosis_id`(必要时另传经验证的 `patient_id`)和原始医生问题。返回可审计的 `context_version/source_summary/source_diagnosis_ids`,UI 展示服务端声明而不是展示客户端自拼文本。
|
||||
|
||||
### G2 — 中高:轻量助手和诊单级分析/报告不是患者纵向全量
|
||||
|
||||
轻量助手、诊单分析、诊单报告都正确绑定 `diagnosis_id`,也经过服务端授权;但上下文只来自单个诊单字段。接诊台有患者报告权限时会优先使用真正的患者纵向报告,缺少该权限时则回退为单诊单分析。产品若把这些入口统称为“患者分析”,应显式区分“本诊单分析”与“患者纵向分析”,或统一到患者级聚合服务。
|
||||
|
||||
### G3 — 中:预约页仍把 `patient_id` 当作诊单 ID 的兼容回退
|
||||
|
||||
`appointments.py:382-394` 在缺少显式 `diagnosis_id` 时把 `patient_id` 作为 diagnosis ID,同时真正患者 ID 只接受 `source_patient_id`。这符合旧 admin 预约行的历史语义,但与规范化 DTO 中 `patient_id` 表示真实患者的常见语义冲突。如果未来接口只返回真实 `patient_id` 而漏掉 `diagnosis_id`,可能把患者 ID 当诊单 ID 发给 AI;若数值恰好命中另一个可访问诊单,仅靠正数/权限校验无法识别语义错绑。
|
||||
|
||||
建议:AI 入口必须要求显式 `diagnosis_id`;旧接口兼容应在 repository DTO 适配层一次性完成,并用契约版本或独立字段证明,不要在 UI 回退。
|
||||
|
||||
### G4 — 中:诊单/处方库 AI 报告弹窗不校验响应所有者
|
||||
|
||||
患者级报告会严格核对响应中的 `patient_id`,AI 工作区也过滤错诊单数据;但通用报告弹窗直接接收 reports。服务端当前会返回 `diagnosis_id`/`prescription_id`,桌面端应拒绝缺失或不匹配的所有者,并在生成、编辑后同样校验,避免代理缓存、服务端回归或测试替身把 A 的报告显示在 B 上。
|
||||
|
||||
### G5 — 低:部分报告 repository 方法缺少一致的正数 ID 前置校验
|
||||
|
||||
助手、结构化分析、患者报告均在 repository 层验证正数 ID;诊单/处方库报告的 list/generate/edit 没有同级校验。UI 通常会拦截无 ID,因此当前主要是防御一致性和未来非 UI 调用风险。
|
||||
|
||||
## 7. 现有测试覆盖与缺口
|
||||
|
||||
### 已覆盖
|
||||
|
||||
- `test_ai_patient_options_repository.py`:专用选择器 endpoint、分页、脱敏 DTO、diagnosis/patient ID 分离。
|
||||
- `test_ai_consult_picker_ui.py`:选择器不自动选中、搜索/迟到响应、接受后才打开、最小脱敏 seed。
|
||||
- `test_ai_consult_workspace_ui.py`:四个资料页均使用选中诊单;seed 不可替换权威 patient ID;错详情/错 patient report/无 owner 的备注、处方、IM、tracking 均 fail closed;A/B 迟到结果隔离。
|
||||
- `test_ai_consult_ui.py`:四个页面入口传递 501/301;完整对话流式顺序、取消;本地上下文的来源过滤、320/500 字截断和拼接;处方本地动作重新核对当前诊单。
|
||||
- `test_patient_ai_report_desktop.py`:patient-only HTTP 契约、精确顶层/行 patient ID 校验、POST 新快照校验、权限组合、A/B/A 单飞与旧 GET/新 POST 并发保护。
|
||||
- `test_prescription_ai_ui.py`:诊单/处方库报告方法路由、权限、生成/编辑;轻量助手精确发送 `diagnosis_id/prompt/task`。
|
||||
- `test_repository_parity.py`:所有 AI endpoint 和 DTO、无 provider 配置、SSE 正常化与单次回退、诊单分析模型白名单。
|
||||
- `test_reception_parity_ui.py`:诊单分析自动加载、Qwen→OpenAI 顺序、迟到结果丢弃、详情失败停止 AI、患者报告完成态和权限回退。
|
||||
- `test_api_client.py`:SSE 请求体、事件顺序、HTTP 行为。
|
||||
|
||||
本次执行:
|
||||
|
||||
```text
|
||||
211 collected tests across the 9 files above
|
||||
211 passed
|
||||
```
|
||||
|
||||
命令使用 `PYTHONDONTWRITEBYTECODE=1` 和 `-p no:cacheprovider`,未写入生产代码或测试。
|
||||
|
||||
### 未覆盖/覆盖不足
|
||||
|
||||
1. 没有测试“工作区仍在加载时立即发送”或“工作区失败后发送”时,断言请求退化为原始问题并验证产品是否允许。
|
||||
2. 当前测试 `test_ask_prepends_patient_context_to_the_ai_prompt` 固化了本地拼 prompt 行为;没有相反的架构契约测试,确保患者上下文只能由服务端组装。
|
||||
3. 没有诊单/处方库报告响应 `diagnosis_id`/`prescription_id` 缺失或错配时 fail closed 的测试。
|
||||
4. 没有预约行“缺少 diagnosis_id、但 patient_id 是真实患者 ID”时拒绝打开 AI 的测试;现有测试只覆盖显式 501/301 分离。
|
||||
5. 诊单级 `aiAnalysis`/assistant 响应本身不返回 `diagnosis_id`,因此目前无法写真正的响应归属断言;只能测试异步请求关联。
|
||||
6. app 测试证明请求 ID 和 UI 并发安全,但没有端到端断言服务器实际采用了患者纵向 source summary。该契约目前只在 server 侧测试/实现中可见。
|
||||
|
||||
## 8. 建议验收标准
|
||||
|
||||
1. 所有患者对话接口只接受 `diagnosis_id`/`patient_id` + 原始用户问题/任务,不接受桌面端病例 envelope。
|
||||
2. 服务端返回 `context_scope`(`diagnosis` 或 `patient_longitudinal`)、`context_version`、`source_diagnosis_ids`、`source_summary`;桌面展示该信息并验证 owner。
|
||||
3. “患者纵向分析”必须传 `patient_id`,服务端按 DataScope 聚合;“本诊单分析”必须显式标注,并只传 `diagnosis_id`。
|
||||
4. 所有 AI response DTO 都回显 owner;app 对 owner 缺失、类型不精确、错配统一 fail closed。
|
||||
5. 预约、诊单、患者三种 ID 在 DTO 层分离;UI 禁止 `patient_id -> diagnosis_id` 语义回退。
|
||||
6. 新增上述六项测试缺口,并保留现有迟到响应、A/B/A 和单飞测试。
|
||||
@@ -0,0 +1,49 @@
|
||||
# AI 全量患者上下文与处方闭环(2026-08-21)
|
||||
|
||||
## 范围
|
||||
|
||||
本次改造覆盖 `app` 医生工作台实际调用的患者级 AI 入口:AI 问诊助手(普通与流式)、AI 智能分析、诊断报告生成、患者纵向报告,以及新增的 AI 处方草稿。客户端不再自行拼装或截断患者资料,统一由服务端在完成诊单权限校验后聚合。
|
||||
|
||||
## 全量上下文覆盖矩阵
|
||||
|
||||
| 必须资料 | 权威数据源 | 发给 AI 的内容 |
|
||||
| --- | --- | --- |
|
||||
| 当前信息、现病史、病例/病历 | `tcm_diagnosis`(同一稳定患者全部未删除诊单) | 结构化字段与完整纵向记录 |
|
||||
| 医生备注 | `tcm_doctor_note` | 备注正文、分类、时间及附件 |
|
||||
| 舌苔/舌象 | `tcm_doctor_note.tongue_images` 等附件字段 | 文字描述与原图文件 |
|
||||
| 报告信息 | 医生备注、诊单等记录中的报告附件 | 元数据与报告原文件 |
|
||||
| 每日视频面诊文字 | `tcm_call_record`、`tcm_call_transcript_segment` | 完整转写文本;有录像地址时同时发送视频文件 |
|
||||
| 日常记录 | `tcm_blood_record`、`patient_diet_record`、`patient_exercise_record` | 血糖/血压/用药、饮食、运动等完整明细 |
|
||||
| 聊天与随访 | IM/微信消息与关联随访记录 | 可用的完整文本和附件 |
|
||||
| 正式处方与记录病历 | `tcm_prescription` | 处方药味、剂量、用法、处方病历、审核状态 |
|
||||
|
||||
聚合结果不再使用客户端 320/500 字符截断,也不再只取当前诊单。超长纵向报告仍可分段送入模型,但不会在分段前丢弃资料。图片、文档、音频和视频通过上游文件参数发送;无法读取的附件要求模型明确说明,不能臆测其内容。
|
||||
|
||||
## AI 处方闭环
|
||||
|
||||
1. 医生在 AI 问诊助手选择“AI 生成处方”。
|
||||
2. 服务端使用同一份全量纵向上下文生成严格 JSON 处方草稿。
|
||||
3. 客户端只接受诊断、药味剂量、用法、辨证与风险说明;患者身份、诊单、预约、医生身份不得由模型覆盖。
|
||||
4. 草稿必须进入现有处方编辑器,由医生复核并签名。
|
||||
5. 保存后始终进入 `audit_status = 0` 的待审核状态,沿用现有审核发布流程;AI 不能伪造签名或绕过审核直接生效。
|
||||
|
||||
## 权限与数据完整性
|
||||
|
||||
- 服务端先按“我的患者”权限校验入口诊单,再按稳定患者 ID 聚合同一患者历史。
|
||||
- 处方新增时再次校验诊单归属,并由服务端回填患者、预约、医生和病例字段,忽略客户端对权威身份字段的伪造。
|
||||
- 处方编辑仅允许创建者,诊单关联不可被改写。
|
||||
- 关键处方写接口在菜单配置缺失时不再默认放行。
|
||||
- 含文档/音视频的请求不会静默降级为不带附件的文本请求。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 桌面端 AI/处方相关回归:244 passed。
|
||||
- 桌面端重点 AI/仓储/处方回归:116 passed。
|
||||
- 服务端 AI 上下文、权限、流式、快照、处方、安全与配置契约测试:通过。
|
||||
- 所有本次涉及的 PHP 与 Python 文件语法检查:通过。
|
||||
|
||||
## 部署验收
|
||||
|
||||
- 配置可接收文件输入的 Dify 工作流,并确保模型/工作流能够读取所需附件类型。
|
||||
- 使用包含舌象、报告、转写、日常记录和历史处方的真实患者,检查 Dify 调用日志中的文本上下文和 `files`。
|
||||
- 让 AI 生成处方,确认医生签名不可省略、保存后为待审核、患者身份不能被请求参数篡改。
|
||||
@@ -0,0 +1,241 @@
|
||||
# AI 对话/报告直接开方链路审计(2026-08-21)
|
||||
|
||||
## 结论
|
||||
|
||||
**当前不能从 AI 的回答或 AI 报告“一键形成并提交处方”。** 当前工作树实现的是另一条链路:医生在 AI 对话框输入一条明确的本地命令(如“开个处方”),桌面端不请求 AI,而是重新核对诊单/患者后打开通用处方编辑器;医生仍需人工填写药材、剂量、用法和手写签名,点击确认后才调用真实创建接口,服务端保存为 `audit_status = 0` 的待审核处方。
|
||||
|
||||
因此应区分三种能力:
|
||||
|
||||
| 能力 | 当前状态 | 判断 |
|
||||
|---|---|---|
|
||||
| AI 输出诊断/风险/用药建议文本 | 已有 | 助手返回 `answer` 文本;结构化分析仅有诊断建议、风险和治疗建议 |
|
||||
| 从 AI 对话入口手工新建并提交待审核处方 | 部分可用 | 输入特定命令可打开编辑器,人工完成后调用 `tcm.prescription/add` |
|
||||
| 把 AI 生成的药味、剂量、用法直接转换成处方草稿/一键提交 | 不存在 | 无处方草稿 schema、无 AI 结果到编辑器的字段映射、无“采用为处方”按钮,也无服务端 AI 开方接口 |
|
||||
| AI 直接生成已审核/生效处方 | 不存在,且不应建设成无人工复核链路 | 创建接口强制待审核;审核由独立权限和角色控制 |
|
||||
|
||||
## 审计范围与验证
|
||||
|
||||
- 审计当前工作树中的桌面端 Python/PySide6、repository/API、PHP controller/logic/validate、权限及数据绑定。
|
||||
- 未修改生产代码或测试,只新增本报告。
|
||||
- 已运行:`uv run pytest tests/test_ai_consult_ui.py tests/test_prescription_ui.py -q`,67 项通过。
|
||||
- 已运行:`DiagnosisAiAssistantContractTest.php`、`DiagnosisAiAssistantStreamContractTest.php`、`DiagnosisWorkspaceRowAuthorizationTest.php`,均通过。
|
||||
- 仓库根目录没有 AGENTS.md 声明的 `.trellis/workflow.md` 和 `.trellis/spec/`,故无法应用缺失的 Trellis 分层规范;本报告按现有实现和测试取证。
|
||||
|
||||
## 端到端链路
|
||||
|
||||
### 1. AI 返回结构:只能给建议,不能形成处方 DTO
|
||||
|
||||
服务端病例助手的返回契约只有 `answer/model_key/model_label/model_name/task`,其中 `answer` 是清洗后的纯文本;没有 `herbs`、`medicine_id`、`dosage`、`usage_*` 或可执行 action。证据:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:276-307`:助手调用上游并交给 `formatAssistantResult()`。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:419-437`:最终响应只有 `answer` 和模型/任务元数据。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:64-104`:`prescription_review` 仅定义为“分析处方/用药并提示复核重点”,不是生成处方。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1084-1106`:提示词要求“简洁、分点的专业回答”和执业医师复核,没有处方 JSON schema。
|
||||
|
||||
另一路结构化 `aiAnalysis` 也只能返回 `diagnosis_advice`、`risk_assessment`、`treatment_advice`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1035-1069`:模型被要求输出的唯一 JSON schema 不包含处方字段。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1281-1352`:解析器严格只接受上述三类业务字段。
|
||||
|
||||
桌面端流式处理也只拼接 `delta.text` 并渲染答案,不解析处方动作或草稿:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5243-5282`:`start/delta/done` 只更新文本和模型标签。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5284-5289`:流式片段直接拼成 `_stream_text`。
|
||||
|
||||
### 2. 桌面 AI UI:有“命令开编辑器”,没有“AI 结果转处方”
|
||||
|
||||
AI 入口和患者选择已接通:
|
||||
|
||||
- `app/src/doctor_workstation/ui/shell.py:1103-1122,1551-1565`:有全局“AI 助手/开始对话”入口,并先进入患者诊单选择器。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult_picker.py:57-119`:选择对象分别保存 `diagnosis_id` 与 `source_patient_id`,不会把诊单主键误当患者主键,只保留掩码手机号。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult_picker.py:274-298,390-419`:通过专用 repository 拉取权限范围内诊单,再携带诊单/患者上下文打开 AI 工作区。
|
||||
|
||||
所谓“开方”实际是本地意图拦截:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:2931-2967`:仅识别不超过 24 字的明确命令;带“怎么/是否/建议/分析/复核/审核”等词时不会触发。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5171-5179`:本地动作发生在 `_compose_ai_prompt()` 和 AI worker 之前,命中后直接返回,不会调用模型。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:4881-4919`:本地检查 `tcm.diagnosis/chufang` 或 `tcm.diagnosis/kaifang`,再开始诊单核对。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:4921-5001`:重新读取只读诊单详情,严格比对诊单 ID 和当前会话患者 ID。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5003-5044`:仅从权威诊单构造患者/诊断种子,没有从 AI 答案提取药材。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5055-5105`:打开通用 `PrescriptionEditorDialog`;只有对话框返回 Accepted 后才调用 `create_prescription`。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 输入“怎么开方更合理”会得到 AI 文本建议,但该回答没有“采用为处方”入口。
|
||||
- 输入“开个处方”不会让 AI 开方,只会打开人工编辑器。
|
||||
- 即便在命令里写药名和剂量,当前代码也不会解析或带入编辑器。
|
||||
- UI 没有可发现的“开方”快捷按钮;现有快捷指令都是病情总结、用药建议、检查建议等(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:762-783,3189-3215`)。
|
||||
|
||||
### 3. 人工编辑与提交:接口已复用,但仍是完整人工处方流程
|
||||
|
||||
通用处方编辑器可复用程度较高:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:2652-2765`:完整新增/编辑处方表单。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:2910-3007`:患者、诊断、诊单提示等表单字段。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3015-3042`:可从处方库或文本导入药材;这不是 AI 回答映射。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3205-3227`:医师姓名和手写签名是必填 UI。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3770-3831`:提交前校验患者、临床诊断、医师、手写签名、至少一味药材、药材主数据选择和正剂量。
|
||||
|
||||
repository/API 已接真实端点:
|
||||
|
||||
- `app/src/doctor_workstation/services/repository.py:1563-1572`:`create_prescription()` POST `tcm.prescription/add`。
|
||||
- `app/src/doctor_workstation/services/repository.py:1574-1593`:`update_prescription()` POST `tcm.prescription/edit`。
|
||||
- `app/src/doctor_workstation/services/repository.py:1643-1649`:按诊单刷新 `tcm.prescription/listByDiagnosis`。
|
||||
- `app/src/doctor_workstation/services/repository.py:3249-3279`:repository 只做浅层 DTO 规范化,不承担患者/诊单一致性校验。
|
||||
|
||||
### 4. 服务端创建与审核:创建即待审,不等于审核通过
|
||||
|
||||
服务端创建流程已有一些正确的安全边界:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:27-35`:控制器忽略客户端 `creator_id`,以当前 `$adminId` 调用创建逻辑。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:220-245`:限制同诊单、同开方人、同日只能有一张未作废处方。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:268-278`:要求药材数组并通过药材主数据解析。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:332-340`:忽略客户端审核状态,强制 `audit_status = 0`,创建人与诊单医助由服务端写入。
|
||||
|
||||
“提交审核”只是保存一条待审核记录;AI 链路不会调用审核接口。真正审核是独立动作:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:102-129`:审核需另行调用 `audit`。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:55-73,796-840`:审核还需允许角色、对象可见性和待审状态;通过后才变为 `audit_status = 1`。
|
||||
|
||||
## 主要缺口与风险
|
||||
|
||||
### P1 / 高:创建接口没有诊单写权限和行级数据域校验
|
||||
|
||||
`PrescriptionLogic::add()` 在有 `diagnosis_id` 时只做 `Diagnosis::find()` 存在性检查,没有复用 `DiagnosisLogic::canManageDiagnosis()` 或 `canViewReadonlyDiagnosis()`,controller 也没有传入 `$adminInfo`:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:27-35`
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:251-266`
|
||||
- 可复用的写权限入口已经存在于 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4458-4468`。
|
||||
|
||||
桌面 AI 流程虽然会先走只读详情并做客户端 ID 比对,但这不是服务端写操作授权。调用者只要能到达 `tcm.prescription/add`,就可能对一个仅知道 ID、但不在其可管理范围内的诊单创建处方。医疗数据完整性和越权写入风险都应由服务端兜底。
|
||||
|
||||
**建议:** `add()` 接收 `$adminInfo`,在任何读取患者/预约信息和写入前调用 `DiagnosisLogic::canManageDiagnosis($diagnosisId, $adminId, $adminInfo)`;不存在和越权统一报错,避免枚举。AI 页面可继续保留客户端核对作为 UX 防误操作,但不能代替服务端鉴权。
|
||||
|
||||
### P1 / 高:诊单、预约、患者和患者快照没有权威一致性校验
|
||||
|
||||
创建逻辑只验证诊单存在,随后直接信任客户端提交的 `appointment_id`、`patient_id`、`patient_name`、`phone` 和病例快照:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:251-260`:只查诊单是否存在。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:291-315`:诊单、预约、患者 ID 和患者快照直接来自 `$params`。
|
||||
- `server/database/migrations/create_tcm_prescription.sql:5-7,27-31`:三个关系字段只有普通索引,没有外键约束。
|
||||
|
||||
因此直接 API 请求可构造“诊单 A + 预约 B + 患者 C + 姓名 D”的处方。即便 UI 正常使用,也存在下一项实际丢字段问题。
|
||||
|
||||
**建议:** 创建时仅接受 `diagnosis_id` 和处方临床字段;由服务端基于授权诊单解析并写入 `appointment_id/patient_id/patient_name/phone/gender/age`,对病例快照使用服务端当前诊单生成。若必须允许修正患者打印信息,应走现有独立 `patchPatient` 权限和审计链路。
|
||||
|
||||
### P1 / 高:桌面处方编辑器会丢失 `patient_id` 和 `phone`
|
||||
|
||||
AI 入口的种子包含患者 ID 和电话:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5021-5030`
|
||||
|
||||
但 `PrescriptionEditorDialog.payload()` 的保留字段没有 `patient_id`、`appointment_id`、`phone`、`case_record`,表单输出也没有这些字段:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3692-3758`
|
||||
- 领域模型 `Prescription` 本身也没有 `patient_id` 字段:`app/src/doctor_workstation/core/models.py:577-588`。
|
||||
|
||||
AI 流程在确认后只强制补回 `diagnosis_id`、`appointment_id` 和 `case_record`,没有补回 `patient_id` 或 `phone`:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5078-5093`
|
||||
|
||||
服务端对缺失值使用 `patient_id = 0`、`phone = ''`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:300-307`
|
||||
|
||||
所以从 AI 对话新建的处方虽然能按 `diagnosis_id` 找到,但患者 ID/手机号快照会为空。诊单详情里的既有手工开方流程采用同样的 payload 回填方式,也有同类问题(`app/src/doctor_workstation/ui/dialogs/diagnosis.py:3085-3104,3117-3127`)。
|
||||
|
||||
现有 AI UI 测试只断言强制回填了诊单、预约和病例快照,没有断言 `patient_id/phone`:`app/tests/test_ai_consult_ui.py:293-305`。
|
||||
|
||||
**建议:** 短期在编辑器 DTO 中不可编辑地保留 `patient_id/phone/appointment_id/case_record` 并补测试;最终仍应由服务端从诊单权威派生,避免信任客户端快照。
|
||||
|
||||
### P1 / 高:编辑接口允许关系漂移,且共享处方的对象级编辑边界过宽
|
||||
|
||||
服务端编辑时允许客户端提供新的 `diagnosis_id`,但没有验证新诊单存在、调用者能管理新诊单,也不会同步/校验 `patient_id`、`appointment_id` 和 `phone`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:388-425`:新诊单 ID 用于唯一性检查,但没有授权/存在性检查。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:438-485`:保存新 `diagnosis_id`,患者 ID、预约 ID、电话和病例快照不在更新集合中。
|
||||
|
||||
同时对象级编辑规则是“创建者或 `is_shared = 1`”,即任何能到达编辑端点的用户都可编辑共享处方:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:408-412`。这会让处方既可能被重新绑定到其他诊单,又可能保留旧患者关系字段。
|
||||
|
||||
**建议:** 编辑禁止修改关系字段;若确需迁移,使用专用、强审计接口并同时校验新诊单写权限和重建全部患者快照。共享应只扩大读取范围,不应自动扩大编辑权。
|
||||
|
||||
### P1 / 高(部署相关):桌面权限码与真实 API 路由没有统一的服务端别名契约
|
||||
|
||||
AI UI 以 `tcm.diagnosis/chufang` 或 `tcm.diagnosis/kaifang` 判断可开方(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:4891-4901`),独立已开处方页则使用 `cf.prescription/add|edit`(`app/src/doctor_workstation/ui/pages/prescriptions.py:558-565,676-683`),但 repository 最终调用的是 `tcm.prescription/add|edit`。
|
||||
|
||||
`AuthMiddleware` 的通用规则是:如果真实路由不在全量菜单 URI 中就直接放行;若已注册则要求真实路由或显式别名:
|
||||
|
||||
- `server/app/adminapi/http/middleware/AuthMiddleware.php:70-92`
|
||||
- 现有别名仅为“处方库列表导入”覆盖多套权限,不包含 `tcm.prescription/add|edit` 的开方别名:`server/app/adminapi/http/middleware/AuthMiddleware.php:169-202`。
|
||||
|
||||
本仓库没有找到为 `tcm.prescription/add|edit` 注册并分配权限的版本化 SQL,故实际安全性依赖部署数据库里是否已有旧菜单记录:
|
||||
|
||||
- 若未注册,middleware 的第 77-82 行会 fail-open。
|
||||
- 若注册但未分配真实路由,拥有 `chufang/kaifang/cf.prescription/add` 的桌面用户可能被 403。
|
||||
|
||||
**建议:** 选择一套 canonical 权限,版本化注册真实路由,并在 middleware 对兼容码做双向、可测试的精确别名;未知业务写路由应 fail-closed,而不是因为未注册就绕过鉴权。
|
||||
|
||||
### P2 / 中:AI “开方命令”不可发现,且会丢弃命令中的处方内容
|
||||
|
||||
界面没有开方快捷按钮或“采用为处方”CTA;只有文本意图正则。命中后清空输入并进入本地编辑流程(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:4881-4889`),未保存原命令里的药味、剂量或 AI 建议。用户容易误解为 AI 已生成处方,实际看到的是诊单预填的空药材表单。
|
||||
|
||||
**建议:** AI 答案与本地命令分离。提供明确的“生成处方草稿”与“采用草稿”按钮,展示字段来源、缺失项和风险提示;任何药材/剂量进入正式表单前都需医生逐项确认。
|
||||
|
||||
### P2 / 中:医师显示名和签名缺少服务端身份约束
|
||||
|
||||
编辑器预填当前用户姓名,但姓名仍可编辑,签名由客户端 data URL 提交(`app/src/doctor_workstation/ui/dialogs/prescription.py:3205-3227,3349-3355,3740-3741`);服务端虽然强制 `creator_id = $adminId`,却直接保存客户端 `doctor_name/doctor_signature`(`server/app/adminapi/logic/tcm/PrescriptionLogic.php:327-339`)。待审核机制降低了风险,但不能防止错误/冒用的签名快照进入系统。
|
||||
|
||||
**建议:** 医师显示名由 authenticated profile 派生;签名使用账号绑定的签名资产或至少保存签名来源、哈希、提交人、时间和确认事件,不接受 AI 生成签名。
|
||||
|
||||
### P2 / 中:删除、作废的对象级服务端授权也不完整
|
||||
|
||||
虽不是 AI 新建的主路径,但同一处方生命周期中:
|
||||
|
||||
- `delete()` 未接收当前管理员,也没有创建者/共享/可见性检查:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:636-675`。
|
||||
- `void()` 接收管理员仅用于记录作废人,没有对象级授权:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:1137-1178`。
|
||||
|
||||
如果路由权限配置漂移或范围过宽,可能修改他人处方。建议所有写操作统一通过同一个处方对象授权策略。
|
||||
|
||||
## 可直接复用的接口和组件
|
||||
|
||||
| 层 | 可复用能力 | 证据 / 用途 |
|
||||
|---|---|---|
|
||||
| AI 患者选择 | `list_ai_patient_options` / `tcm.diagnosis/aiPatientOptions` | `app/src/doctor_workstation/services/repository.py:2029-2044`;用于只暴露数据域内、脱敏的诊单目标 |
|
||||
| 诊单权威读取 | `get_diagnosis_detail(..., readonly=True)` / `readonlyDetail` | `app/src/doctor_workstation/services/repository.py:2046-2060`;服务端在 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4362-4412` 做行级只读授权 |
|
||||
| AI 问答 | `stream_diagnosis_ai` / `aiAssistantStream` | `app/src/doctor_workstation/services/repository.py:1373-1405`;适合继续提供解释,不应直接作为可执行处方 DTO |
|
||||
| 人工处方编辑 | `PrescriptionEditorDialog` | 已有主数据药材选择、剂量、用法、签名和本地校验,可作为 AI 草稿的人工复核容器 |
|
||||
| 药材主数据 | `doctor.medicine/lists` + `RemoteMedicineComboBox` | `app/src/doctor_workstation/ui/dialogs/prescription.py:1425-1536`;AI 草稿必须解析为有效 `medicine_id` |
|
||||
| 处方提交 | `create_prescription` / `tcm.prescription/add` | 可复用,但应先补服务端诊单写授权和权威关系派生 |
|
||||
| 处方刷新 | `list_prescriptions_by_diagnosis` | 创建成功后已能按当前诊单刷新并过滤归属 |
|
||||
| 服务端药材校验 | `normalizeHerbIdentities()` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:165-175`;AI 草稿落表前必须复用 |
|
||||
| 服务端重复控制 | `assertUniquePrescriptionPerDiagnosisDay()` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:220-245` |
|
||||
| 诊单写授权 | `DiagnosisLogic::canManageDiagnosis()` | `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4458-4468`;应接入处方 add/edit |
|
||||
| 审核 | `PrescriptionLogic::audit()` | 保留独立人工审核,不与 AI 生成合并 |
|
||||
|
||||
## 推荐目标链路
|
||||
|
||||
不建议把“AI 可以直接给患者开方”实现为模型静默调用创建接口。更安全且可交付的目标是“AI 生成结构化草稿,医生确认并签名,服务端权威绑定,进入独立审核”。
|
||||
|
||||
1. 新增只读草稿能力:AI 返回 `prescription_draft`,至少包含 `clinical_diagnosis`、主/辅方药材(`medicine_id/name/dosage/formula_type`)、剂数、用法、禁忌、生成依据、缺失信息和风险警示;不得包含可自行决定的患者/诊单/医生身份字段。
|
||||
2. 服务端严格解析草稿:使用药材主数据解析、剂量范围、重复药名、特殊人群/相互作用规则;无法解析的草稿只作为文本展示。
|
||||
3. UI 显示“采用为处方草稿”,而不是“直接提交”;逐字段标记“AI 建议/诊单原值/医生修改”,打开现有 `PrescriptionEditorDialog`。
|
||||
4. 医生必须人工复核、补齐必填项并手写/绑定签名;确认页明确显示“将创建待审核处方”。
|
||||
5. 创建接口仅接收授权 `diagnosis_id` 和临床处方字段;患者、预约、医生身份、病例快照全部由服务端权威派生,并在事务内校验诊单写权限与唯一性。
|
||||
6. 审核继续使用独立角色/权限;AI 生成标记、模型、prompt 版本、草稿哈希、采用人和修改差异写审计日志。
|
||||
|
||||
## 建议补充的最小测试集
|
||||
|
||||
1. AI `done` 事件的合法/非法 `prescription_draft` schema、超量药材、无 `medicine_id`、负剂量和重复药名。
|
||||
2. “AI 建议 → 采用草稿 → 人工修改 → 签名 → 创建待审”端到端桌面测试。
|
||||
3. AI 创建 payload 必须包含当前诊单,并由服务端返回的处方断言 `diagnosis_id/appointment_id/patient_id` 一致。
|
||||
4. 任意其他诊单 ID、跨数据域诊单、错配预约/患者 ID 的创建请求必须失败。
|
||||
5. 编辑请求尝试修改 `diagnosis_id/patient_id/appointment_id` 必须失败。
|
||||
6. 只有读取权限、只有 AI 权限、只有 `cf.prescription/add`、只有 `chufang/kaifang` 的权限矩阵测试,并覆盖 middleware 菜单已注册/未注册两种状态。
|
||||
7. 创建后必须仍为待审核;没有审核权限的创建者不能把处方变为已通过。
|
||||
8. 共享处方仅扩大读取范围,非创建者不能编辑、删除或作废。
|
||||
|
||||
## 最终判断
|
||||
|
||||
- **问:目前能否从 AI 对话/报告一键形成并提交处方?答:不能。**
|
||||
- **问:能否在 AI 对话窗口里用一句“开方”命令进入处方流程,并在人工填写/签名后创建待审核处方?答:当前工作树可以。**
|
||||
- **问:这条半自动链路是否已达到可安全上线的端到端闭环?答:尚未。** 服务端诊单写授权、患者/预约权威绑定、权限 canonical 化和患者字段丢失问题应先修复;之后再建设“AI 结构化草稿 → 医生确认 → 待审核”的链路。
|
||||
@@ -0,0 +1,176 @@
|
||||
# Server AI/LLM 患者上下文与接口安全审计
|
||||
|
||||
审计日期:2026-08-21
|
||||
审计范围:`server/app`、`server/config` 及与真实请求拼装直接相关的 `app/src/doctor_workstation`。
|
||||
方法:只读静态审计;未连接生产数据库、未请求任何模型服务、未修改生产代码或测试。行号以本次工作区内容为准。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前不存在一套被所有患者 AI 入口复用的统一上下文。实际有三套相互独立的患者上下文:
|
||||
|
||||
1. `DiagnosisAiLogic`:诊单 AI 助手、诊单智能分析、诊单 AI 报告共用 `buildCaseContext()`,但它只基于**当前一张诊单详情**,并不聚合医生备注文字、跟踪记录、视频转写、聊天、已开处方或处方病历快照。证据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:349`、`:492`、`:564` 均调用 `buildCaseContext()`;该方法仅遍历诊单及附件数量,见 `:875-947`。
|
||||
2. `PatientAiReportLogic`:患者纵向 AI 报告明确与 `DiagnosisAiLogic` 独立,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:18-22`。它覆盖历次诊单、备注、日常记录、聊天及视频转写,是当前最完整的一套,但**仍不读取 `tcm_prescription` 已开处方、药味和 `case_record`**。
|
||||
3. `DailyDietAiLogic`:患者端饮食建议另建一套上下文,只含姓名、性别、年龄和近 30 天血糖统计/近 7 条明细,见 `server/app/api/logic/tcm/DailyDietAiLogic.php:279-339`。
|
||||
|
||||
此外,桌面端给诊单 AI 助手补上下文时存在两个确定的契约错误:跟踪接口返回 `blood_records`,客户端却读取 `blood_sugar`;处方接口返回 `Prescription` 数据类,客户端只接受 `Mapping`。因此界面声称附带的“每日血糖/处方记录”在真实远端数据形态下会缺失。证据见第 4 节。
|
||||
|
||||
最高优先级安全问题是 `AiChatService` 在阻塞与流式请求中都关闭 TLS 证书和主机名校验,会让患者姓名、血糖及问题内容面临中间人窃取或篡改风险:`server/app/common/service/AiChatService.php:48-59`、`:144-160`。
|
||||
|
||||
## 2. AI/LLM 入口清单
|
||||
|
||||
| 入口 | 是否调用 LLM | 上下文构造 | 备注 |
|
||||
|---|---:|---|---|
|
||||
| `tcm.diagnosis/aiAssistant`、`aiAssistantStream` | 是 | `DiagnosisAiLogic::buildCaseContext()` + 客户端把额外资料塞入 `prompt` | 控制器入口:`server/app/adminapi/controller/tcm/DiagnosisController.php:913-1020`;阻塞/流式最终都用同一 prepared context:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:316-397` |
|
||||
| `tcm.diagnosis/aiAnalysis` | 是 | `DiagnosisAiLogic::buildCaseContext()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1034-1047`;`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:481-516` |
|
||||
| `tcm.diagnosis/generateAiReports` | 是,每次固定生成 qwen/openai 两份 | `DiagnosisAiLogic::buildCaseContext()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1087-1098`;`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:553-618` |
|
||||
| `patientAiReports` | 否,只读历史 | 不组装上游请求 | `server/app/adminapi/controller/tcm/DiagnosisController.php:1052-1064` |
|
||||
| `generatePatientAiReport` | 是 | `PatientAiReportLogic::buildSourceSnapshot()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1069-1082`;全量聚合见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:335-398` |
|
||||
| `aiReports`、`editAiReport`、`aiPatientOptions` | 否,只读/编辑/选择 | 不调用模型 | 控制器见 `server/app/adminapi/controller/tcm/DiagnosisController.php:879-926`、`:1104-1115` |
|
||||
| `dailyDietAiRecommend`、`dailyDietAiAsk` 及两条 Stream | 是 | `DailyDietAiLogic::buildPatientContext()` | `server/app/api/controller/TcmController.php:900-979`;上下文见 `server/app/api/logic/tcm/DailyDietAiLogic.php:279-339` |
|
||||
| 处方库 `generateAiReports` | 是,但不是患者入口 | 处方库名称、类型、最多 80 味有效药材 | `server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:145-222`、`:400-426`;不应强行复用患者上下文 |
|
||||
| `DailyBloodCareAiLogic` | 当前不可达 | 计划复用 DailyDiet | 全仓只有类自身引用,没有控制器/路由;且调用不存在的公开方法 `DailyDietAiLogic::getPatientContext()`,见 `server/app/api/logic/tcm/DailyBloodCareAiLogic.php:161-176`,实际方法是私有 `buildPatientContext()`:`server/app/api/logic/tcm/DailyDietAiLogic.php:279` |
|
||||
|
||||
全仓 PHP 搜索只发现两种上游客户端:`DifyChatService` 和 `AiChatService`。其调用者分别是 `DiagnosisAiLogic`、`PatientAiReportLogic`、`PrescriptionLibraryAiLogic`,以及 `DailyDietAiLogic`、未接线的 `DailyBloodCareAiLogic`。
|
||||
|
||||
## 3. “患者发给 AI”时各类资料的真实覆盖
|
||||
|
||||
符号:✅ 文字进入上游;△ 只有部分字段/数量元数据/依赖客户端;❌ 未进入。
|
||||
|
||||
| 资料类型 | 诊单 AI 助手/分析/诊单报告 | 患者纵向 AI 报告 | 患者端饮食 AI |
|
||||
|---|---|---|---|
|
||||
| 患者基本信息 | △ 性别、年龄、身高、体重、婚姻等;服务端不主动发送姓名/电话/身份证 | ✅ 最新诊单基本信息;上游发送前 `_id`、`_name` 等键脱敏 | △ 姓名、性别、年龄;姓名被直接发送 |
|
||||
| 当前/现病信息 | △ 当前诊单的症状、既往史、当前用药、舌脉、治则等;仓库字段漂移导致实际 `prescription`、`doctor_advice` 漏掉 | ✅ 所有授权诊单的广泛字段,包含诊单上的 `prescription`、`doctor_advice` | ❌ 除血糖外不含症状、当前用药、过敏、肝肾风险、医嘱等 |
|
||||
| 每日视频面诊转写 | ❌ 服务端不查;桌面端最多把一条转写塞入 320 字上下文 | ✅ 所有授权诊单通话及转写段,段落可重建 transcript | ❌ |
|
||||
| 病例与记录病历 | △ 当前诊单字段;不含处方 `case_record` | △ 历次诊单完整,但不含已开处方的 `case_record` | ❌ |
|
||||
| 医生备注/跟踪备注 | ❌ 只聚合备注里的图片,不聚合备注文字 | ✅ 全部医生备注和跟踪备注 | ❌ |
|
||||
| 舌苔/舌象 | △ 舌苔/舌象文字 + 图片数量;不读取图片内容 | △ 文字 + 附件数量元数据;明确禁止声称做视觉识别 | ❌ |
|
||||
| 检查报告 | △ 只发送附件数量和“未提供附件内容” | △ 只发送附件数量元数据,无 OCR/报告正文 | ❌ |
|
||||
| 日常记录 | ❌ 服务端不查;桌面端本想补血糖但字段名错误,饮食/运动也未拼入 | ✅ 血糖血压、饮食、运动全量 | △ 近 30 天血糖统计、近 7 条逐日明细;不含饮食/运动记录 |
|
||||
| 既往处方 | ❌ `tcm_diagnosis.prescription` 被字段白名单漏掉;不查 `tcm_prescription`;桌面端又因类型判断漏掉远端处方 | △ 有历次诊单的自由文本 `prescription`,但没有正式 `tcm_prescription`、药味、服法、`case_record` | ❌ |
|
||||
| IM/企微聊天 | ❌ | ✅ 腾讯 IM 与企微聊天全量 | ❌ |
|
||||
|
||||
### 3.1 诊单 AI 共用的是“窄上下文”
|
||||
|
||||
`DiagnosisAiLogic::CASE_FIELDS` 定义于 `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:210-252`。它会加入血压、空腹血糖、身高体重、舌象/报告附件数量,再遍历白名单字段,见 `:875-923`。`DiagnosisLogic::detail()` 本身只从医生备注聚合舌象与报告图片,未加载备注 `content`,见 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:274-352` 与 `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:140-164`。
|
||||
|
||||
字段白名单存在明确漂移:仓库诊单表包含 `prescription` 和 `doctor_advice`,见 `server/sql/tcm_diagnosis.sql:13-21`;新增/编辑验证器也使用这两个名称,见 `server/app/adminapi/validate/tcm/DiagnosisValidate.php:111`。但 AI 白名单只找 `prescription_opinion`、`prescription_advice`,没有 `prescription`、`doctor_advice`,见 `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:244-251`。相反,白名单中的 `chief_complaint`、`present_illness` 等名称在本仓库 SQL 定义中未找到,应以生产表 `SHOW COLUMNS` 再核实;无论如何,这已说明上下文与真实字段没有单一契约。
|
||||
|
||||
舌象和检查报告只有数量元数据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:905-916` 明确写入“未提供附件内容”。医生备注实际保存 `content`、`tongue_images`、`report_files`,见 `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:12-68`,但诊单 AI 没有读取 `content`。
|
||||
|
||||
### 3.2 患者纵向报告覆盖广,但漏正式处方
|
||||
|
||||
纵向报告先按 `patient_id` 和 `MyPatientLogic` 数据域查出全部有效诊单,不设日期或条数上限:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:254-296`。随后按这些诊单 ID 全量查询:
|
||||
|
||||
- 医生备注、跟踪备注、血糖血压、饮食、运动:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:342-366`;
|
||||
- 腾讯 IM、企微聊天、视频通话:`:367-380`;
|
||||
- 视频转写段:`:382-392`,并在 `:450-472` 重建每次通话的 `transcript_text`;
|
||||
- 汇总对象确实包含 `doctor_notes`、`tracking_notes`、三类 daily records、两类 chat records 和 `video_calls`:`:495-520`。
|
||||
|
||||
但 `buildSourceSnapshot()` 完全没有查询 `tcm_prescription`。正式处方模型把 `herbs`、`case_record`、`aux_usage` 声明为 JSON 字段:`server/app/common/model/tcm/Prescription.php:14-21`;`case_record` 又是明确的“详细病历(诊单快照 JSON)”:`server/database/migrations/2026_03_19_add_prescription_case_record.sql:2`。已有按诊单、逐条可见性过滤的安全入口可复用:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:937-963`。
|
||||
|
||||
上游脱敏总体正确:患者纵向报告会把 ID、`*_id`、`*_name`、账户标识替换为脱敏占位,并把附件 URL 替换成数量,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:820-848`;手机号、身份证、邮箱、URL 正则见 `:851-860`。提示词也明确禁止对附件和视频画面作视觉推断,只能使用文字、转写和元数据,见 `:739-744`。
|
||||
|
||||
### 3.3 患者端饮食 AI 的上下文过窄且发送姓名
|
||||
|
||||
`DailyDietAiLogic` 查询近 30 天血糖、不限显式行数,筛近 7 天后只取 7 条明细,见 `server/app/api/logic/tcm/DailyDietAiLogic.php:291-326`。返回上下文只有 `patient_name`、`age`、`gender_text`、血糖明细和 7/30 天统计,见 `:328-337`。推荐与问答提示词都将患者姓名直接发往模型:`:508-519`、`:560-569`、`:706-712`、`:795-803`。
|
||||
|
||||
对于会给出个体化饮食建议的糖尿病入口,未纳入诊单中的当前用药、过敏、肾脏/肝脏状况、医生医嘱、现有饮食/运动记录和正式处方,可能使建议与真实禁忌或治疗方案冲突。
|
||||
|
||||
## 4. 桌面端实际问答链路的两个确定缺口
|
||||
|
||||
诊单 AI 助手不是只用服务端诊单摘要。桌面端先把额外资料拼成最多 320 字的“患者综合资料”,再与医生问题合成最多 500 字的 `prompt`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2682-2694`、`:2835-2928`。服务端把整个 `prompt` 当作 `<USER_QUESTION>`,同时另放自己构造的 `<CASE_DATA>`:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1052-1078`。真实流式请求体只有 `id`、`prompt`、`task`:`app/src/doctor_workstation/services/repository.py:1373-1391`。
|
||||
|
||||
确定缺口如下:
|
||||
|
||||
1. **每日血糖字段名不一致。** 客户端先读取 `tracking["blood_sugar"]`,再找 `entries|records|items`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2710-2742`。但服务端跟踪接口返回的是 `blood_records`、`diet_records`、`exercise_records`:`server/app/adminapi/logic/tcm/DiagnosisLogic.php:4473-4502`;仓库层原样返回:`app/src/doctor_workstation/services/repository.py:2170-2191`。结果是逐日血糖不会进入 prompt,通常只剩诊单上的一次空腹血糖。
|
||||
2. **远端处方对象类型不一致。** 客户端只处理 `Mapping`,否则 `continue`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2789-2806`。远端仓库却把列表解析为 `Prescription` 数据类:`app/src/doctor_workstation/services/repository.py:1643-1649`;该类定义于 `app/src/doctor_workstation/core/models.py:577-620`。结果是正式环境返回的处方不会进入 prompt。当前脚本 `app/scripts/check_ai_context.py:252-267` 用字典模拟处方,无法覆盖此契约错误。
|
||||
|
||||
另有三个设计缺口:
|
||||
|
||||
- 工作区已加载医生备注 `notes`,见 `app/src/doctor_workstation/ui/dialogs/ai_consult.py:3782-3801`,但调用 `build_patient_ai_context()` 时没有 notes 参数,见 `:3928-3951`。
|
||||
- 上下文顺序固定为血糖、舌脉、视频、历史 AI 报告、处方,并共享 320 字预算,见 `:2855-2898`;后面的处方更容易被预算耗尽,没有逐段保底或截断清单。
|
||||
- “历史AI报告”会作为新一轮模型输入,见 `:2769-2786`,存在把旧模型推断当作新证据反复强化的来源污染;应至少明确标记为模型生成内容并默认不作为临床事实。
|
||||
|
||||
## 5. 鉴权与接口安全
|
||||
|
||||
### 5.1 已有的有效控制
|
||||
|
||||
- 管理端经过登录、权限认证等中间件:`server/app/adminapi/config/route.php:15-27`。诊单 AI 逻辑又独立校验精确权限和 `MyPatientLogic::canAccessDiagnosis()`,再读详情:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:842-869`;权限匹配是精确小写 URI 白名单:`:744-755`。
|
||||
- 患者纵向报告同时要求查看权限和生成权限,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:126-149`,并通过 `MyPatientLogic::applyScope()` 限定医生/医助/部门数据域,且对不存在和越权返回同一错误,见 `:254-296`。
|
||||
- 患者端饮食四个入口不在 `notNeedLogin` 中:`server/app/api/controller/TcmController.php:36-42`;每个入口在调用 AI 前执行 `ensurePatientOwnsDiagnosis()`,通过当前用户的 `diagnosis_view_records` 验证归属:`:495-525`、`:904-977`。
|
||||
- `aiAnalysis`、患者报告读/生成使用严格字段白名单,拒绝客户端传 provider、BASE_URL、凭据和自由来源正文:`server/app/adminapi/validate/tcm/DiagnosisValidate.php:196-213`、`:269-327`。AI 助手 task 受枚举限制,prompt 最长 500:`:27-53`。
|
||||
- `DifyChatService` 只允许 qwen/openai profile、超时 1–300 秒,且启用 TLS 校验:`server/app/common/service/DifyChatService.php:13-50`、`:305-307`、`:329-343`、`:422-440`。它也不会把供应商原始错误正文和密钥回传给控制器。
|
||||
|
||||
### 5.2 缺口与风险等级
|
||||
|
||||
#### P0:患者端 AI 上游关闭 TLS 校验
|
||||
|
||||
`AiChatService` 阻塞和流式路径均设置 `CURLOPT_SSL_VERIFYPEER=false`、`CURLOPT_SSL_VERIFYHOST=false`:`server/app/common/service/AiChatService.php:48-59`、`:144-160`。该服务承载姓名、血糖和自由问题,风险为患者隐私泄露、模型响应被篡改以及服务端密钥被中间人获取。
|
||||
|
||||
最小修复:两处改为 `true`/`2`;对 `base_url` 做与 `DifyChatService::isValidBaseUrl()` 同等级校验;生产只允许 HTTPS。若企业内网使用私有 CA,应配置 CA bundle,不能关闭校验。
|
||||
|
||||
#### P1:上下文不统一且诊单助手缺失关键临床资料
|
||||
|
||||
诊单助手、分析和诊单报告虽然内部共用一份 builder,但这份 builder 没有患者级聚合能力;纵向报告和饮食 AI 又各自维护字段。这会造成同一患者在三个入口得到基于不同事实集的答案。
|
||||
|
||||
最小修复:新增服务端只读 `PatientAiContextBuilder`,先接收已鉴权的 diagnosis/patient scope,再用 profile 决定范围:
|
||||
|
||||
- `assistant`:当前诊单 + 最近 30/90 天日常记录 + 最近 N 条医生/跟踪备注 + 最近 N 次已完成视频转写 + 最近 N 张可见正式处方;
|
||||
- `diagnosis_report`:当前诊单的完整字段、备注、处方及附件元数据;
|
||||
- `longitudinal_report`:现有全病程分片策略,但补正式处方和来源清单;
|
||||
- `daily_diet`:只取饮食决策所需的最小临床子集,避免姓名。
|
||||
|
||||
所有 profile 应返回 `source_manifest`、每类记录数量、时间窗和 `truncated_sections`,使 UI 与审计日志能准确说明模型看到了什么。
|
||||
|
||||
#### P1:患者纵向报告无限查询/无限调用成本
|
||||
|
||||
诊单查询和九类来源查询均未设置日期或行数上限:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:270-283`、`:342-392`。代码会按 120,000 字节切片、逐片调用模型,再最多做 8 轮归并,确保不静默截断:`:44-51`、`:630-729`。完整性设计是优点,但攻击者或异常大患者记录可触发大量数据库内存和模型请求,当前 AI 路径也未发现用户级/患者级频率限制或并发去重。
|
||||
|
||||
最小修复:在查询层分页/游标读取;设置总来源字节、最大 chunk 数和最大上游调用数;生成任务使用 `(patient_id, model, source_hash)` 幂等锁;按管理员/患者限流。达到上限时返回显式“资料过多,需要缩小时间范围”,不可仍标记 `snapshot_complete=true`。
|
||||
|
||||
#### P1:原始患者快照落库,保留范围过大
|
||||
|
||||
上游发送前会脱敏,但落库的 `source_snapshot` 是脱敏前的 `$sourceJson`:快照先在 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:163-166` 构造,脱敏只在 `generateUpstreamReport()` 的 `:638-640` 执行,而原始 JSON 在 `:203-225` 直接写入 `source_snapshot`。它包含姓名、内部 ID、聊天/转写正文和附件 URL。历史接口有意识地不读取这个大字段,见 `:1012-1025`,但数据库静态泄露与过度保留风险仍存在。
|
||||
|
||||
最小修复:若无需法律审计复现,仅存脱敏快照 + 哈希 + source manifest;若必须保存原文,则字段级加密、独立访问权限、明确 TTL/删除策略,并记录谁读取过原始快照。
|
||||
|
||||
#### P1:患者端饮食 AI 暴露不必要姓名且临床约束不足
|
||||
|
||||
姓名对 GI 推荐没有必要,但当前四套 prompt 均发送姓名,证据见第 3.3 节。与此同时,可能直接影响饮食安全的过敏、肾病/肾功能、当前用药和医生医嘱未进入模型。
|
||||
|
||||
最小修复:删除 `patient_name`;加入最小化的风险字段并设置“若过敏/肝肾/用药信息缺失,不给个体化禁忌结论”;将问答文本放进明确的不可信数据边界,防止把用户问题中的指令当系统指令。
|
||||
|
||||
#### P2:上游 URL/响应大小与中间件防线仍可加强
|
||||
|
||||
- `DifyChatService` 的 URL 校验允许 `http` 和任意主机/IP:`server/app/common/service/DifyChatService.php:287-303`。虽然 URL 只来自服务端配置,不是请求参数,因此不是直接请求型 SSRF,但生产误配会把患者资料发往明文或非批准主机。建议 HTTPS-only + 域名 allowlist;若确有内网模型,使用独立显式配置开关并拒绝重定向到私网/环回地址。
|
||||
- 两个上游客户端都没有在传输阶段设置响应体最大字节数。`DifyChatService` 阻塞请求先整段 `RETURNTRANSFER`:`:329-354`,解析器的 32/64 KiB 限制发生在收完之后;流式路径也持续累积内容。建议在 write callback 中按字节中止,并区分“响应过大”错误。
|
||||
- `AuthMiddleware` 对不在全局菜单 URI 集合中的路由直接放行:`server/app/adminapi/http/middleware/AuthMiddleware.php:73-83`。当前诊单/患者 AI 逻辑自带权限校验,故没有形成直接越权;但新 AI 路由若忘记逻辑层校验会失守。最小修复是对 `/ai*` 或配置的敏感控制器 fail-closed,并保留逻辑层二次校验。
|
||||
|
||||
#### P2:未接线的 DailyBloodCare 代码会在接线后立即失败
|
||||
|
||||
`DailyBloodCareAiLogic` 调用不存在的 `DailyDietAiLogic::getPatientContext()`,且自身没有患者归属校验。当前无路由所以不构成线上入口;未来若启用,应先改为共享的公开 context builder,并在控制器进入逻辑前复用 `ensurePatientOwnsDiagnosis()`。不要只修方法名后直接暴露。
|
||||
|
||||
## 6. 字段、时间范围和条数限制清单
|
||||
|
||||
| 路径 | 当前限制 | 审计判断 |
|
||||
|---|---|---|
|
||||
| 诊单 AI 助手 | task 枚举;医生 prompt 500 字;上下文字段逐项最多 800 字 | 输入边界明确,但 500 字中还混入客户端上下文,医生问题会被截短;字段截断无 manifest。证据:`server/app/adminapi/validate/tcm/DiagnosisValidate.php:49-53`、`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1653-1661` |
|
||||
| 诊单 AI 分析 | case 最多 16,000 字,响应最多 32,768 bytes,建议字段/风险条数均有限 | 输出校验较好;仍只看当前诊单且截断不告知模型/UI。证据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:21-58`、`:1025-1048` |
|
||||
| 患者纵向报告 | 数据库查询全历史、无行数上限;每片 120,000 bytes;综合 180,000 bytes;最多 8 轮归并;响应 65,536 bytes | 不静默漏源,但数据库内存、延迟和费用无硬上限。证据:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:36-51`、`:630-729` |
|
||||
| 患者端饮食推荐 | 血糖 30 天;近 7 天最多 7 条明细;问答 80 字;缓存推荐 1 天、问答 1 小时 | 时间窗合理,但 `refresh=1` 可反复绕过推荐缓存,未见限流;30 天查询无显式行数 cap。证据:`server/app/api/logic/tcm/DailyDietAiLogic.php:28-109`、`:291-326` |
|
||||
| 处方库 AI | 最多 80 味有效药材;待生成列表最多 500 条 | 属于非患者入口,边界基本清晰。证据:`server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:83-120`、`:604-625` |
|
||||
| Dify 上游 | profile 仅 qwen/openai;timeout 1–300 秒 | 正向控制;缺传输级响应大小上限与生产域名 allowlist。证据:`server/app/common/service/DifyChatService.php:16-20`、`:305-307` |
|
||||
|
||||
## 7. 建议的最小修复顺序
|
||||
|
||||
1. **当天可改**:恢复 `AiChatService` TLS 校验;饮食 prompt 去掉患者姓名;为上游响应设置硬字节上限。
|
||||
2. **第一批契约修复**:桌面端读取 `blood_records`;处方 helper 同时支持 `Prescription` 对象;把 notes 显式加入或由服务端统一拼装;补覆盖真实远端类型/字段名的测试。
|
||||
3. **服务端临床完整性**:给 `DiagnosisAiLogic::CASE_FIELDS` 补真实 `prescription`、`doctor_advice`;接入最近医生/跟踪备注、视频转写、日常记录和经 `canViewPrescription()` 过滤的正式处方;不要把附件 URL 当内容,若有 OCR 则以独立、带来源的文本字段加入。
|
||||
4. **统一 builder**:让 DiagnosisAi、PatientAiReport、DailyDiet 复用同一数据访问/脱敏/来源清单层,只在 profile 的时间窗和字段最小化上不同。
|
||||
5. **资源与审计**:患者报告增加查询/调用/总字节上限、幂等锁和限流;调整原始 `source_snapshot` 的加密与保留策略;所有结果返回上下文版本、来源数量和截断信息。
|
||||
|
||||
## 8. 最终判断
|
||||
|
||||
目前“诊单 AI 助手会自动拿到患者完整资料”的说法不成立。服务端只自动拿当前诊单摘要;桌面端确实尝试补视频、血糖、处方等,但 320 字预算及两个契约错误使其远达不到完整上下文。患者纵向 AI 报告是唯一真正覆盖视频转写、备注、聊天和日常记录的入口,却没有正式处方/处方病历,并且全历史、无限行的实现带来显著资源与隐私保留风险。
|
||||
|
||||
建议把“统一上下文”定义为**统一的数据访问、鉴权、脱敏、来源清单和截断协议**,而不是要求所有入口发送同样多的数据。饮食问答应最小化;纵向报告可更完整;诊单助手应在可控时间窗内补齐临床关键项。这样才能同时解决答案一致性、隐私最小化和成本边界。
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 886 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="真阳医生工作台">
|
||||
<rect width="64" height="64" rx="16" fill="#123F3A"/>
|
||||
<path d="M29 14h6a3 3 0 0 1 3 3v10h9a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-9v9a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3v-9h-9a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3h9V17a3 3 0 0 1 3-3Z" fill="#F6F4EE"/>
|
||||
<path d="M42 16c-1 8-5 13-12 15 2-7 6-12 12-15Z" fill="#79B9A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 418 B |
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
project_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
python_bin="${PYINSTALLER_PYTHON:-$project_root/.venv-build/bin/python}"
|
||||
companion_root="$project_root/video_companion"
|
||||
macos_icon="$project_root/resources/branding/app-icon.icns"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "The macOS bundle must be built on macOS." >&2
|
||||
@@ -13,6 +14,10 @@ if [[ ! -x "$python_bin" ]]; then
|
||||
echo "Build Python was not found: $python_bin" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$macos_icon" ]]; then
|
||||
echo "macOS application icon was not found: $macos_icon" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
|
||||
npm ci --prefix "$companion_root" --no-audit --no-fund
|
||||
|
||||
@@ -8,6 +8,7 @@ $ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
|
||||
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
|
||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||
|
||||
function Invoke-FrozenGate {
|
||||
param(
|
||||
@@ -153,6 +154,9 @@ if (-not [System.IO.Path]::IsPathRooted($Python)) {
|
||||
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Build Python was not found: $Python"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $WindowsIcon -PathType Leaf)) {
|
||||
throw "Windows application icon was not found: $WindowsIcon"
|
||||
}
|
||||
|
||||
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
|
||||
Push-Location $ProjectRoot
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Smoke test for the AI context builder helpers in ai_consult.py.
|
||||
|
||||
This avoids importing PySide6-bound modules by extracting only the pure
|
||||
helper functions we want to verify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
AI_CONSULT_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "src", "doctor_workstation", "ui", "dialogs",
|
||||
"ai_consult.py",
|
||||
)
|
||||
|
||||
|
||||
def _make_helpers() -> dict[str, object]:
|
||||
"""Pull the pure helpers out of ai_consult.py without importing PySide6."""
|
||||
|
||||
with open(AI_CONSULT_PATH, encoding="utf-8") as stream:
|
||||
source = stream.read()
|
||||
tree = ast.parse(source)
|
||||
wanted_names = {
|
||||
"AI_CONTEXT_MAX_CHARS",
|
||||
"AI_PROMPT_LIMIT",
|
||||
"AI_CONTEXT_SEPARATOR",
|
||||
"_truncate_for_context",
|
||||
"_patient_context_blood_sugar",
|
||||
"_patient_context_tongue",
|
||||
"_patient_context_reports",
|
||||
"_patient_context_prescriptions",
|
||||
"_patient_context_videos",
|
||||
"build_patient_ai_context",
|
||||
"_compose_ai_prompt",
|
||||
}
|
||||
selected: list[ast.stmt] = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name in wanted_names:
|
||||
selected.append(node)
|
||||
continue
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in wanted_names:
|
||||
selected.append(node)
|
||||
break
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
|
||||
def _as_mapping(value: object) -> dict[str, object]:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
raw = getattr(value, "raw", None)
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
def first_value(value: object, *keys: str, default: object = None) -> object:
|
||||
"""Return the first present, non-empty value from ``keys``.
|
||||
|
||||
Mirrors ``widgets.first_value``:
|
||||
``first_value(mapping, "k1", "k2", default=...)``.
|
||||
"""
|
||||
|
||||
for key in keys:
|
||||
if not isinstance(value, dict):
|
||||
break
|
||||
if key in value and value[key] not in (None, "", "—"):
|
||||
return value[key]
|
||||
return default
|
||||
|
||||
def get_value(source: object, key: str, default: object = None) -> object:
|
||||
if isinstance(source, dict) and key in source:
|
||||
return source[key]
|
||||
return default
|
||||
|
||||
def _human_value(value: object, *, empty: str = "未记录") -> str:
|
||||
if value in (None, "", "—"):
|
||||
return empty
|
||||
if isinstance(value, str):
|
||||
return value.strip() or empty
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for key, nested in value.items():
|
||||
rendered = _human_value(nested, empty="")
|
||||
if rendered:
|
||||
parts.append(f"{key}:{rendered}")
|
||||
return ";".join(parts) or empty
|
||||
if isinstance(value, list):
|
||||
parts = [_human_value(item, empty="") for item in value]
|
||||
return "、".join(part for part in parts if part) or empty
|
||||
return str(value).strip() or empty
|
||||
|
||||
def display_text(value: object, *, default: str = "") -> str:
|
||||
if value in (None, "", "—"):
|
||||
return default
|
||||
return str(value).strip() or default
|
||||
|
||||
def _exact_positive_id(value: object, expected: int) -> bool:
|
||||
if value in (None, ""):
|
||||
return False
|
||||
try:
|
||||
return int(value) == expected
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
# Provide fallback names for ``collections.abc`` symbols referenced by
|
||||
# the helpers without forcing the real module imports on this stub box.
|
||||
import collections.abc as _abc
|
||||
Sequence = _abc.Sequence # type: ignore[attr-defined]
|
||||
Mapping = _abc.Mapping # type: ignore[attr-defined]
|
||||
Any = object # type: ignore[assignment]
|
||||
|
||||
namespace.update(
|
||||
{
|
||||
"_as_mapping": _as_mapping,
|
||||
"first_value": first_value,
|
||||
"get_value": get_value,
|
||||
"_human_value": _human_value,
|
||||
"display_text": display_text,
|
||||
"_exact_positive_id": _exact_positive_id,
|
||||
"Sequence": Sequence,
|
||||
"Mapping": Mapping,
|
||||
"Any": Any,
|
||||
}
|
||||
)
|
||||
|
||||
module_ast = ast.Module(body=selected, type_ignores=[])
|
||||
ast.fix_missing_locations(module_ast)
|
||||
exec(compile(module_ast, AI_CONSULT_PATH, "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
def main() -> None:
|
||||
helpers = _make_helpers()
|
||||
AI_CONTEXT_MAX_CHARS = helpers["AI_CONTEXT_MAX_CHARS"]
|
||||
AI_PROMPT_LIMIT = helpers["AI_PROMPT_LIMIT"]
|
||||
_truncate_for_context = helpers["_truncate_for_context"]
|
||||
_patient_context_blood_sugar = helpers["_patient_context_blood_sugar"]
|
||||
_patient_context_tongue = helpers["_patient_context_tongue"]
|
||||
_patient_context_reports = helpers["_patient_context_reports"]
|
||||
_patient_context_videos = helpers["_patient_context_videos"]
|
||||
build_patient_ai_context = helpers["build_patient_ai_context"]
|
||||
_compose_ai_prompt = helpers["_compose_ai_prompt"]
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise AssertionError(message)
|
||||
|
||||
def assertEqual(actual: object, expected: object, message: str) -> None:
|
||||
if actual != expected:
|
||||
fail(f"{message}: expected {expected!r}, got {actual!r}")
|
||||
|
||||
def assertContains(container: object, needle: str, message: str) -> None:
|
||||
if not isinstance(container, str) or needle not in container:
|
||||
fail(f"{message}: {needle!r} missing in output")
|
||||
|
||||
# 1. _truncate_for_context
|
||||
short = _truncate_for_context("hello", max_chars=10)
|
||||
assertEqual(short, "hello", "short text should pass through unchanged")
|
||||
|
||||
long_text = _truncate_for_context(
|
||||
"诊断:血糖偏高,建议调整饮食结构,配合运动每周三次以上。",
|
||||
max_chars=12,
|
||||
)
|
||||
assertContains(long_text, "…", "long text should end with ellipsis")
|
||||
assertEqual(len(long_text), 12, "truncated text should respect max_chars")
|
||||
|
||||
# 2. _patient_context_tongue
|
||||
tongue = _patient_context_tongue(
|
||||
{
|
||||
"diagnosis": {
|
||||
"tongue": "舌红苔黄腻",
|
||||
"tongue_coating": "黄腻",
|
||||
"pulse": "弦滑",
|
||||
"tongue_images": ["url1", "url2", "url3"],
|
||||
}
|
||||
}
|
||||
)
|
||||
assertContains(tongue, "舌红苔黄腻", "tongue text missing")
|
||||
assertContains(tongue, "弦滑", "pulse text missing")
|
||||
assertContains(tongue, "舌苔图片 3 张", "tongue image count missing")
|
||||
|
||||
# 3. _patient_context_blood_sugar
|
||||
blood_sugar = _patient_context_blood_sugar(
|
||||
{"diagnosis": {"fasting_blood_sugar": "7.8"}},
|
||||
{
|
||||
"blood_sugar": {
|
||||
"entries": [
|
||||
{"date": "2026-08-15", "value": "6.2", "period": "空腹"},
|
||||
{"date": "2026-08-14", "value": "9.1", "period": "餐后"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assertContains(blood_sugar, "7.8", "fasting reading missing")
|
||||
assertContains(blood_sugar, "每日血糖", "tracking summary missing")
|
||||
|
||||
# 4. _patient_context_videos: filters by current diagnosis id.
|
||||
videos = _patient_context_videos(
|
||||
[
|
||||
{
|
||||
"diagnosis_id": 99,
|
||||
"transcript_text": "其他诊单",
|
||||
"start_time_text": "今天",
|
||||
},
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"transcript_text": "医生:请问您最近睡眠如何;患者:经常失眠。",
|
||||
"start_time_text": "2026-08-15 10:30",
|
||||
},
|
||||
],
|
||||
diagnosis_id=501,
|
||||
)
|
||||
assertContains(videos, "医生", "transcript text missing")
|
||||
assertContains(videos, "2026-08-15", "transcript timestamp missing")
|
||||
|
||||
# 5. _patient_context_reports
|
||||
reports = _patient_context_reports(
|
||||
{
|
||||
"summary": "近期血糖偏高",
|
||||
"diagnosis_advice": "建议控制饮食",
|
||||
"risk_assessment": ["心血管风险升高", "肾功负担加重"],
|
||||
}
|
||||
)
|
||||
assertContains(reports, "既往AI摘要", "summary missing")
|
||||
assertContains(reports, "诊断建议", "advice missing")
|
||||
assertContains(reports, "心血管风险", "risk bullet missing")
|
||||
|
||||
# 6. build_patient_ai_context: full envelope assembly.
|
||||
detail = {
|
||||
"diagnosis": {
|
||||
"tongue": "舌淡苔白",
|
||||
"pulse": "细弱",
|
||||
"fasting_blood_sugar": "8.0",
|
||||
},
|
||||
"tongue_images": ["a", "b"],
|
||||
}
|
||||
tracking = {
|
||||
"blood_sugar": {
|
||||
"entries": [
|
||||
{"date": "2026-08-19", "value": "6.1", "period": "空腹"},
|
||||
]
|
||||
}
|
||||
}
|
||||
analysis = {
|
||||
"summary": "控制尚可",
|
||||
"diagnosis_advice": "调整饮食",
|
||||
"risk_assessment": ["肾功"],
|
||||
}
|
||||
prescriptions = [
|
||||
{"prescription_name": "六味地黄丸", "prescription_remark": "调理方"},
|
||||
]
|
||||
call_records = [
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"transcript_text": "对话内容:患者表述近期乏力。",
|
||||
"start_time_text": "2026-08-18 14:00",
|
||||
}
|
||||
]
|
||||
context_text, present = build_patient_ai_context(
|
||||
detail=detail,
|
||||
tracking=tracking,
|
||||
analysis=analysis,
|
||||
prescriptions=prescriptions,
|
||||
call_records=call_records,
|
||||
diagnosis_id=501,
|
||||
)
|
||||
for label in (
|
||||
"每日血糖",
|
||||
"舌苔/脉象",
|
||||
"视频问诊文字",
|
||||
"历史AI报告",
|
||||
"处方记录",
|
||||
):
|
||||
assertContains(context_text, label, f"section {label} missing in envelope")
|
||||
if label not in present:
|
||||
fail(f"label {label} not in present labels")
|
||||
if len(context_text) > AI_CONTEXT_MAX_CHARS + 12:
|
||||
fail(f"context exceeds {AI_CONTEXT_MAX_CHARS} chars: {len(context_text)}")
|
||||
|
||||
# 7. _compose_ai_prompt: short answer stays untouched.
|
||||
short_prompt = _compose_ai_prompt("血糖如何?", context_text)
|
||||
assertContains(short_prompt, context_text, "short prompt loses context")
|
||||
assertContains(short_prompt, "血糖如何?", "short prompt loses question")
|
||||
if len(short_prompt) > AI_PROMPT_LIMIT:
|
||||
fail("short prompt exceeds limit")
|
||||
|
||||
# 8. Long answer is truncated with ellipsis.
|
||||
long_question = (
|
||||
"请结合患者既往糖尿病史、家族史以及服用的多种药物,给出一份详尽的"
|
||||
"个性化治疗方案,并解释每一步的理由,最终输出一份结构化报告,"
|
||||
"包括风险评估、用药合理性、并发症筛查和分级随访计划。"
|
||||
) * 6
|
||||
long_prompt = _compose_ai_prompt(long_question, context_text)
|
||||
if len(long_prompt) > AI_PROMPT_LIMIT:
|
||||
fail(f"long prompt exceeds limit: {len(long_prompt)}")
|
||||
if context_text not in long_prompt:
|
||||
fail("long prompt loses context")
|
||||
assertContains(long_prompt, "…", "long prompt should end with ellipsis")
|
||||
|
||||
# 9. Empty context returns bare question.
|
||||
bare = _compose_ai_prompt("血糖?", "")
|
||||
assertEqual(bare, "血糖?", "empty context should drop the envelope entirely")
|
||||
|
||||
# 10. Empty question returns empty string.
|
||||
empty = _compose_ai_prompt("", context_text)
|
||||
assertEqual(empty, "", "empty question returns empty string")
|
||||
|
||||
print("OK: all AI context helper assertions passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$ToolVersion = "6.7.3"
|
||||
$ToolRoot = Join-Path $ProjectRoot ".build-tools\inno-setup-$ToolVersion"
|
||||
$Compiler = Join-Path $ToolRoot "ISCC.exe"
|
||||
$DownloadRoot = Join-Path $ProjectRoot ".build-tools\downloads"
|
||||
$Installer = Join-Path $DownloadRoot "innosetup-$ToolVersion.exe"
|
||||
$InstallerUrl = (
|
||||
"https://github.com/jrsoftware/issrc/releases/download/" +
|
||||
"is-6_7_3/innosetup-$ToolVersion.exe"
|
||||
)
|
||||
$InstallerSha256 = "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732"
|
||||
$LanguageRoot = Join-Path $ProjectRoot ".build-tools\inno-languages"
|
||||
$ChineseMessages = Join-Path $LanguageRoot "ChineseSimplified.isl"
|
||||
$ChineseMessagesUrl = (
|
||||
"https://raw.githubusercontent.com/jrsoftware/issrc/" +
|
||||
"6ef32198ef1f7b7b375cd4b6b90896c2a58eb4c2/Files/Languages/ChineseSimplified.isl"
|
||||
)
|
||||
$ChineseMessagesSha256 = "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D"
|
||||
|
||||
function Test-Compiler {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseNotes = Join-Path (Split-Path -Parent $Candidate) "whatsnew.htm"
|
||||
if (-not (Test-Path -LiteralPath $ReleaseNotes -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseText = [System.IO.File]::ReadAllText($ReleaseNotes)
|
||||
$VersionMatch = [regex]::Match($ReleaseText, '<span class="ver">([0-9.]+)')
|
||||
return $VersionMatch.Success -and $VersionMatch.Groups[1].Value -eq $ToolVersion
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $LanguageRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $ChineseMessages -PathType Leaf) {
|
||||
$ExistingLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ExistingLanguageHash -ne $ChineseMessagesSha256) {
|
||||
Remove-Item -LiteralPath $ChineseMessages -Force
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ChineseMessages -PathType Leaf)) {
|
||||
Write-Host "Downloading the pinned Simplified Chinese installer messages..."
|
||||
Invoke-WebRequest -Uri $ChineseMessagesUrl -OutFile $ChineseMessages -UseBasicParsing
|
||||
}
|
||||
$ActualLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ActualLanguageHash -ne $ChineseMessagesSha256) {
|
||||
throw "Inno Setup language checksum mismatch. Expected $ChineseMessagesSha256; found $ActualLanguageHash"
|
||||
}
|
||||
|
||||
foreach ($Candidate in @(
|
||||
$Compiler,
|
||||
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
|
||||
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
|
||||
)) {
|
||||
if ($Candidate -and (Test-Compiler -Candidate $Candidate)) {
|
||||
Write-Output (Resolve-Path -LiteralPath $Candidate).Path
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DownloadRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $Installer -PathType Leaf) {
|
||||
$ExistingHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ExistingHash -ne $InstallerSha256) {
|
||||
Remove-Item -LiteralPath $Installer -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
|
||||
Write-Host "Downloading pinned Inno Setup $ToolVersion compiler..."
|
||||
Invoke-WebRequest -Uri $InstallerUrl -OutFile $Installer -UseBasicParsing
|
||||
}
|
||||
|
||||
$ActualHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ActualHash -ne $InstallerSha256) {
|
||||
throw "Inno Setup download checksum mismatch. Expected $InstallerSha256; found $ActualHash"
|
||||
}
|
||||
$Signature = Get-AuthenticodeSignature -LiteralPath $Installer
|
||||
if ($Signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
|
||||
throw "Inno Setup download does not have a valid Authenticode signature: $($Signature.Status)"
|
||||
}
|
||||
if (-not $Signature.SignerCertificate -or
|
||||
$Signature.SignerCertificate.Subject -notmatch "O=Pyrsys B\.V\.") {
|
||||
throw "Inno Setup download has an unexpected signer"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $ToolRoot -Force | Out-Null
|
||||
Write-Host "Installing the pinned Inno Setup compiler into $ToolRoot ..."
|
||||
& $Installer /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER "/DIR=$ToolRoot"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Inno Setup compiler installation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Compiler -Candidate $Compiler)) {
|
||||
throw "Inno Setup installation completed without ISCC.exe: $Compiler"
|
||||
}
|
||||
|
||||
Write-Output $Compiler
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Generate deterministic application-brand assets from the approved master PNG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MASTER = PROJECT_ROOT / "resources" / "branding" / "brand-master.png"
|
||||
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
|
||||
VIDEO_PUBLIC_ROOT = PROJECT_ROOT / "video_companion" / "public"
|
||||
ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
|
||||
def _transparent_connected_background(image: Image.Image) -> Image.Image:
|
||||
"""Remove only near-white pixels connected to the crop boundary.
|
||||
|
||||
The logo contains intentional white ECG strokes. A global color-key would
|
||||
erase them, whereas a connected-background mask preserves enclosed whites.
|
||||
"""
|
||||
|
||||
rgb = image.convert("RGB")
|
||||
candidates = Image.new("L", rgb.size)
|
||||
candidates.putdata(
|
||||
[
|
||||
255
|
||||
if min(pixel) >= 185 and max(pixel) - min(pixel) <= 70
|
||||
else 0
|
||||
for pixel in rgb.getdata()
|
||||
]
|
||||
)
|
||||
ImageDraw.floodfill(candidates, (0, 0), 128, thresh=0)
|
||||
alpha = candidates.point(lambda value: 0 if value == 128 else 255)
|
||||
rgba = rgb.convert("RGBA")
|
||||
rgba.putalpha(alpha)
|
||||
return rgba
|
||||
|
||||
|
||||
def _square_icon(image: Image.Image, size: int = 1024, padding: int = 72) -> Image.Image:
|
||||
available = size - padding * 2
|
||||
scale = min(available / image.width, available / image.height)
|
||||
rendered = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
tile_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(tile_mask).rounded_rectangle(
|
||||
(24, 24, size - 24, size - 24),
|
||||
radius=190,
|
||||
fill=255,
|
||||
)
|
||||
white_tile = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
tile.paste(white_tile, mask=tile_mask)
|
||||
tile.alpha_composite(
|
||||
rendered.convert("RGBA"),
|
||||
((size - rendered.width) // 2, (size - rendered.height) // 2),
|
||||
)
|
||||
return tile
|
||||
|
||||
|
||||
def generate(master_path: Path) -> tuple[Path, ...]:
|
||||
master = Image.open(master_path).convert("RGB")
|
||||
if master.size != (1254, 1254):
|
||||
raise ValueError(f"expected a 1254x1254 brand master, got {master.size}")
|
||||
|
||||
BRANDING_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
VIDEO_PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lockup_bbox = _transparent_connected_background(master).getchannel("A").getbbox()
|
||||
if lockup_bbox is None:
|
||||
raise ValueError("brand lockup extraction produced an empty image")
|
||||
left, top, right, bottom = lockup_bbox
|
||||
full_lockup = master.crop(
|
||||
(
|
||||
max(0, left - 28),
|
||||
max(0, top - 28),
|
||||
min(master.width, right + 28),
|
||||
min(master.height, bottom + 28),
|
||||
)
|
||||
)
|
||||
|
||||
# The supplied artwork places the pictorial mark wholly above y=720. The
|
||||
# crop intentionally excludes the Chinese and English lockup for legible
|
||||
# Windows/macOS small icons.
|
||||
mark_crop = master.crop((300, 110, 980, 720))
|
||||
mark_bbox = _transparent_connected_background(mark_crop).getchannel("A").getbbox()
|
||||
if mark_bbox is None:
|
||||
raise ValueError("application icon extraction produced an empty image")
|
||||
left, top, right, bottom = mark_bbox
|
||||
app_icon = _square_icon(
|
||||
mark_crop.crop(
|
||||
(
|
||||
max(0, left - 12),
|
||||
max(0, top - 12),
|
||||
min(mark_crop.width, right + 12),
|
||||
min(mark_crop.height, bottom + 12),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
lockup_path = BRANDING_ROOT / "brand-lockup.png"
|
||||
icon_png_path = BRANDING_ROOT / "app-icon.png"
|
||||
icon_ico_path = BRANDING_ROOT / "app-icon.ico"
|
||||
icon_icns_path = BRANDING_ROOT / "app-icon.icns"
|
||||
favicon_path = VIDEO_PUBLIC_ROOT / "favicon.png"
|
||||
|
||||
full_lockup.save(lockup_path, optimize=True)
|
||||
app_icon.save(icon_png_path, optimize=True)
|
||||
app_icon.save(
|
||||
icon_ico_path,
|
||||
format="ICO",
|
||||
sizes=[(size, size) for size in ICON_SIZES],
|
||||
)
|
||||
app_icon.save(icon_icns_path, format="ICNS")
|
||||
app_icon.resize((64, 64), Image.Resampling.LANCZOS).save(
|
||||
favicon_path,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
return lockup_path, icon_png_path, icon_ico_path, icon_icns_path, favicon_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--master", type=Path, default=DEFAULT_MASTER)
|
||||
args = parser.parse_args()
|
||||
master_path = args.master.resolve()
|
||||
if not master_path.is_file():
|
||||
parser.error(f"brand master is missing: {master_path}")
|
||||
for output in generate(master_path):
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -17,8 +17,12 @@ $Executable = Join-Path $Artifact "DoctorWorkstation.exe"
|
||||
$DistributionRoot = Join-Path $ProjectRoot "dist"
|
||||
$ReleaseLauncherTemplate = Join-Path $ProjectRoot "packaging\windows\start_release.bat"
|
||||
$ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
|
||||
$InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss"
|
||||
$EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1"
|
||||
$InstallerMessagesFile = Join-Path $ProjectRoot ".build-tools\inno-languages\ChineseSimplified.isl"
|
||||
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
|
||||
$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py"
|
||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||
|
||||
function Test-BuildPython {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
@@ -51,7 +55,10 @@ try {
|
||||
(Join-Path $ProjectRoot "uv.lock"),
|
||||
$ProjectMetadata,
|
||||
$MediaSmokeHook,
|
||||
$ReleaseLauncherTemplate
|
||||
$WindowsIcon,
|
||||
$ReleaseLauncherTemplate,
|
||||
$InstallerDefinition,
|
||||
$EnsureInstallerCompiler
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
|
||||
throw "Required build file is missing: $RequiredFile"
|
||||
@@ -76,7 +83,7 @@ try {
|
||||
-not (Test-BuildPython -Candidate $FallbackPython)) {
|
||||
throw "Neither uv nor a usable Python environment with build dependencies was found."
|
||||
}
|
||||
Write-Host "Windows package entry validation passed."
|
||||
Write-Host "Windows package entry validation passed. No artifacts were generated."
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -161,6 +168,8 @@ try {
|
||||
$ReleaseZip = Join-Path $DistributionRoot (
|
||||
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
|
||||
)
|
||||
$InstallerBaseName = "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion"
|
||||
$InstallerArtifact = Join-Path $DistributionRoot "$InstallerBaseName.exe"
|
||||
$ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt"
|
||||
Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force
|
||||
if (Test-Path -LiteralPath $ReleaseZip) {
|
||||
@@ -192,15 +201,54 @@ try {
|
||||
throw "Packaging completed without the expected ZIP: $ReleaseZip"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $InstallerArtifact) {
|
||||
Remove-Item -LiteralPath $InstallerArtifact -Force
|
||||
}
|
||||
Write-Host "Preparing the pinned Inno Setup compiler..."
|
||||
$InnoCompiler = (& $EnsureInstallerCompiler | Select-Object -Last 1)
|
||||
if (-not $InnoCompiler -or
|
||||
-not (Test-Path -LiteralPath $InnoCompiler -PathType Leaf)) {
|
||||
throw "Unable to locate the Inno Setup compiler"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $InstallerMessagesFile -PathType Leaf)) {
|
||||
throw "Simplified Chinese installer messages are missing: $InstallerMessagesFile"
|
||||
}
|
||||
|
||||
Write-Host "Creating the Windows installer..."
|
||||
& $InnoCompiler `
|
||||
"/DAppVersion=$ProjectVersion" `
|
||||
"/DSourceDir=$Artifact" `
|
||||
"/DOutputDir=$DistributionRoot" `
|
||||
"/DSetupBaseName=$InstallerBaseName" `
|
||||
"/DChineseMessagesFile=$InstallerMessagesFile" `
|
||||
"/DAppIconFile=$WindowsIcon" `
|
||||
$InstallerDefinition
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Inno Setup failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $InstallerArtifact -PathType Leaf)) {
|
||||
throw "Installer compilation completed without the expected artifact: $InstallerArtifact"
|
||||
}
|
||||
|
||||
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
|
||||
$ChecksumLine = "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))`r`n"
|
||||
$InstallerHash = (Get-FileHash -LiteralPath $InstallerArtifact -Algorithm SHA256).Hash
|
||||
$ChecksumLines = @(
|
||||
"$InstallerHash $([System.IO.Path]::GetFileName($InstallerArtifact))",
|
||||
"$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))"
|
||||
)
|
||||
$Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ChecksumFile, $ChecksumLine, $Utf8WithoutBom)
|
||||
[System.IO.File]::WriteAllText(
|
||||
$ChecksumFile,
|
||||
(($ChecksumLines -join "`r`n") + "`r`n"),
|
||||
$Utf8WithoutBom
|
||||
)
|
||||
|
||||
Write-Host "Windows package complete." -ForegroundColor Green
|
||||
Write-Host "Artifact: $Artifact" -ForegroundColor Green
|
||||
Write-Host "Installer: $InstallerArtifact" -ForegroundColor Green
|
||||
Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green
|
||||
Write-Host "SHA-256: $ReleaseHash" -ForegroundColor Green
|
||||
Write-Host "Installer SHA-256: $InstallerHash" -ForegroundColor Green
|
||||
Write-Host "ZIP SHA-256: $ReleaseHash" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -31,6 +31,8 @@ class _ScreenshotDiagnosisDialog(QWidget):
|
||||
|
||||
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
|
||||
|
||||
DENSITY_SIZES = ((1366, 768), (1710, 920))
|
||||
|
||||
|
||||
def _row(identifier: int, variant: int) -> dict[str, Any]:
|
||||
common: dict[str, Any] = {
|
||||
@@ -335,7 +337,7 @@ def _save_with_payment_qr(
|
||||
return path
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
||||
# the same CJK face used by the production QSS when it is available.
|
||||
@@ -345,12 +347,35 @@ def render() -> list[Path]:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
return app
|
||||
|
||||
|
||||
def render_density() -> list[Path]:
|
||||
"""Render only the two desktop-density acceptance sizes."""
|
||||
|
||||
app = _application()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
repository = ScreenshotRepository()
|
||||
for width, height in ((1024, 640), (1440, 900)):
|
||||
for width, height in DENSITY_SIZES:
|
||||
page = _new_page(app, repository, width, height)
|
||||
path = output / f"diagnosis_{width}x{height}.png"
|
||||
paths.append(_save(page, path))
|
||||
page.close()
|
||||
app.processEvents()
|
||||
return paths
|
||||
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = _application()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[Path] = []
|
||||
repository = ScreenshotRepository()
|
||||
for width, height in ((1024, 640), *DENSITY_SIZES, (1440, 900)):
|
||||
page = _new_page(app, repository, width, height)
|
||||
path = output / f"diagnosis_{width}x{height}.png"
|
||||
paths.append(_save(page, path))
|
||||
|
||||