Compare commits

...
12 Commits
Author SHA1 Message Date
Your Name 47094cc617 更新 2026-08-25 09:36:25 +08:00
Your Name 01c38d8c5b 更新 2026-08-25 09:35:47 +08:00
Your Name 1f3e580cf8 更新 2026-08-25 09:34:39 +08:00
Your Name 562fe0ea0e 更新 2026-08-24 16:56:37 +08:00
Your Name d9bb94cd3f 更新 2026-08-22 10:50:20 +08:00
Your Name 9646ccd3f6 gengxin 2026-08-22 10:49:34 +08:00
Your Name 43e5411b6a 更新 2026-08-22 10:46:13 +08:00
Your Name c06d293424 更新 2026-08-22 08:51:35 +08:00
Your Name 6c444a4a04 更新 2026-08-20 18:01:36 +08:00
Your Name cc6173e5b0 更新 2026-08-20 17:56:08 +08:00
Your Name 5794f60c5d 更新bug 2026-08-20 17:47:14 +08:00
Your Name 35f91ee37a 更新 2026-08-18 14:14:43 +08:00
562 changed files with 25934 additions and 4452 deletions
+34 -33
View File
@@ -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
+90
View File
@@ -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.…` → 拆出序言 + 编号列表
- 短中文标签 `XXYY` 开头 → 视为 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)
+41 -33
View File
@@ -190,9 +190,9 @@ export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
@@ -202,9 +202,17 @@ export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
}
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
@@ -230,27 +238,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 +268,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',
@@ -98,7 +98,16 @@
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<strong class="metric-value">
{{ formatMetric(metric.key, metric.type) }}
<em
v-if="metric.key === 'add_fans_count' && hasDeletedFans(dashboard.summary.deleted_fans_count)"
class="deleted-fans-marker"
title="加粉总数中已删除"
>
-{{ formatNumber(dashboard.summary.deleted_fans_count) }}
</em>
</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
@@ -145,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间新增加粉按员工+客户去重包含区间内添加后已删客户剔除继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加-N表示加粉总数中已删除挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -178,7 +187,20 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
<el-table-column prop="add_fans_count" label="加粉" min-width="92" align="right">
<template #default="{ row }">
<span class="fan-count-value">
{{ formatNumber(row.add_fans_count) }}
<em
v-if="hasDeletedFans(row.deleted_fans_count)"
class="deleted-fans-marker"
title="加粉总数中已删除"
>
-{{ formatNumber(row.deleted_fans_count) }}
</em>
</span>
</template>
</el-table-column>
<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" />
@@ -335,7 +357,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: '区间新增加粉(含已删除);(-N)为其中已删除' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -481,6 +503,10 @@ function formatNumber(value: any) {
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
}
function hasDeletedFans(value: any) {
return Math.round(Number(value || 0)) > 0
}
function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
@@ -621,6 +647,26 @@ onMounted(loadDashboard)
small { color: #a0a9b6; font-size: 11px; }
}
.metric-value,
.fan-count-value {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
gap: 3px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.metric-value { display: flex; justify-content: flex-start; }
.deleted-fans-marker {
color: #c65f26;
font-size: .58em;
font-style: normal;
font-weight: 650;
}
.fan-count-value .deleted-fans-marker { font-size: 11px; }
.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; }
@@ -239,7 +239,7 @@
<div class="install-tip">
<el-icon><CircleCheck /></el-icon>
<span><strong>无需暴露链接</strong>浮窗点击后仍由服务端执行可用性筛选权重随机和访问统计</span>
<span><strong>一个方案一个官方链接</strong>浮窗会直接打开企业微信链接多名医助由企业微信执行均衡分流</span>
</div>
</section>
</section>
@@ -274,6 +274,8 @@ interface PromotionPool {
name?: string
public_key?: string
script_url?: string
main_url?: string
compat_go_url?: string
go_url?: string
install_code?: string
trigger_code?: string
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
}
function openTestLink() {
if (!props.pool.go_url) {
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
if (!targetUrl) {
ElMessage.warning('当前方案暂无测试链接')
return
}
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
window.open(targetUrl, '_blank', 'noopener,noreferrer')
}
</script>
@@ -5,7 +5,7 @@
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
<div>
<h1>企业微信获客助手</h1>
<p>对接企业微信获客助手官方 API按角色与部门管理获客成员并将链接安全分流到推广页面</p>
<p>一个方案保留一条企业微信官方直链按回调结果自动调度医助权重与数量</p>
</div>
</div>
<div class="heading-actions">
@@ -22,15 +22,15 @@
</article>
<article class="metric-card">
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案对应一个官方链接</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
<div><small>官方链接</small><strong>{{ overview.summary.online_links }}</strong><p>链接不变成员范围自动切换</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
<div><small>今日实际获客</small><strong>{{ overview.summary.today_clicks }}</strong><p>按企业微信回调确认承接成员</p></div>
</article>
</section>
@@ -52,9 +52,9 @@
<div v-if="activeTab === 'links'" class="tab-content links-tab">
<div class="section-heading">
<div>
<h2>获客助手链接分流</h2>
<p>访客点击时由服务端从当前可用的获客助手链接中按权重随机选择页面端不会暴露完整链接</p>
<div>
<h2>获客助手分流方案</h2>
<p>选择几名医助就生成几条成员规则官方链接只在方案顶部复制成员行不重复展示链接</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
</div>
@@ -69,7 +69,7 @@
@click="selectedPoolId = Number(pool.id)"
>
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
<span><strong>{{ pool.name }}</strong><small>{{ memberSummary(pool) }} · {{ pool.main_url ? '官方链接已生成' : '待生成链接' }}</small></span>
<el-icon><ArrowRight /></el-icon>
</button>
</aside>
@@ -84,100 +84,77 @@
</span>
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
</div>
<p>公开键{{ selectedPool.public_key }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button :icon="Refresh" :loading="syncingRemote" @click="syncRemoteLinks">同步企业微信</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
<el-button type="primary" :icon="Plus" @click="openLinkDialog()">创建官方获客链接</el-button>
</div>
</div>
<el-alert
v-if="selectedLegacyCount > 0 && selectedOfficialCount === 0"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
:title="`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
description="官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
/>
<el-table :data="selectedLinks" class="link-table" stripe>
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.name }}</strong><small>{{ row.group_name || '默认分组' }}</small></div>
</template>
</el-table-column>
<el-table-column label="企业微信链接 ID" min-width="280">
<template #default="{ row }">
<div class="link-id-cell">
<span v-if="row.remote_link_id" class="remote-id" :title="row.remote_link_id">{{ row.remote_link_id }}</span>
<span v-else class="legacy-tag">历史手工链接</span>
<a
v-if="row.wecom_url"
class="wecom-url"
:href="row.wecom_url"
:title="row.wecom_url"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ row.wecom_url }}</a>
<el-button
v-if="row.wecom_url"
type="primary"
link
class="copy-url-btn"
@click.stop="copyText(row.wecom_url, '企业微信链接')"
>复制链接</el-button>
<span v-else class="muted">暂无链接地址</span>
</div>
</template>
</el-table-column>
<el-table-column label="可用成员" min-width="150">
<template #default="{ row }">
<span>{{ remoteMemberLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="84" align="center" />
<el-table-column label="今日 / 上限" min-width="120" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.status) === 1" :disabled="Number(row.remote_status) === 2" @change="(value) => handleToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="245" fixed="right">
<template #default="{ row }">
<el-button v-if="row.is_official && Number(row.remote_status) !== 2" type="primary" link :loading="refreshingLinkId === Number(row.id)" @click="refreshRemoteDetail(row)">刷新详情</el-button>
<el-button v-if="!row.is_official || Number(row.remote_status) !== 2" type="primary" link @click="openLinkDialog(row)">编辑</el-button>
<el-dropdown v-if="row.is_official" trigger="click" @command="(command) => handleLinkCommand(command, row)">
<el-button type="primary" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="local">从本地移除</el-dropdown-item>
<el-dropdown-item v-if="Number(row.remote_status) !== 2" command="remote" divided class="danger-command">永久删除企业微信链接</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="danger" link @click="removeLink(row)">从本地移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前方案还没有获客助手链接" /></template>
</el-table>
<p>公开键{{ selectedPool.public_key }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
</div>
</div>
<el-alert
v-if="selectedPool.migration_state === 'needs_resolution'"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
title="检测到旧版方案存在多个官方链接"
description="当前显示最新链接;保存方案后,其余旧链接只会在本地下线,企业微信端与历史客户归因仍会保留。"
/>
<el-alert
class="legacy-sync-alert"
type="info"
show-icon
:closable="false"
title="回调驱动分流"
description="企业微信确认实际承接成员后,系统累计今日数量,并在符合启用状态、有效期和上限的成员中按权重随机抽取下一位;随机结果可能连续命中同一人,并发访问及企微同步存在短暂延迟。"
/>
<el-table :data="selectedMemberRules" class="link-table" stripe>
<el-table-column label="推广成员" min-width="210" fixed="left">
<template #default="{ row }">
<div class="member-cell">
<strong>{{ row.name || row.userid }}</strong>
<small>{{ memberRuleDetail(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="调度" width="105" align="center">
<template #default="{ row }">
<span v-if="row.is_current" class="status-tag is-online">{{ Number(row.sync_status) === 0 ? '当前命中' : '待切换' }}</span>
<span v-else-if="row.is_applied" class="status-tag is-offline">切换中</span>
<span v-else class="muted">候选</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="85" align="center" />
<el-table-column label="今日 / 上限" width="125" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计获客" prop="total_count" width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.enabled) === 1" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
</el-table>
</div>
</div>
<el-empty v-else description="创建一个分流方案,再添加企业微信获客助手链接">
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
</el-empty>
</div>
@@ -187,11 +164,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 +184,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 +197,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 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/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 +221,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 +246,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>
@@ -360,76 +340,90 @@
</div>
</section>
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
<span class="form-tip">仅接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。</span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">保存方案</el-button></template>
</el-dialog>
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑获客助手链接' : '创建企业微信官方获客链接'" width="720px" destroy-on-close>
<el-form label-position="top" class="link-form">
<div class="form-grid">
<el-form-item label="获客链接名称" required><el-input v-model="linkForm.name" maxlength="80" placeholder="用于企业微信和后台识别" /></el-form-item>
<el-form-item label="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="获客成员" required>
<el-select v-model="linkForm.member_admin_ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="仅显示当前角色和部门可管理、且已绑定企业微信的成员" style="width: 100%">
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
</el-select>
<span class="form-tip">官方链接按这里的企业微信 userid 建立可见范围越权成员不会出现在列表中</span>
</el-form-item>
<el-form-item v-else label="企业微信获客助手链接" required>
<el-input v-model="linkForm.wecom_url" type="textarea" :rows="2" :placeholder="overview.customer_acquisition_link_example" />
<span class="form-tip">这是历史手工链接只更新本地分流规则不会调用企业微信创建或修改接口</span>
</el-form-item>
<div class="form-grid">
<el-form-item label="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
<el-form-item label="每日点击上限"><el-input-number v-model="linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip">0 表示不限</span></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="添加客户时跳过验证">
<el-switch v-model="linkForm.skip_verify" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</el-form-item>
<el-form-item label="上线状态"><el-switch v-model="linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" /></el-form-item>
<el-form-item label="生效时间段">
<el-date-picker v-model="linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" />
<span class="form-tip">不选择表示长期有效</span>
</el-form-item>
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
</el-dialog>
<el-dialog
v-model="poolDialogVisible"
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
width="680px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!savingPool"
>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" :disabled="savingPool" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="获客医助" required>
<el-select
v-model="poolForm.member_admin_ids"
:disabled="savingPool"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择后由企业微信生成一个多人分流链接"
style="width: 100%"
>
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
</el-select>
<span class="form-tip">保存后会为每名医助生成一条成员规则官方链接地址始终只有一个</span>
</el-form-item>
<el-form-item label="添加客户时跳过验证">
<el-switch v-model="poolForm.skip_verify" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</el-form-item>
<el-form-item label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" :disabled="savingPool" placeholder="可选;仅供旧版兼容跳转使用" />
<span class="form-tip">新生成的官方直链不经过本站跳转此项仅兼容旧安装代码</span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
</el-dialog>
<el-dialog v-model="memberDialogVisible" title="编辑医助分流规则" width="560px" destroy-on-close :close-on-click-modal="false">
<el-form label-position="top">
<el-form-item label="获客医助">
<el-input :model-value="memberForm.name ? `${memberForm.name} · ${memberForm.userid}` : memberForm.userid" readonly />
</el-form-item>
<div class="rule-form-grid">
<el-form-item label="权重" required>
<el-input-number v-model="memberForm.weight" :min="1" :max="100" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">权重越高在后续回调调度中被选中的频率越高</span>
</el-form-item>
<el-form-item label="每日获客上限">
<el-input-number v-model="memberForm.daily_limit" :min="0" :max="1000000" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">0 表示不限数量按实际获客回调累计</span>
</el-form-item>
</div>
<el-form-item label="有效时间">
<el-date-picker v-model="memberForm.active_range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" :disabled="savingMember" style="width: 100%" />
</el-form-item>
<el-form-item label="上线状态">
<el-switch v-model="memberForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" :disabled="savingMember" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="memberForm.remark" type="textarea" :rows="2" maxlength="255" show-word-limit :disabled="savingMember" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="savingMember" @click="memberDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingMember" @click="saveMemberRule">保存规则</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="firstVisitWecomPromotionPage">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeleteLink,
wecomPromotionDeleteRemoteLink,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionRemoteLinkDetail,
wecomPromotionSaveLink,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionSaveMember,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionToggleMember
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
@@ -444,38 +438,35 @@ const emptyOverview = () => ({
const overview = reactive(emptyOverview())
const loading = ref(false)
const activeTab = ref<TabName>('links')
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
const poolDialogVisible = ref(false)
const linkDialogVisible = ref(false)
const savingPool = ref(false)
const savingLink = ref(false)
const checkingApi = ref(false)
const syncingRemote = ref(false)
const refreshingLinkId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1 })
const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分组', wecom_url: '', member_admin_ids: [] as number[], skip_verify: 0, legacy: false, weight: 1, status: 1, daily_limit: 0, active_range: [] as string[], remark: '' })
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
const poolDialogVisible = ref(false)
const savingPool = ref(false)
const checkingApi = ref(false)
const memberDialogVisible = ref(false)
const savingMember = ref(false)
const togglingMemberId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
const memberForm = reactive({ id: 0, name: '', userid: '', weight: 1, daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
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))
const selectedOfficialCount = computed(() => selectedLinks.value.filter((item: any) => Boolean(item.remote_link_id)).length)
const selectedLegacyCount = computed(() => selectedLinks.value.length - selectedOfficialCount.value)
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const customerLinkOptions = computed(() => customerStats.link_options.length
? customerStats.link_options
@@ -513,24 +504,37 @@ async function loadOverview() {
}
}
function linkCount(poolId: number) {
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
}
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
savingPool.value = true
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? {
id: Number(pool.id),
name: pool.name,
fallback_url: pool.fallback_url || '',
status: Number(pool.status),
member_admin_ids: Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids.map(Number) : [],
skip_verify: Number(pool.skip_verify) === 1 ? 1 : 0,
main_url: pool.main_url || ''
} : {
id: 0,
name: '',
fallback_url: '',
status: 1,
member_admin_ids: [],
skip_verify: 0,
main_url: ''
})
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
if (!poolForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客医助')
savingPool.value = true
try {
const result: any = await wecomPromotionSavePool({ ...poolForm })
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
ElMessage.success('分流方案已保存')
ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} finally {
@@ -538,8 +542,8 @@ async function savePool() {
}
}
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的获客助手链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,本站将停止使用该链接,但不会永久删除企业微信后台中的官方链接。确认继续?`, '删除分流方案', { type: 'warning' })
try {
await wecomPromotionDeletePool({ id: Number(pool.id) })
ElMessage.success('分流方案已删除')
@@ -547,50 +551,60 @@ async function removePool(pool: any) {
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function openLinkDialog(row?: any) {
if (!selectedPool.value) return
const range = row && Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] : []
const selectedUserIds = new Set<string>((row?.range_userids || []).map((item: unknown) => String(item)))
const selectedMemberIds = overview.member_options
.filter((member: any) => selectedUserIds.has(String(member.userid)))
.map((member: any) => Number(member.id))
Object.assign(linkForm, row ? {
id: Number(row.id), pool_id: Number(row.pool_id),
name: row.name, group_name: row.group_name || '默认分组', wecom_url: row.wecom_url,
member_admin_ids: selectedMemberIds, skip_verify: Number(row.skip_verify) === 1 ? 1 : 0, legacy: !row.is_official,
weight: Number(row.weight) || 1, status: Number(row.status), daily_limit: Number(row.daily_limit) || 0,
active_range: range, remark: row.remark || ''
} : {
id: 0, pool_id: Number(selectedPool.value.id), name: '', group_name: '默认分组',
wecom_url: '', member_admin_ids: [], skip_verify: 0, legacy: false,
weight: 1, status: 1, daily_limit: 0, active_range: [], remark: ''
})
linkDialogVisible.value = true
}
async function saveLink() {
if (!linkForm.name.trim()) return ElMessage.warning('请输入获客链接名称')
if (linkForm.legacy && !linkForm.wecom_url.trim()) return ElMessage.warning('请填写企业微信获客助手链接')
if (!linkForm.legacy && !linkForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客成员')
savingLink.value = true
try {
await wecomPromotionSaveLink({
...linkForm,
active_start: linkForm.active_range?.[0] || '',
active_end: linkForm.active_range?.[1] || ''
})
linkDialogVisible.value = false
await loadOverview()
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
} catch (error: any) {
ElMessage.error(error?.message || '获客助手链接保存失败')
} finally {
savingLink.value = false
}
}
}
function openMemberDialog(row: any) {
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: []
Object.assign(memberForm, {
id: Number(row.id),
name: row.name || '',
userid: row.userid || '',
weight: Number(row.weight) || 1,
daily_limit: Number(row.daily_limit) || 0,
status: Number(row.enabled) === 1 ? 1 : 0,
active_range: range,
remark: row.remark || ''
})
memberDialogVisible.value = true
}
async function saveMemberRule() {
savingMember.value = true
try {
const result: any = await wecomPromotionSaveMember({
...memberForm,
active_start: memberForm.active_range?.[0] || '',
active_end: memberForm.active_range?.[1] || ''
})
memberDialogVisible.value = false
await loadOverview()
result?.sync_error
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('医助分流规则已保存')
} catch (error: any) {
ElMessage.error(error?.message || '医助分流规则保存失败')
} finally {
savingMember.value = false
}
}
async function handleMemberToggle(row: any, value: unknown) {
togglingMemberId.value = Number(row.id)
try {
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
await loadOverview()
result?.sync_error
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success(value ? '医助已启用' : '医助已禁用并退出后续调度')
} catch (error: any) {
ElMessage.error(error?.message || '成员状态更新失败')
await loadOverview()
} finally {
togglingMemberId.value = 0
}
}
async function checkApiPermission() {
checkingApi.value = true
@@ -630,10 +644,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 +657,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 +675,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 +691,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()
}
@@ -707,124 +721,45 @@ async function syncCustomers() {
}
}
async function syncRemoteLinks() {
if (!selectedPool.value) return ElMessage.warning('请先选择分流方案')
syncingRemote.value = true
try {
const result: any = await wecomPromotionSyncRemoteLinks({ pool_id: Number(selectedPool.value.id) })
const message = `扫描 ${Number(result?.scanned || 0)} 条,新建 ${Number(result?.created || 0)} 条,更新 ${Number(result?.updated || 0)} 条,按权限跳过 ${Number(result?.skipped || 0)} 条,失败 ${Number(result?.failed || 0)}`
if (Number(result?.scanned || 0) === 0) {
await ElMessageBox.alert(
`${result?.empty_reason || '当前应用没有可同步的官方获客链接'}\n\n${result?.suggestion || ''}`,
'没有可同步的官方链接',
{ type: 'warning', confirmButtonText: '知道了' }
)
} else {
result?.failed ? ElMessage.warning(message) : ElMessage.success(message)
}
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '同步企业微信获客链接失败')
} finally {
syncingRemote.value = false
}
}
async function refreshRemoteDetail(row: any) {
refreshingLinkId.value = Number(row.id)
try {
await wecomPromotionRemoteLinkDetail({ id: Number(row.id) })
ElMessage.success('企业微信官方详情已刷新')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '刷新官方详情失败')
} finally {
refreshingLinkId.value = 0
}
}
async function handleLinkCommand(command: string, row: any) {
if (command === 'local') {
await removeLink(row)
return
}
if (command !== 'remote') return
await ElMessageBox.confirm(
`将永久删除企业微信后台中的“${row.name}”,现有推广页面会立即失效,且无法恢复。确认继续?`,
'永久删除企业微信获客链接',
{ type: 'error', confirmButtonText: '永久删除', cancelButtonText: '取消' }
)
try {
await wecomPromotionDeleteRemoteLink({ id: Number(row.id) })
ElMessage.success('企业微信获客链接已永久删除,本地记录已保留并停止分流')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '永久删除失败')
}
}
async function handleToggle(row: any, value: unknown) {
try {
await wecomPromotionToggleLink({ id: Number(row.id), status: value ? 1 : 0 })
row.status = value ? 1 : 0
ElMessage.success(value ? '链接已上线' : '链接已下线')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '状态更新失败')
await loadOverview()
}
}
async function removeLink(row: any) {
await ElMessageBox.confirm(`仅从本地分流池移除“${row.name}”,不会删除企业微信后台的官方链接。确认继续?`, '从本地移除', { type: 'warning' })
try {
await wecomPromotionDeleteLink({ id: Number(row.id) })
ElMessage.success('已从本地分流池移除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function eligibility(row: any) {
const now = Math.floor(Date.now() / 1000)
if (Number(row.remote_status) === 2) return { label: '企业微信已删除', className: 'is-error' }
if (row.valid_customer_acquisition_link === false) return { label: '非获客助手链接', className: 'is-error' }
if (Number(row.status) !== 1) return { label: '已下线', className: 'is-muted' }
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
function eligibility(row: any) {
const now = Math.floor(Date.now() / 1000)
if (Number(row.enabled) !== 1) return { label: '已禁用', className: 'is-muted' }
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
return { label: '可参与分流', className: 'is-ok' }
}
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
const isSyncTarget = Boolean(row.is_current || row.is_applied)
if (isSyncTarget && Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' }
if (isSyncTarget && Number(row.sync_status) === 4) return { label: '暂无可用调度', className: 'is-error' }
return { label: '可参与分流', className: 'is-ok' }
}
function todayCount(row: any) {
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
return row.today_date === today ? Number(row.today_count || 0) : 0
}
function memberOptionLabel(member: any) {
function memberOptionLabel(member: any) {
const departments = Array.isArray(member.dept_names) && member.dept_names.length
? member.dept_names.join(' / ')
: '未分部门'
return `${member.name} · ${departments} · ${member.userid}`
}
return `${member.name} · ${departments} · ${member.userid}`
}
function memberSummary(pool: any) {
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
return ids.length ? `${ids.length} 名医助` : '未选择医助'
}
function remoteMemberLabel(row: any) {
const userIds = Array.isArray(row.range_userids) ? row.range_userids : []
const names = userIds.map((userid: unknown) => {
const member = overview.member_options.find((item: any) => String(item.userid) === String(userid))
return member?.name || String(userid)
})
if (names.length) return names.length > 2 ? `${names.slice(0, 2).join('、')}${names.length}` : names.join('、')
const departments = Array.isArray(row.range_department_ids) ? row.range_department_ids : []
if (departments.length) return `企微部门 ${departments.join('、')}`
return row.is_official ? '未返回成员范围' : '手工配置'
}
function memberRuleDetail(row: any) {
const departments = Array.isArray(row.dept_names) && row.dept_names.length ? row.dept_names.join(' / ') : '未分部门'
return `${departments} · ${row.userid || '未绑定 userid'}`
}
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 +814,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 '-'
@@ -905,17 +840,17 @@ function formatCustomerRate(value: unknown) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN')
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN')
}
function formatPickerTime(value: unknown) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatPickerTime(value: unknown) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function copyText(value: string, label: string) {
async function copyText(value: string, label: string) {
if (!value) return ElMessage.warning(`${label}暂无内容`)
try {
await navigator.clipboard.writeText(value)
@@ -926,8 +861,9 @@ async function copyText(value: string, label: string) {
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
const copied = document.execCommand('copy')
textarea.remove()
if (!copied) throw new Error('copy command failed')
}
ElMessage.success(`${label}已复制`)
}
@@ -998,7 +934,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; }
@@ -1006,7 +942,7 @@ h1, h2, h3, p { margin: 0; }
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.install-pool-select { width: 220px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
.form-grid, .rule-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
</style>
+12 -5
View File
@@ -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-256Windows 推荐使用
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>
+28 -28
View File
@@ -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 || '获取聊天签名失败')
+22 -16
View File
@@ -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',
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
base: '/admin/',
server: {
port: 5555,
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
+2
View File
@@ -1,6 +1,8 @@
.env
.venv/
.venv-build/
.venv-win7-check/
.build-tools/
.uv-cache/
.uv-python/
__pycache__/
+6 -5
View File
@@ -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%
+4 -2
View File
@@ -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 1809Qt 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 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

+24 -1
View File
@@ -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 1664 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
+8 -1
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
version = "1.0.0"
description = "Cross-platform doctor consultation workstation for Windows and macOS"
readme = "README.md"
requires-python = ">=3.11"
+173
View File
@@ -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 closedA/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 都回显 ownerapp 对 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、超时 1300 秒,且启用 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/openaitimeout 1300 秒 | 正向控制;缺传输级响应大小上限与生产域名 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 报告是唯一真正覆盖视频转写、备注、聊天和日常记录的入口,却没有正式处方/处方病历,并且全历史、无限行的实现带来显著资源与隐私保留风险。
建议把“统一上下文”定义为**统一的数据访问、鉴权、脱敏、来源清单和截断协议**,而不是要求所有入口发送同样多的数据。饮食问答应最小化;纵向报告可更完整;诊单助手应在可控时间窗内补齐临床关键项。这样才能同时解决答案一致性、隐私最小化和成本边界。
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

-5
View File
@@ -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

+5
View File
@@ -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
+4
View File
@@ -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
+315
View File
@@ -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()
+102
View File
@@ -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
+139
View File
@@ -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())
+53 -5
View File
@@ -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 {
@@ -61,6 +61,7 @@ def _appointment_rows() -> list[dict[str, Any]]:
"id": 801 + index,
"diagnosis_id": 901 + index,
"patient_id": 401 + index,
"source_patient_id": 401 + index,
"patient_name": names[index % len(names)],
"patient_phone": f"1380013{8000 + index}",
"gender": 2 if index % 2 == 0 else 1,
@@ -77,6 +78,11 @@ def _appointment_rows() -> list[dict[str, Any]]:
"status_desc": "已挂号",
"revisit_time": "复诊" if index % 2 else "初诊",
"unserved_days": index,
"video_call_hint": (
{"state": "live", "label": "视频通话进行中"}
if index == 0
else {"state": "idle", "label": "等待医生发起"}
),
}
)
return rows
@@ -161,15 +167,17 @@ def render() -> list[Path]:
raise RuntimeError(
f"appointment table only exposes {appointment_slots} ordinary rows"
)
if width == 1024 and not appointments.video_panel.isHidden():
raise RuntimeError("narrow appointment viewport did not collapse the video panel")
if appointments.content_layout.count() != 1:
raise RuntimeError("appointment list still reserves a secondary side panel")
if appointments.table_card.width() != appointments.content_host.width():
raise RuntimeError("appointment table does not fill the content viewport")
appointment_path = output / f"appointments_{width}x{height}.png"
if not shell.grab().save(str(appointment_path), "PNG"):
raise RuntimeError(f"failed to save {appointment_path}")
paths.append(appointment_path)
print(
f"{width}x{height}: patient_slots={patient_slots}, "
f"appointment_slots={appointment_slots}, video_hidden={appointments.video_panel.isHidden()}"
f"appointment_slots={appointment_slots}, table_full_width=True"
)
shell.close()
application.processEvents()
+157
View File
@@ -0,0 +1,157 @@
[CmdletBinding()]
param(
[string]$Installer
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Drawing
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Get-AssociatedIconHash {
param([Parameter(Mandatory = $true)][string]$FilePath)
$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($FilePath)
if (-not $Icon) {
throw "Unable to extract the Windows icon from: $FilePath"
}
$Bitmap = New-Object System.Drawing.Bitmap 32, 32
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
$Stream = New-Object System.IO.MemoryStream
$Hasher = [System.Security.Cryptography.SHA256]::Create()
try {
$Graphics.Clear([System.Drawing.Color]::Transparent)
$Graphics.DrawIcon($Icon, 0, 0)
$Bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png)
$Hash = $Hasher.ComputeHash($Stream.ToArray())
return ([System.BitConverter]::ToString($Hash)).Replace("-", "")
}
finally {
$Hasher.Dispose()
$Stream.Dispose()
$Graphics.Dispose()
$Bitmap.Dispose()
$Icon.Dispose()
}
}
if (-not $Installer) {
$Installer = Get-ChildItem `
-LiteralPath (Join-Path $ProjectRoot "dist") `
-Filter "DoctorWorkstation-Setup-Windows-x64-*.exe" `
-File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 -ExpandProperty FullName
}
if (-not $Installer -or -not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
throw "Windows installer was not found: $Installer"
}
$Installer = (Resolve-Path -LiteralPath $Installer).Path
$InstallerIconHash = Get-AssociatedIconHash -FilePath $Installer
$TempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$SmokeRoot = [System.IO.Path]::GetFullPath((Join-Path $TempBase (
"doctor-workstation-installer-smoke-" + [guid]::NewGuid().ToString("N")
)))
if (-not $SmokeRoot.StartsWith($TempBase, [System.StringComparison]::OrdinalIgnoreCase) -or
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith(
"doctor-workstation-installer-smoke-"
)) {
throw "Refusing to use an unsafe installer smoke directory: $SmokeRoot"
}
$InstallDirectory = Join-Path $SmokeRoot "install"
$SetupLog = Join-Path $SmokeRoot "setup.log"
$UninstallLog = Join-Path $SmokeRoot "uninstall.log"
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
$SetupProcess = Start-Process `
-FilePath $Installer `
-ArgumentList @(
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/CURRENTUSER",
"/DIR=$InstallDirectory",
"/MERGETASKS=!desktopicon",
"/LOG=$SetupLog"
) `
-Wait `
-PassThru `
-WindowStyle Hidden
if ($SetupProcess.ExitCode -ne 0) {
throw "Installer exited with code $($SetupProcess.ExitCode). See $SetupLog"
}
$InstalledExecutable = Join-Path $InstallDirectory "DoctorWorkstation.exe"
if (-not (Test-Path -LiteralPath $InstalledExecutable -PathType Leaf)) {
throw "Installed executable is missing: $InstalledExecutable"
}
$Uninstaller = Join-Path $InstallDirectory "unins000.exe"
if (-not (Test-Path -LiteralPath $Uninstaller -PathType Leaf)) {
throw "Uninstaller is missing: $Uninstaller"
}
$InstalledIconHash = Get-AssociatedIconHash -FilePath $InstalledExecutable
$UninstallerIconHash = Get-AssociatedIconHash -FilePath $Uninstaller
if ($InstalledIconHash -ne $InstallerIconHash) {
throw "Installed executable icon does not match the installer brand icon"
}
if ($UninstallerIconHash -ne $InstallerIconHash) {
throw "Uninstaller icon does not match the installer brand icon"
}
$Environment = @{
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "config")
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "logs")
"DOCTOR_API_BASE_URL" = "https://127.0.0.1:9"
"DOCTOR_DEMO_MODE" = "true"
"DOCTOR_VIDEO_MODE" = "embedded"
"DOCTOR_SMOKE_TEST" = "1"
"QT_QPA_PLATFORM" = "offscreen"
}
$PreviousEnvironment = @{}
$ApplicationExitCode = $null
$UninstallExitCode = $null
try {
foreach ($Name in $Environment.Keys) {
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
}
$ApplicationProcess = Start-Process `
-FilePath $InstalledExecutable `
-ArgumentList "--smoke-test" `
-Wait `
-PassThru `
-WindowStyle Hidden
$ApplicationExitCode = $ApplicationProcess.ExitCode
}
finally {
foreach ($Name in $Environment.Keys) {
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
}
if (Test-Path -LiteralPath $Uninstaller -PathType Leaf) {
$UninstallProcess = Start-Process `
-FilePath $Uninstaller `
-ArgumentList @(
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/LOG=$UninstallLog"
) `
-Wait `
-PassThru `
-WindowStyle Hidden
$UninstallExitCode = $UninstallProcess.ExitCode
}
}
if ($ApplicationExitCode -ne 0) {
throw "Installed application smoke test exited with code $ApplicationExitCode"
}
if ($UninstallExitCode -ne 0) {
throw "Uninstaller exited with code $UninstallExitCode. See $UninstallLog"
}
Start-Sleep -Milliseconds 500
if (Test-Path -LiteralPath $InstalledExecutable) {
throw "Uninstaller left the installed executable behind: $InstalledExecutable"
}
Write-Host "Installer icon/install/start/uninstall smoke test passed." -ForegroundColor Green
Write-Host "Smoke logs and isolated user data: $SmokeRoot"
+13 -5
View File
@@ -26,7 +26,7 @@ from doctor_workstation.config import AppConfig
from doctor_workstation.core import Session
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.logging_setup import configure_logging
from doctor_workstation.resources import resource_path, video_dist_path
from doctor_workstation.resources import app_icon_path, video_dist_path
from doctor_workstation.services import (
DemoDoctorRepository,
RemoteDoctorRepository,
@@ -34,6 +34,7 @@ from doctor_workstation.services import (
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
@@ -255,6 +256,7 @@ class ApplicationController(QObject):
self._shutting_down = False
self._authentication_expiry_in_progress = False
self._rebuild_remote_repository()
self.app_updater = AppUpdateSession(self)
set_authentication_expired_handler(self._on_authentication_expired)
application.aboutToQuit.connect(self.shutdown)
@@ -263,6 +265,7 @@ class ApplicationController(QObject):
self._show_login()
self._begin_session_restore()
self.app_updater.schedule()
def _base_repository(self) -> Any:
return self.remote_repository or _UnconfiguredRepository()
@@ -333,6 +336,7 @@ class ApplicationController(QObject):
self.login_window.repository = self._base_repository()
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.app_updater.schedule()
if self.login_window is not None:
self.login_window.config = self.config
@@ -512,12 +516,16 @@ class ApplicationController(QObject):
self.shell_window = ShellWindow(repository, payload, session.permissions)
self.shell_window.logout_requested.connect(self._logout)
self.shell_window.video_requested.connect(self._request_video)
self.shell_window.update_check_requested.connect(
lambda: self.app_updater.check(interactive=True)
)
self._apply_window_icon(self.shell_window)
if self.login_window is not None:
self.login_window.hide()
self.shell_window.show()
self.shell_window.raise_()
self.shell_window.activateWindow()
self.app_updater.schedule(delay_ms=400)
def _login_guard_error(self, message: str) -> None:
if self.login_window is not None:
@@ -764,9 +772,9 @@ class ApplicationController(QObject):
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
return complete
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = resource_path("icon.svg")
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = app_icon_path()
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
@@ -850,7 +858,7 @@ def _create_application(argv: list[str]) -> QApplication:
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
icon_file = resource_path("icon.svg")
icon_file = app_icon_path()
if icon_file.exists():
application.setWindowIcon(QIcon(str(icon_file)))
apply_theme(application)
+8
View File
@@ -17,6 +17,14 @@ def resource_path(*parts: str) -> Path:
return project_root().joinpath("resources", *parts)
def app_icon_path() -> Path:
return resource_path("branding", "app-icon.png")
def brand_lockup_path() -> Path:
return resource_path("branding", "brand-lockup.png")
def video_dist_path() -> Path:
candidates = (
project_root() / "video_companion" / "dist" / "index.html",
+108 -21
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import json
import time
from collections.abc import Callable, Iterator, Mapping
from threading import Lock, RLock
from contextlib import contextmanager
from threading import Condition, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -23,15 +24,6 @@ from doctor_workstation.core.errors import (
WorkWechatBindingRequiredError,
)
# One shared transport lock serializes every HTTP exchange in the process.
# The GUI fires page loads, dialog bundles and polling timers from many
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
# TLS handshakes on one shared SSLContext have repeatedly crashed the
# process inside libcrypto (access violation at a stable offset), so all
# network traffic now goes through this single lock. Requests stay
# off-thread, so the GUI never blocks; only the network itself is serialized.
_HTTP_TRANSPORT_LOCK = Lock()
class ApiClient:
"""A small, testable client implementing the admin API contract.
@@ -42,6 +34,7 @@ class ApiClient:
"""
API_VERSION = "1.9.4"
DEFAULT_MAX_PARALLEL_REQUESTS = 8
def __init__(
self,
@@ -55,6 +48,7 @@ class ApiClient:
transport: httpx.BaseTransport | None = None,
client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
max_parallel_requests: int = DEFAULT_MAX_PARALLEL_REQUESTS,
) -> None:
"""Create a client without performing any network requests."""
@@ -64,6 +58,8 @@ class ApiClient:
raise ValueError("retry_backoff must be non-negative")
if client is not None and transport is not None:
raise ValueError("pass either client or transport, not both")
if max_parallel_requests <= 0:
raise ValueError("max_parallel_requests must be positive")
self.base_url = self.normalise_base_url(base_url)
self.timeout = timeout
self.max_retries = max_retries
@@ -71,10 +67,68 @@ class ApiClient:
self._sleep = sleep
self._token = token.strip()
self._lock = RLock()
self._transport_lock = RLock()
self._client_condition = Condition(RLock())
self._available_clients: list[httpx.Client] = []
self._pooled_clients: list[httpx.Client] = []
self._active_requests = 0
self._closing = False
self._closed = False
self._max_parallel_requests = max_parallel_requests
# Production requests use a bounded pool of independent clients. This
# permits unrelated patient requests to run concurrently without
# sharing the SSLContext that previously caused native TLS crashes.
# A pool also works across PySide QRunnable boundaries, where Python
# thread-local state is not retained reliably.
# Explicit clients/transports remain on one locked client because
# their ownership and thread-safety contracts are unknown.
self._uses_client_pool = client is None and transport is None
self._owns_client = client is None
self._stream_transport = transport
self._verify = verify
self._client = client or httpx.Client(transport=transport, verify=verify)
self._client = (
None
if self._uses_client_pool
else client or httpx.Client(transport=transport, verify=verify)
)
@contextmanager
def _borrow_client(self) -> Iterator[httpx.Client]:
"""Yield a safe synchronous client for the calling worker thread."""
if not self._uses_client_pool:
with self._transport_lock:
with self._client_condition:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._client is None: # Defensive; this mode always has one.
raise RuntimeError("ApiClient transport is unavailable")
yield self._client
return
with self._client_condition:
while True:
if self._closing or self._closed:
raise RuntimeError("ApiClient is closed")
if self._available_clients:
pooled_client = self._available_clients.pop()
break
if len(self._pooled_clients) < self._max_parallel_requests:
pooled_client = httpx.Client(verify=self._verify)
self._pooled_clients.append(pooled_client)
break
self._client_condition.wait()
self._active_requests += 1
try:
yield pooled_client
finally:
with self._client_condition:
self._active_requests -= 1
self._available_clients.append(pooled_client)
if self._active_requests == 0:
self._client_condition.notify_all()
else:
self._client_condition.notify()
@staticmethod
def normalise_base_url(base_url: str) -> str:
@@ -184,9 +238,9 @@ class ApiClient:
) -> Iterator[dict[str, Any]]:
"""POST JSON and yield parsed server-sent events on an isolated client.
Streaming deliberately does not use ``_HTTP_TRANSPORT_LOCK`` or the
process-wide JSON client. A diagnosis response can remain open for
more than a minute and must not block unrelated page requests.
Streaming deliberately does not use the bounded short-request client
pool. A diagnosis response can remain open for more than a minute and
must not consume capacity needed by unrelated page requests.
"""
url = self._endpoint_url(endpoint)
@@ -245,8 +299,8 @@ class ApiClient:
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must resolve to an absolute http(s) URL")
try:
with _HTTP_TRANSPORT_LOCK:
response = self._client.get(
with self._borrow_client() as client:
response = client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
@@ -324,10 +378,10 @@ class ApiClient:
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
with _HTTP_TRANSPORT_LOCK:
with self._borrow_client() as client:
for attempt in range(attempts):
try:
response = self._client.request(
response = client.request(
verb,
url,
params=dict(params) if params is not None else None,
@@ -360,9 +414,42 @@ class ApiClient:
def close(self) -> None:
"""Close the internally-created HTTP transport."""
if self._owns_client:
with _HTTP_TRANSPORT_LOCK:
self._client.close()
with self._client_condition:
if self._closed:
return
if self._closing:
while not self._closed:
self._client_condition.wait()
return
self._closing = True
self._client_condition.notify_all()
clients: list[httpx.Client] = []
if self._uses_client_pool:
with self._client_condition:
while self._active_requests:
self._client_condition.wait()
clients = list(self._pooled_clients)
self._available_clients.clear()
self._pooled_clients.clear()
else:
with self._transport_lock:
if self._owns_client and self._client is not None:
clients = [self._client]
close_error: Exception | None = None
for client in clients:
try:
client.close()
except Exception as error: # Close every transport before surfacing one failure.
if close_error is None:
close_error = error
with self._client_condition:
self._closed = True
self._closing = False
self._client_condition.notify_all()
if close_error is not None:
raise close_error
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -0,0 +1,734 @@
"""Detect, download and apply doctor-workstation desktop updates."""
from __future__ import annotations
import hashlib
import os
import platform
import shutil
import stat
import subprocess
import sys
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
from doctor_workstation import __version__
from doctor_workstation.core.errors import (
ApiHttpError,
ApiTimeoutError,
ApiTransportError,
)
from doctor_workstation.services.api_client import ApiClient
CHECK_ENDPOINT = "setting.desktop_workstation/check"
MAX_PACKAGE_BYTES = 2 * 1024 * 1024 * 1024
WINDOWS_EXE_NAME = "DoctorWorkstation.exe"
MACOS_APP_NAME = "DoctorWorkstation.app"
PACKAGE_TYPE_ARCHIVE = "archive"
PACKAGE_TYPE_INNO_SETUP = "inno_setup"
SUPPORTED_PACKAGE_TYPES = {PACKAGE_TYPE_ARCHIVE, PACKAGE_TYPE_INNO_SETUP}
ProgressCallback = Callable[[int, int], None]
CancelCallback = Callable[[], bool]
class AppUpdateError(RuntimeError):
"""A desktop update could not be checked, downloaded or applied."""
@dataclass(frozen=True, slots=True)
class UpdatePackage:
url: str
sha256: str
size: int
filename: str
type: str = PACKAGE_TYPE_ARCHIVE
@dataclass(frozen=True, slots=True)
class UpdateOffer:
has_update: bool
force: bool
enabled: bool
current_version: str
latest_version: str
min_version: str
title: str
notes: str
platform: str
arch: str
package: UpdatePackage | None
can_install: bool
def current_app_version() -> str:
return normalize_version(__version__) or "0.0.0"
def is_frozen_install() -> bool:
return bool(getattr(sys, "frozen", False))
def current_platform() -> str:
if sys.platform == "win32":
return "windows"
if sys.platform == "darwin":
return "macos"
return sys.platform
def current_arch() -> str:
machine = platform.machine().lower()
if machine in {"amd64", "x86_64", "x64"}:
return "x64"
if machine in {"arm64", "aarch64"}:
return "arm64"
return machine
def normalize_version(value: str | None) -> str:
text = str(value or "").strip()
if not text:
return ""
parts = []
for raw in text.split("."):
if not raw.isdigit():
return ""
parts.append(str(int(raw)))
if len(parts) == 3:
break
if not parts:
return ""
while len(parts) < 3:
parts.append("0")
return ".".join(parts)
def compare_version(left: str, right: str) -> int:
def parts(value: str) -> tuple[int, int, int]:
normalized = normalize_version(value)
if not normalized:
return (0, 0, 0)
numbers = [int(item) for item in normalized.split(".")]
return (numbers[0], numbers[1], numbers[2])
left_parts = parts(left)
right_parts = parts(right)
return (left_parts > right_parts) - (left_parts < right_parts)
def frozen_install_root() -> Path | None:
if not is_frozen_install():
return None
executable = Path(sys.executable).resolve()
if sys.platform == "darwin":
macos_dir = executable.parent
contents = macos_dir.parent
bundle = contents.parent
if macos_dir.name == "MacOS" and contents.name == "Contents" and bundle.suffix == ".app":
return bundle
return macos_dir
return executable.parent
def parse_update_offer(
payload: dict[str, Any] | None,
*,
current_version: str,
platform_name: str | None = None,
arch: str | None = None,
) -> UpdateOffer:
data = dict(payload or {})
expected_platform = platform_name or current_platform()
expected_arch = arch or current_arch()
response_platform = str(data.get("platform") or expected_platform).strip().lower()
response_arch = str(data.get("arch") or expected_arch).strip().lower()
package_payload = data.get("package")
package = None
if isinstance(package_payload, dict):
url = str(package_payload.get("url") or "").strip()
sha256 = str(package_payload.get("sha256") or "").strip().lower()
filename = str(package_payload.get("filename") or "").strip()
package_type = str(package_payload.get("type") or PACKAGE_TYPE_ARCHIVE).strip().lower()
try:
size = max(0, int(package_payload.get("size") or 0))
except (TypeError, ValueError):
size = 0
package_type_supported = package_type in SUPPORTED_PACKAGE_TYPES
package_platform_supported = (
package_type != PACKAGE_TYPE_INNO_SETUP or expected_platform == "windows"
)
if url and package_type_supported and package_platform_supported:
package = UpdatePackage(
url=url,
sha256=sha256,
size=size,
filename=filename,
type=package_type,
)
normalized_current = normalize_version(current_version) or "0.0.0"
latest_version = normalize_version(str(data.get("latest_version") or "")) or ""
response_matches = response_platform == expected_platform and response_arch == expected_arch
has_update = (
bool(data.get("has_update"))
and bool(data.get("enabled"))
and bool(latest_version)
and compare_version(normalized_current, latest_version) < 0
and response_matches
)
digest_is_valid = bool(
package is not None
and len(package.sha256) == 64
and all(character in "0123456789abcdef" for character in package.sha256)
)
installer_transport_is_valid = bool(
package is None
or package.type != PACKAGE_TYPE_INNO_SETUP
or _is_secure_installer_url(package.url)
)
can_install = (
bool(data.get("can_install"))
and package is not None
and digest_is_valid
and installer_transport_is_valid
and has_update
)
return UpdateOffer(
has_update=has_update,
force=bool(data.get("force")) and can_install,
enabled=bool(data.get("enabled")),
current_version=normalized_current,
latest_version=latest_version,
min_version=normalize_version(str(data.get("min_version") or "")) or "",
title=str(data.get("title") or "").strip(),
notes=str(data.get("notes") or "").strip(),
platform=response_platform,
arch=response_arch,
package=package if can_install else None,
can_install=can_install,
)
def fetch_update_offer(
client: ApiClient,
*,
current_version: str | None = None,
platform_name: str | None = None,
arch: str | None = None,
) -> UpdateOffer:
version = normalize_version(current_version or current_app_version()) or "0.0.0"
try:
payload = client.get(
CHECK_ENDPOINT,
{
"current_version": version,
"platform": platform_name or current_platform(),
"arch": arch or current_arch(),
},
)
except (ApiTimeoutError, ApiTransportError, ApiHttpError) as error:
raise AppUpdateError(str(error)) from error
if payload is not None and not isinstance(payload, dict):
raise AppUpdateError("升级检测返回的数据格式不正确")
return parse_update_offer(
payload if isinstance(payload, dict) else {},
current_version=version,
platform_name=platform_name or current_platform(),
arch=arch or current_arch(),
)
def safe_extract_zip(archive: Path, destination: Path) -> None:
destination = destination.resolve()
destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive) as bundle:
for info in bundle.infolist():
name = info.filename.replace("\\", "/")
if name.startswith("/") or (len(name) > 1 and name[1] == ":"):
raise AppUpdateError("安装包包含非法路径,已拒绝解压")
target = Path(os.path.normpath(destination / name))
try:
target.relative_to(destination)
except ValueError as error:
raise AppUpdateError("安装包包含非法路径,已拒绝解压") from error
bundle.extractall(destination)
def discover_payload(extracted_root: Path, *, platform_name: str | None = None) -> Path:
root = extracted_root.resolve()
os_name = platform_name or current_platform()
if os_name == "macos":
apps = [
path
for path in root.rglob("*.app")
if path.is_dir() and (path / "Contents" / "MacOS").is_dir()
]
named = [path for path in apps if path.name == MACOS_APP_NAME]
candidates = named or apps
if not candidates:
raise AppUpdateError("安装包中未找到 DoctorWorkstation.app")
return sorted(candidates, key=lambda path: len(path.relative_to(root).parts))[0]
executables = [path for path in root.rglob(WINDOWS_EXE_NAME) if path.is_file()]
if not executables:
raise AppUpdateError("安装包中未找到 DoctorWorkstation.exe")
def score(path: Path) -> tuple[int, int]:
has_internal = 0 if (path.parent / "_internal").is_dir() else 1
return (has_internal, len(path.relative_to(root).parts))
return sorted(executables, key=score)[0].parent
def download_package(
url: str,
destination: Path,
*,
sha256: str,
verify: bool = True,
expected_size: int = 0,
progress: ProgressCallback | None = None,
cancelled: CancelCallback | None = None,
transport: httpx.BaseTransport | None = None,
) -> Path:
target = url.strip()
parsed = urlsplit(target)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise AppUpdateError("安装包地址无效")
digest = (sha256 or "").strip().lower()
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
raise AppUpdateError("安装包缺少有效的 SHA-256,已取消下载")
if expected_size < 0 or expected_size > MAX_PACKAGE_BYTES:
raise AppUpdateError("安装包声明的文件大小不正确")
destination.parent.mkdir(parents=True, exist_ok=True)
partial = destination.with_name(f"{destination.name}.part")
partial.unlink(missing_ok=True)
timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
hasher = hashlib.sha256()
received = 0
try:
with (
httpx.Client(
verify=verify,
follow_redirects=True,
timeout=timeout,
transport=transport,
) as client,
client.stream("GET", target) as response,
):
if not 200 <= response.status_code < 300:
raise AppUpdateError(f"下载安装包失败(HTTP {response.status_code}")
try:
total = int(response.headers.get("content-length") or expected_size or 0)
except (TypeError, ValueError):
total = expected_size
if total > MAX_PACKAGE_BYTES:
raise AppUpdateError("安装包超过允许的最大体积")
if expected_size > 0 and total > 0 and total != expected_size:
raise AppUpdateError("安装包文件大小与发布信息不一致")
with partial.open("wb") as handle:
for chunk in response.iter_bytes(256 * 1024):
if cancelled is not None and cancelled():
raise AppUpdateError("已取消下载")
if not chunk:
continue
received += len(chunk)
if received > MAX_PACKAGE_BYTES:
raise AppUpdateError("安装包超过允许的最大体积")
handle.write(chunk)
hasher.update(chunk)
if progress is not None:
progress(received, total)
handle.flush()
os.fsync(handle.fileno())
except AppUpdateError:
partial.unlink(missing_ok=True)
raise
except httpx.TimeoutException as error:
partial.unlink(missing_ok=True)
raise AppUpdateError("下载安装包超时") from error
except httpx.RequestError as error:
partial.unlink(missing_ok=True)
raise AppUpdateError(f"下载安装包失败:{error}") from error
if expected_size > 0 and received != expected_size:
partial.unlink(missing_ok=True)
raise AppUpdateError("安装包文件大小与发布信息不一致")
actual = hasher.hexdigest()
if actual != digest:
partial.unlink(missing_ok=True)
raise AppUpdateError("安装包校验失败,文件可能已损坏或被替换")
os.replace(partial, destination)
if progress is not None:
progress(received, received if total <= 0 else total)
return destination
def _is_secure_installer_url(url: str) -> bool:
parsed = urlsplit(url.strip())
local_hosts = {"localhost", "127.0.0.1", "::1"}
return parsed.scheme == "https" or (
parsed.scheme == "http" and (parsed.hostname or "").lower() in local_hosts
)
def validate_installer_download_policy(url: str, *, verify_ssl: bool) -> None:
"""Require authenticated transport before automatically executing an installer."""
if not verify_ssl:
raise AppUpdateError("自动安装 Windows 更新必须开启 HTTPS 证书校验")
if not _is_secure_installer_url(url):
raise AppUpdateError("自动安装 Windows 更新仅允许使用 HTTPS 下载地址")
def validate_windows_installer(installer: Path) -> Path:
candidate = installer.resolve()
if candidate.suffix.lower() != ".exe" or not candidate.is_file():
raise AppUpdateError("Windows 更新包不是有效的 Setup.exe 安装程序")
try:
with candidate.open("rb") as handle:
header = handle.read(2)
except OSError as error:
raise AppUpdateError(f"无法读取 Windows 安装程序:{error}") from error
if header != b"MZ":
raise AppUpdateError("Windows 更新包不是有效的 PE 安装程序")
return candidate
def apply_extracted_update(payload: Path, *, install_root: Path | None = None) -> None:
target_root = install_root or frozen_install_root()
if target_root is None:
raise AppUpdateError("当前为源码运行,无法自动替换安装目录")
payload = payload.resolve()
target_root = target_root.resolve()
if not payload.exists():
raise AppUpdateError("解压后的安装包不完整")
log_file = payload.parent / "apply.log"
restart_exe = _installed_restart_executable(target_root)
script = _write_apply_script(
payload=payload,
install_root=target_root,
restart_exe=restart_exe,
log_file=log_file,
)
_spawn_applier(
script,
payload=payload,
install_root=target_root,
restart_exe=restart_exe,
log_file=log_file,
)
def apply_downloaded_update(
payload: Path,
*,
package_type: str,
install_root: Path | None = None,
) -> None:
if package_type == PACKAGE_TYPE_ARCHIVE:
apply_extracted_update(payload, install_root=install_root)
return
if package_type == PACKAGE_TYPE_INNO_SETUP:
apply_inno_setup_update(payload, install_root=install_root)
return
raise AppUpdateError("不支持的桌面更新包类型")
def apply_inno_setup_update(
installer: Path,
*,
install_root: Path | None = None,
) -> None:
if sys.platform != "win32":
raise AppUpdateError("Inno Setup 更新仅支持 Windows")
target_root = install_root or frozen_install_root()
if target_root is None:
raise AppUpdateError("当前为源码运行,无法自动安装 Windows 更新")
target_root = target_root.resolve()
installer = validate_windows_installer(installer)
restart_exe = _installed_restart_executable(target_root)
helper_log_file = installer.parent / "update_helper.log"
installer_log_file = installer.parent / "inno_setup.log"
try:
script = _write_inno_setup_script(
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
_spawn_inno_setup_applier(
script,
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
except OSError as error:
raise AppUpdateError(f"无法启动 Windows 更新助手:{error}") from error
def _installed_restart_executable(install_root: Path) -> Path:
if install_root.suffix == ".app" or (install_root / "Contents" / "MacOS").is_dir():
return install_root
exe = install_root / WINDOWS_EXE_NAME
if exe.is_file():
return exe
raise AppUpdateError("当前安装目录中找不到可重启的工作站程序")
def _restart_executable(payload: Path) -> Path:
if payload.suffix == ".app" or (payload / "Contents" / "MacOS").is_dir():
return payload
exe = payload / WINDOWS_EXE_NAME
if exe.is_file():
return exe
raise AppUpdateError("解压结果中找不到可启动的工作站程序")
def _write_apply_script(
*,
payload: Path,
install_root: Path,
restart_exe: Path,
log_file: Path,
) -> Path:
directory = payload.parent
if sys.platform == "win32":
script = directory / "apply_update.ps1"
script.write_text(
"\n".join(
[
"param(",
" [Parameter(Mandatory=$true)][int]$TargetPid,",
" [Parameter(Mandatory=$true)][string]$Payload,",
" [Parameter(Mandatory=$true)][string]$InstallDir,",
" [Parameter(Mandatory=$true)][string]$RestartExe,",
" [Parameter(Mandatory=$true)][string]$LogFile",
")",
'$ErrorActionPreference = "Continue"',
"function Write-Log([string]$Message) {",
' Add-Content -LiteralPath $LogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
"}",
'Write-Log "waiting for pid $TargetPid"',
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
"Start-Sleep -Seconds 1",
'Write-Log "copy $Payload -> $InstallDir"',
'$result = (Start-Process -FilePath "robocopy.exe" -ArgumentList @($Payload, $InstallDir, "/E", "/IS", "/IT", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP") -Wait -PassThru).ExitCode',
'Write-Log "robocopy exit $result"',
'if ($result -ge 8) { Write-Log "copy failed"; exit $result }',
'Write-Log "restart $RestartExe"',
"Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
"exit 0",
"",
]
),
encoding="utf-8",
)
return script
script = directory / "apply_update.sh"
script.write_text(
"\n".join(
[
"#!/bin/bash",
"set -eu",
'TARGET_PID="$1"',
'PAYLOAD="$2"',
'INSTALL_DIR="$3"',
'LOG_FILE="$4"',
'log() { printf "%s %s\\n" "$(date -Iseconds 2>/dev/null || date)" "$1" >>"$LOG_FILE"; }',
'log "waiting for pid $TARGET_PID"',
'while kill -0 "$TARGET_PID" 2>/dev/null; do sleep 0.3; done',
"sleep 1",
'log "replace $INSTALL_DIR with $PAYLOAD"',
'TMP_DIR="${INSTALL_DIR}.next"',
'rm -rf "$TMP_DIR"',
'if command -v ditto >/dev/null 2>&1; then ditto "$PAYLOAD" "$TMP_DIR"; else cp -R "$PAYLOAD" "$TMP_DIR"; fi',
'rm -rf "$INSTALL_DIR"',
'mv "$TMP_DIR" "$INSTALL_DIR"',
'log "open $INSTALL_DIR"',
'open "$INSTALL_DIR"',
"",
]
),
encoding="utf-8",
)
script.chmod(script.stat().st_mode | stat.S_IEXEC)
return script
def _write_inno_setup_script(
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> Path:
script = installer.parent / "install_update.ps1"
script.write_text(
"\n".join(
[
"param(",
" [Parameter(Mandatory=$true)][int]$TargetPid,",
" [Parameter(Mandatory=$true)][string]$Installer,",
" [Parameter(Mandatory=$true)][string]$RestartExe,",
" [Parameter(Mandatory=$true)][string]$HelperLogFile,",
" [Parameter(Mandatory=$true)][string]$InstallerLogFile",
")",
'$ErrorActionPreference = "Stop"',
"function Write-Log([string]$Message) {",
' Add-Content -LiteralPath $HelperLogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
"}",
"function Restart-Application {",
" if (Test-Path -LiteralPath $RestartExe -PathType Leaf) {",
' Write-Log "restart $RestartExe"',
" Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
" } else {",
' Write-Log "restart executable missing: $RestartExe"',
" }",
"}",
'Write-Log "waiting for pid $TargetPid"',
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
"Start-Sleep -Milliseconds 600",
"$arguments = @(",
' "/VERYSILENT",',
' "/SUPPRESSMSGBOXES",',
' "/SP-",',
' "/NORESTART",',
' "/RESTARTEXITCODE=3010",',
' "/CLOSEAPPLICATIONS",',
' "/NOFORCECLOSEAPPLICATIONS",',
' "/NORESTARTAPPLICATIONS",',
' ("/LOG=`"{0}`"" -f $InstallerLogFile)',
")",
"try {",
' Write-Log "launch installer $Installer"',
" $process = Start-Process -FilePath $Installer -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden",
" $exitCode = $process.ExitCode",
' Write-Log "installer exit $exitCode"',
"} catch {",
' Write-Log ("installer launch failed: {0}" -f $_.Exception.Message)',
" Restart-Application",
" exit 1",
"}",
"if ($exitCode -eq 0 -or $exitCode -eq 3010) {",
' Write-Log "installation completed"',
" Restart-Application",
" exit 0",
"}",
'Write-Log "installation failed or was cancelled; restarting existing application"',
"Restart-Application",
"exit $exitCode",
"",
]
),
encoding="utf-8-sig",
)
del restart_exe, helper_log_file, installer_log_file
return script
def _spawn_inno_setup_applier(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> None:
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
str(script),
"-TargetPid",
str(os.getpid()),
"-Installer",
str(installer),
"-RestartExe",
str(restart_exe),
"-HelperLogFile",
str(helper_log_file),
"-InstallerLogFile",
str(installer_log_file),
],
close_fds=True,
creationflags=flags,
cwd=str(script.parent),
)
def _spawn_applier(
script: Path,
*,
payload: Path,
install_root: Path,
restart_exe: Path,
log_file: Path,
) -> None:
pid = os.getpid()
if sys.platform == "win32":
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
str(script),
"-TargetPid",
str(pid),
"-Payload",
str(payload),
"-InstallDir",
str(install_root),
"-RestartExe",
str(restart_exe),
"-LogFile",
str(log_file),
],
close_fds=True,
creationflags=flags,
cwd=str(script.parent),
)
return
subprocess.Popen(
["/bin/bash", str(script), str(pid), str(payload), str(install_root), str(log_file)],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=str(script.parent),
)
del restart_exe
def prepare_update_workspace(config_dir: Path, version: str) -> Path:
workspace = config_dir / "updates" / normalize_version(version).replace(".", "_")
if workspace.exists():
shutil.rmtree(workspace, ignore_errors=True)
workspace.mkdir(parents=True, exist_ok=True)
return workspace
def package_filename(package: UpdatePackage, version: str) -> str:
name = Path(package.filename or urlsplit(package.url).path).name
if not name:
suffix = ".exe" if package.type == PACKAGE_TYPE_INNO_SETUP else ".zip"
name = f"DoctorWorkstation-{version}{suffix}"
return name
@@ -0,0 +1,602 @@
"""Persistent local-audio capture and COS upload queue.
Local call recordings are medical records. They must survive application
restarts and failed network requests, so the queue deliberately keeps the
audio file after a successful upload as well as after a failed one.
"""
from __future__ import annotations
import queue
import sqlite3
import threading
from collections.abc import Callable
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from os import PathLike
from pathlib import Path
from typing import Any
from uuid import uuid4
from platformdirs import user_data_path
_MAX_UPLOAD_WORKERS = 3
_RETRYABLE_STATUSES = {"pending", "failed"}
_RECOVERY_LOCK = threading.Lock()
_RECOVERED_DATABASES: set[Path] = set()
_MANAGER_LOCK = threading.Lock()
_MANAGERS: dict[tuple[int, Path], LocalAudioUploadManager] = {}
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _clean_room_id(value: Any) -> str:
room_id = str(value or "").strip()
return "" if room_id == "0" else room_id
def _clean_call_record_id(value: Any, *, required: bool) -> int | None:
if value in (None, "") and not required:
return None
try:
call_record_id = int(value)
except (TypeError, ValueError) as error:
raise ValueError("通话记录 ID 无效。") from error
if call_record_id <= 0:
raise ValueError("通话记录 ID 无效。")
return call_record_id
def _default_root() -> Path:
return (
user_data_path(
"ZhenyangDoctorWorkstation",
appauthor="ZhenYangTang",
ensure_exists=True,
)
/ "local-call-audio"
)
@dataclass(frozen=True, slots=True)
class LocalAudioRecord:
id: int
session_id: str
diagnosis_id: int
call_record_id: int | None
room_id: str
mime_type: str
file_path: Path
size_bytes: int
status: str
error_text: str
uploaded_url: str
attempts: int
created_at: str
updated_at: str
uploaded_at: str
@property
def exists(self) -> bool:
return self.file_path.is_file()
class LocalAudioQueueStore:
"""SQLite-backed manifest for durable local call recordings."""
def __init__(self, root: str | PathLike[str] | None = None) -> None:
self.root = Path(root).expanduser().resolve() if root else _default_root().resolve()
self.files_dir = self.root / "files"
self.files_dir.mkdir(parents=True, exist_ok=True)
self.database_path = self.root / "queue.sqlite3"
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=10.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 10000")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
# Serialise schema inspection and ALTER TABLE across two workstation
# processes. The second process waits, then re-checks the columns.
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
room_id TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
columns = {
str(row["name"])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
if "room_id" not in columns:
connection.execute(
"ALTER TABLE local_audio_uploads "
"ADD COLUMN room_id TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idx_local_audio_diagnosis "
"ON local_audio_uploads(diagnosis_id, id DESC)"
)
# Recover interrupted work once per process. Creating the management
# dialog while a worker is active must not move that worker back to
# ``pending`` and accidentally upload the same medical file twice.
with _RECOVERY_LOCK:
if self.database_path in _RECOVERED_DATABASES:
return
with self._connect() as connection:
now = _utc_now()
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending',
error_text = '应用上次退出时上传尚未完成,可重试。',
updated_at = ?
WHERE status = 'uploading'
""",
(now,),
)
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'invalid',
error_text = '应用上次退出时录音尚未完成。',
updated_at = ?
WHERE status = 'recording'
""",
(now,),
)
_RECOVERED_DATABASES.add(self.database_path)
@staticmethod
def _from_row(row: sqlite3.Row) -> LocalAudioRecord:
raw_call_record_id = row["call_record_id"]
return LocalAudioRecord(
id=int(row["id"]),
session_id=str(row["session_id"]),
diagnosis_id=int(row["diagnosis_id"]),
call_record_id=(
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
),
room_id=_clean_room_id(row["room_id"]),
mime_type=str(row["mime_type"]),
file_path=Path(str(row["file_path"])).resolve(),
size_bytes=int(row["size_bytes"] or 0),
status=str(row["status"]),
error_text=str(row["error_text"] or ""),
uploaded_url=str(row["uploaded_url"] or ""),
attempts=int(row["attempts"] or 0),
created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]),
uploaded_at=str(row["uploaded_at"] or ""),
)
def begin_recording(
self,
*,
session_id: str,
diagnosis_id: int,
mime_type: str,
call_record_id: int | None = None,
room_id: str = "",
) -> LocalAudioRecord:
suffix = ".ogg" if mime_type.startswith("audio/ogg") else ".webm"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"call-{int(diagnosis_id)}-{timestamp}-{uuid4().hex[:10]}{suffix}.part"
path = (self.files_dir / name).resolve()
now = _utc_now()
clean_call_record_id = _clean_call_record_id(call_record_id, required=False)
clean_room_id = _clean_room_id(room_id)
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, room_id,
mime_type, file_path,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'recording', ?, ?)
""",
(
session_id,
int(diagnosis_id),
clean_call_record_id,
clean_room_id,
mime_type,
str(path),
now,
now,
),
)
record_id = int(cursor.lastrowid)
record = self.get(record_id)
if record is None: # pragma: no cover - SQLite insert/read invariant.
raise RuntimeError("无法创建本地录音记录。")
return record
def finalize_recording(self, record_id: int, *, size_bytes: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status != "recording":
raise RuntimeError("本地录音记录状态不允许完成。")
if record.file_path.suffix == ".part":
final_path = record.file_path.with_suffix("")
record.file_path.replace(final_path)
else:
final_path = record.file_path
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET file_path = ?, size_bytes = ?, status = 'pending',
error_text = '', updated_at = ?
WHERE id = ?
""",
(str(final_path), int(size_bytes), now, int(record_id)),
)
return self.require(record_id)
def mark_invalid(self, record_id: int, message: str) -> LocalAudioRecord:
record = self.require(record_id)
size = record.file_path.stat().st_size if record.file_path.is_file() else 0
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET size_bytes = ?, status = 'invalid', error_text = ?, updated_at = ?
WHERE id = ?
""",
(size, str(message)[:1000], now, int(record_id)),
)
return self.require(record_id)
def bind_identity(
self,
record_id: int,
*,
call_record_id: int,
room_id: str = "",
) -> LocalAudioRecord:
"""Bind one recording to a call without overwriting a different identity."""
clean_call_record_id = _clean_call_record_id(call_record_id, required=True)
assert clean_call_record_id is not None
clean_room_id = _clean_room_id(room_id)
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET call_record_id = CASE
WHEN call_record_id IS NULL THEN ?
ELSE call_record_id
END,
room_id = CASE
WHEN room_id = '' AND ? <> '' THEN ?
ELSE room_id
END,
updated_at = ?
WHERE id = ?
AND (call_record_id IS NULL OR call_record_id = ?)
AND (? = '' OR room_id = '' OR room_id = ?)
""",
(
clean_call_record_id,
clean_room_id,
clean_room_id,
now,
int(record_id),
clean_call_record_id,
clean_room_id,
clean_room_id,
),
)
if cursor.rowcount != 1:
exists = connection.execute(
"SELECT 1 FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
if exists is None:
raise LookupError("本地录音记录不存在。")
raise RuntimeError("本地录音的通话记录 ID 或房间号发生冲突。")
return self.require(record_id)
def set_call_record_id(self, record_id: int, call_record_id: int) -> None:
self.bind_identity(record_id, call_record_id=call_record_id)
def update_status(self, record_id: int, status: str, message: str = "") -> None:
if status not in {"pending", "uploading", "uploaded", "failed", "invalid"}:
raise ValueError("unsupported local-audio status")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = ?, error_text = ?, updated_at = ?
WHERE id = ?
""",
(status, str(message)[:1000], now, int(record_id)),
)
def get(self, record_id: int) -> LocalAudioRecord | None:
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM local_audio_uploads WHERE id = ?",
(int(record_id),),
).fetchone()
return self._from_row(row) if row is not None else None
def require(self, record_id: int) -> LocalAudioRecord:
record = self.get(record_id)
if record is None:
raise LookupError("本地录音记录不存在。")
return record
def list_records(self, *, diagnosis_id: int | None = None) -> list[LocalAudioRecord]:
with self._connect() as connection:
if diagnosis_id is None:
rows = connection.execute(
"SELECT * FROM local_audio_uploads ORDER BY id DESC"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM local_audio_uploads
WHERE diagnosis_id = ?
ORDER BY id DESC
""",
(int(diagnosis_id),),
).fetchall()
return [self._from_row(row) for row in rows]
def claim_upload(self, record_id: int) -> LocalAudioRecord | None:
now = _utc_now()
with self._connect() as connection:
cursor = connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploading', error_text = '', attempts = attempts + 1,
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
""",
(now, int(record_id)),
)
if cursor.rowcount != 1:
return None
return self.require(record_id)
def mark_uploaded(self, record_id: int, uploaded_url: str = "") -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'uploaded', uploaded_url = ?, error_text = '',
uploaded_at = ?, updated_at = ?
WHERE id = ?
""",
(str(uploaded_url)[:2000], now, now, int(record_id)),
)
def mark_failed(self, record_id: int, message: str) -> None:
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'failed', error_text = ?, updated_at = ?
WHERE id = ?
""",
(str(message)[:1000] or "上传失败。", now, int(record_id)),
)
def retry(self, record_id: int) -> LocalAudioRecord:
record = self.require(record_id)
if record.status not in {"failed", "pending"}:
raise RuntimeError("当前录音状态不允许重试上传。")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
raise RuntimeError("本地录音文件不存在或为空。")
now = _utc_now()
with self._connect() as connection:
connection.execute(
"""
UPDATE local_audio_uploads
SET status = 'pending', error_text = '', updated_at = ?
WHERE id = ?
""",
(now, int(record_id)),
)
return self.require(record_id)
def _upload_url(result: Any) -> str:
if not isinstance(result, dict):
return ""
data = result.get("data")
payload = data if isinstance(data, dict) else result
for key in ("file_url", "fileUrl", "url", "recording_url"):
value = payload.get(key)
if value:
return str(value)
return ""
class LocalAudioUploadManager:
"""Small daemon worker pool that uploads up to three recordings at once."""
def __init__(
self,
repository: Any,
store: LocalAudioQueueStore,
*,
max_workers: int = _MAX_UPLOAD_WORKERS,
) -> None:
self.repository = repository
self.store = store
self.max_workers = max(1, min(int(max_workers), 8))
self._jobs: queue.Queue[tuple[int, Future[bool]]] = queue.Queue()
self._active: dict[int, Future[bool]] = {}
self._upload_listeners: set[Callable[[LocalAudioRecord], None]] = set()
self._lock = threading.Lock()
for index in range(self.max_workers):
threading.Thread(
target=self._worker,
name=f"local-audio-upload-{index + 1}",
daemon=True,
).start()
def submit(self, record_id: int) -> Future[bool]:
record_id = int(record_id)
with self._lock:
existing = self._active.get(record_id)
if existing is not None:
return existing
future: Future[bool] = Future()
self._active[record_id] = future
self._jobs.put((record_id, future))
return future
def add_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.add(listener)
def remove_upload_listener(
self,
listener: Callable[[LocalAudioRecord], None],
) -> None:
with self._lock:
self._upload_listeners.discard(listener)
def _notify_uploaded(self, record_id: int) -> None:
try:
record = self.store.require(record_id)
except Exception:
return
with self._lock:
listeners = tuple(self._upload_listeners)
for listener in listeners:
with suppress(Exception):
listener(record)
def submit_retryable(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
futures: list[Future[bool]] = []
for record in self.store.list_records(diagnosis_id=diagnosis_id):
if record.status in _RETRYABLE_STATUSES:
futures.append(self.submit(record.id))
return futures
def submit_pending(self, *, diagnosis_id: int | None = None) -> list[Future[bool]]:
"""Resume only queued work; failed uploads require an explicit retry."""
return [
self.submit(record.id)
for record in self.store.list_records(diagnosis_id=diagnosis_id)
if record.status == "pending"
]
def _worker(self) -> None:
while True:
record_id, future = self._jobs.get()
try:
try:
succeeded = self._upload(record_id)
except Exception as error: # Defensive boundary around a daemon worker.
with suppress(Exception):
self.store.mark_failed(
record_id,
str(error) or "本地录音上传失败。",
)
succeeded = False
if not future.done():
future.set_result(succeeded)
finally:
with self._lock:
self._active.pop(record_id, None)
self._jobs.task_done()
def _upload(self, record_id: int) -> bool:
record = self.store.claim_upload(record_id)
if record is None:
current = self.store.get(record_id)
return bool(current and current.status == "uploaded")
if not record.file_path.is_file() or record.file_path.stat().st_size <= 0:
self.store.mark_failed(record.id, "本地录音文件不存在或为空。")
return False
if record.call_record_id is None or record.call_record_id <= 0:
self.store.mark_failed(record.id, "未保存对应的通话记录编号,暂不能上传。")
return False
upload = getattr(self.repository, "upload_call_recording", None)
if not callable(upload):
self.store.mark_failed(record.id, "当前服务未提供本地录音上传接口。")
return False
try:
result = upload(
path=record.file_path,
diagnosis_id=record.diagnosis_id,
call_record_id=record.call_record_id,
mime_type=record.mime_type,
)
except Exception as error:
self.store.mark_failed(record.id, str(error) or "本地录音上传 COS 失败。")
return False
uploaded_url = _upload_url(result)
if not uploaded_url:
self.store.mark_failed(record.id, "服务器未返回本地录音文件地址。")
return False
self.store.mark_uploaded(record.id, uploaded_url)
self._notify_uploaded(record.id)
return True
def get_local_audio_upload_manager(
repository: Any,
store: LocalAudioQueueStore | None = None,
) -> tuple[LocalAudioQueueStore, LocalAudioUploadManager]:
"""Return one three-worker uploader per repository and manifest.
Video windows and the recording-management dialog may be opened many times
during a workstation session. Sharing the pool prevents each window from
creating another set of background threads while SQLite's atomic claim still
protects against duplicate uploads across processes.
"""
resolved_store = store or LocalAudioQueueStore()
key = (id(repository), resolved_store.database_path)
with _MANAGER_LOCK:
manager = _MANAGERS.get(key)
if manager is None:
manager = LocalAudioUploadManager(
repository,
resolved_store,
max_workers=_MAX_UPLOAD_WORKERS,
)
_MANAGERS[key] = manager
return resolved_store, manager
@@ -28,9 +28,10 @@ from doctor_workstation.core.models import (
from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.core.session import Session
from .repository import (
AuditAction,
_audit_action,
from .repository import (
AuditAction,
_ai_patient_option,
_audit_action,
_body,
_daily_record_body,
_identified_body,
@@ -1049,10 +1050,11 @@ class DemoDoctorRepository:
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
if task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"summary",
"tcm_pattern",
"prescription_review",
"prescription_generate",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
@@ -1074,8 +1076,54 @@ class DemoDoctorRepository:
),
"model_key": model_key,
"model_label": "千问" if model_key == "qwen" else "OpenAI",
"task": task,
}
"task": task,
}
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
"""Return a deterministic full-context prescription draft in demo mode."""
with self._lock:
consultation = self._find_consultation(diagnosis_id)
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
clinical = str(
raw.get("clinical_diagnosis") or raw.get("symptoms") or "待辨证"
).strip()
draft = {
"prescription_name": "AI辨证处方草稿",
"clinical_diagnosis": clinical,
"prescription_type": "饮片",
"tongue": str(raw.get("tongue") or raw.get("tongue_coating") or ""),
"tongue_image": "",
"pulse": str(raw.get("pulse") or ""),
"pulse_condition": str(raw.get("pulse_condition") or ""),
"herbs": [
{"name": "黄芪", "dosage": 15.0, "formula_type": "主方"},
{"name": "山药", "dosage": 15.0, "formula_type": "主方"},
],
"dose_count": 7,
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"usage_instruction": "水煎服,一日二次",
"usage_time": "饭后",
"usage_way": "温服",
"dietary_taboo": ["辛辣食物", "生冷食物"],
"usage_notes": "请结合舌脉与最新检查逐味复核。",
"rationale": "已依据患者纵向资料生成演示草稿,请医生逐项复核并签名。",
"risk_warnings": "演示数据不可替代真实临床判断。",
"requires_doctor_review": True,
"audit_status": 0,
}
return {
"diagnosis_id": consultation.id,
"answer": draft["rationale"],
"model_key": "qwen",
"model_label": "千问",
"task": "prescription_generate",
"context_scope": "patient_longitudinal",
"context_version": "patient-context-assistant-v2",
"prescription_draft": draft,
}
def get_diagnosis_ai_analysis(
self,
@@ -1657,9 +1705,9 @@ class DemoDoctorRepository:
}
return deepcopy(dictionaries.get(dictionary_type, []))
def list_patients(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Patient]:
def list_patients(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Patient]:
"""Return doctor-scoped demo patients and summary extension data."""
with self._lock:
@@ -1695,17 +1743,78 @@ class DemoDoctorRepository:
),
"day_after": 0,
}
return _page(
rows,
page_no,
page_size,
return _page(
rows,
page_no,
page_size,
{
"summary": summary,
"scope": {"label": "演示数据(仅本机内存)"},
},
)
def patient_orders(
},
)
def list_ai_patient_options(
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
) -> PageResult[dict[str, Any]]:
"""Return deterministic diagnosis choices without plaintext patient data."""
clean_keyword = str(keyword or "").strip().lower()
with self._lock:
consultations = {row.id: row for row in self._consultations}
patients = sorted(
self._patients,
key=lambda row: (row.diagnosis_id or row.id, row.source_patient_id or 0),
reverse=True,
)
rows: list[dict[str, Any]] = []
for patient in patients:
diagnosis_id = patient.diagnosis_id or patient.id
consultation = consultations.get(diagnosis_id)
searchable = " ".join(
(
patient.name,
patient.phone,
patient.phone_masked,
str(diagnosis_id),
str(patient.source_patient_id or ""),
consultation.clinical_diagnosis if consultation else "",
str(consultation.raw.get("syndrome_type") or "")
if consultation
else "",
)
).lower()
if clean_keyword and clean_keyword not in searchable:
continue
diagnosis_date = consultation.diagnosis_date if consultation else ""
next_appointment_at = patient.appointment_time_text
rows.append(
_ai_patient_option(
{
"diagnosis_id": diagnosis_id,
"source_patient_id": patient.source_patient_id,
"patient_name": patient.name,
"phone_masked": patient.phone_masked,
"gender": patient.gender,
"gender_desc": patient.gender_desc,
"age": patient.age,
"diagnosis_date": diagnosis_date,
"diagnosis_summary": (
consultation.clinical_diagnosis if consultation else ""
),
"syndrome_type": (
consultation.raw.get("syndrome_type") if consultation else ""
),
"clinical_diagnosis": (
consultation.clinical_diagnosis if consultation else ""
),
"last_visit_at": diagnosis_date,
"next_appointment_at": next_appointment_at,
}
)
)
return _page(rows, page_no, page_size)
def patient_orders(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[dict[str, Any]]:
"""Return the scoped demo order workspace and its summary extension."""
@@ -2588,8 +2697,11 @@ class DemoDoctorRepository:
"room_id": "manual-upload",
"status": 2,
"status_text": "已结束",
"recording_status_text": "待上传",
"recording_urls_list": [],
"recording_status_text": "待上传",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2633,36 +2745,75 @@ class DemoDoctorRepository:
record["recording_status_text"] = "录制完成"
return deepcopy(record)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload then attach a demo video using the production sequence."""
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload then attach a demo local-audio file using the production sequence."""
source_path = Path(path)
is_audio = source_path.suffix.lower() in {
".ogg",
".opus",
".mp3",
".wav",
".m4a",
".aac",
".amr",
".wma",
} or (
source_path.suffix.lower() == ".webm"
and str(mime_type or "").lower().startswith("audio/")
)
file_url = self.upload_material(path, "file" if is_audio else "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id)["id"])
if is_audio:
with self._lock:
record = next(
(
row
for row in self._call_records.setdefault(diagnosis_id, [])
if int(row.get("id") or 0) == int(target_id)
),
None,
)
if record is None:
raise RepositoryNotFoundError("call record not found")
urls = record.setdefault("local_audio_urls_list", [])
if file_url not in urls:
urls.append(file_url)
record["local_audio_status"] = 2
record["local_audio_status_text"] = "已保存"
else:
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
"media_kind": "local_audio" if is_audio else "video",
"completed": True,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
"""Return the demo archive in the server response envelope shape."""
with self._lock:
consultation = self._find_consultation(diagnosis_id)
return deepcopy(
{
"lists": self._im_messages.get(diagnosis_id, []),
with self._lock:
consultation = self._find_consultation(diagnosis_id)
rows = [
{**row, "diagnosis_id": diagnosis_id}
for row in self._im_messages.get(diagnosis_id, [])
]
return deepcopy(
{
"lists": rows,
"patient_im_id": f"patient_{consultation.patient_id}",
"patient_name": consultation.patient_name,
"doctor_accounts_queried": [] if only_archived else ["doctor_1001"],
@@ -2890,8 +3041,11 @@ class DemoDoctorRepository:
replay = {
**record,
"status_text": "呼叫中",
"recording_status_text": "未录制",
"recording_urls_list": [],
"recording_status_text": "未录制",
"recording_urls_list": [],
"local_audio_status": 0,
"local_audio_status_text": "无本地录音",
"local_audio_urls_list": [],
"transcription_status": "not_started",
"transcription_status_text": "未生成文字",
"transcript_text": "",
@@ -2903,13 +3057,21 @@ class DemoDoctorRepository:
self._call_records.setdefault(diagnosis_id, []).insert(0, replay)
return deepcopy(record)
def end_call(self, diagnosis_id: int) -> dict[str, Any]:
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> dict[str, Any]:
"""Persistently mark the demo call record ended."""
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["status"] = "ended"
record["ended_at"] = datetime.now().replace(microsecond=0).isoformat(sep=" ")
replay = next(
@@ -2930,15 +3092,27 @@ class DemoDoctorRepository:
)
return deepcopy(record)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> dict[str, Any]:
"""Persist the room identifier on the active demo call."""
if not room_id.strip():
raise ValueError("room_id is required")
with self._lock:
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
record = self._calls.get(diagnosis_id)
if record is None:
raise RepositoryNotFoundError(f"active call for {diagnosis_id} not found")
if call_record_id is not None and str(record.get("id") or "") != str(
call_record_id
):
raise RepositoryNotFoundError(
f"active call record {call_record_id} for {diagnosis_id} not found"
)
record["room_id"] = room_id.strip()
record["status"] = "connected"
replay = next(
@@ -2957,9 +3131,10 @@ class DemoDoctorRepository:
"status_text": "通话中",
}
)
return {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
return {
"diagnosis_id": diagnosis_id,
"call_record_id": record["id"],
"room_id": room_id.strip(),
"cloud_recording": {"started": False, "message": "演示模式不录制"},
}
+254 -84
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import mimetypes
import re
import time
import mimetypes
import re
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from contextlib import suppress
from datetime import date
from inspect import Parameter, signature
from io import BytesIO
from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from uuid import uuid4
from doctor_workstation.core.errors import (
ApiBusinessError,
@@ -96,10 +98,15 @@ class DoctorRepository(Protocol):
) -> PageResult[Patient]:
"""Return a doctor-scoped patient page."""
def list_consultations(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Consultation]:
"""Return a diagnosis/consultation page."""
def list_consultations(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Consultation]:
"""Return a diagnosis/consultation page."""
def list_ai_patient_options(
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
) -> PageResult[dict[str, Any]]:
"""Return privacy-safe diagnosis choices for patient AI workflows."""
def list_prescription_templates(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
@@ -204,6 +211,9 @@ class DoctorRepository(Protocol):
) -> Iterator[dict[str, Any]]:
"""Yield normalized ``start``/``delta``/``done`` assistant events."""
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
"""Generate a server-validated prescription draft for doctor review."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -631,14 +641,15 @@ class DoctorRepository(Protocol):
) -> Any:
"""Attach one uploaded replay URI to a call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Upload a local video then attach it to a real call record."""
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Upload a locally captured call recording to a real call record."""
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -699,11 +710,19 @@ class DoctorRepository(Protocol):
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create a call record."""
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""End the exact active call when its server identity is available."""
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind a TRTC room to the exact active call and start cloud recording."""
def start_call_transcription(
self,
@@ -1411,6 +1430,15 @@ class RemoteDoctorRepository:
yield {"event": "delta", "text": answer, "fallback": True}
yield {**result, "event": "done", "fallback": True}
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
"""Generate a structured draft using the server's full patient context."""
return self.analyze_diagnosis_ai(
diagnosis_id,
"请依据患者全部纵向资料生成一份可复核的中医处方草稿。",
task="prescription_generate",
)
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -1981,10 +2009,10 @@ class RemoteDoctorRepository:
return self.get_diagnosis_detail(diagnosis_id, readonly=True)
def list_consultations(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Consultation]:
"""List diagnosis records using ``tcm.diagnosis/lists``."""
def list_consultations(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Consultation]:
"""List diagnosis records using ``tcm.diagnosis/lists``."""
request_filters = dict(filters)
start_date = str(request_filters.pop("start_date", "") or "").strip()
@@ -2006,9 +2034,26 @@ class RemoteDoctorRepository:
"tcm.diagnosis/lists",
_page_params(page_no, page_size, request_filters),
)
return PageResult.from_payload(
payload, Consultation.from_dict, page_no=page_no, page_size=page_size
)
return PageResult.from_payload(
payload, Consultation.from_dict, page_no=page_no, page_size=page_size
)
def list_ai_patient_options(
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
) -> PageResult[dict[str, Any]]:
"""List privacy-safe diagnosis choices through the dedicated AI endpoint."""
clean_keyword = str(keyword or "").strip()
payload = self.client.get(
"tcm.diagnosis/aiPatientOptions",
_page_params(page_no, page_size, {"keyword": clean_keyword}),
)
return PageResult.from_payload(
payload,
_ai_patient_option,
page_no=page_no,
page_size=page_size,
)
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Load a full diagnosis using the appropriate routed endpoint."""
@@ -2322,31 +2367,75 @@ class RemoteDoctorRepository:
body["call_record_id"] = call_record_id
return self.client.post("tcm.diagnosis/attachLocalCallRecording", body)
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
"""Perform the same upload/create/attach sequence as ``CallRecordPanel``."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
# Match the admin component exactly: upload first so a failed upload does
# not leave behind an empty synthetic call record.
file_url = self.upload_material(path, "video")
target_id = call_record_id
if target_id is None:
target_id = int(self.create_manual_call_record(diagnosis_id).get("id") or 0)
if target_id <= 0:
raise ApiProtocolError("tcm.diagnosis/createManualCallRecord returned no id")
self.attach_local_call_recording(diagnosis_id, file_url, call_record_id=target_id)
return {
"diagnosis_id": diagnosis_id,
"call_record_id": target_id,
"file_url": file_url,
}
def upload_call_recording(
self,
path: str | PathLike[str],
diagnosis_id: int,
*,
call_record_id: int | None = None,
mime_type: str = "audio/webm",
) -> dict[str, Any]:
"""Chunk-upload one local audio recording and attach it atomically."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if call_record_id is not None and call_record_id <= 0:
raise ValueError("call_record_id must be positive")
recording_path = Path(path).expanduser().resolve()
if not recording_path.is_file():
raise ValueError(f"recording file does not exist: {recording_path}")
file_size = recording_path.stat().st_size
if file_size <= 0:
raise ValueError("recording file must not be empty")
chunk_size = 4 * 1024 * 1024
chunk_total = (file_size + chunk_size - 1) // chunk_size
upload_id = f"local_audio_{uuid4().hex}"
file_name = recording_path.name
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
final_result: Mapping[str, Any] | None = None
bound_call_record_id = call_record_id
with recording_path.open("rb") as stream:
for chunk_index in range(chunk_total):
content = stream.read(chunk_size)
if not content:
raise ApiProtocolError("local recording ended before all chunks were read")
result = self.client.post_multipart(
"tcm.diagnosis/uploadCallRecording",
files={"file": (file_name, content, clean_mime)},
data={
"diagnosis_id": diagnosis_id,
"call_record_id": bound_call_record_id or 0,
"upload_id": upload_id,
"file_name": file_name,
"file_size": file_size,
"mime_type": clean_mime,
"chunk_index": chunk_index,
"chunk_total": chunk_total,
},
)
final_result = _require_mapping(
result,
"tcm.diagnosis/uploadCallRecording",
)
returned_id = int(final_result.get("call_record_id") or 0)
if returned_id <= 0:
raise ApiProtocolError(
"uploadCallRecording returned no call_record_id"
)
if bound_call_record_id is None:
bound_call_record_id = returned_id
elif returned_id != bound_call_record_id:
raise ApiProtocolError(
"uploadCallRecording returned a different call_record_id"
)
if final_result is None or not bool(final_result.get("completed")):
raise ApiProtocolError("uploadCallRecording did not complete the local audio upload")
file_url = str(final_result.get("file_url") or "").strip()
if not file_url:
raise ApiProtocolError("uploadCallRecording returned no COS file URL")
return dict(final_result)
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
@@ -2546,20 +2635,53 @@ class RemoteDoctorRepository:
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _optional_call_record_id(call_record_id: int | str | None) -> int | str | None:
if call_record_id is None:
return None
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id must be a positive identifier")
try:
normalized = int(str(call_record_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError("call_record_id must be a positive identifier") from exc
if normalized <= 0:
raise ValueError("call_record_id must be a positive identifier")
return normalized
def end_call(
self, diagnosis_id: int, *, call_record_id: int | str | None = None
) -> Any:
"""Stop COS cloud recording and end the exact call record."""
body: dict[str, Any] = {"diagnosis_id": diagnosis_id}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post("tcm.diagnosis/endCall", body)
def bind_call_room(
self,
diagnosis_id: int,
room_id: str,
*,
call_record_id: int | str | None = None,
) -> Any:
"""Bind the actual TRTC room and auto-start COS cloud recording."""
if not room_id.strip():
raise ValueError("room_id is required")
body: dict[str, Any] = {
"diagnosis_id": diagnosis_id,
"room_id": room_id.strip(),
}
normalized_id = self._optional_call_record_id(call_record_id)
if normalized_id is not None:
body["call_record_id"] = normalized_id
return self.client.post(
"tcm.diagnosis/bindCallRoom",
body,
)
@staticmethod
def _transcription_identity(
@@ -2703,6 +2825,7 @@ def _diagnosis_ai_request(diagnosis_id: int, prompt: str, task: str) -> tuple[st
"summary",
"tcm_pattern",
"prescription_review",
"prescription_generate",
"medication_review",
"exam_review",
"complication_risk",
@@ -2757,8 +2880,39 @@ def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> di
for key, value in filters.items()
if value is not None and not (isinstance(value, (list, tuple, set)) and not value)
}
result.update({"page_no": page_no, "page_size": page_size})
return result
result.update({"page_no": page_no, "page_size": page_size})
return result
def _ai_patient_option(row: Mapping[str, Any]) -> dict[str, Any]:
"""Project one AI patient option without exposing a plaintext phone number."""
diagnosis_id = _to_int(row.get("diagnosis_id", row.get("id")), 0)
source_patient_id = _to_int(row.get("source_patient_id", row.get("patient_id")), 0)
phone = str(row.get("phone_masked") or "").strip()
digits = re.sub(r"\D", "", phone)
if len(digits) >= 7 and "*" not in phone:
phone = f"{digits[:3]}****{digits[-4:]}"
option: dict[str, Any] = {
"diagnosis_id": diagnosis_id,
"source_patient_id": source_patient_id,
"patient_name": str(row.get("patient_name", row.get("name")) or "").strip(),
"phone_masked": phone,
}
for key in (
"gender",
"gender_desc",
"age",
"diagnosis_date",
"diagnosis_summary",
"syndrome_type",
"clinical_diagnosis",
"last_visit_at",
"next_appointment_at",
):
if key in row:
option[key] = row[key]
return option
def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
@@ -2767,24 +2921,40 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
) -> Any:
"""Call get/post once, omitting timeout for legacy test doubles.
Capability is determined before invocation. Catching ``TypeError`` from
the call itself is unsafe for POST because the transport may already have
submitted the write before raising, and a fallback call would duplicate it.
"""
fn = getattr(client, method)
params = dict(payload or {})
if timeout is not None:
try:
parameters = signature(fn).parameters.values()
except (TypeError, ValueError):
parameters = ()
accepts_timeout = any(
parameter.kind is Parameter.VAR_KEYWORD
or (
parameter.name == "timeout"
and parameter.kind
in {Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY}
)
for parameter in parameters
)
if accepts_timeout:
return fn(endpoint, params, timeout=timeout)
return fn(endpoint, params)
def _material_kind(
@@ -61,44 +61,44 @@ from PySide6.QtWidgets import (
QWidgetItem,
)
from .theme import crisp_pixmap
from .widgets import display_text, first_value, gender_text, get_value
PRIMARY = QColor("#5265F6")
TEXT = QColor("#15224A")
SECONDARY = QColor("#7481A3")
PLACEHOLDER = QColor("#A4ADC3")
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
def _render_signature(value: Any) -> Any:
"""Freeze repository DTOs into a stable, order-independent render key."""
if isinstance(value, Mapping):
items = ((str(key), _render_signature(item)) for key, item in value.items())
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return ("sequence", tuple(_render_signature(item) for item in value))
if isinstance(value, set | frozenset):
items = (_render_signature(item) for item in value)
return ("set", tuple(sorted(items, key=repr)))
try:
hash(value)
except TypeError:
return ("repr", repr(value))
return ("value", value)
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
return tuple(_render_signature(row) for row in rows)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
PRIMARY = QColor("#5265F6")
TEXT = QColor("#15224A")
SECONDARY = QColor("#7481A3")
PLACEHOLDER = QColor("#A4ADC3")
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
def _render_signature(value: Any) -> Any:
"""Freeze repository DTOs into a stable, order-independent render key."""
if isinstance(value, Mapping):
items = ((str(key), _render_signature(item)) for key, item in value.items())
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return ("sequence", tuple(_render_signature(item) for item in value))
if isinstance(value, set | frozenset):
items = (_render_signature(item) for item in value)
return ("set", tuple(sorted(items, key=repr)))
try:
hash(value)
except TypeError:
return ("repr", repr(value))
return ("value", value)
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
return tuple(_render_signature(row) for row in rows)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
"""Draw the admin menu glyphs without relying on a symbol font."""
size = 18
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
color = QColor("#C43E55" if danger else "#667085")
@@ -264,6 +264,27 @@ def _appointment_active(record: Any) -> bool:
return _has_appointment(record) and _appointment_status(record) == 1
def video_call_state(record: Any) -> str:
"""Return the server-owned state of the latest video-call session."""
return str(
first_value(
record,
"video_call_hint.state",
"video_hint.state",
"video_hint",
default="none",
)
or "none"
).strip().lower()
def video_call_is_live(record: Any) -> bool:
"""Only a room already opened by the doctor may be joined from this list."""
return video_call_state(record) == "live"
def _single_cancellable_appointment(record: Any) -> Any | None:
"""Return the sole cancellable appointment, never an ambiguous row-level fallback."""
@@ -317,12 +338,34 @@ def _video_ids_complete(record: Any) -> bool:
)
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
"""Return the label and immutable click intent for the current appointment."""
if force_open:
return "开方"
return "开方", "open"
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
return "查看处方" if audit == 1 and voided != 1 else "开方"
explicit_current = first_value(record, "current_has_prescription", default=None)
if explicit_current is not None:
has_current = _as_bool(explicit_current)
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
has_current = True
else:
explicit_legacy = first_value(record, "has_prescription", default=None)
has_current = (
_as_bool(explicit_legacy)
if explicit_legacy is not None
else audit in {0, 1, 2}
)
if not has_current:
return "开方", "open"
if audit == 1 and voided != 1:
return "查看处方", "view"
return "编辑处方", "edit"
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
return prescription_action(record, force_open=force_open)[0]
class FlowLayout(QLayout):
@@ -472,13 +515,13 @@ class DiagnosisTableModel(QAbstractTableModel):
"操作",
)
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._render_signature = _rows_render_signature(self.rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._render_signature = _rows_render_signature(self.rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.rows)
@@ -549,24 +592,24 @@ class DiagnosisTableModel(QAbstractTableModel):
def record(self, row: int) -> Any:
return self.rows[row] if 0 <= row < len(self.rows) else None
@property
def render_signature(self) -> tuple[Any, ...]:
return self._render_signature
def set_rows(self, rows: Iterable[Any]) -> bool:
materialized = list(rows)
render_signature = _rows_render_signature(materialized)
if render_signature == self._render_signature:
return False
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._render_signature = render_signature
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
return True
@property
def render_signature(self) -> tuple[Any, ...]:
return self._render_signature
def set_rows(self, rows: Iterable[Any]) -> bool:
materialized = list(rows)
render_signature = _rows_render_signature(materialized)
if render_signature == self._render_signature:
return False
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._render_signature = render_signature
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
return True
def checked_records(self) -> list[Any]:
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
@@ -1189,12 +1232,12 @@ class DiagnosisTableView(QTableView):
def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None:
self.itemSelectionChanged.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
if not self._diagnosis_model().set_rows(rows):
return
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
if not self._diagnosis_model().set_rows(rows):
return
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def rowCount(self) -> int: # noqa: N802 - compatibility
return self._diagnosis_model().rowCount()
@@ -1328,6 +1371,9 @@ class DiagnosisTableHost(QFrame):
LEFT_WIDTHS = _TABLE_COLUMN_WIDTHS[:10]
FIXED_WIDTHS = _TABLE_COLUMN_WIDTHS[10:]
#: Inline row actions kept before the rest fall back to the 更多 menu.
MAX_INLINE_ACTIONS = 2
def __init__(
self,
*,
@@ -1336,8 +1382,8 @@ class DiagnosisTableHost(QFrame):
) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisTableHost")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.action_policy = dict(action_policy or {})
self.force_open_prescription = False
self.model = DiagnosisTableModel(parent=self)
@@ -1351,13 +1397,13 @@ class DiagnosisTableHost(QFrame):
view.setModel(self.model)
view.setSelectionModel(self.selection)
view.setItemDelegate(self.delegate)
view.setMinimumHeight(0)
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.setMinimumHeight(0)
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
view.horizontalHeader().setStretchLastSection(False)
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setMinimumWidth(0)
@@ -1373,7 +1419,7 @@ class DiagnosisTableHost(QFrame):
layout.setSpacing(0)
layout.addWidget(self.main, 1)
layout.addWidget(self.fixed)
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
self.fixed_shadow = _FixedColumnShadow(self)
self.fixed_shadow.show()
self.empty_label = QLabel("暂无数据", self)
@@ -1387,27 +1433,27 @@ class DiagnosisTableHost(QFrame):
self.main.hovered_row.connect(self._set_hover_row)
self.fixed.hovered_row.connect(self._set_hover_row)
self.model.selection_changed.connect(self._checked_changed)
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
self._fixed_render_signature: tuple[Any, ...] | None = None
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
rows_changed = self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
if rows_changed or fixed_widgets_changed:
self._rows_changed()
if rows_changed and previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
self._fixed_render_signature: tuple[Any, ...] | None = None
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
rows_changed = self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
if rows_changed or fixed_widgets_changed:
self._rows_changed()
if rows_changed and previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
def selected_records(self) -> list[Any]:
return self.model.checked_records()
@@ -1433,21 +1479,21 @@ class DiagnosisTableHost(QFrame):
def set_sort_direction(self, direction: str) -> None:
self.model.set_sort_direction(direction)
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self._fixed_render_signature = self._fixed_widgets_signature()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._position_empty()
self._position_fixed_shadow()
self._schedule_fixed_height_sync()
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
return (
self.model.render_signature,
self.force_open_prescription,
tuple(sorted(self.action_policy.items())),
)
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self._fixed_render_signature = self._fixed_widgets_signature()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._position_empty()
self._position_fixed_shadow()
self._schedule_fixed_height_sync()
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
return (
self.model.render_signature,
self.force_open_prescription,
tuple(sorted(self.action_policy.items())),
)
def _sync_row_heights(self) -> None:
for row, record in enumerate(self.model.rows):
@@ -1486,12 +1532,13 @@ class DiagnosisTableHost(QFrame):
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
if video_capable and _appointment_active(record):
call_state = video_call_state(record)
if video_capable and _appointment_active(record) and call_state == "live":
button = QToolButton(host)
button.setText("进入视频问诊")
button.setProperty("rowLink", "primary")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("进入医生端视频问诊(将使用摄像头和麦克风)")
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
button.setEnabled(_video_ids_complete(record))
if not button.isEnabled():
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
@@ -1502,9 +1549,15 @@ class DiagnosisTableHost(QFrame):
else:
status = ""
if video_capable:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record), "未挂号" if not _has_appointment(record) else ""
)
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
status = "暂无通话"
else:
status = {3: "已完成", 4: "已过号"}.get(
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "",
)
label = QLabel(display_text(status), host)
label.setProperty("fixedMuted", True)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -1518,25 +1571,33 @@ class DiagnosisTableHost(QFrame):
layout = QHBoxLayout(host)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(2)
# 该列曾把全部有权限的操作平铺出来,一行最多七个链接,既撑宽操作列,
# 又让每一行的宽度都不一样。这里只保留“看诊单 / 开方”两个闭环主操作,
# 其余按原顺序降级进本就存在的“更多”菜单。
candidates: list[tuple[str, str, str]] = []
if self.action_policy.get("view", False):
layout.addWidget(self._action_button("查看", "view", record, "primary"))
candidates.append(("查看", "view", "primary"))
if self.action_policy.get("edit", False):
layout.addWidget(self._action_button("诊单", "edit", record, "primary"))
candidates.append(("诊单", "edit", "primary"))
if self.action_policy.get("prescription", False):
layout.addWidget(
self._action_button(
candidates.append(
(
_prescription_action_label(record, force_open=self.force_open_prescription),
"prescription",
record,
"primary",
)
)
if self.action_policy.get("ai_consult", False):
layout.addWidget(self._action_button("AI 分析", "ai_consult", record, "primary"))
if self.action_policy.get("ai_consult", False):
candidates.append(("AI 分析", "ai_consult", "primary"))
if self.action_policy.get("appointment", False):
layout.addWidget(self._action_button("预约", "appointment", record, "success"))
candidates.append(("预约", "appointment", "success"))
if self.action_policy.get("edit", False):
layout.addWidget(self._action_button("补全身份证", "fill_id_card", record, "warning"))
candidates.append(("补全身份证", "fill_id_card", "warning"))
inline_actions = candidates[: self.MAX_INLINE_ACTIONS]
demoted_actions = candidates[self.MAX_INLINE_ACTIONS :]
for label, action, semantic in inline_actions:
layout.addWidget(self._action_button(label, action, record, semantic))
more = QToolButton(host)
more.setText("更多")
more.setArrowType(Qt.ArrowType.DownArrow)
@@ -1553,6 +1614,17 @@ class DiagnosisTableHost(QFrame):
"QMenu::item:disabled{color:#98A2B3;}"
"QMenu::separator{height:1px;background:#D8DEEA;margin:5px 8px;}"
)
for label, action, _semantic in demoted_actions:
_add_menu_action(
menu,
label,
"document",
lambda _checked=False, command=action, item=record: self.action_requested.emit(
command, item
),
)
if demoted_actions and self.action_policy.get("assign", False):
menu.addSeparator()
if self.action_policy.get("assign", False):
_add_menu_action(
menu,
@@ -1658,23 +1730,23 @@ class DiagnosisTableHost(QFrame):
self.set_sort_direction(direction)
self.sort_unserved_requested.emit(direction)
def _schedule_fixed_height_sync(self, *_range: int) -> None:
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
QTimer.singleShot(0, self._sync_fixed_height)
def _sync_fixed_height(self) -> None:
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
target = max(0, self.height() - reserved)
if self.fixed.height() != target:
self.fixed.setFixedHeight(target)
def _schedule_fixed_height_sync(self, *_range: int) -> None:
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
QTimer.singleShot(0, self._sync_fixed_height)
def _sync_fixed_height(self) -> None:
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
target = max(0, self.height() - reserved)
if self.fixed.height() != target:
self.fixed.setFixedHeight(target)
def _position_empty(self) -> None:
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
body_height = max(0, self.height() - 39 - reserved)
self.empty_label.setGeometry(0, 39, self.width(), body_height)
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
body_height = max(0, self.height() - 39 - reserved)
self.empty_label.setGeometry(0, 39, self.width(), body_height)
self.empty_label.raise_()
def _position_fixed_shadow(self) -> None:
@@ -1689,7 +1761,7 @@ class DiagnosisTableHost(QFrame):
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._sync_fixed_height()
self._sync_fixed_height()
self._position_empty()
self._position_fixed_shadow()
@@ -1703,16 +1775,16 @@ class DiagnosisPager(QWidget):
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
self._setting = False
self._page_buttons: list[QToolButton] = []
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 4, 12, 4)
layout.setSpacing(6)
layout.setContentsMargins(12, 4, 12, 4)
layout.setSpacing(6)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("pagerMuted", True)
@@ -2204,4 +2276,5 @@ __all__ = [
"DiagnosisTableModel",
"FlowLayout",
"FlowWidget",
"prescription_action",
]
@@ -1,13 +1,24 @@
"""Reusable doctor-workstation dialogs."""
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
from .ai_consult_picker import (
AiConsultTarget,
AiConsultTargetDialog,
select_and_present_ai_consult,
)
from .app_update import AppUpdateDialog, AppUpdateSession
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
__all__ = [
"AiConsultDialog",
"AiConsultTarget",
"AiConsultTargetDialog",
"AppUpdateDialog",
"AppUpdateSession",
"DiagnosisDialog",
"OrderDetailDrawer",
"can_open_ai_consult",
"present_ai_consult",
"present_order_detail",
"select_and_present_ai_consult",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,428 @@
"""Permission-scoped diagnosis picker for the global AI assistant entry points."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QAbstractItemView,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..theme import mark_business_dialog
from ..widgets import (
BusyOverlay,
EmptyState,
MessageBanner,
OverlayHost,
Pager,
SortableTable,
TableColumn,
first_value,
friendly_error,
gender_text,
get_value,
invoke,
page_items,
page_total,
run_async,
)
from .ai_consult import can_open_ai_consult, present_ai_consult
def _positive_int(value: Any) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return 0
return number if number > 0 else 0
def _gender_age(_value: Any, row: Any) -> str:
gender = gender_text(get_value(row, "gender", None))
age = str(get_value(row, "age", "") or "").strip()
parts = [part for part in (gender if gender != "" else "", f"{age}" if age else "") if part]
return " · ".join(parts) or ""
@dataclass(frozen=True, slots=True)
class AiConsultTarget:
"""A diagnosis-level AI target with a separately verified patient identity."""
diagnosis_id: int
patient_id: int
patient_name: str
gender: Any = None
age: Any = None
phone_masked: str = ""
diagnosis_date: str = ""
diagnosis_summary: str = ""
last_visit_at: str = ""
next_appointment_at: str = ""
@classmethod
def from_row(cls, row: Any) -> AiConsultTarget | None:
diagnosis_id = _positive_int(first_value(row, "diagnosis_id", "id"))
if not diagnosis_id:
return None
# Patient identity must never fall back to the diagnosis primary key.
patient_id = _positive_int(first_value(row, "source_patient_id", "patient_id"))
return cls(
diagnosis_id=diagnosis_id,
patient_id=patient_id,
patient_name=str(first_value(row, "patient_name", "name", default="")).strip(),
gender=get_value(row, "gender", None),
age=get_value(row, "age", None),
# Deliberately do not fall back to a raw phone field.
phone_masked=str(get_value(row, "phone_masked", "") or "").strip(),
diagnosis_date=str(get_value(row, "diagnosis_date", "") or "").strip(),
diagnosis_summary=str(
first_value(
row,
"diagnosis_summary",
"clinical_diagnosis",
"syndrome_type",
default="",
)
or ""
).strip(),
last_visit_at=str(get_value(row, "last_visit_at", "") or "").strip(),
next_appointment_at=str(
get_value(row, "next_appointment_at", "") or ""
).strip(),
)
@property
def seed(self) -> dict[str, Any]:
"""Return only the minimum non-sensitive context needed by the AI dialog."""
return {
"diagnosis_id": self.diagnosis_id,
"source_patient_id": self.patient_id,
"patient_name": self.patient_name,
"gender": self.gender,
"age": self.age,
"phone_masked": self.phone_masked,
"diagnosis_date": self.diagnosis_date,
"diagnosis_summary": self.diagnosis_summary,
"last_visit_at": self.last_visit_at,
"next_appointment_at": self.next_appointment_at,
}
class AiConsultTargetDialog(QDialog):
"""Search and select one accessible patient diagnosis before opening AI chat."""
PAGE_SIZE = 20
SEARCH_DELAY_MS = 300
def __init__(
self,
repository: Any,
permissions: Any,
parent: QWidget | None = None,
*,
initial_query: str = "",
) -> None:
super().__init__(parent)
self.repository = repository
self.permissions = permissions
self._page = 1
self._generation = 0
self._active = True
self._loading = False
self._selected: AiConsultTarget | None = None
self._started = False
self.setWindowTitle("选择患者资料")
self.resize(980, 650)
self.setMinimumSize(760, 520)
root = QVBoxLayout(self)
root.setContentsMargins(24, 22, 24, 20)
root.setSpacing(14)
title = QLabel("选择患者资料", self)
title.setProperty("role", "pageTitle")
root.addWidget(title)
description = QLabel(
"从当前账号可查看的全部患者中选择一份诊单,AI 将结合患者资料进行分析。",
self,
)
description.setProperty("role", "muted")
description.setWordWrap(True)
root.addWidget(description)
filters = QHBoxLayout()
filters.setSpacing(10)
self.search_edit = QLineEdit(self)
self.search_edit.setObjectName("AiConsultTargetSearch")
self.search_edit.setPlaceholderText("搜索患者姓名、掩码手机号或诊单号")
self.search_edit.setClearButtonEnabled(True)
self.search_edit.setText(str(initial_query or "").strip())
self.search_edit.textEdited.connect(self._queue_search)
self.search_edit.returnPressed.connect(self.search_now)
filters.addWidget(self.search_edit, 1)
self.search_button = QPushButton("查询", self)
self.search_button.setProperty("variant", "secondary")
self.search_button.setAutoDefault(False)
self.search_button.clicked.connect(self.search_now)
filters.addWidget(self.search_button)
root.addLayout(filters)
self.banner = MessageBanner(parent=self)
root.addWidget(self.banner)
self.body = OverlayHost(self)
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(0)
self.table = SortableTable(
(
TableColumn("patient_name", "患者", 100),
TableColumn("gender", "性别 / 年龄", 90, _gender_age),
TableColumn("phone_masked", "手机号", 110),
TableColumn("diagnosis_id", "诊单号", 80),
TableColumn("diagnosis_date", "建档日期", 100),
TableColumn("last_visit_at", "最近就诊", 120),
TableColumn("next_appointment_at", "下次预约", 150),
TableColumn("diagnosis_summary", "诊断摘要", 140),
),
self.body,
)
# Preserve the server's permission-scoped, recent-first order.
self.table.setSortingEnabled(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self.accept())
body_layout.addWidget(self.table, 1)
self.empty_state = EmptyState(
"未找到患者诊单",
"可尝试患者姓名、掩码手机号或诊单号。",
"重新加载",
self.body,
)
self.empty_state.action_requested.connect(self.retry)
self.empty_state.hide()
body_layout.addWidget(self.empty_state, 1)
self.busy_overlay = BusyOverlay(self.body, "正在加载患者诊单…")
self.body.busy_overlay = self.busy_overlay
root.addWidget(self.body, 1)
self.pager = Pager(self.PAGE_SIZE, self)
self.pager.page_changed.connect(self._change_page)
root.addWidget(self.pager)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
cancel = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
cancel.setText("取消")
cancel.setAutoDefault(False)
self.start_button = self.buttons.addButton(
"开始对话", QDialogButtonBox.ButtonRole.AcceptRole
)
self.start_button.setObjectName("AiConsultTargetStart")
self.start_button.setProperty("variant", "primary")
self.start_button.setAutoDefault(False)
self.start_button.setDefault(False)
self.start_button.setEnabled(False)
self.start_button.clicked.connect(self.accept)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(self.SEARCH_DELAY_MS)
self._search_timer.timeout.connect(self.search_now)
mark_business_dialog(self, "AiConsultTargetDialog")
QTimer.singleShot(0, self._initial_load)
def _initial_load(self) -> None:
if self._active and not self._started:
self._started = True
self.load(1)
self.search_edit.setFocus(Qt.FocusReason.OtherFocusReason)
def selected_target(self) -> AiConsultTarget | None:
return self._selected
def _queue_search(self, _text: str) -> None:
self._invalidate_selection()
self._search_timer.start()
def search_now(self) -> None:
self._search_timer.stop()
self.load(1)
def retry(self) -> None:
self.load(self._page)
def _change_page(self, page: int) -> None:
self.load(page)
def load(self, page: int) -> None:
if not self._active:
return
self._page = max(1, int(page))
self._generation += 1
generation = self._generation
page_snapshot = self._page
keyword_snapshot = self.search_edit.text().strip()
self._invalidate_selection()
self._set_loading(True)
self.banner.clear()
run_async(
lambda: invoke(
self.repository,
"list_ai_patient_options",
page_no=page_snapshot,
page_size=self.PAGE_SIZE,
keyword=keyword_snapshot,
),
on_success=lambda result: self._apply_result(
result, generation, page_snapshot, keyword_snapshot
),
on_error=lambda error: self._apply_error(error, generation),
)
def _apply_result(
self,
result: Any,
generation: int,
page_snapshot: int,
keyword_snapshot: str,
) -> None:
if not self._is_current(generation):
return
# A text edit can arrive before its debounce timer fires. Never paint a
# response for the previous text during that interval.
if keyword_snapshot != self.search_edit.text().strip():
return
targets = [
target
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
if target is not None
]
total = max(0, page_total(result, len(targets)))
page_count = max(1, (total + self.PAGE_SIZE - 1) // self.PAGE_SIZE)
if page_snapshot > page_count:
self.load(page_count)
return
self._page = page_snapshot
self.table.set_rows(targets)
self.table.setSortingEnabled(False)
self.table.clearSelection()
self.pager.update_state(page_snapshot, total)
self.empty_state.setVisible(not targets)
self.table.setVisible(bool(targets))
self.banner.clear()
self._set_loading(False)
def _apply_error(self, error: Exception, generation: int) -> None:
if not self._is_current(generation):
return
self.table.set_rows(())
self.table.setSortingEnabled(False)
self.table.clearSelection()
self.table.hide()
self.empty_state.show()
self.pager.update_state(1, 0)
self.banner.show_message(
f"患者诊单加载失败:{friendly_error(error)}", "danger"
)
self._set_loading(False)
def _is_current(self, generation: int) -> bool:
return self._active and generation == self._generation
def _invalidate_selection(self) -> None:
self._selected = None
self.table.clearSelection()
self.start_button.setEnabled(False)
def _selection_changed(self) -> None:
target = self.table.current_data()
self.start_button.setEnabled(
not self._loading
and isinstance(target, AiConsultTarget)
and target.diagnosis_id > 0
)
def _set_loading(self, loading: bool) -> None:
self._loading = loading
self.table.setEnabled(not loading)
self.pager.setEnabled(not loading)
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
self.busy_overlay.setVisible(loading)
if loading:
self.busy_overlay.raise_()
def accept(self) -> None:
target = self.table.current_data()
if not isinstance(target, AiConsultTarget) or target.diagnosis_id <= 0:
self.banner.show_message("请选择一条患者诊单。", "warning")
self._selected = None
return
self._selected = target
super().accept()
def done(self, result: int) -> None:
self._active = False
self._generation += 1
self._search_timer.stop()
super().done(result)
def select_and_present_ai_consult(
repository: Any,
permissions: Any,
parent: QWidget | None,
*,
initial_query: str = "",
) -> bool:
"""Select an accessible diagnosis, then open the existing AI workspace."""
if not can_open_ai_consult(permissions):
return False
dialog = AiConsultTargetDialog(
repository,
permissions,
parent,
initial_query=initial_query,
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return False
target = dialog.selected_target()
if target is None or target.diagnosis_id <= 0:
return False
present_ai_consult(
repository,
permissions,
parent,
diagnosis_id=target.diagnosis_id,
patient_id=target.patient_id,
seed=target.seed,
source_title="AI 助手",
)
return True
__all__ = [
"AiConsultTarget",
"AiConsultTargetDialog",
"select_and_present_ai_consult",
]
@@ -0,0 +1,487 @@
"""Desktop update prompt with progress and optional forced install."""
from __future__ import annotations
import logging
import os
import sys
import traceback
from collections.abc import Callable
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
from PySide6.QtGui import QCloseEvent, QShowEvent
from PySide6.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdateOffer,
apply_downloaded_update,
current_app_version,
discover_payload,
download_package,
fetch_update_offer,
frozen_install_root,
is_frozen_install,
package_filename,
prepare_update_workspace,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
from doctor_workstation.ui.widgets import friendly_error, show_toast
LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class _PreparedUpdate:
package_type: str
payload: Path
def _format_bytes(value: int) -> str:
size = max(0, int(value))
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
if size < 1024 * 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
return f"{size / (1024 * 1024 * 1024):.2f} GB"
class _TaskSignals(QObject):
result = Signal(object)
error = Signal(object, str)
finished = Signal()
progress = Signal(int, int)
status = Signal(str)
class _Task(QRunnable):
def __init__(self, function: Callable[[], Any], signals: _TaskSignals) -> None:
super().__init__()
self.function = function
self.signals = signals
@Slot()
def run(self) -> None:
try:
result = self.function()
except Exception as exc:
self.signals.error.emit(exc, traceback.format_exc())
else:
self.signals.result.emit(result)
finally:
self.signals.finished.emit()
class AppUpdateDialog(QDialog):
"""Offer, download and apply a workstation package."""
update_accepted = Signal()
update_deferred = Signal()
download_cancelled = Signal()
def __init__(self, offer: UpdateOffer, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.offer = offer
self._busy = False
self.setObjectName("AppUpdateDialog")
self.setProperty("businessDialog", True)
title = offer.title.strip() or (
f"医生工作站 {offer.latest_version}".strip() if offer.latest_version else "软件更新"
)
self.setWindowTitle(title)
self.setModal(True)
self.setMinimumWidth(520)
flags = (
Qt.WindowType.Dialog
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowSystemMenuHint
)
if not offer.force:
flags |= Qt.WindowType.WindowCloseButtonHint
self.setWindowFlags(flags)
if offer.force:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 18)
root.setSpacing(12)
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
self.badge.setObjectName("UpdateBadge")
self.badge.setStyleSheet(
"color:#B45309;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
if offer.force
else "color:#4451E2;background:#F0F2FF;border-radius:8px;padding:4px 10px;font-weight:600;"
)
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
self.headline = QLabel(title or "医生工作站有新版本")
self.headline.setObjectName("UpdateHeadline")
self.headline.setStyleSheet("font-size:18px;font-weight:700;")
self.headline.setWordWrap(True)
root.addWidget(self.headline)
current = offer.current_version or current_app_version()
latest = offer.latest_version or "新版本"
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
self.version_label.setObjectName("UpdateVersionLabel")
self.version_label.setStyleSheet("color:#7886AA;")
root.addWidget(self.version_label)
self.notes = QTextEdit()
self.notes.setObjectName("UpdateNotes")
self.notes.setReadOnly(True)
self.notes.setPlainText(offer.notes or "本次更新包含功能与稳定性改进。")
self.notes.setMinimumHeight(140)
self.notes.setMaximumHeight(220)
root.addWidget(self.notes)
self.status_label = QLabel("")
self.status_label.setObjectName("UpdateStatus")
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet("color:#3F4E75;")
self.status_label.hide()
root.addWidget(self.status_label)
self.progress = QProgressBar()
self.progress.setObjectName("UpdateProgress")
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(10)
self.progress.hide()
root.addWidget(self.progress)
self.progress_text = QLabel("")
self.progress_text.setObjectName("UpdateProgressText")
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
self.progress_text.hide()
root.addWidget(self.progress_text)
buttons = QHBoxLayout()
buttons.addStretch(1)
self.later_button = QPushButton("稍后提醒")
self.later_button.setObjectName("UpdateLaterButton")
self.later_button.setProperty("variant", "secondary")
self.later_button.clicked.connect(self._defer)
self.later_button.setVisible(not offer.force)
buttons.addWidget(self.later_button)
self.cancel_button = QPushButton("取消下载")
self.cancel_button.setObjectName("UpdateCancelButton")
self.cancel_button.setProperty("variant", "secondary")
self.cancel_button.clicked.connect(self.download_cancelled.emit)
self.cancel_button.hide()
buttons.addWidget(self.cancel_button)
self.update_button = QPushButton("立即更新" if offer.can_install else "暂不可安装")
self.update_button.setObjectName("UpdateNowButton")
self.update_button.setProperty("variant", "primary")
self.update_button.clicked.connect(self._accept_update)
buttons.addWidget(self.update_button)
root.addLayout(buttons)
if not offer.can_install:
self.status_label.setText("后台尚未配置当前系统对应的安装包,请联系管理员上传后再试。")
self.status_label.show()
self.update_button.setEnabled(False)
def set_busy(self, busy: bool) -> None:
self._busy = busy
self.update_button.setEnabled((not busy) and self.offer.can_install)
self.later_button.setEnabled(not busy)
self.cancel_button.setVisible(busy and not self.offer.force)
self.notes.setEnabled(not busy)
def show_download_progress(self, received: int, total: int) -> None:
self.progress.show()
self.progress_text.show()
self.status_label.setStyleSheet("color:#3F4E75;")
self.status_label.setText("正在下载安装包…")
self.status_label.show()
if total > 0:
self.progress.setRange(0, 100)
self.progress.setValue(min(100, int(received * 100 / total)))
self.progress_text.setText(f"{_format_bytes(received)} / {_format_bytes(total)}")
else:
self.progress.setRange(0, 0)
self.progress_text.setText(_format_bytes(received))
def show_status(self, message: str, *, determinate: bool = False) -> None:
self.status_label.setStyleSheet("color:#3F4E75;")
self.status_label.setText(message)
self.status_label.show()
self.progress.show()
self.progress_text.hide()
if determinate:
self.progress.setRange(0, 100)
self.progress.setValue(100)
else:
self.progress.setRange(0, 0)
def show_error(self, message: str) -> None:
self._busy = False
self.status_label.setText(message)
self.status_label.setStyleSheet("color:#F15B67;")
self.status_label.show()
self.progress.hide()
self.progress_text.hide()
self.update_button.setText("重试")
self.update_button.setEnabled(self.offer.can_install)
self.later_button.setEnabled(True)
self.cancel_button.hide()
self.notes.setEnabled(True)
def _accept_update(self) -> None:
if self._busy or not self.offer.can_install:
return
self.update_accepted.emit()
def _defer(self) -> None:
if self.offer.force or self._busy:
return
self.update_deferred.emit()
self.reject()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
if self.offer.force or self._busy:
event.ignore()
return
super().closeEvent(event)
def keyPressEvent(self, event: Any) -> None: # noqa: N802
if self.offer.force and event.key() == Qt.Key.Key_Escape:
event.ignore()
return
super().keyPressEvent(event)
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
super().showEvent(event)
self.setProperty("businessDialog", True)
class AppUpdateSession(QObject):
"""Owns update checks for the login and authenticated shell."""
def __init__(self, host: Any) -> None:
super().__init__(host)
self.host = host
self.dialog: AppUpdateDialog | None = None
self._generation = 0
self._dismissed: set[str] = set()
self._cancel = False
self._signals: _TaskSignals | None = None
def schedule(self, *, interactive: bool = False, delay_ms: int = 700) -> None:
self._generation += 1
generation = self._generation
QTimer.singleShot(
max(0, delay_ms),
lambda: self.check(interactive=interactive, generation=generation),
)
def check(self, *, interactive: bool = False, generation: int | None = None) -> None:
if generation is None:
self._generation += 1
generation = self._generation
if generation != self._generation:
return
if not self._should_check() and not interactive:
return
client = self._client()
parent = self._parent_window()
if client is None:
if interactive and parent is not None:
show_toast(parent, "请先填写服务器地址后再检查更新。", "warning")
return
signals = _TaskSignals()
self._signals = signals
worker = _Task(
lambda: fetch_update_offer(client, current_version=current_app_version()),
signals,
)
signals.result.connect(
lambda offer: self._on_offer(offer, interactive=interactive, generation=generation)
)
signals.error.connect(
lambda error, _tb: self._on_check_error(
error, interactive=interactive, generation=generation
)
)
QThreadPool.globalInstance().start(worker)
def _should_check(self) -> bool:
if getattr(self.host, "_shutting_down", False):
return False
if os.getenv("DOCTOR_SMOKE_TEST") == "1":
return False
if "--smoke-test" in sys.argv:
return False
config = getattr(self.host, "config", None)
return bool(getattr(config, "api_base_url", ""))
def _client(self) -> Any:
repository = getattr(self.host, "remote_repository", None)
return getattr(repository, "client", None) if repository is not None else None
def _parent_window(self) -> QWidget | None:
shell = getattr(self.host, "shell_window", None)
if shell is not None:
return shell
return getattr(self.host, "login_window", None)
def _on_check_error(self, error: Exception, *, interactive: bool, generation: int) -> None:
if generation != self._generation:
return
LOGGER.info("desktop update check failed: %s", error)
parent = self._parent_window()
if interactive and parent is not None:
show_toast(parent, f"检查更新失败:{friendly_error(error)}", "warning", 4200)
def _on_offer(self, offer: object, *, interactive: bool, generation: int) -> None:
if generation != self._generation or not isinstance(offer, UpdateOffer):
return
parent = self._parent_window()
if not offer.has_update:
if interactive and parent is not None:
current = offer.current_version or current_app_version()
show_toast(parent, f"当前已是最新版本 {current}", "success")
return
if not offer.force and not interactive and offer.latest_version in self._dismissed:
return
if offer.package is not None and offer.package.type == PACKAGE_TYPE_INNO_SETUP:
config = getattr(self.host, "config", None)
try:
validate_installer_download_policy(
offer.package.url,
verify_ssl=bool(getattr(config, "verify_ssl", True)),
)
except AppUpdateError:
offer = replace(offer, force=False, package=None, can_install=False)
if offer.force and not is_frozen_install():
offer = replace(offer, force=False)
self._present(offer)
def _present(self, offer: UpdateOffer) -> None:
parent = self._parent_window()
if self.dialog is not None:
self.dialog.close()
self.dialog.deleteLater()
dialog = AppUpdateDialog(offer, parent)
dialog.update_accepted.connect(lambda: self._start_install(dialog, offer))
dialog.update_deferred.connect(lambda: self._dismissed.add(offer.latest_version))
dialog.download_cancelled.connect(lambda: self._request_cancel(dialog))
self.dialog = dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
def _request_cancel(self, dialog: AppUpdateDialog) -> None:
if dialog.offer.force:
return
self._cancel = True
dialog.show_status("正在取消…")
def _start_install(self, dialog: AppUpdateDialog, offer: UpdateOffer) -> None:
package = offer.package
if package is None:
return
if not is_frozen_install():
dialog.show_error("当前为源码运行,无法自动安装。请先安装正式发布版后再更新。")
return
if frozen_install_root() is None:
dialog.show_error("无法确定当前安装目录,已取消自动更新。")
return
config = getattr(self.host, "config", None)
if config is None:
dialog.show_error("无法读取本地配置目录,已取消自动更新。")
return
config_dir = Path(config.config_dir)
verify = bool(getattr(config, "verify_ssl", True))
self._cancel = False
dialog.set_busy(True)
dialog.show_download_progress(0, package.size)
signals = _TaskSignals()
self._signals = signals
def job() -> _PreparedUpdate:
workspace = prepare_update_workspace(config_dir, offer.latest_version)
downloaded = workspace / package_filename(package, offer.latest_version)
if package.type == PACKAGE_TYPE_INNO_SETUP:
validate_installer_download_policy(package.url, verify_ssl=verify)
download_package(
package.url,
downloaded,
sha256=package.sha256,
verify=verify,
expected_size=package.size,
progress=lambda received, total: signals.progress.emit(received, total),
cancelled=lambda: self._cancel,
)
if package.type == PACKAGE_TYPE_INNO_SETUP:
signals.status.emit("正在校验 Windows 安装程序…")
return _PreparedUpdate(
package_type=package.type,
payload=validate_windows_installer(downloaded),
)
signals.status.emit("正在校验并解压安装包…")
extracted = workspace / "payload"
safe_extract_zip(downloaded, extracted)
return _PreparedUpdate(
package_type=package.type,
payload=discover_payload(extracted),
)
worker = _Task(job, signals)
signals.progress.connect(dialog.show_download_progress)
signals.status.connect(dialog.show_status)
signals.result.connect(lambda prepared: self._finish_install(dialog, prepared))
signals.error.connect(lambda error, _tb: self._install_failed(dialog, error))
QThreadPool.globalInstance().start(worker)
def _install_failed(self, dialog: AppUpdateDialog, error: Exception) -> None:
LOGGER.exception("desktop update failed")
if self._cancel and not dialog.offer.force:
dialog.show_error("已取消下载。")
self._cancel = False
return
message = str(error) if isinstance(error, AppUpdateError) else friendly_error(error)
dialog.show_error(f"更新失败:{message}")
def _finish_install(self, dialog: AppUpdateDialog, prepared: object) -> None:
if not isinstance(prepared, _PreparedUpdate):
dialog.show_error("安装包准备结果无效。")
return
message = (
"即将关闭程序并自动安装,系统可能会请求管理员权限…"
if prepared.package_type == PACKAGE_TYPE_INNO_SETUP
else "即将关闭并完成安装…"
)
dialog.show_status(message, determinate=True)
try:
apply_downloaded_update(
prepared.payload,
package_type=prepared.package_type,
)
except AppUpdateError as error:
dialog.show_error(str(error))
return
application = QApplication.instance()
if application is not None:
QTimer.singleShot(300, application.quit)
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
import sqlite3
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
@@ -69,6 +70,7 @@ from ..widgets import (
page_total,
run_async,
)
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
@@ -747,6 +749,7 @@ class DiagnosisDialog(QDialog):
"""
saved = Signal()
_local_audio_upload_completed = Signal(int, int)
def __init__(
self,
@@ -841,6 +844,10 @@ class DiagnosisDialog(QDialog):
self._recording_players: list[RecordingPlayerDialog] = []
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
self._local_audio_upload_manager: Any = None
self._local_audio_upload_listener: Any = None
self._video_reload_pending = False
self._orders_page = 1
self._orders_page_size = 10
self._orders_total = 0
@@ -868,6 +875,10 @@ class DiagnosisDialog(QDialog):
self._daily_panels: list[DailyRecordPanel] = []
self._owner = None
self._owner_filter_installed = False
self._local_audio_upload_completed.connect(
self._local_audio_upload_succeeded,
type=Qt.ConnectionType.QueuedConnection,
)
self.setObjectName("DiagnosisDialogRoot")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
@@ -1433,6 +1444,11 @@ class DiagnosisDialog(QDialog):
hint.setWordWrap(True)
toolbar.addWidget(hint, 1)
toolbar.addStretch(1)
self.local_audio_queue_button = QPushButton("本机录音文件")
self.local_audio_queue_button.setObjectName("DiagnosisLocalAudioQueueButton")
self.local_audio_queue_button.setAccessibleName("查看本机录音文件及上传状态")
self.local_audio_queue_button.clicked.connect(self._open_local_audio_queue)
toolbar.addWidget(self.local_audio_queue_button)
self.video_upload_button = QPushButton("上传本地回放")
self.video_upload_button.setProperty("variant", "primary")
self.video_upload_button.clicked.connect(
@@ -1556,10 +1572,10 @@ class DiagnosisDialog(QDialog):
),
"video": (
("录制回放", 440),
("房间号", 180),
("开始时间", 170),
("结束时间", 170),
("通话类型", 100),
("房间号", 180),
("时长", 110),
("状态", 90),
("录制 / 文字", 120),
@@ -1695,6 +1711,10 @@ class DiagnosisDialog(QDialog):
)
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
self.local_audio_queue_button.setVisible(self._can_video_upload)
self.local_audio_queue_button.setEnabled(
self._diagnosis_id > 0 and self._can_video_upload
)
offset_visible = self._editable and self._can_offset
self.order_offset.setVisible(offset_visible)
self.order_offset_save.setVisible(offset_visible)
@@ -1914,6 +1934,10 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
self._last_order_detail_dialog.close()
self._last_order_detail_dialog = None
if self._local_audio_dialog is not None:
with suppress(RuntimeError):
self._local_audio_dialog.close()
self._local_audio_dialog = None
self._diagnosis_id = int(diagnosis_id)
self._patient_id = 0
self._editable = bool(
@@ -1939,6 +1963,7 @@ class DiagnosisDialog(QDialog):
self._tab_generations[key] += 1
self._loaded_tabs.clear()
self._loading_tabs.clear()
self._video_reload_pending = False
self._daily_todo_status = None
self._orders_page = 1
self._orders_total = 0
@@ -2208,6 +2233,8 @@ class DiagnosisDialog(QDialog):
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2232,6 +2259,11 @@ class DiagnosisDialog(QDialog):
with suppress(RuntimeError):
order_dialog.close()
self._last_order_detail_dialog = None
local_audio_dialog = getattr(self, "_local_audio_dialog", None)
if local_audio_dialog is not None:
with suppress(RuntimeError):
local_audio_dialog.close()
self._local_audio_dialog = None
if hasattr(self, "readonly_loading"):
self.readonly_loading.hide()
if hasattr(self, "drawer_loading"):
@@ -2474,7 +2506,11 @@ class DiagnosisDialog(QDialog):
def _tab_changed(self, _index: int) -> None:
self._sync_save_button()
if self._authoritative_detail_loaded and not self._standalone_readonly:
self._ensure_tab_loaded(self._current_tab_key())
key = self._current_tab_key()
if key == "video":
self._reload_video_records(self._diagnosis_id)
else:
self._ensure_tab_loaded(key)
def _load_visible_readonly_sections(self) -> None:
permission_map = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
@@ -2635,6 +2671,8 @@ class DiagnosisDialog(QDialog):
panel.set_messages(rows)
if not self._standalone_readonly and self._current_tab_key() == key:
self._clear_message()
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _tab_load_error(
self,
@@ -2650,6 +2688,8 @@ class DiagnosisDialog(QDialog):
self._set_tab_error(key, text)
if not self._standalone_readonly and self._current_tab_key() == key:
self._show_message(text, "danger", action_text="重试")
if key == "video":
self._flush_video_reload_if_pending(diagnosis_id)
def _load_daily_range(self, start: str, end: str, *, force: bool = False) -> None:
key = "daily"
@@ -3116,6 +3156,91 @@ class DiagnosisDialog(QDialog):
"video",
)
def _open_local_audio_queue(self) -> None:
if self._diagnosis_id <= 0 or not self._can_video_upload:
self._show_message("当前账号无本机录音上传权限或接口不可用。", "warning")
return
current = self._local_audio_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(
self.repository,
self._diagnosis_id,
self,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._show_message(f"无法打开本机录音文件:{error}", "danger")
return
self._local_audio_dialog = dialog
opened_diagnosis_id = self._diagnosis_id
self._watch_local_audio_uploads(dialog.manager)
def clear_dialog(_result: int) -> None:
if self._local_audio_dialog is dialog:
self._local_audio_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
# Opening the queue commonly follows a background upload that completed
# before this dialog existed, so also refresh the server-backed rows now.
self._reload_video_records(opened_diagnosis_id)
def _local_audio_upload_succeeded(
self,
diagnosis_id: int,
_call_record_id: int,
) -> None:
if self._local_audio_upload_manager is None:
return
self._reload_video_records(diagnosis_id)
def _reload_video_records(self, diagnosis_id: int) -> None:
if int(diagnosis_id) != self._diagnosis_id:
return
if "video" in self._loading_tabs:
self._video_reload_pending = True
return
self._video_reload_pending = False
self._loaded_tabs.discard("video")
self._ensure_tab_loaded("video", force=True)
def _watch_local_audio_uploads(self, manager: Any) -> None:
if self._local_audio_upload_manager is manager:
return
self._stop_watching_local_audio_uploads()
def notify(record: Any) -> None:
with suppress(RuntimeError):
self._local_audio_upload_completed.emit(
int(record.diagnosis_id),
int(record.call_record_id or 0),
)
manager.add_upload_listener(notify)
self._local_audio_upload_manager = manager
self._local_audio_upload_listener = notify
def _stop_watching_local_audio_uploads(self) -> None:
manager = self._local_audio_upload_manager
listener = self._local_audio_upload_listener
self._local_audio_upload_manager = None
self._local_audio_upload_listener = None
if manager is not None and listener is not None:
with suppress(Exception):
manager.remove_upload_listener(listener)
def _flush_video_reload_if_pending(self, diagnosis_id: int) -> None:
if not self._video_reload_pending or diagnosis_id != self._diagnosis_id:
return
self._video_reload_pending = False
QTimer.singleShot(
0,
lambda expected_id=diagnosis_id: self._reload_video_records(expected_id),
)
def _sync_chat_archive(self) -> None:
if not self._can_chat_sync or self._diagnosis_id <= 0:
self._show_message("当前账号无聊天归档同步权限或接口不可用。", "warning")
@@ -3616,6 +3741,7 @@ class DiagnosisDialog(QDialog):
self._stop_inline_recordings()
matrix: list[tuple[Any, ...]] = []
row_urls: list[list[str]] = []
row_audio_urls: list[list[str]] = []
record_ids: list[int] = []
transcripts: list[str] = []
for row in rows:
@@ -3629,6 +3755,22 @@ class DiagnosisDialog(QDialog):
if url and url not in normalized:
normalized.append(url)
row_urls.append(normalized)
raw_audio_urls = first_value(
row,
"local_audio_urls_list",
"local_audio_urls",
default=[],
) or []
if isinstance(raw_audio_urls, str) or not isinstance(
raw_audio_urls, Sequence
):
raw_audio_urls = [raw_audio_urls]
normalized_audio: list[str] = []
for candidate in raw_audio_urls:
url = str(candidate or "").strip()
if url and url not in normalized_audio:
normalized_audio.append(url)
row_audio_urls.append(normalized_audio)
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
transcript = self._call_transcript_text(row)
transcripts.append(transcript)
@@ -3651,22 +3793,41 @@ class DiagnosisDialog(QDialog):
recording_status = first_value(
row, "recording_status_text", "record_status_text", "record_status"
)
local_audio_status = first_value(
row,
"local_audio_status_text",
default="本机录音已保存" if normalized_audio else "无本机录音",
)
transcript_status = first_value(
row,
"transcription_status_text",
"transcript_status_text",
default="文字已生成" if transcript else "未生成文字",
)
room_id = str(
first_value(
row,
"room_id_text",
"room_id",
"room_no",
default="",
)
or ""
).strip()
matrix.append(
(
"" if normalized else "暂无录制回放",
room_id or "历史记录未保存",
first_value(row, "start_time_text", "start_time"),
first_value(row, "end_time_text", "end_time"),
call_type,
first_value(row, "room_id", "room_no"),
first_value(row, "duration_text", "duration"),
status,
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
(
f"云端视频:{display_text(recording_status)}\n"
f"本机录音:{display_text(local_audio_status)}\n"
f"转写文字:{display_text(transcript_status)}"
),
"",
)
)
@@ -3692,6 +3853,19 @@ class DiagnosisDialog(QDialog):
item.setText("")
call_record_id = record_ids[row_index]
actions: list[QPushButton] = []
audio_urls = row_audio_urls[row_index]
if audio_urls and call_record_id > 0:
play_audio = self._action_button(
"播放录音", f"播放通话记录 #{call_record_id} 的本机混音录音"
)
play_audio.setObjectName("DiagnosisLocalAudioPlayback")
play_audio.setProperty("callRecordId", call_record_id)
play_audio.clicked.connect(
lambda _checked=False, target=audio_urls[0]: self._open_recording_player(
target
)
)
actions.append(play_audio)
transcript = transcripts[row_index]
if transcript and call_record_id > 0:
view_transcript = self._action_button(
@@ -0,0 +1,625 @@
"""Local call-audio upload queue shown from a diagnosis video-history tab."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QDialog,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioRecord,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..widgets import run_async
_STATUS_LABELS = {
"recording": "录制中",
"pending": "待上传",
"uploading": "上传中",
"uploaded": "已上传",
"failed": "上传失败",
"invalid": "无效录音",
}
_STATUS_COLORS = {
"recording": "#5364F5",
"pending": "#B26A00",
"uploading": "#2F6FEB",
"uploaded": "#07966B",
"failed": "#DC4054",
"invalid": "#7886AA",
}
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
_LOCAL_AUDIO_QSS = """
QDialog#LocalAudioQueueDialog {
background: #F6F8FD;
color: #111F46;
}
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
background: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 14px;
}
QLabel#LocalAudioQueueTitle {
color: #111F46;
font-size: 20px;
font-weight: 700;
}
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
color: #6E7C9F;
font-size: 13px;
}
QLabel[queueSummary="true"] {
background: #F3F5FB;
border: 1px solid #E6EAF5;
border-radius: 10px;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
}
QPushButton {
min-height: 34px;
border: 1px solid #D9E0F2;
border-radius: 9px;
background: #FFFFFF;
color: #354365;
padding: 0 14px;
font-weight: 600;
}
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
QPushButton[variant="primary"] {
color: #FFFFFF;
background: #5661F4;
border-color: #5661F4;
}
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
QTableWidget#LocalAudioQueueTable {
background: #FFFFFF;
alternate-background-color: #FAFBFE;
border: 0;
gridline-color: #E8ECF5;
color: #263452;
selection-background-color: #EEF1FF;
selection-color: #111F46;
}
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
QHeaderView::section {
background: #F5F7FC;
color: #53617F;
border: 0;
border-bottom: 1px solid #E1E6F1;
padding: 10px 8px;
font-weight: 700;
}
"""
def _human_size(size: int) -> str:
value = max(0, int(size))
if value < 1024:
return f"{value} B"
if value < 1024 * 1024:
return f"{value / 1024:.1f} KB"
return f"{value / 1024 / 1024:.1f} MB"
def _display_time(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw.replace("T", " ")
if parsed.tzinfo is not None:
parsed = parsed.astimezone(_BUSINESS_TIMEZONE)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
class LocalAudioQueueDialog(QDialog):
"""Persistent local-audio list with concurrent upload and retry."""
def __init__(
self,
repository: Any,
diagnosis_id: int | None,
parent: QWidget | None = None,
*,
store: LocalAudioQueueStore | None = None,
manager: LocalAudioUploadManager | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self._global_scope = diagnosis_id is None
self.diagnosis_id = int(diagnosis_id) if diagnosis_id is not None else None
if manager is None:
self.store, self.manager = get_local_audio_upload_manager(repository, store)
else:
self.store = store or manager.store
self.manager = manager
self._signature: tuple[Any, ...] = ()
self._room_fetch_generation = 0
self._room_fetch_inflight: set[int] = set()
self._room_fetch_attempted: set[int] = set()
self._room_fetch_errors: dict[int, str] = {}
self._closed = False
self.setObjectName("LocalAudioQueueDialog")
self.setProperty("businessDialog", True)
self.setWindowTitle(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self._build_ui()
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(500)
self.poll_timer.timeout.connect(self.refresh_records)
self.poll_timer.start()
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
root.setSpacing(12)
header = QFrame()
header.setObjectName("LocalAudioQueueHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(18, 14, 18, 14)
header_layout.setSpacing(4)
self.title_label = QLabel(
"本机录音上传管理" if self._global_scope else "本机录音文件"
)
self.title_label.setObjectName("LocalAudioQueueTitle")
header_layout.addWidget(self.title_label)
if self._global_scope:
subtitle_text = (
"集中查看所有诊单的本机录音上传记录;成功文件和未成功文件都会保留。"
"最多同时上传 3 个文件,失败后可重试。"
)
else:
subtitle_text = (
"每次通话的本地录音会先保存在本机,再上传到 COS;最多同时上传 3 个文件。"
"上传失败不会删除文件,可随时重试。"
)
subtitle = QLabel(subtitle_text)
subtitle.setObjectName("LocalAudioQueueSubtitle")
subtitle.setWordWrap(True)
header_layout.addWidget(subtitle)
root.addWidget(header)
summary = QFrame()
summary.setObjectName("LocalAudioQueueSummary")
summary_layout = QHBoxLayout(summary)
summary_layout.setContentsMargins(12, 10, 12, 10)
summary_layout.setSpacing(8)
self.summary_total = self._summary_label()
self.summary_pending = self._summary_label()
self.summary_uploading = self._summary_label()
self.summary_uploaded = self._summary_label()
self.summary_failed = self._summary_label()
for label in (
self.summary_total,
self.summary_pending,
self.summary_uploading,
self.summary_uploaded,
self.summary_failed,
):
summary_layout.addWidget(label)
summary_layout.addStretch(1)
self.upload_pending_button = QPushButton("上传待处理")
self.upload_pending_button.setObjectName("LocalAudioUploadPendingButton")
self.upload_pending_button.setProperty("variant", "primary")
self.upload_pending_button.clicked.connect(self._upload_pending)
summary_layout.addWidget(self.upload_pending_button)
self.retry_failed_button = QPushButton("重试全部失败")
self.retry_failed_button.setObjectName("LocalAudioRetryAllButton")
self.retry_failed_button.setProperty("variant", "danger")
self.retry_failed_button.clicked.connect(self._retry_all_failed)
summary_layout.addWidget(self.retry_failed_button)
refresh_button = QPushButton("刷新")
refresh_button.setObjectName("LocalAudioRefreshButton")
refresh_button.clicked.connect(self._manual_refresh)
summary_layout.addWidget(refresh_button)
root.addWidget(summary)
card = QFrame()
card.setObjectName("LocalAudioQueueTableCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(1, 1, 1, 1)
headers = ["录制时间"]
if self._global_scope:
headers.append("诊单 ID")
headers.extend(
[
"通话记录 ID",
"房间号",
"音频文件",
"大小",
"上传状态",
"失败原因",
"操作",
]
)
self._diagnosis_column = 1 if self._global_scope else None
self._call_column = 2 if self._global_scope else 1
self._room_column = 3 if self._global_scope else 2
self._file_column = 4 if self._global_scope else 3
self._size_column = 5 if self._global_scope else 4
self._status_column = 6 if self._global_scope else 5
self._error_column = 7 if self._global_scope else 6
self._action_column = 8 if self._global_scope else 7
self.table = QTableWidget(0, len(headers))
self.table.setObjectName("LocalAudioQueueTable")
self.table.setHorizontalHeaderLabels(headers)
self.table.verticalHeader().hide()
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setWordWrap(False)
header_view = self.table.horizontalHeader()
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header_view.setSectionResizeMode(
self._file_column, QHeaderView.ResizeMode.Stretch
)
header_view.setSectionResizeMode(
self._error_column, QHeaderView.ResizeMode.Stretch
)
self.table.setColumnWidth(0, 160)
if self._diagnosis_column is not None:
self.table.setColumnWidth(self._diagnosis_column, 90)
self.table.setColumnWidth(self._call_column, 90)
self.table.setColumnWidth(self._room_column, 180)
self.table.setColumnWidth(self._size_column, 88)
self.table.setColumnWidth(self._status_column, 100)
self.table.setColumnWidth(self._action_column, 190)
card_layout.addWidget(self.table)
root.addWidget(card, 1)
footer = QFrame()
footer.setObjectName("LocalAudioQueueFooter")
footer_layout = QHBoxLayout(footer)
footer_layout.setContentsMargins(14, 9, 14, 9)
hint = QLabel("已上传文件仍保留在本机;请按机构的数据保留制度定期归档。")
hint.setObjectName("LocalAudioQueueHint")
footer_layout.addWidget(hint, 1)
close_button = QPushButton("关闭")
close_button.setObjectName("LocalAudioQueueCloseButton")
close_button.setProperty("variant", "primary")
close_button.clicked.connect(self.accept)
footer_layout.addWidget(close_button)
root.addWidget(footer)
@staticmethod
def _summary_label() -> QLabel:
label = QLabel()
label.setProperty("queueSummary", True)
return label
def _records(self) -> list[LocalAudioRecord]:
return self.store.list_records(diagnosis_id=self.diagnosis_id)
def refresh_records(self, *, force: bool = False) -> None:
records = self._records()
self._schedule_room_lookups(records)
signature = tuple(
(
record.id,
record.diagnosis_id,
record.status,
record.error_text,
record.call_record_id,
record.room_id,
record.size_bytes,
record.uploaded_url,
record.exists,
record.attempts,
self._room_fetch_state(record),
)
for record in records
)
if not force and signature == self._signature:
return
self._signature = signature
self._update_summary(records)
self.table.setRowCount(len(records))
for row, record in enumerate(records):
self.table.setRowHeight(row, 54)
self._set_item(row, 0, _display_time(record.created_at))
if self._diagnosis_column is not None:
self._set_item(row, self._diagnosis_column, str(record.diagnosis_id))
self._set_item(
row, self._call_column, str(record.call_record_id or "待关联")
)
room_text, room_tooltip = self._room_display(record)
room_item = self._set_item(row, self._room_column, room_text)
room_item.setToolTip(room_tooltip)
file_item = self._set_item(row, self._file_column, record.file_path.name)
file_item.setToolTip(str(record.file_path))
self._set_item(row, self._size_column, _human_size(record.size_bytes))
status_item = self._set_item(
row,
self._status_column,
_STATUS_LABELS.get(record.status, record.status),
)
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
status_item.setToolTip(
f"已尝试 {record.attempts}"
+ (f"\nCOS{record.uploaded_url}" if record.uploaded_url else "")
)
error_item = self._set_item(
row, self._error_column, record.error_text or ""
)
error_item.setToolTip(record.error_text)
self.table.setCellWidget(row, self._action_column, self._actions(record))
def _room_fetch_state(self, record: LocalAudioRecord) -> str:
diagnosis_id = record.diagnosis_id
if record.room_id:
return record.room_id
if diagnosis_id in self._room_fetch_inflight:
return "loading"
if diagnosis_id in self._room_fetch_errors:
return f"error:{self._room_fetch_errors[diagnosis_id]}"
if diagnosis_id in self._room_fetch_attempted:
return "missing"
return "idle"
def _room_display(self, record: LocalAudioRecord) -> tuple[str, str]:
if record.room_id:
return record.room_id, record.room_id
if record.call_record_id is None:
return "", "尚未关联通话记录,无法取得房间号。"
diagnosis_id = record.diagnosis_id
if diagnosis_id in self._room_fetch_inflight:
return "查询中…", "正在从服务端通话记录查询房间号。"
error = self._room_fetch_errors.get(diagnosis_id)
if error:
return "获取失败", error
return "未记录", "服务端通话记录未保存房间号。"
def _schedule_room_lookups(self, records: list[LocalAudioRecord]) -> None:
if self._closed:
return
method = getattr(self.repository, "list_call_records", None)
if not callable(method):
method = getattr(self.repository, "get_call_records", None)
if not callable(method):
return
target_ids: dict[int, set[int]] = {}
for record in records:
call_record_id = int(record.call_record_id or 0)
if record.room_id or call_record_id <= 0:
continue
target_ids.setdefault(record.diagnosis_id, set()).add(call_record_id)
diagnosis_ids = tuple(
sorted(
diagnosis_id
for diagnosis_id in target_ids
if diagnosis_id not in self._room_fetch_inflight
and diagnosis_id not in self._room_fetch_attempted
)
)
if not diagnosis_ids:
return
generation = self._room_fetch_generation
self._room_fetch_inflight.update(diagnosis_ids)
self._room_fetch_attempted.update(diagnosis_ids)
def load_rooms() -> tuple[dict[tuple[int, int], str], dict[int, str]]:
rooms: dict[tuple[int, int], str] = {}
errors: dict[int, str] = {}
for diagnosis_id in diagnosis_ids:
try:
payload = method(diagnosis_id)
rows = payload if isinstance(payload, list) else []
for row in rows:
if not isinstance(row, Mapping):
continue
raw_record_id = row.get("id", row.get("call_record_id"))
try:
call_record_id = int(raw_record_id or 0)
except (TypeError, ValueError):
continue
if call_record_id not in target_ids[diagnosis_id]:
continue
room_id = str(
row.get("room_id")
or row.get("roomId")
or row.get("room_id_text")
or row.get("room_no")
or ""
).strip()
if room_id and room_id != "0":
rooms[(diagnosis_id, call_record_id)] = room_id
except Exception as error:
errors[diagnosis_id] = str(error)[:300] or "房间号获取失败。"
return rooms, errors
run_async(
load_rooms,
on_success=lambda result: self._apply_room_lookups(
generation,
diagnosis_ids,
result,
),
on_error=lambda error: self._fail_room_lookups(
generation,
diagnosis_ids,
error,
),
)
def _apply_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
result: tuple[dict[tuple[int, int], str], dict[int, str]],
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
rooms, errors = result
self._room_fetch_inflight.difference_update(diagnosis_ids)
self._room_fetch_errors.update(errors)
records_by_key: dict[tuple[int, int], list[LocalAudioRecord]] = {}
for record in self._records():
if record.call_record_id is None:
continue
key = (record.diagnosis_id, record.call_record_id)
records_by_key.setdefault(key, []).append(record)
for key, room_id in rooms.items():
for record in records_by_key.get(key, []):
try:
self.store.bind_identity(
record.id,
call_record_id=key[1],
room_id=room_id,
)
except (LookupError, RuntimeError, ValueError) as error:
self._room_fetch_errors[key[0]] = str(error)[:300]
self.refresh_records(force=True)
def _fail_room_lookups(
self,
generation: int,
diagnosis_ids: tuple[int, ...],
error: Exception,
) -> None:
if self._closed or generation != self._room_fetch_generation:
return
self._room_fetch_inflight.difference_update(diagnosis_ids)
message = str(error)[:300] or "房间号获取失败。"
self._room_fetch_errors.update(
{diagnosis_id: message for diagnosis_id in diagnosis_ids}
)
self.refresh_records(force=True)
def _manual_refresh(self) -> None:
self._room_fetch_generation += 1
self._room_fetch_inflight.clear()
self._room_fetch_attempted.clear()
self._room_fetch_errors.clear()
self.refresh_records(force=True)
def _set_item(self, row: int, column: int, text: str) -> QTableWidgetItem:
item = QTableWidgetItem(str(text))
item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
self.table.setItem(row, column, item)
return item
def _update_summary(self, records: list[LocalAudioRecord]) -> None:
counts = {status: 0 for status in _STATUS_LABELS}
for record in records:
counts[record.status] = counts.get(record.status, 0) + 1
self.summary_total.setText(f"全部 {len(records)}")
self.summary_pending.setText(f"待上传 {counts['pending']}")
self.summary_uploading.setText(f"上传中 {counts['uploading']}")
self.summary_uploaded.setText(f"已上传 {counts['uploaded']}")
self.summary_failed.setText(f"失败 {counts['failed']}")
self.upload_pending_button.setEnabled(counts["pending"] > 0)
self.retry_failed_button.setEnabled(counts["failed"] > 0)
def _actions(self, record: LocalAudioRecord) -> QWidget:
host = QWidget()
layout = QHBoxLayout(host)
layout.setContentsMargins(4, 5, 4, 5)
layout.setSpacing(5)
if record.status == "pending":
upload = QPushButton("上传")
upload.setAccessibleName(f"上传录音 {record.file_path.name}")
upload.clicked.connect(lambda _checked=False, record_id=record.id: self._upload_one(record_id))
layout.addWidget(upload)
elif record.status == "failed":
retry = QPushButton("重试")
retry.setProperty("variant", "danger")
retry.setAccessibleName(f"重试上传录音 {record.file_path.name}")
retry.clicked.connect(lambda _checked=False, record_id=record.id: self._retry_one(record_id))
layout.addWidget(retry)
elif record.status == "uploading":
busy = QPushButton("上传中")
busy.setEnabled(False)
layout.addWidget(busy)
if record.exists:
reveal = QPushButton("打开位置")
reveal.setAccessibleName(f"打开录音文件位置 {record.file_path.name}")
reveal.clicked.connect(
lambda _checked=False, path=record.file_path: self._open_location(path)
)
layout.addWidget(reveal)
layout.addStretch(1)
return host
def _upload_pending(self) -> None:
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
self.refresh_records(force=True)
def _upload_one(self, record_id: int) -> None:
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_one(self, record_id: int) -> None:
try:
self.store.retry(record_id)
except (OSError, RuntimeError, LookupError) as error:
self._show_local_error(str(error))
return
self.manager.submit(record_id)
self.refresh_records(force=True)
def _retry_all_failed(self) -> None:
for record in self._records():
if record.status != "failed":
continue
try:
self.store.retry(record.id)
except (OSError, RuntimeError, LookupError):
continue
self.manager.submit(record.id)
self.refresh_records(force=True)
def _show_local_error(self, message: str) -> None:
self.summary_failed.setText(str(message)[:160])
self.summary_failed.setToolTip(str(message))
@staticmethod
def _open_location(path: Path) -> None:
target = path.parent if path.parent.is_dir() else path
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
def done(self, result: int) -> None:
self._closed = True
self._room_fetch_generation += 1
if hasattr(self, "poll_timer"):
self.poll_timer.stop()
super().done(result)
@@ -93,6 +93,7 @@ from ..widgets import (
MessageBanner,
display_text,
first_value,
format_record_time,
friendly_error,
get_value,
has_permission,
@@ -3692,6 +3693,11 @@ class PrescriptionEditorDialog(QDialog):
hidden_keys = (
"id",
"diagnosis_id",
"appointment_id",
"patient_id",
"phone",
"case_record",
"prescription_name",
"creator_id",
"is_system_auto",
"is_shared",
@@ -4233,20 +4239,9 @@ def render_prescription_html(
def date_text() -> str:
raw = source.get("create_time") or source.get("update_time")
if raw not in (None, ""):
raw_text = str(raw).strip()
if re.fullmatch(r"\d{10,13}(?:\.\d+)?", raw_text):
stamp = float(raw_text)
if stamp >= 10_000_000_000:
stamp /= 1000
try:
return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M")
except (OSError, OverflowError, ValueError):
pass
normalized = raw_text.replace("T", " ").replace("Z", "")
if len(normalized) >= 16:
return normalized[:16]
if normalized:
return normalized
formatted = format_record_time(raw, default="")
if formatted:
return formatted
return display_text(source.get("prescription_date"))
def usage_text(values: Mapping[str, Any], *, fallback: Mapping[str, Any]) -> str:
@@ -8,7 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from PySide6.QtCore import Qt
from PySide6.QtCore import QCoreApplication, QEvent, Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -1086,11 +1086,15 @@ class PrescriptionAiReportDialog(QDialog):
def _clear_host(self) -> None:
while self.host_layout.count():
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
item = self.host_layout.takeAt(0)
widget = item.widget()
if widget is not None:
# Keep the parent until Qt processes the deferred deletion.
# Detaching a visible child promotes it to a native top-level
# window on Windows and causes a small title-bar window to flash.
widget.hide()
widget.deleteLater()
QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete)
def _add_label(
self,
+25 -56
View File
@@ -40,6 +40,10 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path
from .theme import crisp_pixmap
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -131,47 +135,7 @@ class _BrandPanel(QWidget):
self.setMinimumWidth(320)
self.setMaximumWidth(824)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
@staticmethod
def _draw_mark(painter: QPainter, rect: QRectF, *, faint: bool = False) -> None:
alpha = 112 if faint else 255
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0.0, QColor(122, 137, 255, alpha))
gradient.setColorAt(1.0, QColor(71, 82, 238, alpha))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(gradient)
painter.drawRoundedRect(rect, rect.width() * 0.27, rect.width() * 0.27)
pen = QPen(
QColor(255, 255, 255, 235 if not faint else 150),
max(2.0, rect.width() / 24),
)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
bag = QRectF(
rect.left() + rect.width() * 0.24,
rect.top() + rect.height() * 0.35,
rect.width() * 0.52,
rect.height() * 0.38,
)
painter.drawRoundedRect(bag, rect.width() * 0.06, rect.width() * 0.06)
handle = QRectF(
rect.left() + rect.width() * 0.39,
rect.top() + rect.height() * 0.25,
rect.width() * 0.22,
rect.height() * 0.15,
)
painter.drawRoundedRect(handle, rect.width() * 0.04, rect.width() * 0.04)
cx, cy = rect.center().x(), rect.top() + rect.height() * 0.54
painter.drawLine(
QPointF(cx - rect.width() * 0.13, cy), QPointF(cx + rect.width() * 0.13, cy)
)
painter.drawLine(
QPointF(cx, cy - rect.height() * 0.13),
QPointF(cx, cy + rect.height() * 0.13),
)
self._brand_lockup = QPixmap(str(brand_lockup_path()))
@staticmethod
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
@@ -337,10 +301,17 @@ class _BrandPanel(QWidget):
self._draw_illustration(painter, width, height)
left = 64.0 if width >= 620 else 38.0
self._draw_mark(painter, QRectF(left, 51, 70, 70))
painter.setPen(QColor("#14224A"))
painter.setFont(_font(28, QFont.Weight.Bold))
painter.drawText(QPointF(left + 93, 98), "甄养堂医疗")
if not self._brand_lockup.isNull():
logo_width = 180.0
logo_height = logo_width * (
self._brand_lockup.height() / self._brand_lockup.width()
)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(
QRectF(left, 24, logo_width, logo_height),
self._brand_lockup,
QRectF(self._brand_lockup.rect()),
)
tag_rect = QRectF(left, 238, 119, 40)
painter.setPen(Qt.PenStyle.NoPen)
@@ -900,6 +871,12 @@ class LoginWindow(QMainWindow):
footnote.setFixedHeight(40)
footnote_row.addWidget(footnote, 1)
card_layout.addLayout(footnote_row)
self.version_label = QLabel(f"当前版本 {__version__}")
self.version_label.setObjectName("LoginVersionLabel")
self.version_label.setProperty("role", "muted")
self.version_label.setStyleSheet("color:#8B98B5; font-size:13px;")
self.version_label.setContentsMargins(0, 8, 0, 0)
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
outer.addWidget(
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
@@ -910,18 +887,11 @@ class LoginWindow(QMainWindow):
@staticmethod
def _window_icon() -> QIcon:
pixmap = QPixmap(64, 64)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
_BrandPanel._draw_mark(painter, QRectF(1, 1, 62, 62))
painter.end()
return QIcon(pixmap)
return QIcon(str(app_icon_path()))
@staticmethod
def _account_icon() -> QIcon:
pixmap = QPixmap(24, 24)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(24)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(_round_pen("#8292B6", 2))
@@ -933,8 +903,7 @@ class LoginWindow(QMainWindow):
@staticmethod
def _lock_icon() -> QIcon:
pixmap = QPixmap(20, 20)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(20)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(_round_pen("#8FA0C4", 1.6))
@@ -9,8 +9,8 @@ from html import escape
from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
@@ -21,10 +21,9 @@ from PySide6.QtWidgets import (
QDialogButtonBox,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
@@ -93,9 +92,11 @@ _SEMANTIC_COLORS = {
}
APPOINTMENTS_LIGHT_QSS = """
/* 页头此前被压到只剩面包屑26px与其余列表页的面包屑+标题+副标题
骨架不一致这里给它与 PageHeader 自然高度相符的空间 */
#AppointmentsPage QWidget#PageHeader {
min-height: 26px;
max-height: 26px;
min-height: 62px;
max-height: 62px;
}
#AppointmentsPage QFrame#AppointmentFilterPanel {
min-height: 80px;
@@ -104,8 +105,7 @@ APPOINTMENTS_LIGHT_QSS = """
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard,
#AppointmentsPage QFrame#VideoConsultPanel {
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -130,17 +130,13 @@ APPOINTMENTS_LIGHT_QSS = """
border-color: #5265F6;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentStatKind="pending"] {
color: #5265F6;
background-color: #F6F4FF;
}
#AppointmentsPage QPushButton[appointmentStatKind="success"] {
color: #159C79;
background-color: #F1FAF7;
}
#AppointmentsPage QPushButton[appointmentStatKind="warning"] {
/* 这一排全是筛选片此前按业务语义分别染成紫/绿/琥珀一行出现四种底色
而且颜色和是否选中这一真正需要区分的状态互相打架筛选片一律保持中性
只有待分配医助在确有待办时才提示为琥珀色 */
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] {
color: #C17A16;
background-color: #FFF8ED;
border-color: #F0DCB6;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
min-height: 30px;
@@ -209,6 +205,34 @@ APPOINTMENTS_LIGHT_QSS = """
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QWidget[appointmentImHost="true"] {
background-color: transparent;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 74px;
min-height: 26px;
max-height: 26px;
padding: 0 9px;
color: #5265F6;
background-color: #F0F2FF;
border: 1px solid #D7DEFF;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
border-color: #8D9BFF;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
color: #98A3BC;
background-color: #F7F8FC;
border-color: #E4E8F2;
}
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
background-color: #FFFFFF;
}
@@ -257,77 +281,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QLabel#VideoConsultTitle {
color: #15224A;
font-size: 16px;
font-weight: 700;
}
#AppointmentsPage QListWidget#VideoConsultList {
background-color: transparent;
border: 0;
border-radius: 0;
outline: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item {
min-height: 80px;
margin: 0 0 7px 0;
padding: 0;
background-color: transparent;
border: 0;
}
#AppointmentsPage QListWidget#VideoConsultList::item:hover {
background-color: transparent;
}
#AppointmentsPage QListWidget#VideoConsultList::item:selected {
background-color: transparent;
}
#AppointmentsPage QFrame[videoPatientCard="true"] {
background-color: #FCFDFF;
border: 1px solid #E1E6F1;
border-radius: 9px;
}
#AppointmentsPage QFrame[videoPatientCard="true"]:hover {
background-color: #F9FAFF;
border-color: #C9D2FF;
}
#AppointmentsPage QLabel[videoStatus="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 7px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[videoIdentity="true"] {
color: #24335E;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel[videoAssistant="true"] {
color: #59698E;
font-size: 11px;
}
#AppointmentsPage QPushButton[videoAction="true"] {
min-height: 20px;
max-height: 20px;
padding: 0 5px;
color: #5265F6;
background-color: transparent;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QPushButton[videoAction="true"]:hover {
background-color: #EEF1FF;
}
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
@@ -342,11 +295,6 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #F0F1FF;
border-color: #E0E4FF;
}
#AppointmentsPage QPushButton#VideoMoreButton {
min-height: 36px;
color: #405074;
background-color: #FFFFFF;
}
"""
@@ -452,14 +400,37 @@ def _status_value(row: Any) -> int:
return _as_int(first_value(row, "status", default=0))
_IM_CONSULT_ACTIVE_STATUSES = frozenset({1, 4})
def _im_consult_status_error(row: Any) -> str:
"""Return why a queue row cannot open the doctor's IM consultation."""
status = _status_value(row)
if status in _IM_CONSULT_ACTIVE_STATUSES:
return ""
if status == 2:
return "已取消的挂号不可进入 IM 问诊"
if status == 3:
return "已完成的挂号不可再进入 IM 问诊"
return "当前挂号状态不可进入 IM 问诊"
def prescription_action_label(row: Any) -> str:
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
explicit = first_value(row, "has_prescription", default=None)
explicit = first_value(row, "current_has_prescription", default=None)
if explicit is None and _as_int(first_value(row, "current_prescription_id", default=0)) <= 0:
explicit = first_value(row, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
else _as_int(
first_value(row, "current_prescription_id", "prescription_id", default=0),
0,
)
> 0
or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
@@ -689,7 +660,6 @@ class AppointmentsPage(QWidget):
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -699,9 +669,9 @@ class AppointmentsPage(QWidget):
root = QVBoxLayout(self)
root.setContentsMargins(18, 3, 6, 8)
root.setSpacing(4)
self.header = PageHeader("问诊列表")
self.header.title_label.hide()
self.header.subtitle_label.hide()
# 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了,
# 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。
self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。")
root.addWidget(self.header)
self.filter_panel = self._build_filter_panel()
root.addWidget(self.filter_panel)
@@ -762,13 +732,11 @@ class AppointmentsPage(QWidget):
self.pending_stat_button = QPushButton("待预约 0")
self.pending_stat_button.setProperty("appointmentStat", True)
self.pending_stat_button.setProperty("appointmentStatKind", "pending")
self.pending_stat_button.setMinimumWidth(0)
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
date_row.addWidget(self.pending_stat_button, 1)
self.completed_stat_button = QPushButton("已完成 0")
self.completed_stat_button.setProperty("appointmentStat", True)
self.completed_stat_button.setProperty("appointmentStatKind", "success")
self.completed_stat_button.setMinimumWidth(0)
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
date_row.addWidget(self.completed_stat_button, 1)
@@ -889,35 +857,9 @@ class AppointmentsPage(QWidget):
layout.setSpacing(4)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
layout.addWidget(self.table_card, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
more = QPushButton("查看更多患者 ﹀")
more.setObjectName("VideoMoreButton")
more.clicked.connect(lambda: self.pager.next.click())
layout.addWidget(more)
return panel
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
@@ -1004,12 +946,6 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
@@ -1034,13 +970,23 @@ class AppointmentsPage(QWidget):
TableColumn("assistant_name", "助理", 110),
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell),
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell),
TableColumn(
"_im_consult",
"IM 问诊",
104,
alignment=Qt.AlignmentFlag.AlignCenter,
),
]
)
self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0)
self.table.setWordWrap(True)
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeader().setFixedHeight(34)
header = self.table.horizontalHeader()
header.setFixedHeight(34)
header.setStretchLastSection(False)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
@@ -1051,157 +997,21 @@ class AppointmentsPage(QWidget):
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
"""Collapse only the date filters when horizontal space is limited."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
self.date_buttons[preset].setVisible(not narrow)
self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
if self.video_list.currentItem()
else 0
)
self.video_list.clear()
row_to_select = -1
for index, row in enumerate(rows):
status = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}" if age not in (None, "") else "")
if part and part != ""
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
item = QListWidgetItem(
f"{status} {identity} {assistant}\n"
"进入视频问诊 查看 诊单 开方 预约 补全身份"
)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setSizeHint(QSize(0, 84))
self.video_list.addItem(item)
self.video_list.setItemWidget(item, self._build_video_patient_card(row))
if selected_id and _appointment_id(row) == selected_id:
row_to_select = index
if row_to_select >= 0:
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
card.setProperty("videoPatientCard", True)
layout = QVBoxLayout(card)
layout.setContentsMargins(10, 7, 10, 6)
layout.setSpacing(5)
status_text = display_text(first_value(row, "status_desc"), "已预约")
name = display_text(first_value(row, "patient_name"), "患者")
gender = gender_text(first_value(row, "gender", "gender_desc", default=None))
age = first_value(row, "age", default="")
identity = " · ".join(
part
for part in (name, gender, f"{age}" if age not in (None, "") else "")
if part and part != ""
)
assistant = display_text(first_value(row, "assistant_name"), "未分配")
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(7)
status = QLabel(status_text)
status.setProperty("videoStatus", True)
status.setProperty("videoStatusKind", "warning" if "挂号" in status_text else "primary")
heading.addWidget(status)
identity_label = QLabel(identity)
identity_label.setProperty("videoIdentity", True)
heading.addWidget(identity_label)
heading.addStretch(1)
assistant_label = QLabel(assistant)
assistant_label.setProperty("videoAssistant", True)
heading.addWidget(assistant_label)
layout.addLayout(heading)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(1)
def add_action(
label: str,
callback: Any,
permission: str | None = None,
kind: str = "",
) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
button = QPushButton(label, card)
button.setProperty("videoAction", True)
if kind:
button.setProperty("videoActionKind", kind)
button.clicked.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
actions.addWidget(button)
add_action(
"进入视频问诊",
self._request_video,
"doctor.appointment/prescription",
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
more.setProperty("videoAction", True)
more.setProperty("videoActionKind", "warning")
more.clicked.connect(
lambda _checked=False, source=row, anchor=more: self._show_video_more_menu(
anchor, source
)
)
actions.addWidget(more)
actions.addStretch(1)
layout.addLayout(actions)
return card
def _run_video_row_action(self, row: Any, callback: Any) -> None:
appointment_id = _appointment_id(row)
for row_index in range(self.table.rowCount()):
@@ -1214,47 +1024,6 @@ class AppointmentsPage(QWidget):
break
callback()
def _show_video_more_menu(self, anchor: QPushButton, row: Any) -> None:
menu = QMenu(anchor)
def add_action(label: str, callback: Any, permission: str | None = None) -> None:
if permission and not _canonical_allowed(self.permissions, permission, default=False):
return
action = QAction(label, menu)
action.triggered.connect(
lambda _checked=False, source=row, slot=callback: self._run_video_row_action(
source, slot
)
)
menu.addAction(action)
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
if menu.actions():
menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))
def _video_row_selected(self, index: int) -> None:
if index < 0:
return
item = self.video_list.item(index)
source = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
appointment_id = _appointment_id(source)
if appointment_id <= 0:
return
for row_index in range(self.table.rowCount()):
cell = self.table.item(row_index, 0)
if (
cell is not None
and _appointment_id(cell.data(Qt.ItemDataRole.UserRole)) == appointment_id
):
self.table.selectRow(row_index)
break
def _action_button(
self,
label: str,
@@ -1484,7 +1253,7 @@ class AppointmentsPage(QWidget):
self.table.set_rows(rows)
self._install_table_selectors()
self._install_appointment_info_cells()
self._sync_video_list(rows)
self._install_im_consult_actions()
self._fit_table_rows()
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
@@ -1555,7 +1324,13 @@ class AppointmentsPage(QWidget):
if _as_int(first_value(row, "assistant_id", default=0)) <= 0
and not str(first_value(row, "assistant_name", default="") or "").strip()
)
self.unassigned_stat_button.setText(f"待分配医助 {_as_int(unassigned)}")
unassigned_count = _as_int(unassigned)
self.unassigned_stat_button.setText(f"待分配医助 {unassigned_count}")
# 只有真的有待办时才点亮,避免筛选栏常态就是一排彩色。
self.unassigned_stat_button.setProperty("hasPending", unassigned_count > 0)
style = self.unassigned_stat_button.style()
style.unpolish(self.unassigned_stat_button)
style.polish(self.unassigned_stat_button)
def _install_table_selectors(self) -> None:
for row_index in range(self.table.rowCount()):
@@ -1634,6 +1409,67 @@ class AppointmentsPage(QWidget):
font.setBold(True)
patient.setFont(font)
def _install_im_consult_actions(self) -> None:
"""Render a doctor-initiated IM entry for every appointment row."""
action_column = 10
has_permission = _canonical_allowed(
self.permissions,
"doctor.appointment/prescription",
default=False,
)
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
host = QWidget(self.table)
host.setProperty("appointmentImHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(host)
layout.setContentsMargins(5, 0, 5, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
button = QPushButton("IM 问诊", host)
button.setObjectName("AppointmentImConsultButton")
button.setProperty("appointmentImAction", True)
patient_name = display_text(first_value(row, "patient_name"), "患者")
button.setAccessibleName(f"{patient_name}进行 IM 问诊")
identifiers_ready = (
_appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
status_error = _im_consult_status_error(row)
enabled = (
has_permission
and self._native_video_capable
and identifiers_ready
and not status_error
)
button.setEnabled(enabled)
if not has_permission:
tooltip = "当前账号无 IM 问诊权限"
elif not self._native_video_capable:
tooltip = "当前工作站未配置完整的视频问诊能力"
elif not identifiers_ready:
tooltip = "患者、诊单或挂号标识不完整"
elif status_error:
tooltip = status_error
else:
tooltip = "打开患者 IM,可发送消息并从会话中发起视频"
button.setToolTip(tooltip)
button.clicked.connect(
lambda _checked=False, source=row: self._run_video_row_action(
source,
self._request_video,
)
)
layout.addWidget(button)
self.table.setCellWidget(row_index, action_column, host)
item = self.table.item(row_index, action_column)
if item is not None:
item.setText("")
def _fit_table_rows(self) -> None:
"""Give each appointment row enough height for its real line count."""
@@ -1685,7 +1521,12 @@ class AppointmentsPage(QWidget):
and _as_int(first_value(row, "doctor_id", default=0)) > 0
)
self.call_button.setEnabled(
not_completed and self._native_video_capable and _appointment_id(row) > 0
has_row
and not _im_consult_status_error(row)
and self._native_video_capable
and _appointment_id(row) > 0
and _diagnosis_id(row) > 0
and _video_patient_id(row) > 0
)
self.complete_button.setEnabled(not_completed and _appointment_id(row) > 0)
self.prescription_button.setEnabled(has_row and _diagnosis_id(row) > 0)
@@ -1736,9 +1577,17 @@ class AppointmentsPage(QWidget):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, payload)
def _open_ai_consult(self) -> None:
def open_selected_ai_consult(self) -> bool:
"""Open the diagnosis-scoped assistant for the selected appointment."""
row = self._current_row()
diagnosis_id = _diagnosis_id(row)
if (
row is None
or diagnosis_id <= 0
or not can_open_ai_consult(self.permissions)
):
return False
present_ai_consult(
self.repository,
self.permissions,
@@ -1748,6 +1597,11 @@ class AppointmentsPage(QWidget):
seed=row,
source_title="问诊列表",
)
return True
def _open_ai_consult(self) -> None:
if not self.open_selected_ai_consult():
show_toast(self, "请先选择一条有关联诊单的问诊记录。", "warning")
def _request_video(self) -> None:
if not _canonical_allowed(
@@ -1760,15 +1614,15 @@ class AppointmentsPage(QWidget):
row = self._current_row()
if row is None:
return
status = _status_value(row)
if status == 3:
show_toast(self, "已完成的挂号不可再发起通话", "warning")
status_error = _im_consult_status_error(row)
if status_error:
show_toast(self, f"{status_error}", "warning")
return
appointment_id = _appointment_id(row)
diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row)
if appointment_id <= 0 or diagnosis_id <= 0 or patient_id <= 0:
show_toast(self, "患者、诊单或挂号标识不完整,无法进入视频问诊。", "warning")
show_toast(self, "患者、诊单或挂号标识不完整,无法进入 IM 问诊。", "warning")
return
self.video_requested.emit(
{
@@ -1777,6 +1631,8 @@ class AppointmentsPage(QWidget):
"patient_id": patient_id,
"diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"),
# Open IM first. The doctor explicitly starts video from the
# conversation instead of requiring an already-live call.
"mode": "im",
"record": row,
}
@@ -2069,7 +1925,8 @@ class AppointmentsPage(QWidget):
if _diagnosis_id(row) <= 0 or _appointment_id(row) <= 0:
show_toast(self, "该预约缺少诊单信息,请先编辑患者。", "warning")
return
self._begin_prescription_load(row)
mode = "view" if prescription_action_label(row) == "查看" else "open"
self._begin_prescription_load(row, mode=mode)
def _view_case(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang", default=False):
@@ -2112,7 +1969,7 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
def _begin_prescription_load(self, row: Any) -> None:
def _begin_prescription_load(self, row: Any, *, mode: str = "open") -> None:
snapshot = deepcopy(row)
self._prescription_generation += 1
generation = self._prescription_generation
@@ -2126,18 +1983,51 @@ class AppointmentsPage(QWidget):
"get_prescription_by_appointment",
appointment_id=appointment_id,
),
on_success=lambda existing: self._prescription_loaded(existing, snapshot, generation),
on_success=lambda existing: self._prescription_loaded(
existing,
snapshot,
generation,
mode,
),
on_error=lambda error: self._prescription_error(error, generation),
on_finished=lambda: self._prescription_finished(generation),
)
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
def _prescription_loaded(
self,
existing: Any,
row: Any,
generation: int,
mode: str = "open",
) -> None:
if generation != self._prescription_generation:
return
self.banner.clear()
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(row, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(row)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -2151,6 +2041,12 @@ class AppointmentsPage(QWidget):
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
dialog.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(row)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -45,6 +45,8 @@ from ..diagnosis_index_widgets import (
DiagnosisPager,
DiagnosisTableHost,
FlowWidget,
prescription_action,
video_call_is_live,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
@@ -373,6 +375,9 @@ def _video_payload(record: Any) -> dict[str, Any]:
"patient_id": first_value(record, "patient_id", "source_patient_id"),
"diagnosis_id": first_value(record, "diagnosis_id", "id"),
"patient_name": first_value(record, "patient_name", default="患者"),
# This page is the assistant/admin receiver. IM mode logs in and waits
# for the doctor's existing invitation; it must never call startCall.
"mode": "im",
"record": record,
}
@@ -389,17 +394,7 @@ def is_diagnosis_confirmed(record: Any) -> bool:
def prescription_action_label(record: Any) -> str:
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
explicit = first_value(record, "has_prescription", default=None)
has_prescription = (
_as_bool(explicit)
if explicit is not None
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
)
if not has_prescription:
return "开方"
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
return prescription_action(record)[0]
def can_void_prescription(record: Any) -> bool:
@@ -2931,10 +2926,21 @@ class ConsultationsPage(QWidget):
self.edit_button.setEnabled(has_record)
self.delete_button.setEnabled(has_record)
self.prescription_button.setText(
prescription_action_label(record) if record is not None else "开方"
prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)[0]
if record is not None
else "开方"
)
self.prescription_button.setEnabled(has_record and not self._prescription_busy)
row_has_prescription = _as_bool(first_value(record, "has_prescription", default=False))
row_has_prescription = _as_bool(
first_value(
record,
"current_has_prescription",
default=first_value(record, "has_prescription", default=False),
)
)
self.void_button.setEnabled(
has_record
and row_has_prescription
@@ -2946,7 +2952,12 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
self.video_button.setEnabled(has_record and is_video_available(record) and valid_ids)
self.video_button.setEnabled(
has_record
and is_video_available(record)
and video_call_is_live(record)
and valid_ids
)
@property
def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -2966,9 +2977,17 @@ class ConsultationsPage(QWidget):
return
self._diagnosis_dialog.open_view_only(diagnosis_id, seed=record)
def _open_ai_consult(self) -> None:
def open_selected_ai_consult(self) -> bool:
"""Open the assistant with the selected diagnosis as its authority key."""
record = self.table.current_data()
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
if (
record is None
or diagnosis_id <= 0
or not can_open_ai_consult(self.permissions)
):
return False
present_ai_consult(
self.repository,
self.permissions,
@@ -2978,8 +2997,13 @@ class ConsultationsPage(QWidget):
first_value(record, "source_patient_id", "patient_id", default=0)
),
seed=record,
source_title="患者列表",
source_title="问诊列表",
)
return True
def _open_ai_consult(self) -> None:
if not self.open_selected_ai_consult():
show_toast(self, "请先选择一条有效诊单。", "warning")
def _open_edit(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"):
@@ -3076,7 +3100,11 @@ class ConsultationsPage(QWidget):
record = self.table.current_data()
if _as_int(first_value(record, "diagnosis_id", "id", default=0)) <= 0:
return
self._begin_prescription_load(record, mode="open")
_label, mode = prescription_action(
record,
force_open=self.table_host.force_open_prescription,
)
self._begin_prescription_load(record, mode=mode)
def _void_selected_prescription(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/kaifang"):
@@ -3114,6 +3142,28 @@ class ConsultationsPage(QWidget):
if existing is not None:
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
actual_id = _as_int(first_value(existing, "id", "prescription_id", default=0))
expected_id = _as_int(
first_value(record, "current_prescription_id", default=0)
)
actual_appointment_id = _as_int(
first_value(existing, "appointment_id", default=0)
)
expected_appointment_id = _appointment_id(record)
identity_changed = (
(expected_id > 0 and actual_id != expected_id)
or (
actual_appointment_id > 0
and expected_appointment_id > 0
and actual_appointment_id != expected_appointment_id
)
)
if mode == "view" and (not approved or voided or identity_changed):
self.banner.show_message(
"当前挂号的处方状态已变化,请刷新列表后重试。",
"warning",
)
return
if not approved or voided:
self._open_existing_prescription_editor(existing)
else:
@@ -3127,6 +3177,12 @@ class ConsultationsPage(QWidget):
detail.diagnosis_requested.connect(self._open_diagnosis_id)
detail.exec()
return
if mode == "view":
self.banner.show_message(
"当前挂号没有可查看的处方,请刷新列表后重试。",
"warning",
)
return
self._begin_case_record_load(record)
def _open_existing_prescription_editor(self, prescription: Any) -> None:
@@ -3339,6 +3395,9 @@ class ConsultationsPage(QWidget):
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
return
payload = _video_payload(record)
if not all(
_as_int(payload.get(key), 0) > 0
+78 -39
View File
@@ -40,12 +40,14 @@ from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..theme import mark_business_dialog
from ..theme import crisp_pixmap, mark_business_dialog
from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
RowAction,
RowActions,
SortableTable,
StatusBadge,
TableColumn,
@@ -302,8 +304,7 @@ PATIENTS_LIGHT_QSS = """
def _summary_calendar_icon() -> QIcon:
"""Paint the compact calendar tile from the reference without a font glyph."""
pixmap = QPixmap(34, 34)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(34)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
@@ -1317,9 +1318,13 @@ class PatientListWorkspace(QWidget):
panel.setSpacing(6)
top_row = QHBoxLayout()
top_row.setObjectName("PatientFilterTopRow")
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(6)
top_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.keyword_edit = QLineEdit()
self.keyword_edit.setObjectName("PatientKeywordInput")
self.keyword_edit.setMaximumWidth(620)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.returnPressed.connect(self.search)
@@ -1335,6 +1340,8 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
self.status_host = QWidget()
self.status_host.setObjectName("PatientStatusFilterHost")
self.status_host.setMaximumWidth(440)
status_row = QHBoxLayout(self.status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(2)
@@ -1370,21 +1377,27 @@ class PatientListWorkspace(QWidget):
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
top_row.addWidget(self.status_host, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.clicked.connect(self.search)
top_row.addWidget(search)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self.reset_filters)
top_row.addWidget(reset)
self.search_button = QPushButton("查询")
self.search_button.setObjectName("PatientSearchButton")
self.search_button.setProperty("variant", "primary")
self.search_button.setProperty("patientSearchAction", True)
self.search_button.clicked.connect(self.search)
top_row.addWidget(self.search_button)
self.reset_button = QPushButton("重置")
self.reset_button.setObjectName("PatientResetButton")
self.reset_button.setProperty("variant", "ghost")
self.reset_button.clicked.connect(self.reset_filters)
top_row.addWidget(self.reset_button)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setObjectName("PatientFilterDateRow")
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(6)
bottom_row.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.quick_host = QWidget()
self.quick_host.setObjectName("PatientQuickDateHost")
self.quick_host.setMaximumWidth(620)
quick = QHBoxLayout(self.quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(2)
@@ -1411,10 +1424,13 @@ class PatientListWorkspace(QWidget):
bottom_row.addWidget(self.quick_host, 3)
self.date_host = QWidget()
self.date_host.setObjectName("PatientDateRangeHost")
self.date_host.setMaximumWidth(420)
dates = QHBoxLayout(self.date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(6)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setObjectName("PatientStartDateEdit")
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
self.start_date.setEnabled(False)
@@ -1425,22 +1441,26 @@ class PatientListWorkspace(QWidget):
separator.setProperty("role", "muted")
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setObjectName("PatientEndDateEdit")
self.end_date.setCalendarPopup(True)
self.end_date.setDisplayFormat("yyyy-MM-dd")
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(self.date_host, 2)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(custom)
self.custom_date_button = QPushButton("自定义")
self.custom_date_button.setObjectName("PatientCustomDateButton")
self.custom_date_button.setProperty("variant", "ghost")
self.custom_date_button.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(self.custom_date_button)
panel.addLayout(bottom_row)
return card
def _build_summary(self) -> QHBoxLayout:
layout = QHBoxLayout()
layout.setObjectName("PatientSummaryRow")
layout.setSpacing(8)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.summary_buttons: dict[str, QPushButton] = {}
for key, label, mode in (
("today", "今日预约", "today"),
@@ -1448,8 +1468,16 @@ class PatientListWorkspace(QWidget):
("day_after", "后天预约", "day_after"),
):
button = QPushButton(f"{label}\n0 人")
button.setObjectName(
{
"today": "PatientSummaryToday",
"tomorrow": "PatientSummaryTomorrow",
"day_after": "PatientSummaryDayAfter",
}[key]
)
button.setProperty("summaryCard", True)
button.setFixedHeight(42)
button.setMaximumWidth(420)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
@@ -1484,39 +1512,41 @@ class PatientListWorkspace(QWidget):
self.table = SortableTable(
[
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
# 手机号另有独立列,姓名列不再重复第二行,单行即可放下完整姓名。
TableColumn(
"patient_name",
"患者信息",
180,
168,
lambda _value, row: (
f"{display_text(first_value(row, 'patient_name', 'name'))} · "
f"{gender_text(first_value(row, 'gender_desc', 'gender'))} · "
f"{display_text(first_value(row, 'age'))}\n"
f"{display_text(first_value(row, 'phone_masked', 'phone'), '')}"
f"{display_text(first_value(row, 'age'))}"
),
),
TableColumn("assistant_name", "归属助理", 84),
TableColumn("assistant_name", "归属助理", 90),
TableColumn(
"appointment_doctor_name",
"预约医生",
108,
150,
lambda _value, row: (
f"{display_text(first_value(row, 'appointment_doctor_name'), '未预约')} / "
f"{_patient_status(row)[0]}"
),
),
TableColumn("appointment_time_text", "预约时间", 116),
TableColumn("appointment_time_text", "预约时间", 150),
TableColumn(
"revisit_count",
"复诊",
72,
64,
lambda value, _row: f"{display_text(value, '0')}",
Qt.AlignmentFlag.AlignCenter,
),
TableColumn("confirmation_text", "确认信息", 88),
TableColumn("diagnosis_date_text", "诊单日期", 96),
TableColumn("diagnosis_date_text", "诊单日期", 104),
TableColumn("phone_masked", "手机", 116),
TableColumn("_actions", "操作", 500),
# 行操作收敛为“2 个常用 + 更多”后,这一列不再需要 500px 的宽度,
# 让出的空间还给此前被省略号截断的数据列。
TableColumn("_actions", "操作", 210),
]
)
self.table.setObjectName("PatientTable")
@@ -1565,25 +1595,16 @@ class PatientListWorkspace(QWidget):
selector_item = self.table.item(row_index, 0)
if selector_item is not None:
selector_item.setText("")
host = QWidget(self.table)
layout = QHBoxLayout(host)
layout.setContentsMargins(5, 3, 5, 3)
layout.setSpacing(4)
row_actions: list[RowAction] = []
def add_action(
label: str,
callback: Any,
*,
danger: bool = False,
_host: QWidget = host,
_layout: QHBoxLayout = layout,
_sink: list[RowAction] = row_actions,
) -> None:
button = QPushButton(label, _host)
button.setProperty("rowAction", True)
if danger:
button.setProperty("variant", "danger")
button.clicked.connect(callback)
_layout.addWidget(button)
_sink.append(RowAction(label, callback, danger=danger))
if editable or readable:
add_action(
@@ -1625,12 +1646,12 @@ class PatientListWorkspace(QWidget):
lambda _checked=False, value=row: self.cancel_requested.emit(value),
danger=True,
)
layout.addStretch(1)
host = RowActions(row_actions, self.table)
self.table.setCellWidget(row_index, action_column, host)
action_item = self.table.item(row_index, action_column)
if action_item is not None:
action_item.setText("")
self.table.setRowHeight(row_index, 40)
self.table.setRowHeight(row_index, 36)
def _build_actions(self) -> QHBoxLayout:
layout = QHBoxLayout()
@@ -2713,6 +2734,24 @@ class PatientsPage(QWidget):
source_title="我的患者",
)
def open_selected_ai_consult(self) -> bool:
"""Open AI chat for the selected row in the active patient workspace."""
if not can_open_ai_consult(self.permissions):
return False
workspace = self.tabs.currentWidget()
row = None
for table_name in ("table", "queue_table"):
table = getattr(workspace, table_name, None)
current_data = getattr(table, "current_data", None)
if callable(current_data):
row = current_data()
break
if row is None or self._diagnosis_id(row) <= 0:
return False
self._open_ai_consult(row)
return True
def _open_order_diagnosis(self, row: Any) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
@@ -34,6 +34,7 @@ from ..widgets import (
TableColumn,
display_text,
first_value,
format_record_time,
friendly_error,
get_value,
has_permission,
@@ -214,6 +215,11 @@ def _efficacy_text(_value: Any, row: Any) -> str:
)
def _create_time_cell(value: Any, _row: Any) -> str:
raw = first_value(_row, "create_time_text", "create_time", default=value)
return format_record_time(raw)
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(64)
@@ -427,7 +433,7 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("efficacy", "功效主治", 170, _efficacy_text),
TableColumn("is_public", "公开范围", 135, _visibility_text),
TableColumn("creator_name", "创建人", 100),
TableColumn("create_time", "创建时间", 178),
TableColumn("create_time", "创建时间", 178, _create_time_cell),
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
]
)
@@ -36,6 +36,7 @@ from ..dialogs.prescription import (
PrescriptionOrderDialog,
PrescriptionOrderListDialog,
)
from ..theme import crisp_pixmap
from ..widgets import (
BusinessPager,
EmptyState,
@@ -45,6 +46,7 @@ from ..widgets import (
TableColumn,
display_text,
first_value,
format_record_time,
friendly_error,
get_value,
has_permission,
@@ -105,6 +107,10 @@ PRESCRIPTIONS_PAGE_QSS = """
padding: 0; border-radius: 7px; background: #FFFFFF;
border: 1px solid #DCE3F5;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; padding: 0 9px;
color: #315CF4; font-weight: 600;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover {
background: #F3F5FF; border-color: #AAB7FF;
}
@@ -157,8 +163,7 @@ PRESCRIPTIONS_PAGE_QSS = """
def _painted_icon(kind: str, color: str = "#5265F6", size: int = 16) -> QIcon:
"""Return a crisp page-local icon without relying on emoji or icon fonts."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(QColor(color), max(1.25, size / 11.5))
@@ -265,13 +270,20 @@ def _row_action_button(
*,
danger: bool = False,
enabled: bool = True,
label: str = "",
mutation: bool = False,
) -> QPushButton:
button = QPushButton(parent)
button.setProperty("rowAction", True)
button.setProperty("danger", danger)
button.setProperty("labeled", bool(label))
button.setProperty("mutationAction", mutation)
button.setProperty("rowAllowed", enabled)
button.setText(label)
button.setAccessibleName(tooltip)
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFixedSize(64 if label else 28, 28)
button.setIcon(_painted_icon(kind, "#F34E64" if danger else "#4965F5", 15))
button.setIconSize(QSize(15, 15))
button.setEnabled(enabled)
@@ -427,6 +439,11 @@ def _doctor_cell(_value: Any, row: Any) -> str:
return str(first_value(row, "doctor_name", "creator_name", default=""))
def _create_time_cell(value: Any, _row: Any) -> str:
raw = first_value(_row, "create_time_text", "create_time", default=value)
return format_record_time(raw)
class DoctorMultiSelect(QWidget):
"""Compact checkable doctor selector fed by list rows/extend data."""
@@ -682,6 +699,7 @@ class PrescriptionsPage(QWidget):
[
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
TableColumn("sn", "处方编号", 174, _sn_cell),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
TableColumn("prescription_type", "处方类型", 96),
TableColumn("is_system_auto", "来源", 88, _source_cell),
TableColumn("patient_name", "患者信息", 190, _patient_cell),
@@ -689,8 +707,7 @@ class PrescriptionsPage(QWidget):
TableColumn("void_status", "作废", 72, _void_cell),
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
TableColumn("assistant_name", "医助", 125),
TableColumn("create_time", "创建时间", 180),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
TableColumn("create_time", "创建时间", 180, _create_time_cell),
]
)
self.table.verticalHeader().setDefaultSectionSize(36)
@@ -840,7 +857,7 @@ class PrescriptionsPage(QWidget):
return
rows = page_items(result)
self.table.set_rows(rows)
self._decorate_rows(rows)
self._decorate_rows()
total = page_total(result, len(rows))
self.pager.update_state(requested_page, total)
self.count_badge.setText(f"{total}")
@@ -864,20 +881,27 @@ class PrescriptionsPage(QWidget):
self.table.selectRow(0)
self._selection_changed()
def _decorate_rows(self, rows: list[Any]) -> None:
def _decorate_rows(self) -> None:
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
for row_index, row in enumerate(rows):
for row_index in range(self.table.rowCount()):
selector = self.table.item(row_index, 0)
if selector is not None:
selector.setFlags(
selector.flags()
| Qt.ItemFlag.ItemIsUserCheckable
| Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
)
selector.setCheckState(Qt.CheckState.Unchecked)
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if selector is None:
continue
# Read the row back from the visual table. SortableTable may have
# already reordered rows, so decorating the original result list
# can bind an action button to the wrong visible prescription.
row = selector.data(Qt.ItemDataRole.UserRole)
if row is None:
continue
selector.setFlags(
selector.flags()
| Qt.ItemFlag.ItemIsUserCheckable
| Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
)
selector.setCheckState(Qt.CheckState.Unchecked)
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
sn_item = self.table.item(row_index, 1)
if sn_item is not None:
@@ -894,7 +918,7 @@ class PrescriptionsPage(QWidget):
)
self.table.setCellWidget(
row_index,
2,
3,
_style_row_host(
_cell_host(_tag_label(prescription_type, "accent", self.table.viewport())),
row_index,
@@ -903,11 +927,11 @@ class PrescriptionsPage(QWidget):
audit_text, audit_kind = prescription_status(row)
audit = _tag_label(audit_text, audit_kind, self.table.viewport())
audit_item = self.table.item(row_index, 5)
audit_item = self.table.item(row_index, 6)
if audit_item is not None and "\n" in audit_item.text():
audit.setToolTip(audit_item.text())
self.table.setCellWidget(
row_index, 5, _style_row_host(_cell_host(audit), row_index)
row_index, 6, _style_row_host(_cell_host(audit), row_index)
)
doctor_name = _doctor_cell(None, row)
@@ -928,13 +952,13 @@ class PrescriptionsPage(QWidget):
doctor_label.setStyleSheet("color:#31416A;background:transparent;border:0;")
doctor_layout.addWidget(doctor_label)
doctor_layout.addStretch(1)
self.table.setCellWidget(row_index, 7, doctor_host)
self.table.setCellWidget(row_index, 8, doctor_host)
actions_host = QWidget(self.table.viewport())
_style_row_host(actions_host, row_index)
actions = QHBoxLayout(actions_host)
actions.setContentsMargins(7, 0, 7, 0)
actions.setSpacing(7)
actions.setContentsMargins(3, 0, 3, 0)
actions.setSpacing(3)
actions.addStretch(1)
if has_permission(self.permissions, "cf.prescription/read"):
actions.addWidget(
@@ -957,6 +981,8 @@ class PrescriptionsPage(QWidget):
),
actions_host,
enabled=can_edit_or_delete(row),
label="编辑",
mutation=True,
)
)
if has_permission(self.permissions, "cf.prescription/del"):
@@ -970,23 +996,31 @@ class PrescriptionsPage(QWidget):
actions_host,
danger=True,
enabled=can_edit_or_delete(row),
mutation=True,
)
)
actions.addStretch(1)
self.table.setCellWidget(row_index, 10, actions_host)
self.table.setCellWidget(row_index, 2, actions_host)
self._sync_row_mutation_actions()
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = first_value(row, "id", "prescription_id", default=None)
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
if target_id <= 0:
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
return
for row_index in range(self.table.rowCount()):
item = self.table.item(row_index, 0)
candidate = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
candidate_id = first_value(candidate, "id", "prescription_id", default=None)
candidate_id = _int(
first_value(candidate, "id", "prescription_id", default=None), 0
)
if candidate is row or (
target_id is not None and str(candidate_id) == str(target_id)
candidate_id > 0 and candidate_id == target_id
):
self.table.selectRow(row_index)
break
callback()
callback()
return
self.banner.show_message("该处方已不在当前列表,请刷新后重试。", "warning")
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
@@ -1013,8 +1047,17 @@ class PrescriptionsPage(QWidget):
self._mutation_pending = pending
self.add_button.setEnabled(not pending)
self.orders_button.setEnabled(not pending)
self._sync_row_mutation_actions()
self._selection_changed()
def _sync_row_mutation_actions(self) -> None:
for button in self.table.findChildren(QPushButton):
if not bool(button.property("mutationAction")):
continue
button.setEnabled(
not self._mutation_pending and bool(button.property("rowAllowed"))
)
def _selected(self) -> Any:
return self.table.current_data()
@@ -1054,6 +1097,10 @@ class PrescriptionsPage(QWidget):
) -> None:
if generation != self._detail_generation or prescription_id != self._detail_target:
return
detail_id = _int(first_value(detail, "id", "prescription_id"), 0)
if detail_id != prescription_id:
self.banner.show_message("处方详情与当前选择不一致,请刷新后重试。", "danger")
return
self.banner.clear()
callback(detail)
@@ -1148,6 +1195,7 @@ class PrescriptionsPage(QWidget):
row = self._selected()
if (
row is None
or self._mutation_pending
or not has_permission(self.permissions, "cf.prescription/edit")
or not can_edit_or_delete(row)
):
@@ -1159,6 +1207,9 @@ class PrescriptionsPage(QWidget):
)
def _open_editor(self, detail: Any) -> None:
if not can_edit_or_delete(detail):
self.banner.show_message("该处方状态已变化,当前不能编辑。", "warning")
return
dialog = PrescriptionEditorDialog(
self.repository,
detail,
@@ -1208,6 +1259,7 @@ class PrescriptionsPage(QWidget):
row = self._selected()
if (
row is None
or self._mutation_pending
or not has_permission(self.permissions, "cf.prescription/del")
or not can_edit_or_delete(row)
):
@@ -1222,6 +1274,9 @@ class PrescriptionsPage(QWidget):
if answer != QMessageBox.StandardButton.Yes:
return
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
if prescription_id <= 0:
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
return
self._set_mutation_pending(True)
run_async(
lambda: self.repository.delete_prescription(prescription_id),
File diff suppressed because it is too large Load Diff
+149 -52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
@@ -13,7 +14,6 @@ from PySide6.QtGui import (
QFont,
QIcon,
QKeySequence,
QLinearGradient,
QMouseEvent,
QPainter,
QPen,
@@ -22,6 +22,7 @@ from PySide6.QtGui import (
QShortcut,
)
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFrame,
QHBoxLayout,
@@ -38,6 +39,11 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation.resources import app_icon_path
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
ConsultationsPage,
@@ -46,6 +52,7 @@ from .pages import (
PrescriptionsPage,
ReceptionPage,
)
from .theme import crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
@@ -61,6 +68,25 @@ _SHELL_TOPBAR_HEIGHT = 62
_SHELL_TABS_HEIGHT = 0
_SHELL_OUTER_GUTTER = 13
_SHELL_PANEL_GAP = 0
_SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
def _bounded_initial_window_size(available_size: QSize | None) -> QSize:
"""Fit the design viewport inside the screen's logical available geometry."""
if available_size is None or not available_size.isValid():
return QSize(_SHELL_DESIGN_SIZE.width(), _SHELL_DESIGN_SIZE.height())
return QSize(
max(
_SHELL_MINIMUM_SIZE.width(),
min(_SHELL_DESIGN_SIZE.width(), available_size.width()),
),
max(
_SHELL_MINIMUM_SIZE.height(),
min(_SHELL_DESIGN_SIZE.height(), available_size.height()),
),
)
# The references place the workspace at page-specific global x anchors while
# keeping a 13 px outer gutter. These are the actual rail widths inside that
@@ -86,8 +112,10 @@ class NavigationItem:
NAVIGATION = (
NavigationItem(
# 挂号与诊单是两条独立队列,早期两项都叫“问诊列表”,侧边栏出现两个同名
# 入口,医生无法判断该点哪个。按各自的业务对象命名以消除歧义。
"appointments",
"问诊列表",
"挂号列表",
"",
AppointmentsPage,
("doctor.appointment/lists",),
@@ -327,8 +355,7 @@ def _resolve_navigation(
def _painted_shell_icon(kind: str, size: int = 18) -> QIcon:
"""Create a font-independent shell icon once, before widget painting."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
try:
@@ -496,8 +523,7 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
"""Return a compact line icon with a dedicated checked-state color."""
def render(color: str) -> QPixmap:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
pixmap = crisp_pixmap(size)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor(color), 1.55)
@@ -550,25 +576,19 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
class _ShellBrandMark(QWidget):
"""Paint the supplied indigo M mark without a font-glyph asset."""
"""Render the approved application brand mark in the navigation rail."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._brand_pixmap = QPixmap(str(app_icon_path()))
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0.0, QColor("#6F7BFA"))
gradient.setColorAt(1.0, QColor("#5260ED"))
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
painter.setBrush(gradient)
painter.drawRoundedRect(rect, 10, 10)
painter.setPen(QColor("#FFFFFF"))
font = QFont(painter.font())
font.setPixelSize(18)
font.setWeight(QFont.Weight.Bold)
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
if not self._brand_pixmap.isNull():
painter.drawPixmap(rect, self._brand_pixmap, QRectF(self._brand_pixmap.rect()))
class _AssistantRobot(QWidget):
@@ -660,24 +680,29 @@ class _PaintedIconButton(QToolButton):
self.setFixedSize(size, size)
self.setIcon(_painted_shell_icon(kind, icon_size))
self.setIconSize(QSize(icon_size, icon_size))
content_size = max(0, size - 2)
self.setStyleSheet(
"""
QToolButton#ShellPaintedIconButton {
f"""
QToolButton#ShellPaintedIconButton {{
min-width: {content_size}px;
max-width: {content_size}px;
min-height: {content_size}px;
max-height: {content_size}px;
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
}
QToolButton#ShellPaintedIconButton:hover {
}}
QToolButton#ShellPaintedIconButton:hover {{
background: #F0F3FC;
border-color: #E2E7F4;
}
QToolButton#ShellPaintedIconButton:pressed { background: #EEF1FF; }
QToolButton#ShellPaintedIconButton:focus { border-color: #8D9BFF; }
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {
}}
QToolButton#ShellPaintedIconButton:pressed {{ background: #EEF1FF; }}
QToolButton#ShellPaintedIconButton:focus {{ border-color: #8D9BFF; }}
QToolButton#ShellPaintedIconButton[windowControl="close"]:hover {{
background: #FFF0F2;
border-color: #F4C4CC;
}
QToolButton#ShellPaintedIconButton::menu-indicator { image: none; width: 0; }
}}
QToolButton#ShellPaintedIconButton::menu-indicator {{ image: none; width: 0; }}
"""
)
@@ -697,8 +722,7 @@ class _UserMenuButton(QToolButton):
self.setFixedWidth(max(104, min(174, name_width + 72)))
self.setAccessibleName(f"用户菜单:{self.display_name}")
self.setText(self.display_name)
avatar = QPixmap(34, 34)
avatar.fill(Qt.GlobalColor.transparent)
avatar = crisp_pixmap(34)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
@@ -815,6 +839,7 @@ class ShellWindow(QMainWindow):
logout_requested = Signal()
video_requested = Signal(dict)
page_changed = Signal(str)
update_check_requested = Signal()
def __init__(
self,
@@ -845,6 +870,7 @@ class ShellWindow(QMainWindow):
if session_permissions is not None
else get_value(self.current_user, "permissions", None)
)
self._can_ai_assistant = can_open_ai_consult(self.permissions)
session_menu = get_value(self.session, "menu", None)
self.menu = (
session_menu if session_menu is not None else get_value(session, "menu", [])
@@ -868,9 +894,12 @@ class ShellWindow(QMainWindow):
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
self.setMinimumSize(1024, 640)
self.resize(1710, 920)
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
screen = self.screen() or QApplication.primaryScreen()
available_size = screen.availableGeometry().size() if screen is not None else None
self.resize(_bounded_initial_window_size(available_size))
canvas = _ShellCanvas(self)
canvas.setObjectName("AppCanvas")
@@ -1002,7 +1031,20 @@ class ShellWindow(QMainWindow):
font-weight: 700;
}
QPushButton#ShellAssistantButton:hover { background-color: #4658E8; }
QLabel#ShellModelLabel { color: #8A95AF; font-size: 10px; }
QPushButton#ShellSettingsButton {
min-height: 42px;
color: #6E7C9F;
font-size: 11px;
font-weight: 600;
background-color: transparent;
border: 1px solid transparent;
border-radius: 10px;
}
QPushButton#ShellSettingsButton:hover {
color: #5265F6;
background-color: #EEF1FF;
}
QPushButton#ShellSettingsButton:focus { border-color: #8D9BFF; }
"""
)
layout = QVBoxLayout(sidebar)
@@ -1073,12 +1115,19 @@ class ShellWindow(QMainWindow):
outer_assistant.setContentsMargins(13, 0, 13, 0)
outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant)
self.assistant_card.setVisible(self._can_ai_assistant)
self.model_label = QLabel("模型:GPT-4o 医疗版 ", sidebar)
self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76)
layout.addWidget(self.model_label)
self.upload_settings_button = QPushButton("设置 ", sidebar)
self.upload_settings_button.setObjectName("ShellSettingsButton")
self.upload_settings_button.setAccessibleName("本机录音上传设置")
self.upload_settings_button.setToolTip("查看本机录音上传记录")
self.upload_settings_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.upload_settings_button.setFixedHeight(76)
self.upload_settings_button.clicked.connect(self._open_local_audio_settings)
# Keep the former attribute as a compatibility alias for integrations
# that inspect the bottom sidebar control.
self.model_label = self.upload_settings_button
layout.addWidget(self.upload_settings_button)
return sidebar
def _build_topbar(self) -> QWidget:
@@ -1095,10 +1144,6 @@ class ShellWindow(QMainWindow):
border-top-right-radius: 16px;
}
QFrame#ShellGlobalSearch {
min-width: 265px;
max-width: 265px;
min-height: 36px;
max-height: 36px;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -1112,6 +1157,23 @@ class ShellWindow(QMainWindow):
border: 0;
border-radius: 9px;
}
QLineEdit#ShellGlobalSearchInput QToolButton {
min-width: 22px;
max-width: 22px;
min-height: 18px;
max-height: 18px;
margin: 0;
padding: 0;
background-color: transparent;
border: 0;
border-radius: 0;
}
QLineEdit#ShellGlobalSearchInput QToolButton:hover,
QLineEdit#ShellGlobalSearchInput QToolButton:pressed,
QLineEdit#ShellGlobalSearchInput QToolButton:focus {
background-color: transparent;
border: 0;
}
QLabel#ShellShortcutHint {
color: #8A95AF;
background-color: #F0F3FC;
@@ -1207,7 +1269,9 @@ class ShellWindow(QMainWindow):
search_layout.addWidget(self.global_search, 1)
shortcut_hint = QLabel("Ctrl K", search_host)
shortcut_hint.setObjectName("ShellShortcutHint")
search_layout.addWidget(shortcut_hint)
shortcut_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
shortcut_hint.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
search_layout.addWidget(shortcut_hint, 0, Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(search_host)
self.context_label = QLabel("工作台", topbar)
@@ -1224,6 +1288,7 @@ class ShellWindow(QMainWindow):
self.ai_top_button.setAccessibleName("AI 助手")
self.ai_top_button.clicked.connect(self._open_ai_assistant)
layout.addWidget(self.ai_top_button)
self.ai_top_button.setVisible(self._can_ai_assistant)
self.notification_button = _PaintedIconButton(
"notification", size=38, parent=topbar
@@ -1260,6 +1325,9 @@ class ShellWindow(QMainWindow):
account_action = user_menu.addAction(self._role_text())
account_action.setEnabled(False)
user_menu.addSeparator()
update_action = user_menu.addAction("检查更新")
update_action.triggered.connect(self.update_check_requested.emit)
user_menu.addSeparator()
logout_action = user_menu.addAction("退出登录")
logout_action.triggered.connect(lambda: self.logout_requested.emit())
self.user_menu_button.setMenu(user_menu)
@@ -1392,8 +1460,10 @@ class ShellWindow(QMainWindow):
else self._expanded_sidebar_width()
)
self.brand_copy.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(not self._sidebar_collapsed)
self.model_label.setVisible(not self._sidebar_collapsed)
self.assistant_card.setVisible(
self._can_ai_assistant and not self._sidebar_collapsed
)
self.upload_settings_button.setVisible(not self._sidebar_collapsed)
margins = (7, 14, 7, 0) if self._sidebar_collapsed else (12, 19, 12, 0)
self.nav_layout.setContentsMargins(*margins)
self.fold_button.kind = "expand" if self._sidebar_collapsed else "fold"
@@ -1478,14 +1548,41 @@ class ShellWindow(QMainWindow):
show_toast(self, f"正在当前页面搜索“{query}”。", "info")
def _open_ai_assistant(self) -> None:
self.navigate("reception")
show_toast(
if not self._can_ai_assistant:
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
return
# The shell assistant is the global entry point. Always let the doctor
# choose from the complete permission-scoped patient list, even when a
# page happens to retain a selected row. Page-level AI actions keep the
# faster "open the selected patient" behaviour.
select_and_present_ai_consult(
self.repository,
self.permissions,
self,
"已进入接诊台;选择患者后可查看 AI 报告与辅助分析。",
"info",
3600,
initial_query=self.global_search.text().strip(),
)
def _open_local_audio_settings(self) -> None:
current = self._local_audio_settings_dialog
if current is not None and current.isVisible():
current.raise_()
current.activateWindow()
return
try:
dialog = LocalAudioQueueDialog(self.repository, None, self)
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
show_toast(self, f"无法打开本机录音上传设置:{error}", "error")
return
self._local_audio_settings_dialog = dialog
def clear_dialog(_result: int) -> None:
if self._local_audio_settings_dialog is dialog:
self._local_audio_settings_dialog = None
dialog.finished.connect(clear_dialog)
dialog.open()
def _tab_index_for_key(self, key: str) -> int:
for index in range(self.tab_bar.count()):
if self.tab_bar.tabData(index) == key:
+184 -70
View File
@@ -2,6 +2,11 @@
The palette and density follow the supplied product references: a quiet blue
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
Every size in the interface comes from the token tables below. Before they
existed the UI had grown 19 distinct font sizes and 8 control heights, which is
what made neighbouring controls look subtly mismatched; keep new work on the
scale instead of introducing another one-off pixel value.
"""
from __future__ import annotations
@@ -12,7 +17,7 @@ from pathlib import Path
from string import Template
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -66,13 +71,70 @@ COLORS = {
}
# --- Type scale -----------------------------------------------------------
# Six steps, tuned for 简体中文 at Windows 100%150% scaling. CJK glyphs carry
# more ink than Latin at the same pixel size, so the steps are spaced widely
# enough that two adjacent levels are always distinguishable.
TYPE = {
"fs_caption": "12px", # table headers, hints, badges, timestamps
"fs_body": "13px", # default UI text
"fs_strong": "14px", # emphasised body, dialog prompts
"fs_section": "16px", # card and section titles
"fs_title": "20px", # page titles, dialog titles
"fs_display": "26px", # metric values, empty-state glyphs
}
# --- Control metrics ------------------------------------------------------
# Three interactive heights. ``h_default`` drops from the previous 36px: the
# old value made every toolbar, filter row and inline action read as heavy,
# which is the main reason the workspace felt clunky.
METRICS = {
"h_compact": "28px", # inline row actions, chips, links
"h_default": "32px", # buttons, inputs, combos, tabs
"h_cta": "38px", # primary dialog actions, sidebar navigation
"h_bar": "52px", # dialog header / footer bars
"r_sm": "6px",
"r_md": "8px",
"r_lg": "12px",
"r_xl": "16px",
"pad_control": "12px", # horizontal padding inside default controls
"pad_compact": "9px",
}
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS}
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
"""Return a transparent pixmap that stays sharp on scaled displays.
Every shell/page icon is painted by hand rather than shipped as an asset.
Allocating those pixmaps at their logical size makes Qt upscale them on the
125%/150% Windows scale factors most clinic workstations run at, which is
what made the icon set look soft next to crisply hinted text. Backing the
pixmap with the screen's device pixel ratio keeps the painter in logical
coordinates while giving it real device pixels to draw into.
"""
height = width if height is None else height
ratio = 1.0
app = QApplication.instance()
if app is not None:
screen = app.primaryScreen()
if screen is not None:
ratio = max(1.0, float(screen.devicePixelRatio()))
pixmap = QPixmap(round(width * ratio), round(height * ratio))
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(Qt.GlobalColor.transparent)
return pixmap
GLOBAL_QSS = Template(
r"""
QWidget {
color: $text;
background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
font-size: $fs_body;
}
QMainWindow, QDialog, QWidget#LoginCanvas {
@@ -89,24 +151,24 @@ QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
}
QDialog[businessDialog="true"] QFrame#DialogHeader,
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
min-height: 58px;
min-height: $h_bar;
background-color: $surface;
border: 0;
border-bottom: 1px solid $line;
}
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
color: $text;
font-size: 18px;
font-size: $fs_section;
font-weight: 700;
}
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
color: $muted;
font-size: 12px;
font-size: $fs_caption;
}
QDialog[businessDialog="true"] QDialogButtonBox,
QMessageBox QDialogButtonBox,
QInputDialog QDialogButtonBox {
min-height: 58px;
min-height: $h_bar;
background-color: $surface;
border: 0;
border-top: 1px solid $line;
@@ -115,7 +177,7 @@ QDialog[businessDialog="true"] QDialogButtonBox QPushButton,
QMessageBox QDialogButtonBox QPushButton,
QInputDialog QDialogButtonBox QPushButton {
min-width: 88px;
min-height: 38px;
min-height: $h_cta;
}
QMessageBox[businessDialog="true"],
QInputDialog[businessDialog="true"] {
@@ -125,7 +187,7 @@ QMessageBox QLabel#qt_msgbox_label {
min-width: 300px;
padding: 8px 2px;
color: $text_soft;
font-size: 14px;
font-size: $fs_strong;
}
QMessageBox QLabel#qt_msgboxex_icon_label {
min-width: 44px;
@@ -138,7 +200,7 @@ QMessageBox[messageKind="critical"] QLabel#qt_msgbox_label {
}
QInputDialog QLabel {
color: $text_soft;
font-size: 14px;
font-size: $fs_strong;
}
QWidget#AppCanvas {
background-color: qlineargradient(
@@ -155,33 +217,33 @@ QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
QLabel[role="muted"] { color: $muted; }
QLabel[role="danger"] { color: $danger; }
QLabel[role="breadcrumb"] { color: $muted; font-size: 12px; }
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: 14px; }
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: 12px; font-weight: 600; }
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; }
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; }
QLabel[role="eyebrow"] {
color: $indigo_hover;
font-size: 11px;
font-size: $fs_caption;
font-weight: 700;
letter-spacing: 0.04em;
}
QLabel[role="pageTitle"] {
color: $text;
font-size: 22px;
font-size: $fs_title;
font-weight: 700;
}
QLabel[role="sectionTitle"] {
color: $text;
font-size: 16px;
font-size: $fs_section;
font-weight: 700;
}
QLabel[role="display"] {
color: $text;
font-size: 30px;
font-size: $fs_display;
font-weight: 700;
}
QLabel[role="metric"] {
color: $text;
font-size: 22px;
font-size: $fs_title;
font-weight: 700;
}
@@ -203,9 +265,9 @@ QFrame#MetricCard {
border-radius: 11px;
}
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: 12px; }
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: 21px; font-weight: 700; }
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: 11px; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: 700; }
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
QFrame#ReceptionAiCard {
min-height: 132px;
@@ -215,20 +277,20 @@ QFrame#ReceptionAiCard {
}
QLabel#ReceptionAiTitle {
color: $indigo_pressed;
font-size: 14px;
font-size: $fs_strong;
font-weight: 700;
}
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
min-height: 30px;
min-height: $h_compact;
padding: 0 9px;
font-size: 11px;
font-size: $fs_caption;
}
QLabel#MetricGlyph {
color: $indigo;
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 11px;
font-size: 16px;
font-size: $fs_section;
font-weight: 700;
}
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
@@ -255,10 +317,10 @@ QGroupBox::title {
}
QPushButton {
min-height: 36px;
padding: 0 16px;
min-height: $h_default;
padding: 0 $pad_control;
border: 1px solid $line;
border-radius: 9px;
border-radius: $r_md;
background-color: $surface;
color: $text;
font-weight: 600;
@@ -328,6 +390,50 @@ QPushButton[variant="secondary"]:checked {
border-color: $indigo_pressed;
}
/* Inline table row actions: quiet links that only gain a surface on hover, so
a column of them reads as text rather than as a wall of buttons. */
QWidget#RowActions QPushButton[rowAction="true"] {
min-height: $h_compact;
padding: 0 $pad_compact;
color: $info;
background-color: transparent;
border: 1px solid transparent;
border-radius: $r_sm;
font-weight: 600;
}
QWidget#RowActions QPushButton[rowAction="true"]:hover {
color: $indigo_pressed;
background-color: $indigo_pale;
border-color: $line_soft;
}
QWidget#RowActions QPushButton[rowAction="true"]:pressed {
color: #FFFFFF;
background-color: $indigo_pressed;
border-color: $indigo_pressed;
}
QWidget#RowActions QPushButton[rowAction="true"][variant="dangerGhost"] { color: $danger; }
QWidget#RowActions QPushButton[rowAction="true"][variant="dangerGhost"]:hover {
color: $danger;
background-color: $danger_pale;
border-color: rgba(240, 120, 134, 96);
}
QToolButton#RowActionsMore {
min-width: 0;
min-height: $h_compact;
padding: 0 $pad_compact;
color: $muted;
background-color: transparent;
border: 1px solid transparent;
border-radius: $r_sm;
font-weight: 600;
}
QToolButton#RowActionsMore:hover {
color: $text;
background-color: $surface_alt;
border-color: $line;
}
QToolButton#RowActionsMore::menu-indicator { width: 0; height: 0; image: none; }
QFrame#NoteAttachmentTile {
background-color: $surface;
border: 1px solid $line;
@@ -352,7 +458,7 @@ QPushButton#NoteAttachmentPreview[loadState="failed"] {
}
QLabel#NoteAttachmentName {
color: $text_soft;
font-size: 12px;
font-size: $fs_caption;
}
QPushButton[variant="danger"] {
@@ -413,8 +519,8 @@ QPushButton[variant="ghost"]:checked {
border-color: $indigo_pressed;
}
QPushButton[variant="link"] {
min-height: 28px;
padding: 0 6px;
min-height: $h_compact;
padding: 0 $pad_compact;
color: $info;
background-color: transparent;
border-color: transparent;
@@ -422,8 +528,8 @@ QPushButton[variant="link"] {
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
QPushButton[variant="chip"] {
min-height: 30px;
padding: 0 12px;
min-height: $h_compact;
padding: 0 $pad_compact;
color: $text_soft;
background-color: $surface_alt;
border-color: $line;
@@ -440,10 +546,10 @@ QPushButton[variant="chip"]:checked {
border-color: $indigo_hover;
}
QPushButton[variant="nav"] {
min-height: 44px;
padding: 0 15px;
min-height: $h_cta;
padding: 0 12px;
border: 0;
border-radius: 10px;
border-radius: $r_md;
background-color: transparent;
color: $muted;
text-align: left;
@@ -454,10 +560,10 @@ QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
QPushButton[variant="nav"]:checked { background-color: $indigo_pressed; color: #FFFFFF; }
QToolButton {
min-width: 32px;
min-height: 32px;
min-width: $h_default;
min-height: $h_default;
border: 1px solid transparent;
border-radius: 8px;
border-radius: $r_md;
color: $text_soft;
background-color: transparent;
}
@@ -483,16 +589,16 @@ QToolButton[diagnosisChip="true"][semantic="warning"] { color: $warning; backgro
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QDateTimeEdit,
QTimeEdit, QSpinBox, QDoubleSpinBox, QKeySequenceEdit {
min-height: 36px;
padding: 0 12px;
min-height: $h_default;
padding: 0 $pad_control;
border: 1px solid $line;
border-radius: 9px;
border-radius: $r_md;
background-color: $surface;
color: $text;
selection-background-color: $indigo;
selection-color: #FFFFFF;
}
QTextEdit, QPlainTextEdit { padding: 9px 12px; }
QTextEdit, QPlainTextEdit { padding: 7px $pad_control; }
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover,
QDateEdit:hover, QDateTimeEdit:hover, QTimeEdit:hover, QSpinBox:hover,
QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
@@ -569,7 +675,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
}
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
QTableWidget::item, QTableView::item {
padding: 8px 8px;
padding: 6px 8px;
border-bottom: 1px solid $line;
}
QTableWidget::item:hover, QTableView::item:hover { background-color: $surface_alt; }
@@ -583,16 +689,16 @@ QHeaderView::section {
border: 0;
border-right: 1px solid $line;
border-bottom: 1px solid $line;
padding: 9px 8px;
font-size: 12px;
padding: 7px 8px;
font-size: $fs_caption;
font-weight: 700;
}
QHeaderView::section:hover { color: $text; background-color: $raised; }
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
QListWidget::item, QListView::item, QTreeWidget::item, QTreeView::item {
border: 0;
padding: 7px 9px;
margin: 2px 0;
padding: 6px $pad_compact;
margin: 1px 0;
}
QListWidget::item:selected, QListView::item:selected,
QTreeWidget::item:selected, QTreeView::item:selected {
@@ -612,9 +718,9 @@ QTabWidget::pane {
background-color: $glass;
}
QTabBar::tab {
min-height: 36px;
padding: 0 16px;
margin-right: 4px;
min-height: $h_default;
padding: 0 14px;
margin-right: 2px;
color: $muted;
background-color: transparent;
border: 1px solid transparent;
@@ -634,7 +740,7 @@ QTabBar#ReceptionDetailTabs {
border-bottom: 1px solid $line;
}
QTabBar#ReceptionDetailTabs::tab {
min-height: 38px;
min-height: $h_cta;
padding: 0 14px;
margin: 0 4px 0 0;
color: $muted;
@@ -665,10 +771,10 @@ QMenu {
padding: 6px;
}
QMenu::item {
min-width: 112px;
min-height: 32px;
padding: 0 12px 0 30px;
border-radius: 7px;
min-width: 128px;
min-height: $h_default;
padding: 0 14px 0 30px;
border-radius: $r_sm;
background-color: transparent;
}
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
@@ -721,7 +827,7 @@ QScrollBar:vertical {
}
QScrollBar::handle:vertical {
background: $line;
min-height: 30px;
min-height: $h_compact;
border-radius: 4px;
}
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
@@ -764,10 +870,10 @@ QSlider::handle:horizontal {
QSlider::handle:horizontal:hover { border-color: $focus; }
QLabel#StatusBadge {
padding: 4px 9px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 9px;
font-size: 11px;
border-radius: $r_sm;
font-size: $fs_caption;
font-weight: 700;
}
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
@@ -778,18 +884,18 @@ QLabel#StatusBadge[kind="info"] { color: $info; background-color: $info_pale; bo
QLabel#StatusBadge[kind="accent"] { color: $indigo_hover; background-color: $indigo_pale; border-color: $line_soft; }
QWidget#Pager QLabel#PagerActive {
min-width: 42px;
min-height: 30px;
min-width: 32px;
min-height: $h_compact;
color: #FFFFFF;
background-color: $indigo;
border: 1px solid $indigo;
border-radius: 8px;
font-size: 12px;
font-size: $fs_caption;
font-weight: 700;
}
QWidget#Pager QPushButton {
min-height: 30px;
padding: 0 10px;
min-height: $h_compact;
padding: 0 $pad_compact;
border: 1px solid $line;
background-color: $surface;
}
@@ -800,7 +906,7 @@ QLabel#EmptyStateGlyph {
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 22px;
font-size: 28px;
font-size: $fs_display;
font-weight: 500;
}
@@ -854,13 +960,13 @@ QFrame#TopBar, QFrame#MultipleTabs {
QLabel#UserAvatar {
min-width: 36px;
max-width: 36px;
min-height: 36px;
max-height: 36px;
min-height: $h_default;
max-height: $h_default;
color: #FFFFFF;
background-color: $indigo_pressed;
border: 1px solid $line_soft;
border-radius: 18px;
font-size: 15px;
font-size: $fs_strong;
font-weight: 700;
}
QWidget#LoginBrandPanel {
@@ -888,7 +994,7 @@ QToolTip {
padding: 6px 8px;
}
"""
).substitute(COLORS)
).substitute(_QSS_TOKENS)
def _apply_group(
@@ -1107,4 +1213,12 @@ def apply_theme(app: QApplication) -> None:
_install_business_dialog_styling(app)
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme", "mark_business_dialog"]
__all__ = [
"COLORS",
"GLOBAL_QSS",
"METRICS",
"TYPE",
"apply_theme",
"crisp_pixmap",
"mark_business_dialog",
]
+181 -44
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import inspect
import re
import traceback
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
@@ -16,10 +17,12 @@ from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QMenu,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -91,6 +94,43 @@ def display_text(value: Any, default: str = "—") -> str:
return str(value)
_UNIX_TIMESTAMP_RE = re.compile(r"\d{10,13}(?:\.\d+)?")
_RECORD_TIME_FORMAT = "%Y-%m-%d %H:%M"
def format_record_time(value: Any, default: str = "") -> str:
"""Format a record timestamp that may arrive as Unix seconds, milliseconds, or ISO text.
The desktop pages used to render raw ``str(int)`` for fields like
``create_time`` whenever the server returned a numeric epoch. This
helper keeps the legacy strings (already formatted dates) untouched
while normalising the numeric and ISO variants to ``YYYY-MM-DD HH:MM``
so the column reads as a real creation date.
"""
if value is None or value == "":
return default
if isinstance(value, datetime):
return value.strftime(_RECORD_TIME_FORMAT)
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
raw = str(value).strip()
if not raw:
return default
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
stamp = float(raw)
if stamp >= 10_000_000_000:
stamp /= 1000.0
try:
return datetime.fromtimestamp(stamp).strftime(_RECORD_TIME_FORMAT)
except (OSError, OverflowError, ValueError):
return raw
normalized = raw.replace("T", " ").replace("Z", "")
if len(normalized) >= 16:
return normalized[:16]
return raw
def gender_text(value: Any, default: str = "") -> str:
"""Format the legacy gender codes without exposing numeric API values."""
@@ -312,9 +352,14 @@ def run_async(
on_error: Callable[[Exception], None] | None = None,
on_finished: Callable[[], None] | None = None,
pool: QThreadPool | None = None,
priority: int = 0,
**kwargs: Any,
) -> Worker:
"""Run ``function`` off the GUI thread and return its Worker handle."""
"""Run ``function`` off the GUI thread and return its Worker handle.
Higher-priority queued work starts first when a pool thread becomes free.
Running work is never interrupted.
"""
worker = Worker(function, *args, **kwargs)
_RUNNING_WORKERS.add(worker)
@@ -324,7 +369,7 @@ def run_async(
if on_finished is not None:
worker.signals.finished.connect(on_finished)
worker.signals.finished.connect(lambda: _RUNNING_WORKERS.discard(worker))
(pool or QThreadPool.globalInstance()).start(worker)
(pool or QThreadPool.globalInstance()).start(worker, priority)
return worker
@@ -370,6 +415,11 @@ def friendly_error(error: Any) -> str:
return "当前账号无权执行此操作。"
if "not found" in lowered:
return "未找到所需数据。"
# 友好的 Dify / AI 上游错误映射。原文来自服务端 DifyChatService 的 error_code 分支,
# 当上游拒绝、超时或 quota 受限时,原文对医生不友好,按场景给出可执行引导。
upstream_hint = _ai_upstream_hint(text)
if upstream_hint is not None:
return upstream_hint
if any("\u4e00" <= character <= "\u9fff" for character in text):
return text
if isinstance(error, TypeError):
@@ -377,6 +427,38 @@ def friendly_error(error: Any) -> str:
return "操作未完成,请稍后重试。"
_AI_UPSTREAM_HINTS: tuple[tuple[str, str], ...] = (
("模型未能处理本次请求", "AI 助手暂时无法处理本次请求,请稍后重试;若多次出现请联系管理员检查 AI 服务配置。"),
("模型响应超时", "AI 助手响应超时,请稍后重试。"),
("暂时无法连接 AI 服务", "无法连接 AI 助手服务,请检查网络或稍后重试。"),
("模型服务繁忙", "AI 助手服务繁忙,请稍后重试。"),
("AI 助手未返回内容", "AI 助手未返回内容,请稍后重试。"),
("AI 助手暂时不可用", "AI 助手暂时不可用,请稍后重试。"),
("病例数据编码失败", "病例数据无法发送到 AI 助手,请联系管理员。"),
("无法初始化 AI 请求", "无法初始化 AI 助手请求,请联系管理员。"),
("AI 服务凭据无效或无权限", "AI 助手服务凭据无效或无权限,请联系管理员。"),
("AI 服务配置无效", "AI 助手服务配置无效,请联系管理员。"),
("该模型服务尚未完整配置", "AI 助手服务尚未完整配置,请联系管理员。"),
("AI 报告功能未启用", "AI 助手功能尚未启用,请联系管理员。"),
("不支持的 AI 模型", "AI 助手模型不受支持,请联系管理员。"),
)
_AI_UPSTREAM_CODE = re.compile(r"[(]([A-Z][A-Z0-9_]{2,39})[)]")
def _ai_upstream_hint(text: str) -> str | None:
for marker, hint in _AI_UPSTREAM_HINTS:
if marker in text:
# 服务端会在消息尾部附带上游错误码。保留它,医生截图反馈时管理员
# 能直接区分是配置问题、资料体积超限还是上游拒绝。
match = _AI_UPSTREAM_CODE.search(text)
if match is not None:
return f"{hint}{match.group(1)}"
return hint
return None
class PageHeader(QWidget):
"""Reference-design page heading with breadcrumb, copy and actions."""
@@ -757,6 +839,92 @@ class SortableTable(QTableWidget):
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
@dataclass(frozen=True)
class RowAction:
"""One entry in a table row's action cell."""
label: str
callback: Callable[[], None]
danger: bool = False
#: Keep this action inline even when it would otherwise overflow.
pinned: bool = False
class RowActions(QWidget):
"""Row action cell that shows a couple of buttons and hides the rest.
List rows used to render every permitted action as its own small button
up to seven on the diagnosis list. That widened the action column, pushed
the data columns into ellipsis, and left each row a different width, which
is what made the tables feel heavy and misaligned. Only the first
``max_visible`` non-destructive actions stay inline now; everything else,
destructive actions included, moves into a single 更多 menu.
"""
def __init__(
self,
actions: Sequence[RowAction],
parent: QWidget | None = None,
*,
max_visible: int = 2,
) -> None:
super().__init__(parent)
self.setObjectName("RowActions")
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(2)
inline: list[RowAction] = []
overflow: list[RowAction] = []
for action in actions:
room_left = len(inline) < max_visible
if action.pinned or (room_left and not action.danger):
inline.append(action)
else:
overflow.append(action)
self.buttons: list[QPushButton] = []
for action in inline:
button = QPushButton(action.label, self)
button.setProperty("rowAction", True)
if action.danger:
button.setProperty("variant", "dangerGhost")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, run=action.callback: run())
layout.addWidget(button)
self.buttons.append(button)
self.more_button: QToolButton | None = None
if overflow:
more = QToolButton(self)
more.setObjectName("RowActionsMore")
more.setText("更多")
more.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
more.setCursor(Qt.CursorShape.PointingHandCursor)
menu = QMenu(more)
for action in overflow:
entry = menu.addAction(action.label)
if action.danger:
entry.setProperty("danger", True)
entry.triggered.connect(lambda _checked=False, run=action.callback: run())
more.setMenu(menu)
layout.addWidget(more)
self.more_button = more
self.menu = menu
layout.addStretch(1)
@property
def visible_labels(self) -> list[str]:
return [button.text() for button in self.buttons]
@property
def overflow_labels(self) -> list[str]:
if self.more_button is None:
return []
return [entry.text() for entry in self.more_button.menu().actions()]
class BusinessPager(QWidget):
"""Compact numbered pager shared by dense business-list pages."""
@@ -856,51 +1024,19 @@ class BusinessPager(QWidget):
self.page_changed.emit(page)
class Pager(QWidget):
page_changed = Signal(int)
class Pager(BusinessPager):
"""Numbered pager for the appointment/patient lists.
This used to be a separate ``上一页 / 1 / 1 / 下一页`` control, so the
workstation shipped three different pagination footers depending on which
list you opened. It now reuses :class:`BusinessPager` verbatim and only
keeps its own object name and default page size, giving every list the same
footer while leaving the ``page_changed`` / ``update_state`` API unchanged.
"""
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
super().__init__(page_size, parent)
self.setObjectName("Pager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
self.summary = QLabel("共 0 条")
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("上一页")
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
self.page_label.setObjectName("PagerActive")
self.page_label.setMinimumWidth(58)
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_label)
self.next = QPushButton("下一页")
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
self.page_label.setText(f"{self.page} / {self.page_count}")
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < self.page_count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def card_layout(card: QFrame, margins: int = 18, spacing: int = 12) -> QVBoxLayout:
@@ -952,6 +1088,7 @@ __all__ = [
"clear_layout",
"display_text",
"first_value",
"format_record_time",
"friendly_error",
"get_value",
"has_permission",
+133 -5
View File
@@ -15,6 +15,7 @@ import threading
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from .launcher import VideoCallRequest
@@ -110,6 +111,29 @@ def _extract_call_record_id(result: Any) -> int | str | None:
return None
def _extract_cloud_recording_outcome(result: Any) -> tuple[bool, str] | None:
"""Read the cloud mixed-video recording outcome returned by room binding."""
pending = [result]
visited: set[int] = set()
while pending:
candidate = pending.pop(0)
mapping = _mapping_candidate(candidate)
if mapping is None or id(mapping) in visited:
continue
visited.add(id(mapping))
recording = mapping.get("cloud_recording", mapping.get("cloudRecording"))
if isinstance(recording, Mapping):
started = bool(recording.get("started"))
message = str(recording.get("message") or "").strip()[:200]
return started, message
for key in ("data", "result"):
nested = mapping.get(key)
if isinstance(nested, Mapping):
pending.append(nested)
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
@@ -239,6 +263,7 @@ class OrderedCallLifecycle:
self._claimed_room_id: str | None = None
self._start_future: Future[bool] | None = None
self._bind_future: Future[bool] | None = None
self._local_recording_future: Future[bool] | None = None
self._end_future: Future[bool] | None = None
self._transcription_start_future: Future[bool] | None = None
self._transcription_finish_future: Future[bool] | None = None
@@ -256,6 +281,13 @@ class OrderedCallLifecycle:
def transcription_session_id(self) -> str | None:
return self._transcription_session_id
@property
def current_room_id(self) -> str | None:
"""Return the room observed for this call cycle, including an active bind."""
with self._lock:
return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]:
with self._lock:
if self._start_future is not None:
@@ -318,18 +350,31 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
if not callable(method):
self.logger.warning(
"video repository does not implement bind_call_room",
extra={"video_call": self.request.safe_log_context()},
)
return False
_call_repository_method(
result = _call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id, "room_id": cleaned},
{
"diagnosis_id": self.request.diagnosis_id,
"room_id": cleaned,
"call_record_id": record_id,
},
)
recording_outcome = _extract_cloud_recording_outcome(result)
if recording_outcome is not None and not recording_outcome[0]:
raise RuntimeError(
recording_outcome[1]
or "automatic Tencent cloud mixed-video recording did not start"
)
with self._lock:
self.bound_room_id = cleaned
self.logger.info(
@@ -338,8 +383,85 @@ class OrderedCallLifecycle:
)
return True
self._bind_future = self._worker.submit("bind", operation)
return self._bind_future
future = self._worker.submit("bind", operation)
self._bind_future = future
def release_failed_claim(completed: Future[bool]) -> None:
try:
succeeded = bool(completed.result())
except Exception:
succeeded = False
if succeeded:
return
# A transient bridge/API failure must not permanently pin the
# room to a failed Future. The companion retries the exact
# same room after the host acknowledgement, so release only
# this failed claim while preserving successful bindings.
with self._lock:
if self._bind_future is completed and self.bound_room_id is None:
self._bind_future = None
self._claimed_room_id = None
future.add_done_callback(release_failed_claim)
return future
def save_local_audio_recording(
self,
path: str | Path,
*,
mime_type: str = "audio/webm",
) -> Future[bool]:
"""Upload one locally mixed audio file to this exact call record.
The operation shares the lifecycle FIFO, so a caller that queues this
before :meth:`end` is guaranteed to attach the COS object before the
call record is marked ended.
"""
recording_path = Path(path).expanduser().resolve()
clean_mime = str(mime_type or "audio/webm").strip()[:120] or "audio/webm"
if not recording_path.is_file() or recording_path.stat().st_size <= 0:
raise ValueError("local audio recording is empty or missing")
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._local_recording_future is not None:
return self._local_recording_future
if self._start_future is None:
self.start()
method = getattr(self.repository, "upload_call_recording", None)
if not callable(method):
raise ValueError("video repository does not implement local recording upload")
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
return False
if record_id is None:
raise ValueError("server did not return the current call_record_id")
result = _call_repository_method(
method,
{
"path": recording_path,
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
"mime_type": clean_mime,
},
)
if result is False:
raise RuntimeError("local audio recording upload failed")
self.logger.info(
"local call audio uploaded and attached",
extra={"video_call": self.request.safe_log_context()},
)
return True
self._local_recording_future = self._worker.submit(
"local-audio-upload", operation
)
return self._local_recording_future
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
"""Upload one video frame and append it to the diagnosis doctor notes."""
@@ -554,15 +676,21 @@ class OrderedCallLifecycle:
def operation() -> bool:
with self._lock:
started = self.started
record_id = self.call_record_id
if not started:
with self._lock:
self.ended = True
return False
if not callable(method):
raise ValueError("video repository does not implement end_call")
if record_id is None:
raise ValueError("server did not return the current call_record_id")
_call_repository_method(
method,
{"diagnosis_id": self.request.diagnosis_id},
{
"diagnosis_id": self.request.diagnosis_id,
"call_record_id": record_id,
},
)
with self._lock:
self.ended = True
+504 -10
View File
@@ -11,6 +11,9 @@ import base64
import binascii
import json
import logging
import os
import re
import sqlite3
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
@@ -20,6 +23,11 @@ from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from ..services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .launcher import (
VideoCallRequest,
VideoTicketError,
@@ -29,7 +37,7 @@ from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QUrl, Signal, Slot
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
@@ -39,7 +47,7 @@ try: # Optional by design: core-only builds must still import this module.
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QObject = QTimer = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
@@ -63,6 +71,18 @@ class CompanionLocation:
is_local: bool
@dataclass(slots=True)
class _LocalAudioCapture:
record_id: int
session_id: str
mime_type: str
path: Path
handle: Any
lifecycle: OrderedCallLifecycle
next_sequence: int = 0
bytes_written: int = 0
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
@@ -204,13 +224,67 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}
)
@Slot(str, str) # type: ignore[misc]
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, mime_type: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-start",
"sessionId": session_id,
"mimeType": mime_type,
}
)
@Slot(str, int, str) # type: ignore[misc]
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
self, session_id: str, sequence: int, encoded: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-chunk",
"sessionId": session_id,
"sequence": sequence,
"data": encoded,
}
)
@Slot(str, int) # type: ignore[misc]
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, total_bytes: int
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-finish",
"sessionId": session_id,
"totalBytes": total_bytes,
}
)
@Slot(str) # type: ignore[misc]
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
self._callback(
{
"source": "doctor-call",
"event": "local-audio-abort",
"sessionId": session_id,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
def __init__(
self,
@@ -247,8 +321,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self._shutdown_timer = QTimer(self)
self._shutdown_timer.setSingleShot(True)
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
self.setWindowTitle(
f"{self.patient_name} IM 问诊" if self.open_im else "视频面诊"
@@ -284,8 +365,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -428,6 +511,28 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "local-audio-start":
self._start_local_audio_recording(
str(message.get("sessionId") or ""),
str(message.get("mimeType") or "audio/webm"),
)
return
if event == "local-audio-chunk":
self._append_local_audio_chunk(
str(message.get("sessionId") or ""),
message.get("sequence"),
str(message.get("data") or ""),
)
return
if event == "local-audio-finish":
self._finish_local_audio_recording(
str(message.get("sessionId") or ""),
message.get("totalBytes"),
)
return
if event == "local-audio-abort":
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
@@ -445,7 +550,13 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
self.lifecycle.bind_room(room_id)
clean_room_id = str(room_id).strip()
future = self.lifecycle.bind_room(clean_room_id)
future.add_done_callback(
lambda completed, current_room_id=clean_room_id: (
self._notify_room_completed(current_room_id, completed)
)
)
if event == "room":
return
if event == "status":
@@ -457,7 +568,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
@@ -466,9 +577,343 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im:
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
else:
message = (
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
if succeeded
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
)
with suppress(RuntimeError):
self._room_completed.emit(room_id, succeeded, message)
def _on_room_completed(
self,
room_id: str,
succeeded: bool,
message: str,
) -> None:
if self._closing:
return
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.roomBindingResult?.("
f"{room_payload}, {state}, {payload});"
)
def _emit_local_recording_result(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
with suppress(RuntimeError):
self._local_recording_completed.emit(
operation,
session_id,
sequence,
succeeded,
str(message)[:200],
)
def _on_local_recording_completed(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
if self._closing or self._released:
return
self._page.runJavaScript(
"window.doctorConsultation?.localRecordingResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
f"{'true' if succeeded else 'false'}, "
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
)
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
cleaned = str(session_id or "").strip()
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
self._emit_local_recording_result(
"start", cleaned, -1, False, "本地录音会话标识无效。"
)
return
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
self._emit_local_recording_result(
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
)
return
if self._local_audio_capture is not None:
existing = self._local_audio_capture.session_id == cleaned
self._emit_local_recording_result(
"start",
cleaned,
-1,
existing,
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
)
return
try:
lifecycle = self.lifecycle
raw_call_record_id = lifecycle.call_record_id
call_record_id = (
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
)
store, uploads = get_local_audio_upload_manager(
lifecycle.repository,
self._local_audio_store,
)
self._local_audio_store = store
self._local_audio_uploads = uploads
record = store.begin_recording(
session_id=cleaned,
diagnosis_id=self.request.diagnosis_id,
mime_type=clean_mime,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
# The handle intentionally remains open across WebChannel chunks.
handle = record.file_path.open("w+b")
except (OSError, RuntimeError, sqlite3.Error) as error:
self._emit_local_recording_result(
"start", cleaned, -1, False, str(error)[:200]
)
return
self._local_audio_capture = _LocalAudioCapture(
record_id=record.id,
session_id=cleaned,
mime_type=clean_mime,
path=record.file_path,
handle=handle,
lifecycle=lifecycle,
)
self._emit_local_recording_result(
"start", cleaned, -1, True, "本机语音录音已启动。"
)
def _append_local_audio_chunk(
self,
session_id: str,
sequence_value: Any,
encoded: str,
) -> None:
capture = self._local_audio_capture
try:
sequence = int(sequence_value)
except (TypeError, ValueError):
sequence = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
)
return
if sequence != capture.next_sequence:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
)
return
if not encoded or len(encoded) > 16_384:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
)
return
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
)
return
if not content or len(content) > 12 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
)
return
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
)
self._abort_local_audio_recording(session_id)
return
try:
capture.handle.write(content)
except OSError as error:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, str(error)[:200]
)
self._abort_local_audio_recording(session_id)
return
capture.bytes_written += len(content)
capture.next_sequence += 1
def _finish_local_audio_recording(
self, session_id: str, total_bytes_value: Any
) -> None:
capture = self._local_audio_capture
try:
total_bytes = int(total_bytes_value)
except (TypeError, ValueError):
total_bytes = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
)
return
self._local_audio_capture = None
try:
capture.handle.flush()
os.fsync(capture.handle.fileno())
capture.handle.close()
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
if total_bytes != capture.bytes_written or total_bytes <= 0:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件不完整。"
)
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音文件不完整。"
)
return
if capture.bytes_written < 1024:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件为空或只有容器信息。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音文件为空或只有容器信息,已阻止上传。",
)
return
try:
with capture.path.open("rb") as recording:
signature = recording.read(4)
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
valid_signature = (
capture.mime_type.startswith("audio/webm")
and signature == b"\x1aE\xdf\xa3"
) or (
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
)
if not valid_signature:
self._mark_local_audio_invalid(
capture.record_id, "本地录音格式校验失败。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音格式校验失败,已阻止上传无效文件。",
)
return
store = self._local_audio_store
uploads = self._local_audio_uploads
if store is None or uploads is None:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
)
return
try:
store.finalize_recording(
capture.record_id,
size_bytes=capture.bytes_written,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
lifecycle = capture.lifecycle
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
try:
if start_result is not None and not bool(start_result.result()):
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
raw_call_record_id = lifecycle.call_record_id
call_record_id = int(raw_call_record_id or 0)
if call_record_id <= 0:
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
store.bind_identity(
capture.record_id,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
uploads.submit(capture.record_id)
except Exception as error:
with suppress(Exception):
store.update_status(
capture.record_id,
"failed",
str(error)[:1000],
)
if lifecycle.call_record_id:
enqueue_upload()
else:
try:
lifecycle.start().add_done_callback(enqueue_upload)
except Exception as error:
store.update_status(
capture.record_id,
"failed",
str(error)[:1000] or "通话记录创建失败。",
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
True,
"本地录音已保存,正在后台上传 COS。",
)
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
store = self._local_audio_store
if store is None:
return
with suppress(Exception):
store.mark_invalid(record_id, message)
def _abort_local_audio_recording(self, session_id: str) -> None:
capture = self._local_audio_capture
if capture is None or (session_id and capture.session_id != session_id):
return
self._local_audio_capture = None
with suppress(OSError):
capture.handle.flush()
capture.handle.close()
self._mark_local_audio_invalid(
capture.record_id,
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
@@ -633,7 +1078,49 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.close()
def hangup(self) -> None:
self._close_reason = "desktop-hangup"
self._request_companion_shutdown("desktop-hangup")
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
self._close_reason = reason
if self._closing or self._released:
return
should_wait_for_companion = (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
)
if not should_wait_for_companion:
self.close()
return
if self._shutdown_requested:
return
self._shutdown_requested = True
# doctorConsultation.close() stops MediaRecorder, drains every queued
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
# only then emits hangup. Keeping _closing false here is essential:
# bridge callbacks are deliberately rejected once final destruction
# begins.
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.start(190_000)
def _force_requested_shutdown(self) -> None:
"""Bound a failed companion shutdown without racing queued uploads."""
if not self._shutdown_requested or self._closing or self._released:
return
if self._start_requested and not self._call_cycle_closed:
# OrderedCallLifecycle places end after any upload that already
# reached the Qt bridge.
self.lifecycle.end(f"{self._close_reason}-timeout")
self._call_cycle_closed = True
self._start_requested = False
self._abort_local_audio_recording("")
self._companion_ended = True
self.close()
def _begin_shutdown(self) -> None:
@@ -641,12 +1128,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
self._closing = True
self._media_active = False
if self._injected and not self._companion_ended and not self._released:
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
@@ -698,6 +1183,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
):
event.ignore()
self._request_companion_shutdown(self._close_reason)
return
self._begin_shutdown()
event.accept()
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult_picker as picker_module
from doctor_workstation.ui.dialogs.ai_consult_picker import (
AiConsultTarget,
AiConsultTargetDialog,
select_and_present_ai_consult,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(picker_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "张三",
"gender": 1,
"age": 52,
"phone": "13800138000",
"phone_masked": "138****8000",
"id_card": "110101199001011234",
"diagnosis_date": "2026-08-20",
"diagnosis_summary": "2型糖尿病",
"last_visit_at": "2026-08-19 09:30",
"next_appointment_at": "2026-08-25 10:00",
}
row.update(changes)
return row
def test_target_keeps_diagnosis_and_patient_ids_distinct_and_sanitizes_seed() -> None:
target = AiConsultTarget.from_row(_row())
assert target is not None
assert target.diagnosis_id == 501
assert target.patient_id == 301
assert target.phone_masked == "138****8000"
assert target.seed["diagnosis_id"] == 501
assert target.seed["source_patient_id"] == 301
assert "phone" not in target.seed
assert "id_card" not in target.seed
without_patient = AiConsultTarget.from_row(
{"id": 502, "patient_name": "仅有诊单号"}
)
assert without_patient is not None
assert without_patient.diagnosis_id == 502
assert without_patient.patient_id == 0
def test_picker_loads_masked_rows_without_auto_selecting(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_ai_patient_options(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [_row()], "count": 1}
dialog = AiConsultTargetDialog(
Repository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
initial_query=" 张三 ",
)
dialog.show()
application.processEvents()
assert calls == [{"page_no": 1, "page_size": 20, "keyword": "张三"}]
assert dialog.table.rowCount() == 1
assert dialog.table.currentRow() == -1
assert not dialog.start_button.isEnabled()
assert dialog.table.item(0, 2).text() == "138****8000"
assert "13800138000" not in " ".join(
dialog.table.item(0, column).text() for column in range(dialog.table.columnCount())
)
dialog.table.selectRow(0)
application.processEvents()
assert dialog.start_button.isEnabled()
dialog.accept()
assert dialog.result() == QDialog.DialogCode.Accepted
assert dialog.selected_target() is not None
assert dialog.selected_target().diagnosis_id == 501
assert dialog.selected_target().patient_id == 301
def test_search_return_reloads_but_never_accepts_old_selection(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[str] = []
class Repository:
def list_ai_patient_options(self, **kwargs: Any) -> dict[str, Any]:
calls.append(str(kwargs["keyword"]))
return {"lists": [_row(patient_name=str(kwargs["keyword"]) or "最近患者")], "count": 1}
dialog = AiConsultTargetDialog(
Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
)
dialog.show()
application.processEvents()
dialog.table.selectRow(0)
dialog.search_edit.setText("李四")
dialog.search_edit.returnPressed.emit()
application.processEvents()
assert calls == ["", "李四"]
assert dialog.result() == 0
assert dialog.table.currentRow() == -1
assert not dialog.start_button.isEnabled()
dialog.reject()
def test_picker_ignores_late_success_and_error(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(picker_module, "run_async", queue_async)
repository = SimpleNamespace(list_ai_patient_options=lambda **_kwargs: None)
dialog = AiConsultTargetDialog(
repository, PermissionSet(["tcm.diagnosis/aiAssistant"])
)
dialog.show()
application.processEvents()
assert len(callbacks) == 1
dialog.search_edit.setText("新患者")
dialog.search_now()
assert len(callbacks) == 2
callbacks[1]["on_success"](
{"lists": [_row(diagnosis_id=700, id=700, patient_name="新患者")], "count": 1}
)
callbacks[0]["on_success"]({"lists": [_row(patient_name="旧患者")], "count": 1})
callbacks[0]["on_error"](RuntimeError("旧请求失败"))
application.processEvents()
assert dialog.table.rowCount() == 1
assert dialog.table.item(0, 0).text() == "新患者"
assert not dialog.banner.isVisible()
dialog.reject()
callbacks[1]["on_error"](RuntimeError("关闭后的错误"))
application.processEvents()
assert not dialog.isVisible()
def test_picker_error_has_retry_and_no_confirm(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def list_ai_patient_options(self, **_kwargs: Any) -> dict[str, Any]:
raise RuntimeError("服务暂不可用")
dialog = AiConsultTargetDialog(
Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
)
dialog.show()
application.processEvents()
assert dialog.banner.isVisible()
assert "加载失败" in dialog.banner.label.text()
assert dialog.empty_state.isVisible()
assert not dialog.start_button.isEnabled()
dialog.reject()
def test_selector_orchestrator_opens_ai_only_after_accept(
monkeypatch: pytest.MonkeyPatch,
) -> None:
target = AiConsultTarget.from_row(_row())
assert target is not None
opened: list[dict[str, Any]] = []
class AcceptedDialog:
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
pass
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def selected_target(self) -> AiConsultTarget:
return target
monkeypatch.setattr(picker_module, "AiConsultTargetDialog", AcceptedDialog)
monkeypatch.setattr(
picker_module,
"present_ai_consult",
lambda *_args, **kwargs: opened.append(kwargs),
)
allowed = PermissionSet(["tcm.diagnosis/aiAssistant"])
assert select_and_present_ai_consult(object(), allowed, None, initial_query="张三")
assert opened == [
{
"diagnosis_id": 501,
"patient_id": 301,
"seed": target.seed,
"source_title": "AI 助手",
}
]
class RejectedDialog(AcceptedDialog):
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Rejected
monkeypatch.setattr(picker_module, "AiConsultTargetDialog", RejectedDialog)
assert not select_and_present_ai_consult(object(), allowed, None)
assert len(opened) == 1
File diff suppressed because it is too large Load Diff
+30
View File
@@ -710,6 +710,36 @@ def test_ownerless_notes_prescriptions_and_tracking_rows_fail_closed_as_warning(
dialog.close()
def test_ownerless_im_messages_fail_closed_before_chat_rendering(
application: QApplication,
immediate_async: None,
) -> None:
class OwnerlessMessageRepository(WorkspaceRepository):
def list_im_chat_messages(
self,
diagnosis_id: int,
*,
only_archived: bool = True,
) -> list[dict[str, Any]]:
del diagnosis_id, only_archived
return [
{
"msg_id": "ownerless-message",
"msg_type": "text",
"text": "不应展示的无归属会话",
"is_from_doctor": False,
}
]
dialog = _open_dialog(application, OwnerlessMessageRepository())
chat_text = "\n".join(
label.text() for label in dialog.chat_host.findChildren(QLabel)
)
assert "不应展示的无归属会话" not in chat_text
dialog.close()
def test_tracking_response_without_diagnosis_owner_is_filtered_without_retry(
application: QApplication,
immediate_async: None,
@@ -0,0 +1,128 @@
"""Repository contracts for privacy-safe AI patient diagnosis options."""
from __future__ import annotations
from datetime import date
from inspect import signature
from typing import Any
import pytest
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import DoctorRepository, RemoteDoctorRepository
class AiPatientOptionsClient:
"""Record the exact AI option request and return deliberately unsafe extras."""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.calls.append((endpoint, dict(params or {})))
return {
"lists": [
{
"diagnosis_id": 9001,
"source_patient_id": 42,
"patient_name": "测试患者",
"phone_masked": "13800138000",
"phone": "13800138000",
"id_card": "110101199001011234",
"gender": 2,
"age": 36,
"diagnosis_date": "2026-08-19",
"diagnosis_summary": "随访诊单",
"last_visit_at": "2026-08-19 09:30:00",
"next_appointment_at": "2026-08-26 09:30:00",
}
],
"count": 1,
}
def test_protocol_exposes_ai_patient_option_page_defaults() -> None:
method = signature(DoctorRepository.list_ai_patient_options)
assert method.parameters["page_no"].default == 1
assert method.parameters["page_size"].default == 20
assert method.parameters["keyword"].default == ""
def test_remote_ai_patient_options_use_exact_endpoint_params_and_safe_dto() -> None:
client = AiPatientOptionsClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
page = repository.list_ai_patient_options(
page_no=3,
page_size=7,
keyword=" 测试 ",
)
assert client.calls == [
(
"tcm.diagnosis/aiPatientOptions",
{"keyword": "测试", "page_no": 3, "page_size": 7},
)
]
assert page.total == 1
assert page.page_no == 3
assert page.page_size == 7
assert page.items == [
{
"diagnosis_id": 9001,
"source_patient_id": 42,
"patient_name": "测试患者",
"phone_masked": "138****8000",
"gender": 2,
"age": 36,
"diagnosis_date": "2026-08-19",
"diagnosis_summary": "随访诊单",
"last_visit_at": "2026-08-19 09:30:00",
"next_appointment_at": "2026-08-26 09:30:00",
}
]
assert page.items[0]["diagnosis_id"] != page.items[0]["source_patient_id"]
assert "phone" not in page.items[0]
assert "id_card" not in page.items[0]
@pytest.mark.parametrize(("page_no", "page_size"), [(0, 20), (1, 0)])
def test_remote_ai_patient_options_reject_invalid_pagination_before_get(
page_no: int,
page_size: int,
) -> None:
client = AiPatientOptionsClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
with pytest.raises(ValueError, match="must be positive"):
repository.list_ai_patient_options(page_no=page_no, page_size=page_size)
assert client.calls == []
def test_demo_ai_patient_options_are_stable_searchable_paginated_and_private() -> None:
repository = DemoDoctorRepository(today=date(2026, 8, 20))
first = repository.list_ai_patient_options(page_no=1, page_size=2)
second = repository.list_ai_patient_options(page_no=2, page_size=2)
repeated = repository.list_ai_patient_options(page_no=1, page_size=2)
assert first.total == 4
assert first.pages == 2
assert [row["diagnosis_id"] for row in first.items] == [504, 503]
assert [row["diagnosis_id"] for row in second.items] == [502, 501]
assert repeated.items == first.items
assert all(row["diagnosis_id"] != row["source_patient_id"] for row in first.items)
assert all("****" in row["phone_masked"] for row in first.items)
assert all("phone" not in row and "id_card" not in row for row in first.items)
assert "13700006618" not in repr(first.items)
by_diagnosis = repository.list_ai_patient_options(keyword=" 503 ")
by_plain_phone = repository.list_ai_patient_options(keyword="15900007732")
assert [row["diagnosis_id"] for row in by_diagnosis.items] == [503]
assert [row["diagnosis_id"] for row in by_plain_phone.items] == [503]
assert by_plain_phone.items[0]["phone_masked"] == "159****7732"
assert "15900007732" not in repr(by_plain_phone.items)
+39
View File
@@ -3,7 +3,10 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier, get_ident
from typing import Any
import httpx
import pytest
@@ -17,6 +20,7 @@ from doctor_workstation.core.errors import (
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
@@ -48,6 +52,41 @@ def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
)
def test_default_client_allows_parallel_requests_with_independent_transports(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Production workers must not queue behind one process-wide HTTP lock."""
rendezvous = Barrier(2, timeout=2)
created: list[Any] = []
request_threads: set[int] = set()
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
request_threads.add(get_ident())
rendezvous.wait()
return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_retries=0)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(client.get, "patient/first")
second = executor.submit(client.get, "patient/second")
assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"}
client.close()
assert len(created) == 2
assert len(request_threads) == 2
assert all(item.closed for item in created)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
+425
View File
@@ -0,0 +1,425 @@
"""Desktop auto-update check, download and payload discovery."""
from __future__ import annotations
import hashlib
import zipfile
from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
def test_normalize_and_compare_versions() -> None:
assert normalize_version("0.2") == "0.2.0"
assert normalize_version("1.2.3.4") == "1.2.3"
assert normalize_version("nope") == ""
assert compare_version("0.1.0", "0.2.0") < 0
assert compare_version("0.2.0", "0.2.0") == 0
assert compare_version("1.0.0", "0.9.9") > 0
def test_parse_offer_requires_hash_before_install() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"package": {
"url": "https://cdn.example.com/app.zip",
"sha256": "",
"size": 12,
"filename": "app.zip",
},
"can_install": True,
},
current_version="0.1.0",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "http://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"code": 1,
"data": {
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"title": "医生工作站 0.2.0",
"notes": "修复登录",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation.zip",
"sha256": "a" * 64,
"size": 2048,
"filename": "DoctorWorkstation.zip",
},
"can_install": True,
},
},
)
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
offer = fetch_update_offer(
client,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.force is True
assert offer.can_install is True
assert offer.package is not None
assert "setting.desktop_workstation/check" in str(requests[0].url)
assert "current_version=0.1.0" in str(requests[0].url)
assert "platform=windows" in str(requests[0].url)
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("../outside.txt", "nope")
with pytest.raises(AppUpdateError, match="非法路径"):
safe_extract_zip(archive, tmp_path / "out")
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
wrapped = tmp_path / "DoctorWorkstation"
wrapped.mkdir()
(wrapped / "_internal").mkdir()
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="windows") == wrapped
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
app = tmp_path / "DoctorWorkstation.app"
macos = app / "Contents" / "MacOS"
macos.mkdir(parents=True)
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="macos") == app
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
payload = b"doctor-workstation-zip"
digest = hashlib.sha256(payload).hexdigest()
progress: list[tuple[int, int]] = []
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(
200,
content=payload,
headers={"content-length": str(len(payload))},
)
destination = tmp_path / "pkg.zip"
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256=digest,
progress=lambda received, total: progress.append((received, total)),
transport=httpx.MockTransport(handler),
)
assert destination.read_bytes() == payload
assert progress[-1][0] == len(payload)
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=b"tampered")
destination = tmp_path / "pkg.zip"
with pytest.raises(AppUpdateError, match="校验失败"):
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256="b" * 64,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> None:
spawned.update(
script=script,
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
assert "$HelperLogFile" in script_text
assert "$InstallerLogFile" in script_text
assert "Restart-Application" in script_text
def test_archive_applier_restarts_from_install_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
assert captured["restart_exe"] == installed_exe
def test_inno_setup_applier_reports_helper_start_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ")
monkeypatch.setattr(app_update.sys, "platform", "win32")
def fail_spawn(*args: object, **kwargs: object) -> None:
del args, kwargs
raise OSError("blocked")
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", fail_spawn)
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
apply_inno_setup_update(installer, install_root=install_root)
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
+70
View File
@@ -0,0 +1,70 @@
"""Update dialog contract for optional and forced desktop upgrades."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.app_update import UpdateOffer, UpdatePackage
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
from doctor_workstation.ui.theme import apply_theme
def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
package = (
UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation.zip",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation.zip",
)
if can_install
else None
)
return UpdateOffer(
has_update=True,
force=force,
enabled=True,
current_version="0.1.0",
latest_version="0.2.0",
min_version="",
title="医生工作站 0.2.0",
notes="修复若干问题",
platform="windows",
arch="x64",
package=package,
can_install=can_install,
)
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=False))
dialog.show()
app.processEvents()
assert dialog.later_button.isVisible()
assert dialog.update_button.text() == "立即更新"
assert dialog.notes.toPlainText() == "修复若干问题"
dialog.close()
def test_forced_update_dialog_hides_defer_and_blocks_escape(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=True))
dialog.show()
app.processEvents()
assert not dialog.later_button.isVisible()
assert "必须更新" in dialog.badge.text()
dialog.close()
app.processEvents()
assert dialog.isVisible()
dialog.offer = _offer(force=False)
dialog._busy = False
dialog.close()
+250 -23
View File
@@ -10,7 +10,14 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QPushButton,
)
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -188,6 +195,106 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert prescription_action_label(pending) == "编辑处方"
historical_only = Appointment.from_dict(
{
"id": 104,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
}
)
assert prescription_action_label(historical_only) == "开方"
def test_appointment_view_intent_never_degrades_to_edit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"current_has_prescription": 1,
"current_prescription_id": 81,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "_current_row", lambda: row)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda source, *, mode="open": requested.append((source, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 81,
"appointment_id": 101,
"audit_status": 2,
"void_status": 0,
},
row,
page._prescription_generation,
"view",
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_appointment_im_action_opens_chat_without_an_existing_live_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"patient_id": 501,
"source_patient_id": 301,
"patient_name": "测试患者",
"status": 1,
}
emitted: list[dict[str, Any]] = []
monkeypatch.setattr(page, "_current_row", lambda: row)
page.video_requested.connect(emitted.append)
page._request_video()
assert len(emitted) == 1
assert emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert emitted[0]["diagnosis_id"] == 501
assert emitted[0]["patient_id"] == 301
warnings: list[str] = []
monkeypatch.setattr(
appointments_module,
"show_toast",
lambda _parent, message, _kind: warnings.append(message),
)
row["status"] = 2
page._request_video()
assert len(emitted) == 1
assert warnings == ["已取消的挂号不可进入 IM 问诊。"]
page.close()
application.processEvents()
def test_appointment_pending_prescription_uses_full_edit_contract(
application: QApplication,
@@ -508,15 +615,20 @@ def test_appointment_ai_report_button_visible_with_reception_permission(
application.processEvents()
def test_appointments_reference_split_layout_and_video_list(
def test_appointments_use_full_width_table_with_per_row_im_consult(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
permissions=PermissionSet(
["doctor.appointment/lists", "doctor.appointment/prescription"]
),
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page.show()
page._apply_responsive_layout()
page._loaded(
{
@@ -524,6 +636,7 @@ def test_appointments_reference_split_layout_and_video_list(
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "赵俊霞",
"gender": 0,
"age": 53,
@@ -532,6 +645,7 @@ def test_appointments_reference_split_layout_and_video_list(
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
"video_call_hint": {"state": "live", "label": "视频通话进行中"},
}
],
"count": 1,
@@ -541,32 +655,143 @@ def test_appointments_reference_split_layout_and_video_list(
)
application.processEvents()
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert 300 <= page.video_panel.width() <= 420
assert page.content_layout.count() == 1
assert not hasattr(page, "video_panel")
assert not hasattr(page, "video_list")
assert page.table_card.width() == page.content_host.width()
assert page.table.objectName() == "AppointmentTable"
assert (
page.table.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert (
page.video_list.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert page.table.horizontalHeaderItem(10).text() == "IM 问诊"
im_host = page.table.cellWidget(0, 10)
assert im_host is not None
im_button = im_host.findChild(QPushButton, "AppointmentImConsultButton")
assert im_button is not None
assert im_button.text() == "IM 问诊"
assert im_button.isEnabled()
assert im_button.accessibleName() == "与赵俊霞进行 IM 问诊"
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
im_button.click()
assert emitted and emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert page.date_buttons["today"].isChecked()
page.resize(1024, 640)
page._apply_responsive_layout()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
application.processEvents()
assert page.table_card.width() == page.content_host.width()
assert not page.date_overflow_button.isHidden()
assert page.date_buttons["yesterday"].isHidden()
assert not page.date_buttons["today"].isHidden()
page.video_panel_button.click()
assert not page.video_panel.isHidden()
assert 250 <= page.video_panel.width() <= 300
page.video_panel_button.click()
assert page.video_panel.isHidden()
page.close()
application.processEvents()
def test_im_entry_does_not_require_a_live_video_hint(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "已接通患者",
"status": 1,
"video_call_hint": {"state": "ended", "label": "视频通话已结束"},
},
{
"id": 102,
"diagnosis_id": 502,
"source_patient_id": 302,
"patient_name": "等待患者",
"status": 1,
},
]
page._loaded({"lists": rows, "count": 2}, page._generation, False)
application.processEvents()
buttons = [
page.table.cellWidget(index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
for index in range(page.table.rowCount())
]
buttons_by_name = {
button.accessibleName(): button for button in buttons if button is not None
}
assert set(buttons_by_name) == {
"与已接通患者进行 IM 问诊",
"与等待患者进行 IM 问诊",
}
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
page.close()
application.processEvents()
def test_im_entry_allows_fulfillable_statuses_and_rejects_terminal_or_unknown_statuses(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": index + 100,
"diagnosis_id": index + 500,
"source_patient_id": index + 300,
"patient_name": name,
"status": status,
}
for index, (name, status) in enumerate(
(
("已预约患者", 1),
("已过号患者", 4),
("已取消患者", 2),
("已完成患者", 3),
("未知状态患者", 0),
)
)
]
page._loaded({"lists": rows, "count": len(rows)}, page._generation, False)
application.processEvents()
buttons = {
button.accessibleName(): button
for row_index in range(page.table.rowCount())
if (
button := page.table.cellWidget(row_index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
)
is not None
}
assert buttons["与已预约患者进行 IM 问诊"].isEnabled()
assert buttons["与已过号患者进行 IM 问诊"].isEnabled()
assert not buttons["与已取消患者进行 IM 问诊"].isEnabled()
assert buttons["与已取消患者进行 IM 问诊"].toolTip() == (
"已取消的挂号不可进入 IM 问诊"
)
assert not buttons["与已完成患者进行 IM 问诊"].isEnabled()
assert buttons["与已完成患者进行 IM 问诊"].toolTip() == (
"已完成的挂号不可再进入 IM 问诊"
)
assert not buttons["与未知状态患者进行 IM 问诊"].isEnabled()
assert buttons["与未知状态患者进行 IM 问诊"].toolTip() == (
"当前挂号状态不可进入 IM 问诊"
)
page.close()
application.processEvents()
@@ -601,19 +826,20 @@ def test_identical_appointment_poll_keeps_existing_cell_widgets(
page._loaded(result, page._generation, True)
selector = page.table.cellWidget(0, 0)
appointment_info = page.table.cellWidget(0, 4)
video_card = page.video_list.itemWidget(page.video_list.item(0))
im_action = page.table.cellWidget(0, 10)
page._loaded(deepcopy(result), page._generation, True)
assert page.table.cellWidget(0, 0) is selector
assert page.table.cellWidget(0, 4) is appointment_info
assert page.video_list.itemWidget(page.video_list.item(0)) is video_card
assert page.table.cellWidget(0, 10) is im_action
changed = deepcopy(result)
changed["lists"][0]["assistant_name"] = "新医助"
page._loaded(changed, page._generation, True)
assert page.table.cellWidget(0, 0) is not selector
assert page.table.cellWidget(0, 4) is not appointment_info
assert page.table.cellWidget(0, 10) is not im_action
page.close()
application.processEvents()
@@ -660,16 +886,17 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
application.processEvents()
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
assert page.header.height() == 26
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
assert page.header.height() == 62
assert page.filter_panel.height() <= 84
assert all(60 <= height <= 66 for height in heights)
assert page.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page)
assert 300 <= page.video_panel.width() < 420
assert page.content_layout.count() == 1
assert page.table_card.width() == page.content_host.width()
page.resize(1024, 640)
application.processEvents()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
assert page.table_card.width() == page.content_host.width()
page.close()
application.processEvents()
+118 -12
View File
@@ -169,7 +169,7 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
@@ -195,6 +195,62 @@ def test_nested_appointments_confirmation_and_prescription_labels() -> None:
)
== "编辑处方"
)
assert (
prescription_action_label(
{
"has_prescription": 1,
"current_has_prescription": 0,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
)
== "开方"
)
def test_view_prescription_intent_is_readonly_and_fails_closed_on_state_change(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = _row(
has_prescription=1,
current_has_prescription=1,
current_prescription_id=701,
prescription_audit_status=1,
prescription_void_status=0,
)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.table_host.set_rows([row])
page.table.selectRow(0)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda record, *, mode: requested.append((record, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 701,
"appointment_id": 101,
"audit_status": 0,
"void_status": 0,
},
row,
"view",
page._prescription_generation,
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_default_query_matches_admin_today_and_page_size_contract(
@@ -611,7 +667,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -627,10 +683,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
live_row = _row(
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
page.table.selectRow(0)
application.processEvents()
@@ -638,12 +701,55 @@ def test_native_call_does_not_reuse_video_qr_permission(
assert page.video_button.isEnabled()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
video_action = next(button for button in video_cell.findChildren(QToolButton))
assert video_action.text() == "进入视频问诊"
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
assert video_action.text() == "进入视频问诊"
assert emitted == []
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(live_row)]
assert emitted[0]["mode"] == "im"
page.close()
application.processEvents()
@pytest.mark.parametrize(
("hint", "status_text"),
[
({"state": "none", "label": ""}, "暂无通话"),
({"state": "pending_room", "label": "通话发起中,待同步房间"}, "等待接通"),
],
)
def test_video_join_action_is_hidden_until_doctor_session_is_live(
application: QApplication,
hint: dict[str, Any],
status_text: str,
) -> None:
class VideoRepository:
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
pass
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
pass
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
pass
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert video_cell.findChildren(QToolButton) == []
assert status_text in " ".join(label.text() for label in video_cell.findChildren(QLabel))
page._request_video()
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize(
+13 -3
View File
@@ -146,6 +146,8 @@ def _row(identifier: int, **changes: Any) -> dict[str, Any]:
"assistant_name": "赵医助",
"assign_read_at": None,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"unserved_days": 2,
@@ -280,12 +282,15 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
assert model.hover_row == 1
second_fixed_cell.hovered_row.emit(-1)
assert model.hover_row == -1
direct_links = {
# 行内只保留“看诊单 / 开方”这两个闭环主操作,其余操作一律降级进“更多”,
# 保证每一行的操作列宽度一致、数据列不再被挤成省略号。
direct_links = [
button.text() for button in action_cell.findChildren(QToolButton) if button.menu() is None
}
assert {"查看", "诊单", "开方", "预约", "补全身份证"} <= direct_links
]
assert direct_links == ["查看", "诊单"]
more = next(button for button in action_cell.findChildren(QToolButton) if button.menu())
menu_texts = [action.text() for action in more.menu().actions() if not action.isSeparator()]
assert menu_texts[:4] == ["开方", "AI 分析", "预约", "补全身份证"]
assert "指派" in menu_texts
assert "取消挂号" in menu_texts
assert {"视频二维码", "二维码", "挂号日志", "创建订单"}.isdisjoint(menu_texts)
@@ -450,7 +455,12 @@ def test_full_more_menu_requires_each_real_repository_capability(
page.table_host.set_rows([record])
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
# 前三项是从行内降级下来的次要操作,其后才是本就属于“更多”的能力项。
assert [action.text() for action in more.menu().actions() if not action.isSeparator()] == [
"开方",
"AI 分析",
"预约",
"补全身份证",
"指派",
"取消指派",
"视频二维码",
+136 -1
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import threading
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLabel, QPushButton, QVBoxLayout
from doctor_workstation.ui import diagnosis_media
from doctor_workstation.ui.diagnosis_drawer import RecordTable
@@ -378,6 +380,7 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
[
{
"id": 48,
"room_id": "doctor-501-20260819-143247",
"recording_urls_list": [
"https://media.example.invalid/replay.mp4",
"https://media.example.invalid/replay-backup.m3u8",
@@ -407,6 +410,12 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
table = dialog._table_registry["video"][1]
assert table.horizontalHeaderItem(1).text() == "房间号"
assert table.item(0, 1).text() == "doctor-501-20260819-143247"
assert table.item(1, 1).text() == "历史记录未保存"
room_rect = table.visualItemRect(table.item(0, 1))
assert room_rect.left() >= 0
assert room_rect.right() < table.viewport().width()
playback = table.cellWidget(0, 0)
assert isinstance(playback, RecordingPlaybackCell)
assert playback.property("callRecordId") == 48
@@ -465,6 +474,132 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
def test_video_table_exposes_local_audio_separately_from_cloud_video_and_text(
application: QApplication,
) -> None:
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._editable = False
dialog._can_video_upload = False
dialog._diagnosis_id = 501
opened: list[str] = []
dialog._open_recording_player = lambda target: opened.append(target) # type: ignore[method-assign]
audio_url = "https://cos.example.invalid/calls/local-audio.webm"
dialog._fill_video(
[
{
"id": 49,
"recording_urls_list": [
"https://cos.example.invalid/calls/cloud-mixed-video.mp4"
],
"local_audio_urls_list": [audio_url],
"local_audio_status_text": "已保存",
"transcript_text": "医生:请描述症状。\n患者:最近口渴。",
"transcription_status_text": "已完成",
"call_type": 2,
"status": 2,
"recording_status_text": "已生成",
}
]
)
table = dialog._table_registry["video"][1]
action_host = table.cellWidget(0, 8)
assert action_host is not None
audio = action_host.findChild(QPushButton, "DiagnosisLocalAudioPlayback")
transcript = action_host.findChild(QPushButton, "DiagnosisVideoTranscriptView")
assert audio is not None and audio.property("callRecordId") == 49
assert transcript is not None and transcript.property("callRecordId") == 49
status = table.item(0, 7).text()
assert "云端视频:已生成" in status
assert "本机录音:已保存" in status
assert "转写文字:已完成" in status
audio.click()
assert opened == [audio_url]
dialog.close()
application.processEvents()
def test_local_audio_upload_and_queue_close_refresh_server_backed_video_rows(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[QDialog] = []
class _UploadManager:
def __init__(self) -> None:
self.listeners: set[Any] = set()
def add_upload_listener(self, listener: Any) -> None:
self.listeners.add(listener)
def remove_upload_listener(self, listener: Any) -> None:
self.listeners.discard(listener)
def complete(self, diagnosis_id: int, call_record_id: int) -> None:
record = SimpleNamespace(
diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
)
for listener in tuple(self.listeners):
listener(record)
manager = _UploadManager()
class _QueueDialog(QDialog):
def __init__(
self,
_repository: Any,
_diagnosis_id: int,
parent: QDialog,
) -> None:
super().__init__(parent)
self.manager = manager
created.append(self)
monkeypatch.setattr(diagnosis_module, "LocalAudioQueueDialog", _QueueDialog)
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._diagnosis_id = 501
dialog._can_video_upload = True
reloads: list[tuple[str, bool]] = []
dialog._ensure_tab_loaded = ( # type: ignore[method-assign]
lambda key, force=False: reloads.append((key, force))
)
dialog._open_local_audio_queue()
assert len(created) == 1
queue_dialog = created[0]
assert reloads == [("video", True)]
dialog._loading_tabs.add("video")
manager.complete(501, 49)
manager.complete(501, 50)
manager.complete(999, 51)
application.processEvents()
assert reloads == [("video", True)]
assert dialog._video_reload_pending is True
dialog._loading_tabs.discard("video")
dialog._flush_video_reload_if_pending(501)
application.processEvents()
assert reloads == [("video", True), ("video", True)]
queue_dialog.accept()
application.processEvents()
assert reloads == [("video", True), ("video", True)]
assert manager.listeners
worker = threading.Thread(target=lambda: manager.complete(501, 52))
worker.start()
worker.join(timeout=5)
assert worker.is_alive() is False
application.processEvents()
assert reloads == [("video", True), ("video", True), ("video", True)]
dialog.reject()
application.processEvents()
assert manager.listeners == set()
def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None:
class _Owner:
_tab_generations = {"video": 4}
+93
View File
@@ -0,0 +1,93 @@
"""Tests for friendly_error's handling of AI upstream error messages.
These cover the DifyChatService error_code user-facing copy mapping that
the doctor workstation must apply when the server-side AI assistant returns
``ok=false`` with a Chinese ``error`` string. The legacy behaviour simply
echoed the raw text, which made incidents like ``UPSTREAM_REJECTED`` opaque
to clinicians.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
from doctor_workstation.ui.widgets import _ai_upstream_hint, friendly_error
@pytest.mark.parametrize(
("raw", "expected_fragment"),
[
# 当上游 Dify 服务 HTTP 4xx 时返回的"UPSTREAM_REJECTED"文案。
("模型未能处理本次请求", "稍后重试"),
("AI 服务凭据无效或无权限", "联系管理员"),
("AI 服务配置无效", "联系管理员"),
("AI 报告功能未启用", "联系管理员"),
("不支持的 AI 模型", "联系管理员"),
("该模型服务尚未完整配置", "联系管理员"),
("病例数据编码失败", "联系管理员"),
("无法初始化 AI 请求", "联系管理员"),
("暂时无法连接 AI 服务,请稍后重试", "网络"),
("模型服务繁忙,请稍后重试", "稍后重试"),
("模型响应超时,请稍后重试", "稍后重试"),
("AI 助手未返回内容,请重试", "稍后重试"),
],
)
def test_friendly_error_translates_ai_upstream_strings(raw: str, expected_fragment: str) -> None:
"""Upstream Dify messages should be replaced with actionable copy."""
rendered = friendly_error(raw)
assert expected_fragment in rendered
# 上游原文不应该再原样透传。
assert rendered != raw
def test_friendly_error_passes_through_unrelated_chinese_text() -> None:
"""中文业务文案不属于上游错误时,必须原样透传,避免误伤。"""
text = "AI 返回的处方草稿格式不符合要求,请重试"
assert friendly_error(text) == text
def test_friendly_error_handles_api_business_error_with_upstream_payload() -> None:
"""服务端通过 ApiBusinessError(code=0) 透传时仍要触发映射。"""
err = ApiBusinessError("模型未能处理本次请求", code=0)
rendered = friendly_error(err)
assert "稍后重试" in rendered
assert "联系管理员" in rendered
def test_friendly_error_keeps_existing_transport_mappings() -> None:
"""对网络/超时/未授权等既有规则的回归保护。"""
assert "证书" in friendly_error(Exception("certificate_verify_failed"))
assert "网络" in friendly_error(ApiTransportError("connection refused"))
assert "重新登录" in friendly_error(AuthenticationExpiredError("expired"))
timeout_render = friendly_error(ApiTimeoutError("timed out"))
assert "超时" in timeout_render
http_render = friendly_error(ApiHttpError("boom", status_code=503))
assert "503" in http_render
protocol_render = friendly_error(ApiProtocolError("api response envelope invalid"))
assert "数据格式" in protocol_render
def test_friendly_error_returns_default_for_empty_string() -> None:
"""Fallback 应当落到"操作未完成"而不是崩溃。"""
assert friendly_error(SimpleNamespace(__str__=lambda self: " ")) == "操作未完成,请稍后重试。"
def test_ai_upstream_hint_returns_none_for_unrelated_text() -> None:
assert _ai_upstream_hint("AI 返回的处方草稿格式不符合要求,请重试") is None
assert _ai_upstream_hint("connection refused") is None
assert _ai_upstream_hint("") is None
+482
View File
@@ -0,0 +1,482 @@
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from typing import Any
import pytest
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
)
from doctor_workstation.ui.dialogs.local_audio_queue import (
LocalAudioQueueDialog,
_display_time,
)
def _application() -> QApplication:
return QApplication.instance() or QApplication([])
def _ready_record(
store: LocalAudioQueueStore,
*,
diagnosis_id: int,
call_record_id: int,
session_id: str,
room_id: str = "",
) -> int:
record = store.begin_recording(
session_id=session_id,
diagnosis_id=diagnosis_id,
mime_type="audio/webm",
call_record_id=call_record_id,
room_id=room_id,
)
payload = b"\x1aE\xdf\xa3" + (b"local-call-audio" * 128)
record.file_path.write_bytes(payload)
finalized = store.finalize_recording(record.id, size_bytes=len(payload))
return finalized.id
class _ConcurrentRepository:
def __init__(self, expected: int) -> None:
self.expected = expected
self.lock = threading.Lock()
self.release = threading.Event()
self.all_started = threading.Event()
self.active = 0
self.maximum_active = 0
self.calls: list[dict[str, Any]] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
path = Path(payload["path"])
assert path.is_file()
with self.lock:
self.active += 1
self.maximum_active = max(self.maximum_active, self.active)
self.calls.append(payload)
if self.active >= self.expected:
self.all_started.set()
try:
assert self.release.wait(5), "concurrent uploads did not receive release"
return {"file_url": f"cos://recordings/{path.name}"}
finally:
with self.lock:
self.active -= 1
class _RetryRepository:
def __init__(self) -> None:
self.calls = 0
self.call_records: dict[int, list[dict[str, Any]]] = {}
self.list_calls: list[int] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
self.calls += 1
if self.calls == 1:
raise RuntimeError("COS 暂时不可用")
return {"file_url": f"cos://recordings/{Path(payload['path']).name}"}
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
self.list_calls.append(int(diagnosis_id))
return list(self.call_records.get(int(diagnosis_id), []))
def test_existing_queue_schema_adds_room_id_without_losing_rows(
tmp_path: Path,
) -> None:
root = tmp_path / "old-audio-queue"
root.mkdir()
database = root / "queue.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"""
CREATE TABLE local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, mime_type, file_path,
size_bytes, status, error_text, uploaded_url, attempts,
created_at, updated_at, uploaded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"legacy-uploaded-session",
8169,
901,
"audio/webm",
str(root / "legacy.webm"),
2048,
"uploaded",
"",
"cos://recordings/legacy.webm",
2,
"2026-08-20T01:00:00+00:00",
"2026-08-20T01:02:00+00:00",
"2026-08-20T01:02:00+00:00",
),
)
store = LocalAudioQueueStore(root)
record = store.list_records()[0]
with sqlite3.connect(database) as connection:
columns = {
str(row[1])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
assert "room_id" in columns
assert record.room_id == ""
assert record.status == "uploaded"
assert record.uploaded_url == "cos://recordings/legacy.webm"
assert record.attempts == 2
assert LocalAudioQueueStore(root).require(record.id).room_id == ""
def test_local_audio_identity_binding_is_idempotent_and_rejects_conflicts(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record = store.begin_recording(
session_id="identity-binding-session",
diagnosis_id=8169,
mime_type="audio/webm",
call_record_id=901,
room_id="00123456",
)
assert record.call_record_id == 901
assert record.room_id == "00123456"
assert store.bind_identity(
record.id,
call_record_id=901,
room_id="00123456",
).room_id == "00123456"
assert store.bind_identity(record.id, call_record_id=901).room_id == "00123456"
with pytest.raises(RuntimeError, match="房间号发生冲突"):
store.bind_identity(record.id, call_record_id=901, room_id="99887766")
with pytest.raises(RuntimeError, match="通话记录 ID 或房间号发生冲突"):
store.bind_identity(record.id, call_record_id=902, room_id="00123456")
assert store.require(record.id).call_record_id == 901
assert store.require(record.id).room_id == "00123456"
def test_local_audio_queue_uploads_three_files_concurrently(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_ids = [
_ready_record(
store,
diagnosis_id=8169,
call_record_id=900 + index,
session_id=f"session-{index}",
)
for index in range(3)
]
repository = _ConcurrentRepository(expected=3)
manager = LocalAudioUploadManager(repository, store, max_workers=3)
futures = [manager.submit(record_id) for record_id in record_ids]
try:
assert repository.all_started.wait(5)
assert repository.maximum_active == 3
finally:
repository.release.set()
assert [future.result(timeout=5) for future in futures] == [True, True, True]
records = [store.require(record_id) for record_id in record_ids]
assert all(record.status == "uploaded" for record in records)
assert all(record.exists for record in records)
assert all(record.uploaded_url.startswith("cos://recordings/") for record in records)
assert {call["call_record_id"] for call in repository.calls} == {900, 901, 902}
def test_failed_local_audio_is_kept_and_can_be_retried(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="retry-session",
)
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
assert manager.submit(record_id).result(timeout=5) is False
failed = store.require(record_id)
assert failed.status == "failed"
assert failed.exists
assert failed.attempts == 1
assert "COS 暂时不可用" in failed.error_text
store.retry(record_id)
assert manager.submit(record_id).result(timeout=5) is True
uploaded = store.require(record_id)
assert uploaded.status == "uploaded"
assert uploaded.exists
assert uploaded.attempts == 2
assert uploaded.uploaded_url.startswith("cos://recordings/")
def test_local_audio_manager_notifies_when_an_upload_reaches_uploaded(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.calls = 1
manager = LocalAudioUploadManager(repository, store, max_workers=1)
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="upload-notification-session",
)
uploads: list[tuple[int, int, str]] = []
def record_upload(record: Any) -> None:
uploads.append(
(record.diagnosis_id, int(record.call_record_id or 0), record.uploaded_url)
)
manager.add_upload_listener(record_upload)
assert manager.submit(record_id).result(timeout=5) is True
assert manager.submit(record_id).result(timeout=5) is True
manager.remove_upload_listener(record_upload)
assert uploads == [
(
8169,
901,
store.require(record_id).uploaded_url,
)
]
def test_local_audio_manager_does_not_report_success_without_uploaded_url(
tmp_path: Path,
) -> None:
class _IncompleteRepository:
@staticmethod
def upload_call_recording(**_payload: Any) -> dict[str, bool]:
return {"completed": True}
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="missing-url-session",
)
manager = LocalAudioUploadManager(_IncompleteRepository(), store, max_workers=1)
uploads: list[Any] = []
manager.add_upload_listener(uploads.append)
assert manager.submit(record_id).result(timeout=5) is False
record = store.require(record_id)
assert record.status == "failed"
assert "文件地址" in record.error_text
assert uploads == []
def test_local_audio_dialog_displays_utc_recording_time_in_business_timezone() -> None:
assert _display_time("2026-08-20T01:51:00+00:00") == (
"2026-08-20 09:51:00"
)
def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="uploaded-session",
room_id="1692231119",
)
store.mark_uploaded(uploaded_id, "cos://recordings/uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.objectName() == "LocalAudioQueueDialog"
assert dialog.table.columnCount() == 8
assert dialog.table.rowCount() == 2
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
assert dialog.retry_failed_button.isEnabled()
assert dialog.table.horizontalHeaderItem(1).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "房间号"
assert dialog.table.horizontalHeaderItem(5).text() == "上传状态"
assert dialog.table.horizontalHeaderItem(6).text() == "失败原因"
assert dialog.table.columnWidth(dialog._room_column) == 180
rooms = {
dialog.table.item(row, 2).text() for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "1692231119"}
statuses = {
dialog.table.item(row, 5).text() for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
finally:
dialog.close()
application.processEvents()
def test_global_local_audio_dialog_lists_all_diagnoses_and_outcomes(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="global-failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=9001,
call_record_id=902,
session_id="global-uploaded-session",
room_id="407179477",
)
store.mark_uploaded(uploaded_id, "cos://recordings/global-uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
None,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.title_label.text() == "本机录音上传管理"
assert dialog.table.columnCount() == 9
assert dialog.table.rowCount() == 2
assert dialog.table.horizontalHeaderItem(1).text() == "诊单 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(3).text() == "房间号"
assert dialog.table.horizontalHeaderItem(6).text() == "上传状态"
diagnosis_ids = {
dialog.table.item(row, 1).text()
for row in range(dialog.table.rowCount())
}
assert diagnosis_ids == {"8169", "9001"}
statuses = {
dialog.table.item(row, 6).text()
for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
rooms = {
dialog.table.item(row, 3).text()
for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "407179477"}
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
finally:
dialog.close()
application.processEvents()
def test_dialog_fetches_and_persists_historical_room_ids_once_per_diagnosis(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.call_records[8169] = [
{"id": 902, "room_id": "1692231119"},
{"id": 901, "room_id": "67534825"},
]
manager = LocalAudioUploadManager(repository, store, max_workers=1)
first_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="legacy-room-first",
)
second_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="legacy-room-second",
)
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
try:
for _ in range(200):
application.processEvents()
if store.require(first_id).room_id and store.require(second_id).room_id:
break
QTest.qWait(10)
assert store.require(first_id).room_id == "67534825"
assert store.require(second_id).room_id == "1692231119"
assert repository.list_calls == [8169]
for _ in range(3):
dialog.refresh_records()
application.processEvents()
assert repository.list_calls == [8169]
assert {
dialog.table.item(row, 2).text()
for row in range(dialog.table.rowCount())
} == {"67534825", "1692231119"}
finally:
dialog.close()
application.processEvents()
+28
View File
@@ -295,6 +295,34 @@ def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
assert "final words" in record["transcript_text"]
def test_demo_local_audio_is_preserved_separately_from_cloud_video_and_text(
repository: DemoDoctorRepository,
tmp_path: Path,
) -> None:
started = repository.start_call(501, 301)
call_record_id = int(started["id"])
audio = tmp_path / "local-call.webm"
audio.write_bytes(b"webm-opus-audio")
result = repository.upload_call_recording(
audio,
501,
call_record_id=call_record_id,
mime_type="audio/webm;codecs=opus",
)
record = next(
row for row in repository.list_call_records(501) if row["id"] == call_record_id
)
assert result["media_kind"] == "local_audio"
assert result["call_record_id"] == call_record_id
assert record["local_audio_status"] == 2
assert record["local_audio_status_text"] == "已保存"
assert record["local_audio_urls_list"] == [result["file_url"]]
assert record["recording_urls_list"] == []
assert record["transcript_text"] == ""
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely."""
+34
View File
@@ -20,6 +20,9 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
run_script = read("scripts/run_windows.ps1")
package_script = read("scripts/package_windows.ps1")
compiler_script = read("scripts/ensure_inno_setup.ps1")
installer_definition = read("packaging/windows/doctor_workstation.iss")
installer_smoke = read("scripts/smoke_windows_installer.ps1")
release_launcher = read("packaging/windows/start_release.bat")
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
@@ -32,11 +35,42 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
assert "& $Npm ci --prefix" in package_script
assert "build_windows.ps1" in package_script
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
assert "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion" in package_script
assert "Get-FileHash" in package_script
assert "ensure_inno_setup.ps1" in package_script
assert "doctor_workstation.iss" in package_script
assert package_script.index("& $BuildScript") < package_script.index("& $InnoCompiler")
assert "Start_DoctorWorkstation.bat" in package_script
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
assert "6.7.3" in compiler_script
assert "Get-FileHash" in compiler_script
assert "Get-AuthenticodeSignature" in compiler_script
assert "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732" in (
compiler_script
)
assert "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D" in (
compiler_script
)
assert "AppId=" in installer_definition
assert "MinVersion=10.0.17763" in installer_definition
assert "ArchitecturesAllowed=x64compatible" in installer_definition
assert "PrivilegesRequiredOverridesAllowed=dialog commandline" in installer_definition
assert "UsePreviousAppDir=yes" in installer_definition
assert "UsePreviousPrivileges=yes" in installer_definition
assert "recursesubdirs createallsubdirs" in installer_definition
assert "{autoprograms}" in installer_definition
assert "{autodesktop}" in installer_definition
assert "UninstallDisplayIcon=" in installer_definition
assert "ChineseMessagesFile" in installer_definition
assert '"/CURRENTUSER"' in installer_smoke
assert '-ArgumentList "--smoke-test"' in installer_smoke
assert "unins000.exe" in installer_smoke
assert "-WindowStyle Hidden" in installer_smoke
assert "Uninstaller left the installed executable behind" in installer_smoke
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
debug_script = read("Debug_DoctorWorkstation.bat")
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import hashlib
import struct
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
MASTER_SHA256 = "c76f19b9a1c89c23d9923a0903c673dc5127acecc640272e4974241021f1100e"
def _png_info(path: Path) -> tuple[int, int, int]:
data = path.read_bytes()
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert data[12:16] == b"IHDR"
width, height = struct.unpack(">II", data[16:24])
color_type = data[25]
return width, height, color_type
def _ico_sizes(path: Path) -> set[tuple[int, int]]:
data = path.read_bytes()
reserved, image_type, count = struct.unpack_from("<HHH", data)
assert reserved == 0
assert image_type == 1
sizes: set[tuple[int, int]] = set()
for index in range(count):
width, height = struct.unpack_from("BB", data, 6 + index * 16)
sizes.add((width or 256, height or 256))
return sizes
def test_brand_assets_are_complete_and_derived_from_the_approved_master() -> None:
master = BRANDING_ROOT / "brand-master.png"
lockup = BRANDING_ROOT / "brand-lockup.png"
icon_png = BRANDING_ROOT / "app-icon.png"
icon_ico = BRANDING_ROOT / "app-icon.ico"
icon_icns = BRANDING_ROOT / "app-icon.icns"
favicon = PROJECT_ROOT / "video_companion" / "public" / "favicon.png"
assert hashlib.sha256(master.read_bytes()).hexdigest() == MASTER_SHA256
assert _png_info(master)[:2] == (1254, 1254)
assert _png_info(lockup)[0] >= 800
assert _png_info(lockup)[1] >= 900
assert _png_info(icon_png) == (1024, 1024, 6)
assert _png_info(favicon)[:2] == (64, 64)
assert {(16, 16), (32, 32), (48, 48), (64, 64), (256, 256)} <= _ico_sizes(
icon_ico
)
icns = icon_icns.read_bytes()
assert icns.startswith(b"icns")
assert struct.unpack(">I", icns[4:8])[0] == len(icns)
def test_runtime_and_packaging_use_the_same_brand_icon() -> None:
app_source = (PROJECT_ROOT / "src" / "doctor_workstation" / "app.py").read_text(
encoding="utf-8"
)
resources_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "resources.py"
).read_text(encoding="utf-8")
login_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "login.py"
).read_text(encoding="utf-8")
shell_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
).read_text(encoding="utf-8")
spec = (PROJECT_ROOT / "packaging" / "doctor_workstation.spec").read_text(
encoding="utf-8"
)
installer = (
PROJECT_ROOT / "packaging" / "windows" / "doctor_workstation.iss"
).read_text(encoding="utf-8")
package_script = (PROJECT_ROOT / "scripts" / "package_windows.ps1").read_text(
encoding="utf-8"
)
companion_html = (PROJECT_ROOT / "video_companion" / "index.html").read_text(
encoding="utf-8"
)
assert 'resource_path("branding", "app-icon.png")' in resources_source
assert "app_icon_path()" in app_source
assert "brand_lockup_path()" in login_source
assert "return QIcon(str(app_icon_path()))" in login_source
assert "QPixmap(str(app_icon_path()))" in shell_source
assert 'icon=str(WINDOWS_ICON) if sys.platform == "win32" else None' in spec
assert "icon=str(MACOS_ICON)" in spec
assert "SetupIconFile={#AppIconFile}" in installer
assert installer.count('IconFilename: "{app}\\{#AppExecutableName}"') == 2
assert '"/DAppIconFile=$WindowsIcon"' in package_script
assert '<link rel="icon" type="image/png" href="/favicon.png" />' in companion_html
assert "icon.svg" not in app_source
assert "_draw_mark" not in login_source
assert not (PROJECT_ROOT / "resources" / "icon.svg").exists()
+620
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import date
from typing import Any
@@ -40,8 +41,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -305,6 +309,64 @@ def test_openai_failure_keeps_new_qwen_snapshot(
page.close()
def test_finished_only_cached_regeneration_unlocks_retry_and_keeps_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(109, 301, 509)
saved = _snapshot("qwen", 1, "2026-08-14 10:45:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": [saved]})
jobs[1]["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_analysis_regenerate_button.isEnabled()
page.ai_analysis_regenerate_button.click()
assert len(jobs) == 3
assert page._ai_analysis_regenerating
assert not page.ai_analysis_regenerate_button.isEnabled()
jobs[2]["on_finished"]()
assert not page._ai_analysis_regenerating
assert page._ai_analysis_regeneration_model is None
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
assert page.ai_analysis_regenerate_button.isEnabled()
assert "未返回有效结果" in page.ai_analysis_secondary_status.text()
page.close()
def test_late_patient_history_response_is_discarded_after_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -353,6 +415,7 @@ def test_late_patient_history_response_is_discarded_after_switch(
first_history_job = jobs[1]
page._select_record(second["appointment"])
assert first_history_job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
finish(jobs[2])
second_history_job = jobs[3]
finish(second_history_job)
@@ -364,6 +427,563 @@ def test_late_patient_history_response_is_discarded_after_switch(
page.close()
def test_aba_switch_attaches_to_inflight_patient_generation_without_duplicate_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(110, 301, 510)
second = _detail(111, 302, 511)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:00:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
first_qwen_job = jobs[2]
qwen_result = first_qwen_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 5
first_qwen_job["on_success"](qwen_result)
first_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == 6
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_history_get_is_singleflight_across_aba_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(112, 301, 512)
second = _detail(113, 302, 513)
saved = _snapshot("qwen", 1, "2026-08-14 12:10:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history exists: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
first_list_job = jobs[1]
list_result = first_list_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[3]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 4
assert repository.list_calls == [301]
first_list_job["on_success"](list_result)
first_list_job["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_ai_finished_only_tracks_qwen_workers_not_unrelated_openai(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(114, 301, 514)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"worker must remain queued: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
current_list_job = jobs[1]
stale_qwen = (page._ai_analysis_generation - 2, 114, 301, "qwen")
stale_openai = (page._ai_analysis_generation - 1, 114, 301, "openai")
page._patient_ai_generation_requests.add(stale_qwen)
page._patient_ai_generation_finished(*stale_qwen)
assert page._ai_analysis_model_states["qwen"] == "loading"
page._patient_ai_generation_requests.add(stale_openai)
current_list_job["on_finished"]()
page._patient_ai_generation_finished(*stale_openai)
assert not page._patient_ai_list_requests
assert not page._patient_ai_generation_requests
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert "未返回有效结果" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_aba_cancelled_generation_is_replaced_instead_of_becoming_false_error(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(115, 301, 515)
second = _detail(116, 302, 516)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
5.0,
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:20:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
old_qwen_job = jobs[2]
with ThreadPoolExecutor(max_workers=1) as executor:
cancelled_future = executor.submit(old_qwen_job["function"])
page._select_record(second["appointment"])
cancelled = cancelled_future.result(timeout=1.0)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
assert cancelled is reception_module._ASYNC_REQUEST_CANCELLED
assert repository.generate_calls == []
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 6
current_list_job = jobs[5]
current_list_job["on_success"]({"patient_id": 301, "reports": []})
assert len(jobs) == 7
replacement_qwen_job = jobs[6]
current_list_job["on_finished"]()
assert len(jobs) == 7
qwen_result = replacement_qwen_job["function"]()
replacement_qwen_job["on_success"](qwen_result)
replacement_qwen_job["on_finished"]()
jobs_after_replacement = len(jobs)
old_qwen_job["on_success"](cancelled)
old_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_replacement
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
@pytest.mark.parametrize("late_completion", ["cancelled", "error"])
def test_late_history_completion_cannot_clear_newer_generated_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
late_completion: str,
) -> None:
detail = _detail(117, 301, 517)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"history worker remains pending: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 9, "2026-08-14 12:30:00")
generated["id"] = 901 if model == "qwen" else 902
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
qwen_job = jobs[2]
qwen_result = qwen_job["function"]()
qwen_job["on_success"](qwen_result)
qwen_job["on_finished"]()
jobs_after_generation = len(jobs)
if late_completion == "cancelled":
history_job["on_success"](reception_module._ASYNC_REQUEST_CANCELLED)
else:
history_job["on_error"](RuntimeError("late history failure"))
history_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_generation
assert page._ai_analysis_model_states["qwen"] == "success"
assert page._ai_analysis_payloads["qwen"]["id"] == 901
assert page.ai_summary_label.text() == "千问第 9 版诊断建议"
page.close()
application.processEvents()
def test_cancelled_queued_generation_does_not_invalidate_valid_history_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(118, 301, 518)
second = _detail(119, 302, 519)
saved = _snapshot("qwen", 10, "2026-08-14 12:40:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("cancelled queued POST must not enter repository")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
history_job = jobs[1]
history_result = history_job["function"]()
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
queued_qwen_job = jobs[2]
page._select_record(second["appointment"])
cancelled = queued_qwen_job["function"]()
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
queued_qwen_job["on_success"](cancelled)
queued_qwen_job["on_finished"]()
history_job["on_success"](history_result)
history_job["on_finished"]()
assert repository.list_calls == [301]
assert repository.generate_calls == []
assert len(jobs) == 5
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 10 版诊断建议"
page.close()
application.processEvents()
def test_saturated_automatic_ai_slots_end_in_retryable_state_without_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(120, 301, 520)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("busy automatic work must not submit a POST")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
automatic_qwen_job = jobs[2]
deferred = automatic_qwen_job["function"]()
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.generate_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert not page._ai_analysis_regenerating
assert page.ai_analysis_regenerate_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_saturated_history_slots_end_in_retryable_state_without_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(121, 301, 521)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
raise AssertionError("busy automatic read must not enter repository")
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history did not complete: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
deferred = history_job["function"]()
history_job["on_success"](deferred)
history_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.list_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "查询任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
valid = {"patient_id": 301, "reports": [row]}
+27 -7
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtCore import QDate, QPoint
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
@@ -636,26 +636,46 @@ def test_patient_list_reference_geometry_and_row_actions(
button.minimumHeight() == 44 and button.maximumHeight() == 44
for button in workspace.summary_buttons.values()
)
assert all(
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost"
assert (
workspace.keyword_edit.maximumWidth(),
workspace.status_host.maximumWidth(),
workspace.quick_host.maximumWidth(),
workspace.date_host.maximumWidth(),
) == (620, 440, 620, 420)
assert workspace.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514):
page.resize(width, 680)
application.processEvents()
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.search_button,
workspace.reset_button,
workspace.quick_host,
workspace.date_host,
)
)
workspace.custom_date_button,
):
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
assert top_left.x() >= 0
assert top_left.x() + widget.width() <= workspace.filter_card.width()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
assert workspace.bottom_actions.isHidden()
assert workspace.table.viewport().height() // 40 >= 6
assert workspace.table.viewport().height() // 36 >= 6
assert workspace.pager.isVisibleTo(page)
assert workspace.table.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
if workspace.table.rowCount():
assert workspace.table.rowHeight(0) == 40
assert workspace.table.rowHeight(0) == 36
assert workspace.table.cellWidget(0, 0) is not None
assert workspace.table.cellWidget(0, 9) is not None
page.close()
+22 -1
View File
@@ -10,7 +10,14 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QFrame,
QPushButton,
QWidget,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
@@ -201,6 +208,20 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and 84 <= filters.height() <= 92
actions_host = page.table.cellWidget(0, 2)
assert actions_host is not None
row_edit = next(
button
for button in actions_host.findChildren(QPushButton)
if button.accessibleName() == "编辑处方"
)
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
edit_bottom_right = row_edit.mapTo(
page.table.viewport(), row_edit.rect().bottomRight()
)
assert row_edit.text() == "编辑"
assert page.table.viewport().rect().contains(edit_top_left)
assert page.table.viewport().rect().contains(edit_bottom_right)
else:
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
assert filters is not None
+92 -3
View File
@@ -303,10 +303,17 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
page._selection_changed()
assert page.table.columnCount() == 11
assert page.table.horizontalHeaderItem(10).text() == "操作"
assert page.table.horizontalHeaderItem(2).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 5) is not None
assert page.table.cellWidget(0, 10) is not None
assert page.table.cellWidget(0, 3) is not None
assert page.table.cellWidget(0, 6) is not None
row_edit = next(
button
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
if button.accessibleName() == "编辑处方"
)
assert row_edit.text() == "编辑"
assert row_edit.isEnabled()
assert calls == [
{
@@ -343,6 +350,88 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
application.processEvents()
def test_issued_row_edit_targets_clicked_prescription_without_checkbox(
application: QApplication,
immediate_async: None,
) -> None:
requested: list[int] = []
opened: list[dict[str, Any]] = []
callbacks: list[str] = []
rows = [
{
"id": 11,
"sn": "CF-11",
"patient_name": "患者甲",
"audit_status": 0,
"void_status": 0,
"creator_id": 7,
},
{
"id": 22,
"sn": "CF-22",
"patient_name": "患者乙",
"audit_status": 0,
"void_status": 0,
"creator_id": 7,
},
]
class Repository:
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return []
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": rows, "count": len(rows)}
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
requested.append(prescription_id)
return {
**next(row for row in rows if row["id"] == prescription_id),
"clinical_diagnosis": "脾气虚",
}
page = PrescriptionsPage(
Repository(),
PermissionSet(["cf.prescription/edit"]),
SimpleNamespace(id=7, name="周医生"),
)
page.refresh()
page._open_editor = lambda detail: opened.append(detail) # type: ignore[method-assign]
assert page.table.current_data()["id"] == 11
assert all(
page.table.item(row_index, 0).checkState() == Qt.CheckState.Unchecked
for row_index in range(page.table.rowCount())
)
second_actions = page.table.cellWidget(1, 2)
assert second_actions is not None
second_edit = next(
button
for button in second_actions.findChildren(QPushButton)
if button.accessibleName() == "编辑处方"
)
second_edit.click()
application.processEvents()
assert requested == [22]
assert [detail["id"] for detail in opened] == [22]
assert page.table.current_data()["id"] == 22
assert all(
page.table.item(row_index, 0).checkState() == Qt.CheckState.Unchecked
for row_index in range(page.table.rowCount())
)
page._run_row_action({"id": 999}, lambda: callbacks.append("stale"))
assert callbacks == []
page._set_mutation_pending(True)
assert not second_edit.isEnabled()
page._set_mutation_pending(False)
assert second_edit.isEnabled()
page.close()
application.processEvents()
def test_editor_builds_complete_add_payload(
application: QApplication,
immediate_async: None,
+413 -2
View File
@@ -10,14 +10,32 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtCore import (
QDate,
QEvent,
QEventLoop,
QObject,
QPoint,
Qt,
QThreadPool,
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui import widgets as widgets_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
@@ -41,8 +59,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -79,6 +100,135 @@ def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
return jobs
def test_api_client_reuses_a_bounded_transport_across_qt_runnables(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[Any] = []
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
return httpx.Response(200, json={"code": 1, "data": url})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_parallel_requests=2)
pool = QThreadPool()
pool.setMaxThreadCount(1)
pool.setExpiryTimeout(-1)
remaining = 40
results: list[str] = []
loop = QEventLoop()
def finished() -> None:
nonlocal remaining
remaining -= 1
if remaining == 0:
loop.quit()
for index in range(remaining):
widgets_module.run_async(
lambda index=index: client.get(f"patient/{index}"),
on_success=results.append,
on_finished=finished,
pool=pool,
)
QTimer.singleShot(5_000, loop.quit)
loop.exec()
assert pool.waitForDone(2_000)
client.close()
assert remaining == 0
assert len(results) == 40
assert len(created) == 1
assert created[0].closed
application.processEvents()
def test_clearing_ai_layout_does_not_promote_children_to_windows(
application: QApplication,
) -> None:
host = QWidget()
layout = QVBoxLayout(host)
dynamic_label = QLabel("正在加载患者 AI 分析", host)
layout.addWidget(dynamic_label)
host.show()
application.processEvents()
destroyed: list[bool] = []
dynamic_label.destroyed.connect(lambda: destroyed.append(True))
reception_module._clear_ai_layout(layout)
assert layout.count() == 0
assert destroyed == [True]
assert dynamic_label not in application.topLevelWidgets()
host.close()
host.deleteLater()
def test_queue_row_never_shows_loading_fields_as_parentless_windows(
application: QApplication,
) -> None:
tracked_names = {
"StatusBadge",
"ReceptionQueueTime",
"ReceptionQueueMetric",
}
class OrphanShowRecorder(QObject):
def __init__(self) -> None:
super().__init__()
self.object_names: list[str] = []
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QWidget)
and watched.objectName() in tracked_names
and watched.parentWidget() is None
and watched.isWindow()
):
self.object_names.append(watched.objectName())
return False
recorder = OrphanShowRecorder()
application.installEventFilter(recorder)
try:
active_row = QueueRow(
{
"patient_name": "问诊患者",
"status": 2,
"status_desc": "问诊中",
"fasting_blood_sugar": 8.6,
}
)
waiting_row = QueueRow(
{
"patient_name": "待接诊患者",
"status": 1,
"appointment_time": "14:30",
}
)
finally:
application.removeEventFilter(recorder)
assert recorder.object_names == []
for row in (active_row, waiting_row):
for object_name in tracked_names:
widget = row.findChild(QWidget, object_name)
assert widget is not None
assert widget.parentWidget() is row
assert not widget.isWindow()
row.deleteLater()
@pytest.mark.parametrize(
("path", "expected"),
[
@@ -714,6 +864,101 @@ def test_fast_patient_switch_rejects_late_detail(
application.processEvents()
def test_rapid_aba_switch_prioritizes_current_detail_and_skips_stale_bundles(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(73, name="甲患者")
second = _detail(74, name="乙患者")
class Repository:
def __init__(self) -> None:
self.calls: list[int] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
self.calls.append(appointment_id)
return first if appointment_id == 73 else second
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
page._select_record(second["appointment"])
page._select_record(first["appointment"])
assert [job["priority"] for job in queued_async] == [1, 2, 3]
for stale_job in queued_async[:2]:
assert stale_job["function"]() == {"cancelled": True}
stale_job["on_finished"]()
assert repository.calls == []
current_job = queued_async[2]
current_job["on_success"](current_job["function"]())
current_job["on_finished"]()
assert repository.calls == [73]
assert page._selected_appointment_id == 73
assert page.patient_name_label.text() == "甲患者"
assert not page._detail_loading
page.close()
application.processEvents()
def test_switching_patient_resets_stale_daily_panel_loading(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
page.daily_panel.set_loading(True)
assert page.daily_panel._loading
assert not page.daily_panel.refresh_button.isEnabled()
page._reset_detail_content(
seed={"id": 75, "patient_name": "新患者", "diagnosis_id": 275}
)
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert all(button.isEnabled() for button in page.daily_panel.range_buttons.values())
assert page.daily_panel.start_date.isEnabled()
assert page.daily_panel.end_date.isEnabled()
page.close()
application.processEvents()
def test_stale_daily_worker_skips_repository_after_patient_switch(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(76, name="日常甲患者")
second = _detail(77, name="日常乙患者")
class Repository:
def __init__(self) -> None:
self.tracking_calls: list[int] = []
def get_tracking_window(self, diagnosis_id: int, **_options: Any) -> dict[str, Any]:
self.tracking_calls.append(diagnosis_id)
return {}
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
page._request_daily_range("2026-08-13", "2026-08-19")
stale_daily_job = queued_async[1]
page._select_record(second["appointment"])
assert stale_daily_job["function"]() == {"cancelled": True}
assert repository.tracking_calls == []
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summary(
application: QApplication,
) -> None:
@@ -1169,6 +1414,28 @@ def test_phone_permission_and_ungated_notify_video_actions(
application.processEvents()
def test_im_consult_is_visible_immediately_after_history(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page.resize(1280, 760)
page.detail_stack.setCurrentIndex(1)
page.show()
application.processEvents()
assert page.video_button.text() == "IM 问诊"
assert page.video_button.objectName() == "ReceptionImButton"
assert not page.video_button.isHidden()
assert page.history_button.geometry().right() < page.video_button.geometry().left()
assert page.video_button.geometry().right() < page.more_button.geometry().left()
assert "IM 问诊" not in [
action.text() for action in page.more_button.menu().actions()
]
page.close()
application.processEvents()
def test_reception_ai_report_button_follows_permission(
application: QApplication,
immediate_async: None,
@@ -1423,6 +1690,65 @@ def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
application.processEvents()
def test_saturated_fallback_ai_slots_do_not_leave_current_analysis_loading(
application: QApplication,
queued_async: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(86, name="后台分析繁忙患者")
calls: list[tuple[int, str]] = []
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
raise AssertionError("busy automatic work must not enter repository")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
automatic_qwen_job = queued_async[1]
deferred = automatic_qwen_job["function"](*automatic_qwen_job["args"])
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_reception_openai_failure_keeps_qwen_success_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
@@ -1886,6 +2212,89 @@ def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
jobs[0]["on_finished"]()
assert not page._detail_loading
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_finished_only_detail_request_cannot_leave_loading_placeholders(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
page = ReceptionPage(object(), PermissionSet(["*"]))
record = {
"id": 86,
"patient_id": 186,
"diagnosis_id": 286,
"patient_name": "无结果患者",
}
page._select_record(record)
queued_async[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_detail is None
assert page.detail_banner.property("kind") == "danger"
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert page._ai_analysis_state == "error"
page.close()
application.processEvents()
def test_finished_only_patient_ai_query_becomes_retryable(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(87, name="AI 无结果患者")
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
assert len(queued_async) == 2
assert page._ai_analysis_state == "loading"
queued_async[1]["on_finished"]()
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "请重试" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page.ai_analysis_retry_button.click()
assert len(queued_async) == 3
assert page._ai_analysis_state == "loading"
page.close()
application.processEvents()
@@ -1960,6 +2369,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 2
page._select_record(second["appointment"])
assert queued_async[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[2]["on_success"]({"detail": second, "warnings": []})
assert len(queued_async) == 4
second_qwen = _analysis_payload("第二位千问风险")
@@ -1968,6 +2378,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 5
page._select_record(third["appointment"])
assert queued_async[4]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[5]["on_success"]({"detail": third, "warnings": []})
assert len(queued_async) == 7
third_qwen = _analysis_payload("第三位千问风险")
+104
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import pytest
@@ -480,6 +481,81 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
]
def test_remote_call_record_identity_is_reused_for_room_recording_and_end() -> None:
"""Room binding and COS finalization must never select a different latest call."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.bind_call_room(501, " room-901 ", call_record_id=901)
repository.end_call(501, call_record_id=901)
assert client.post_calls == [
(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": 501, "room_id": "room-901", "call_record_id": 901},
),
(
"tcm.diagnosis/endCall",
{"diagnosis_id": 501, "call_record_id": 901},
),
]
def test_remote_local_audio_upload_keeps_exact_call_identity_and_mime(
tmp_path: Path,
) -> None:
class MultipartClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.multipart_calls: list[
tuple[str, dict[str, tuple[str, bytes, str]], dict[str, Any]]
] = []
def post_multipart(
self,
endpoint: str,
*,
files: dict[str, tuple[str, bytes, str]],
data: dict[str, Any],
) -> dict[str, Any]:
self.multipart_calls.append((endpoint, files, data))
completed = int(data["chunk_index"]) == int(data["chunk_total"]) - 1
return {
"call_record_id": 901,
"completed": completed,
"file_url": "https://cos.example.test/calls/local-audio.webm"
if completed
else "",
"media_kind": "local_audio",
}
recording = tmp_path / "local-audio.webm"
recording.write_bytes(b"a" * (4 * 1024 * 1024 + 3))
client = MultipartClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repository.upload_call_recording(
recording,
501,
call_record_id=901,
mime_type="audio/webm;codecs=opus",
)
assert result["completed"] is True
assert result["call_record_id"] == 901
assert result["media_kind"] == "local_audio"
assert len(client.multipart_calls) == 2
assert all(call[0] == "tcm.diagnosis/uploadCallRecording" for call in client.multipart_calls)
for _endpoint, files, data in client.multipart_calls:
assert data["diagnosis_id"] == 501
assert data["call_record_id"] == 901
assert data["mime_type"] == "audio/webm;codecs=opus"
assert str(data["upload_id"]).startswith("local_audio_")
assert files["file"][0] == "local-audio.webm"
assert files["file"][2] == "audio/webm;codecs=opus"
@pytest.mark.parametrize(
"response",
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
@@ -645,6 +721,34 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
assert client.timeouts == [105.0]
def test_ai_post_type_error_after_dispatch_is_never_retried() -> None:
class FailingAfterDispatchClient:
token = "token"
def __init__(self) -> None:
self.calls = 0
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
assert endpoint == "tcm.diagnosis/generatePatientAiReport"
assert payload == {"patient_id": 301, "model": "qwen"}
assert timeout == 105.0
self.calls += 1
raise TypeError("transport failed after dispatch")
client = FailingAfterDispatchClient()
with pytest.raises(TypeError, match="after dispatch"):
RemoteDoctorRepository(client).generate_patient_ai_report(301, model="qwen")
assert client.calls == 1
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
class StreamingClient(RecordingClient):
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
+303 -10
View File
@@ -6,11 +6,17 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _logical_pixel(image: Any, x: int, y: int):
device_scale = image.devicePixelRatio()
return image.pixelColor(round(x * device_scale), round(y * device_scale))
class _ShellPageDouble(QWidget):
@@ -27,10 +33,16 @@ class _ShellPageDouble(QWidget):
self.current_user = current_user
self.refresh_count = 0
self.show_count = 0
self.ai_context_available = False
self.ai_open_count = 0
def refresh(self) -> None:
self.refresh_count += 1
def open_selected_ai_consult(self) -> bool:
self.ai_open_count += 1
return self.ai_context_available
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
self.show_count += 1
@@ -39,7 +51,26 @@ class _ShellPageDouble(QWidget):
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.mark.parametrize(
("available_size", "expected_size"),
[
(QSize(1920, 1080), QSize(1710, 920)),
(QSize(1486, 1000), QSize(1486, 920)),
(QSize(1600, 800), QSize(1600, 800)),
(QSize(800, 600), QSize(1024, 640)),
(None, QSize(1710, 920)),
],
)
def test_shell_initial_size_is_bounded_by_logical_available_geometry(
available_size: QSize | None,
expected_size: QSize,
) -> None:
assert shell_module._bounded_initial_window_size(available_size) == expected_size
def test_patients_navigation_keeps_the_product_menu_title() -> None:
@@ -78,8 +109,9 @@ def test_appointments_navigation_is_named_reception_and_always_first() -> None:
demo_mode=False,
)
# 挂号与诊单是两条队列,侧边栏此前两项同名。现在与服务端菜单的“挂号列表”一致。
assert [(item.key, title) for item, title in resolved] == [
("appointments", "问诊列表"),
("appointments", "挂号列表"),
("patients", "我的患者"),
]
@@ -92,7 +124,7 @@ def shell_window(
navigation = [
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
for key, title, glyph, permission in (
("appointments", "问诊列表", "", "doctor.appointment/lists"),
("appointments", "挂号列表", "", "doctor.appointment/lists"),
("reception", "接诊台", "", "doctor.appointment/lists"),
(
"prescription_library",
@@ -118,7 +150,8 @@ def shell_window(
"user": {"name": "陈医生", "department_name": "中医门诊", "role_ids": [1]},
"demo_mode": True,
},
permissions={item.permissions[0] for item in navigation},
permissions={item.permissions[0] for item in navigation}
| {"tcm.diagnosis/aiAssistant"},
)
window.show()
application.processEvents()
@@ -145,14 +178,60 @@ def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(20, 300).name().lower() in {
assert _logical_pixel(image, 20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
application: QApplication,
shell_window: ShellWindow,
) -> None:
for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height)
application.processEvents()
search_host = shell_window.topbar.findChild(QFrame, "ShellGlobalSearch")
shortcut_hint = search_host.findChild(QWidget, "ShellShortcutHint")
assert search_host.size().toTuple() == (265, 36)
assert shell_window.fold_button.size().toTuple() == (38, 38)
assert shortcut_hint.size() == shortcut_hint.sizeHint()
assert (
abs(search_host.geometry().center().y() - shell_window.fold_button.geometry().center().y())
<= 1
)
assert (
abs(
shortcut_hint.mapTo(search_host, shortcut_hint.rect().center()).y()
- search_host.rect().center().y()
)
<= 1
)
shell_window.global_search.setText("患者")
application.processEvents()
action_buttons = shell_window.global_search.findChildren(QToolButton)
assert len(action_buttons) == 2
for button in action_buttons:
assert button.size().toTuple() == (22, 18)
assert shell_window.global_search.rect().contains(button.geometry())
assert (
abs(
button.geometry().center().y()
- shell_window.global_search.rect().center().y()
)
<= 1
)
clear_button = max(action_buttons, key=lambda button: button.x())
clear_right = clear_button.mapTo(search_host, clear_button.rect().topRight()).x()
assert clear_right < shortcut_hint.x()
shell_window.global_search.clear()
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
@@ -170,7 +249,11 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.upload_settings_button.text().strip().startswith("设置")
assert (
shell_window.upload_settings_button.accessibleName() == "本机录音上传设置"
)
assert shell_window.model_label is shell_window.upload_settings_button
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""
@@ -180,6 +263,216 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
assert "在线" in shell_window.assistant_status.text()
def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
current = shell_window.pages["appointments"]
assert isinstance(current, _ShellPageDouble)
current.ai_context_available = True
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
monkeypatch.setattr(
shell_module,
"select_and_present_ai_consult",
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
)
shell_window.assistant_button.click()
application.processEvents()
assert current.ai_open_count == 0
assert len(opened) == 1
assert opened[0][0][0] is shell_window.repository
assert opened[0][0][2] is shell_window
assert shell_window.stack.currentWidget() is current
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
appointments = shell_window.pages["appointments"]
reception = shell_window.pages["reception"]
assert isinstance(appointments, _ShellPageDouble)
assert isinstance(reception, _ShellPageDouble)
opened: list[dict[str, Any]] = []
def open_picker(repository: Any, permissions: Any, parent: Any, **kwargs: Any) -> bool:
opened.append(
{
"repository": repository,
"permissions": permissions,
"parent": parent,
**kwargs,
}
)
return False
monkeypatch.setattr(shell_module, "select_and_present_ai_consult", open_picker)
shell_window.global_search.setText("张医生")
shell_window.ai_top_button.click()
application.processEvents()
assert appointments.ai_open_count == 0
assert reception.ai_open_count == 0
assert shell_window.stack.currentWidget() is appointments
assert opened == [
{
"repository": shell_window.repository,
"permissions": shell_window.permissions,
"parent": shell_window,
"initial_query": "张医生",
}
]
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
reception = shell_window.pages["reception"]
assert isinstance(reception, _ShellPageDouble)
assert shell_window.navigate("reception")
reception.ai_context_available = True
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
monkeypatch.setattr(
shell_module,
"select_and_present_ai_consult",
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
)
shell_window.ai_top_button.click()
application.processEvents()
assert reception.ai_open_count == 0
assert len(opened) == 1
assert shell_window.stack.currentWidget() is reception
def test_shell_hides_global_ai_entries_without_ai_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
navigation = [
NavigationItem(
"appointments",
"问诊列表",
"",
_ShellPageDouble,
("doctor.appointment/lists",),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
window = ShellWindow(
object(),
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
permissions={"doctor.appointment/lists"},
)
window.show()
application.processEvents()
assert window.assistant_card.isHidden()
assert window.ai_top_button.isHidden()
window.close()
application.processEvents()
def test_shell_ai_entry_does_not_require_reception_page(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
navigation = [
NavigationItem(
"appointments",
"问诊列表",
"",
_ShellPageDouble,
("doctor.appointment/lists",),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
opened: list[Any] = []
monkeypatch.setattr(
shell_module,
"select_and_present_ai_consult",
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
)
window = ShellWindow(
object(),
{"user": {"name": "无接诊台医生"}, "demo_mode": True},
permissions={
"doctor.appointment/lists",
"tcm.diagnosis/aiAssistant",
},
)
window.show()
application.processEvents()
window.assistant_button.click()
application.processEvents()
assert len(opened) == 1
assert opened[0][0][0] is window.repository
assert opened[0][0][2] is window
assert window.stack.currentWidget() is window.pages["appointments"]
window.close()
application.processEvents()
def test_shell_settings_opens_global_local_audio_upload_manager(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def dialog_factory(
repository: Any,
diagnosis_id: int | None,
parent: QWidget,
) -> QDialog:
dialog = QDialog(parent)
dialog.setObjectName("LocalAudioQueueDialog")
captured.update(
repository=repository,
diagnosis_id=diagnosis_id,
parent=parent,
dialog=dialog,
)
return dialog
monkeypatch.setattr(shell_module, "LocalAudioQueueDialog", dialog_factory)
shell_window.upload_settings_button.click()
application.processEvents()
assert captured["repository"] is shell_window.repository
assert captured["diagnosis_id"] is None
assert captured["parent"] is shell_window
assert captured["dialog"].isVisible()
captured["dialog"].reject()
application.processEvents()
assert shell_window._local_audio_settings_dialog is None
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
shell_window: ShellWindow,
) -> None:
+386 -6
View File
@@ -28,6 +28,233 @@ from doctor_workstation.video.security import ( # noqa: E402
)
def test_companion_uses_legacy_safe_transcription_session_identity() -> None:
"""Generated session IDs stay below 32 chars so upgraded databases cannot truncate."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
function_source = source.split("function newTranscriptionSessionId", 1)[1].split(
"function requestTranscriptionStart", 1
)[0]
assert "replaceAll('-', '')" in function_source
assert ".slice(0, 28)" in function_source
assert "return `tr-${" in function_source
def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> None:
"""A connected call starts three independent artifacts before hangup."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "context.createMediaStreamDestination()" in source
assert "cloud.getAudioTrack({ processed: true })" in source
assert "userId: activeConfig.targetUserId" in source
assert "new MediaRecorder(destination.stream" in source
assert "recorder.start(1000)" in source
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
assert "bridge.appendLocalAudioChunk(" in source
assert "bridge.finishLocalAudioRecording(sessionId, totalBytes)" in source
assert "operations.push(stopLocalRecording())" in source
assert "operations.push(stopTranscription('completed'))" in source
assert "Promise.allSettled(operations)" in source
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
"""A slowly-created TRTC room must still bind to the exact call record."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
room_source = source.split("function readRoomId", 1)[1].split(
"function handleStatusChanged", 1
)[0]
assert "TUIStore.watch(StoreName.CALL, roomIdWatchOptions)" in room_source
assert "[NAME.ROOM_ID]: handleRoomIdChanged" in room_source
assert "cycle === callCycleGeneration" in room_source
assert "while (activeConfig && !endNotified" in room_source
assert "attempt < 40" not in room_source
assert "diagnosisId: activeConfig.diagnosisId" in room_source
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
companion_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
window_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "observeRoomId(roomId)" in companion_source
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
assert "function roomBindingResult(" in companion_source
assert "if (boundRoomId) return" in companion_source
assert "roomBindingResult?.(" in window_source
def test_im_conversation_renders_deduplicated_video_call_status_timeline() -> None:
"""Video lifecycle feedback belongs in the IM timeline as local status events."""
companion_root = PROJECT_ROOT / "video_companion" / "src"
source = (companion_root / "main.ts").read_text(encoding="utf-8")
app_source = (companion_root / "App.vue").read_text(encoding="utf-8")
styles = (companion_root / "style.css").read_text(encoding="utf-8")
timeline_source = source.split("function appendVideoCallStatus", 1)[1].split(
"function onMessageReceived", 1
)[0]
assert "activeConfig.mode !== 'chat'" in timeline_source
assert "local-video-call-${callCycleGeneration}-${callStatus}" in timeline_source
assert "findIndex((item) => item.id === id)" in timeline_source
assert "appendVideoCallStatus('starting', '正在创建安全视频通话')" in source
assert "appendVideoCallStatus('dialing', '正在呼叫患者')" in source
assert "appendVideoCallStatus('connected', '视频通话已接通')" in source
assert "appendVideoCallStatus('ended', '视频通话已结束')" in source
assert "appendVideoCallStatus('failed', `视频通话发起失败:${message}`)" in source
assert "message.type === 'call-status'" in app_source
assert 'class="call-status-event"' in app_source
assert 'role="status"' in app_source
assert "IM 已连接 · ${props.statusText.value}" in app_source
assert ".message-row--call-status" in styles
assert ".call-status-event--connected" in styles
assert ".call-status-event--failed" in styles
def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallbacks() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "querySelectorAll<HTMLMediaElement>('video, audio')" in source
assert "stream.getAudioTracks()" in source
assert "navigator.mediaDevices.getUserMedia" in source
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
assert "localRecordingAttachedSourceCount <= 0" in source
assert "localRecordingBytes < 1024" in source
assert "已阻止上传空文件" in source
def test_qt_close_waits_for_local_audio_finish_before_destroying_webengine() -> None:
"""A title-bar/desktop hangup must keep accepting bridge chunks until COS ack."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
request_shutdown = source.split(
"def _request_companion_shutdown", 1
)[1].split("def _force_requested_shutdown", 1)[0]
begin_shutdown = source.split("def _begin_shutdown", 1)[1].split(
"def wait_for_lifecycles", 1
)[0]
close_event = source.split("def closeEvent", 1)[1].split(
"else:", 1
)[0]
assert "window.doctorConsultation?.close?.()" in request_shutdown
assert "self._shutdown_requested = True" in request_shutdown
assert "self._closing = True" not in request_shutdown
assert "event.ignore()" in close_event
assert "self._request_companion_shutdown" in close_event
assert "self._shutdown_timer.stop()" in begin_shutdown
assert "window.doctorConsultation?.close?.()" not in begin_shutdown
assert "if not self.open_im or self._shutdown_requested" in source
def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() -> None:
"""A later IM call cycle must not relabel an earlier recording."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "lifecycle: OrderedCallLifecycle" in source
assert "lifecycle = capture.lifecycle" in source
assert "call_record_id=call_record_id" in source
assert 'room_id=lifecycle.current_room_id or ""' in source
assert "store.bind_identity(" in source
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
component_source = (
PROJECT_ROOT / "video_companion" / "src" / "App.vue"
).read_text(encoding="utf-8")
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
"function subscribeTranscriber", 1
)[0]
assert "showLiveCaption(message)" in handler
assert "if (message.isCompleted !== true) return" in handler
assert handler.index("showLiveCaption(message)") < handler.index(
"if (message.isCompleted !== true) return"
)
assert 'aria-label="实时语音字幕"' in component_source
assert "liveCaptions.value" in component_source
assert "caption.speaker" in component_source
assert "caption.text" in component_source
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
capture = source.split("async function captureScreenshot", 1)[1].split(
"function discardScreenshot", 1
)[0]
confirm = source.split("async function confirmScreenshot", 1)[1].split(
"watch(", 1
)[0]
assert "screenshotPreview.value = canvas.toDataURL" in capture
assert "onSaveScreenshot" not in capture
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
assert "确认画面后再保存到患者资料" in source
assert "确认并上传" in source
assert "取消" in source
def test_companion_loads_im_history_without_an_empty_first_page_cursor() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
load_messages = source.split("async function loadMessages", 1)[1].split(
"async function sendText", 1
)[0]
assert "nextReqMessageID: prepend ? nextReqMessageID : ''" not in load_messages
assert "nextReqMessageID?: string" in load_messages
assert (
"if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID"
in load_messages
)
assert "chat.getMessageList(request)" in load_messages
def test_companion_preserves_im_scroll_position_for_live_and_older_messages() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
watcher = source.split("() => props.messages.value.length", 1)[1].split(
"watch(", 1
)[0]
load_earlier = source.split("async function loadEarlierMessages", 1)[1].split(
"async function runAction", 1
)[0]
assert "if (!stickToMessageBottom.value) return" in watcher
assert "stickToMessageBottom.value = false" in load_earlier
assert "container.scrollHeight - previousHeight" in load_earlier
assert '@scroll="handleMessageScroll"' in source
assert '@click="loadEarlierMessages"' in source
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
request = normalize_backend_ticket(
{
@@ -196,11 +423,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 900}
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> None:
events.append(("bind", diagnosis_id, room_id, call_record_id))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
@@ -226,17 +455,19 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
assert lifecycle.wait(0.01) is False
assert duplicate_bind is bind_future
assert changed_bind.result(timeout=0) is False
assert lifecycle.current_room_id == "456789"
release_start.set()
assert start_future.result(timeout=2) is True
assert bind_future.result(timeout=2) is True
assert end_future.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert lifecycle.current_room_id == "456789"
assert events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("end", 123),
("bind", 123, "456789", 900),
("end", 123, 900),
]
@@ -479,6 +710,155 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
assert events == ["start"]
def test_explicit_cos_recording_failure_fails_room_binding_without_losing_call_identity() -> None:
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 904}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 904)
return {
"call_record_id": 904,
"cloud_recording": {
"started": False,
"message": "COS bucket is unavailable",
},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="COS bucket is unavailable"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.call_record_id == 904
assert lifecycle.bound_room_id is None
assert lifecycle.wait(1) is True
def test_failed_room_binding_releases_claim_and_can_retry_same_room() -> None:
bind_attempts = 0
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 906}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
nonlocal bind_attempts
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 906)
bind_attempts += 1
if bind_attempts == 1:
raise RuntimeError("temporary bind failure")
return {
"call_record_id": 906,
"cloud_recording": {"started": True},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="temporary bind failure"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.current_room_id is None
assert lifecycle.bind_room("456789").result(timeout=2) is True
assert lifecycle.bound_room_id == "456789"
assert lifecycle.current_room_id == "456789"
assert bind_attempts == 2
assert lifecycle.wait(1) is True
def test_local_audio_upload_uses_exact_started_record_and_precedes_end(
tmp_path: Path,
) -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 905}
def upload_call_recording(
self,
path: Path,
diagnosis_id: int,
*,
call_record_id: int,
mime_type: str,
) -> dict[str, object]:
events.append(
(
"local-audio",
path.read_bytes(),
diagnosis_id,
call_record_id,
mime_type,
)
)
return {
"completed": True,
"call_record_id": call_record_id,
"media_kind": "local_audio",
}
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
recording = tmp_path / "call-audio.webm"
recording.write_bytes(b"opus-webm-audio")
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
uploaded = lifecycle.save_local_audio_recording(
recording,
mime_type="audio/webm;codecs=opus",
)
ended = lifecycle.end("doctor-hangup")
assert uploaded.result(timeout=2) is True
assert ended.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 8, 2),
("local-audio", b"opus-webm-audio", 123, 905, "audio/webm;codecs=opus"),
("end", 123, 905),
]
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
events: list[tuple[object, ...]] = []

Some files were not shown because too many files have changed in this diff Show More