Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef6eb8d67 | ||
|
|
d5164b7369 | ||
|
|
cf3fbdc5ef | ||
|
|
928f72ec3d | ||
|
|
b4c11881b4 | ||
|
|
58ffde808f | ||
|
|
486acc465d | ||
|
|
398f9f3726 | ||
|
|
5bd5eae62d | ||
|
|
456dd667df | ||
|
|
ed48f8be31 | ||
|
|
43ad07208f | ||
|
|
4b8b4eb649 | ||
|
|
75e214dc08 | ||
|
|
74ff568ba4 | ||
|
|
2fa8492c56 | ||
|
|
b5b14516a1 | ||
|
|
381fe65367 | ||
|
|
af603a4e9a | ||
|
|
f24afa116f | ||
|
|
b8ccbaf567 | ||
|
|
af1db59c07 | ||
|
|
47094cc617 | ||
|
|
01c38d8c5b | ||
|
|
1f3e580cf8 | ||
|
|
562fe0ea0e | ||
|
|
d9bb94cd3f | ||
|
|
9646ccd3f6 | ||
|
|
43e5411b6a | ||
|
|
c06d293424 | ||
|
|
6c444a4a04 | ||
|
|
cc6173e5b0 | ||
|
|
5794f60c5d | ||
|
|
35f91ee37a |
@@ -1,33 +1,35 @@
|
||||
# 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
|
||||
app/artifacts/
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 2026-08-20 — 医生工作站 AI 问诊对话框布局优化
|
||||
|
||||
## 改动
|
||||
- 文件: `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
|
||||
- 模块: `AiConsultDialog` 右侧「智能分析」卡片 + 问诊对话卡片密度优化
|
||||
- 新增 helpers: `_InsightItem`, `_split_insight_segments`, `_split_numbered_list`, `_split_plain_paragraph`, `_split_label_or_sentence`, `_escape_html`
|
||||
- `_set_insights` 改成将稠密段落拆成 lead / list / plain 三类, 每类单独 QLabel 显示
|
||||
- CSS 新增 `AiConsultInsightMarker` / `AiConsultInsightText` / `AiConsultInsightPlaceholder`
|
||||
- 卡片 padding `10,8,10,8` → `14,12,14,14`; 卡间距 `8` → `10`
|
||||
- 聊天气泡垂直 margin `4` → `6`, chat_layout spacing `10` → `12`
|
||||
|
||||
## 拆分策略
|
||||
- 文本以 `1./2、/3)/4)` 起头 → 视为完整编号列表
|
||||
- 文本中嵌入 `…:1.…2.…` → 拆出序言 + 编号列表
|
||||
- 短中文标签 `XX:YY` 开头 → 视为 lead, 标签部分加粗
|
||||
- 其余按 `。/;/;/` 分句 → 单独成行
|
||||
|
||||
## 测试
|
||||
- 现有 `tests/test_ai_consult_ui.py` 通过 `widget.objectName() == "AiConsultRecordTitle"` 校验标题仍在, 未受影响
|
||||
- 暂未运行 UI 测试 (开发环境未装 PySide6); 解析逻辑单独脚本验证通过
|
||||
|
||||
## 注意
|
||||
- PySide6 QSS 对 `QLabel` 的 `line-height` 支持有限; 主要靠 item 间距 `8px` 与 `padding:1px 0` 提供节奏
|
||||
- 如果后续接入 `AiConsultInsightText` 还嫌密集, 可再调 `body_host.setSpacing`
|
||||
|
||||
---
|
||||
|
||||
# 接诊台 AI 智能分析详情对话框 (`ReceptionAiAnalysisDialog`) 优化
|
||||
用户截图(`clipboard-2026-08-20T08-42-40`...)反馈: 「问诊详情 → AI 智能分析」卡片里的诊断建议 / 风险评估 / 治疗建议三段文字太稠密, 不利于阅读。
|
||||
|
||||
## 关键差异
|
||||
这次的目标文件和上次不同 — 用户截图里出现 "诊断建议 / 风险评估 / 治疗建议" 标题, 而 `ai_consult.py` 用的是 "血糖控制评估 / 并发症风险评估 / 用药合理性评估"。实际是 `app/src/doctor_workstation/ui/pages/reception.py` 中的 `_ReceptionAiAnalysisDialog` 类。
|
||||
|
||||
## 改动
|
||||
- 文件: `app/src/doctor_workstation/ui/pages/reception.py`
|
||||
- 新增 helpers (与 ai_consult.py 类似但导出名加 `_ai_` 前缀):
|
||||
- `_ai_split_into_segments` / `_ai_split_numbered_list` / `_ai_split_plain_paragraph`
|
||||
- `_ai_segments_to_html` / `_ai_narrative_structured_html`
|
||||
- `_ai_split_label_or_sentence` / `_ai_escape_html`
|
||||
- 正则: `_AI_INSIGHT_LIST_RE`, `_AI_INSIGHT_LIST_LOOKAHEAD_RE`, `_AI_INSIGHT_LABEL_RE`
|
||||
- `add_text_section` 闭包内多挂一个 RichText `_structured_labels[name]` 标签 (`.setObjectName(name + "Structured")`, `property="dialogAiStructured"`)
|
||||
- 原 PlainText `value_label` 仍存在 (`hide()` + `setMaximumHeight(0)`) 用于兼容既有 findChild 测试
|
||||
- `_render_model` 同步刷新两个结构化标签; 空 HTML 时回退显示原 label 文本
|
||||
- 风险评估 FlowLayout spacing `7×7` → `9×9`
|
||||
- `body_layout.setSpacing` `12` → `14`
|
||||
- 卡片 contentsMargins `14,13,16,15` → `16,14,18,16`
|
||||
- QSS: `dialogAiBody` 加 `line-height: 1.85`, 新增 `dialogAiStructured` 选择器 (line-height 175%); 风险 pill `min-height` `28` → `30`, padding `4 10` → `5 12`, font 12 → 12.5, border-radius `7` → `8`
|
||||
|
||||
## 兼容约束
|
||||
- `tests/test_reception_parity_ui.py` 严格校验:
|
||||
- `findChild(QLabel, "ReceptionAiAnalysisDialogDiagnosisText")` 必须能找到
|
||||
- `.text()` 必须等于 `_ai_narrative_text` 归一化结果
|
||||
- `.textFormat() == Qt.TextFormat.PlainText`
|
||||
- 因此 PlainText 标签必须原样保留, 仅做 `.hide()` + `setMaximumHeight(0)` (避免 QSS `>` 子选择器不被 Qt 支持)
|
||||
|
||||
## 验证
|
||||
- `py_compile` 通过, 无 `SyntaxWarning`
|
||||
- 单独脚本跑过 4 组样本文本 (稠密段落 / 嵌入式编号列表 / 短句 / 纯编号列表), 拆分结果符合预期
|
||||
- 未在本机跑 UI 测试 (缺 PySide6)
|
||||
|
||||
---
|
||||
|
||||
# 患者列表 AI 分析: 发送完整患者资料给 AI
|
||||
|
||||
用户需求: 患者列表 AI 分析时, 每日血糖报告、舌苔、AI 报告、视频录制回放的文字对话都要发给 AI。
|
||||
|
||||
## 改动
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py`
|
||||
- `_load_workspace` 新增 `list_call_records` section (视频问诊记录, 含 `transcript_text`)
|
||||
- 模块级纯函数 (无 Qt 依赖, 便于测试):
|
||||
- `AI_CONTEXT_MAX_CHARS=320`, `AI_PROMPT_LIMIT=500`, `AI_CONTEXT_SEPARATOR="\n\n— 医生提问 —\n"`
|
||||
- `_truncate_for_context` / `_patient_context_blood_sugar` / `_patient_context_tongue` / `_patient_context_reports` / `_patient_context_prescriptions` / `_patient_context_videos` / `build_patient_ai_context` / `_compose_ai_prompt`
|
||||
- `build_patient_ai_context` 返回 `(text, labels)`, 按 每日血糖→舌苔/脉象→视频问诊文字→历史AI报告→处方记录 顺序装入 320 字预算的信封
|
||||
- `_patient_context_videos` 按 `diagnosis_id` 过滤归属 (`_exact_positive_id`), 只取最近一条有文字的
|
||||
- `_compose_ai_prompt` 本地强制 500 字上限: 上下文 + "— 医生提问 —" + 问题, 超长时截断问题加 `…` (服务端 `tcm.diagnosis/aiAssistant` prompt 上限 500)
|
||||
- `_ChatBubble.attach_extra()` 支持气泡下方挂载控件
|
||||
- `_render_patient_context`: 聊天区系统气泡 "AI 已加载本诊单上下文(N 项…)", `_attach_context_detail` 挂 "查看本次发送给 AI 的资料全文" 展开按钮 (QSS: `AiConsultContextToggle`/`AiConsultContextReveal`)
|
||||
- `_ask` 用 `_compose_ai_prompt(text, self._patient_ai_context)` 组装后传给 `_AiStreamWorker`; 医生气泡仍显示原始问题
|
||||
- `open_for` / `_clear_chat` 重置 `_patient_ai_context*` 三个状态, 防止跨患者残留
|
||||
|
||||
## 测试
|
||||
- `app/tests/test_ai_consult_ui.py` 新增 5 个测试 (总 15 个全通过):
|
||||
纯函数: 全 section 信封+视频按诊单过滤 / 仅基本病历返回空 / 长问题截断≤500
|
||||
UI: 上下文气泡+展开按钮+视频记录计数 / `_ask` 注入上下文 (FakeWorker 捕获 prompt, monkeypatch `QThreadPool`)
|
||||
- `app/scripts/check_ai_context.py`: AST 提取纯函数的冒烟脚本 (无需 Qt)
|
||||
- `tests/test_ai_consult_workspace_ui.py` 18 个测试无回归
|
||||
|
||||
## 环境要点 (重要)
|
||||
- **PySide6 + pytest 在 `app/.venv` 里可用**: 用 `D:/web/zyt/app/.venv/Scripts/python.exe -m pytest` 跑 UI 测试 (QT_QPA_PLATFORM=offscreen)。之前"本机缺 PySide6"的结论只对系统 Python 成立。
|
||||
- 测试 FakeWorker 需实现 `signals.event/error/finished.connect()` 和 `cancel()` (closeEvent 会调 cancel)
|
||||
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
|
||||
host?: string
|
||||
cdn_domain?: string
|
||||
key_prefix?: string
|
||||
object_key?: string
|
||||
max_size?: number
|
||||
duration?: number
|
||||
expired_time?: number
|
||||
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
|
||||
|
||||
/** 申请 STS 临时凭证 */
|
||||
export function getOssCredentials(params: { type: 'video' }) {
|
||||
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
|
||||
return request.post({
|
||||
url: '/upload/ossCredentials',
|
||||
params
|
||||
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
|
||||
|
||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||
export function confirmOssUpload(params: {
|
||||
type: 'video'
|
||||
type: OssDirectUploadType
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
|
||||
@@ -154,6 +154,51 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFansDetailParams extends FirstVisitConversionParams {
|
||||
entity_type: 'dept' | 'member'
|
||||
entity_id: string | number
|
||||
admin_id?: number
|
||||
page_no: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFanDetailRow {
|
||||
external_userid: string
|
||||
customer_name: string
|
||||
wecom_userid: string
|
||||
wecom_staff_name: string
|
||||
add_time: string | null
|
||||
is_deleted?: boolean
|
||||
delete_time?: string | null
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFansDetailResult {
|
||||
can_view_deleted_fans?: boolean
|
||||
lists?: FirstVisitConversionFanDetailRow[]
|
||||
rows?: FirstVisitConversionFanDetailRow[]
|
||||
total?: number
|
||||
count?: number
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
entity?: {
|
||||
type?: 'dept' | 'member'
|
||||
id?: string | number
|
||||
admin_id?: number
|
||||
name?: string
|
||||
add_fans_count?: number
|
||||
deleted_fans_count?: number
|
||||
}
|
||||
date_range?: [string, string]
|
||||
}
|
||||
|
||||
/** 一诊加粉明细:时间、渠道及行实体均由调用方显式透传,服务端仍需按 DataScope 收窄。 */
|
||||
export function firstVisitConversionFansDetail(params: FirstVisitConversionFansDetailParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.conversion/fansDetail', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitRegistrationStatsParams {
|
||||
time_type: 'today' | 'yesterday' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
@@ -190,21 +235,123 @@ 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 interface WecomPromotionTagGroup {
|
||||
group_id: string
|
||||
group_name: string
|
||||
tag: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export function wecomPromotionTagOptions() {
|
||||
return request.get<{ tag_groups: WecomPromotionTagGroup[] }>({
|
||||
url: '/firstvisit.wecomPromotion/tagOptions'
|
||||
})
|
||||
}
|
||||
|
||||
export function wecomPromotionCreateTag(params: { name: string }) {
|
||||
return request.post<{ tag: { id: string; name: string }; group_id: string; group_name: string; reused: boolean }>({
|
||||
url: '/firstvisit.wecomPromotion/createTag', params, timeout: 30000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionUploadWelcomeMedia(file: File, type: 'image' | 'video' | 'file') {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
data.append('type', type)
|
||||
return request.post<{ asset_id: string; name: string; type: string }>({
|
||||
url: '/firstvisit.wecomPromotion/uploadWelcomeMedia',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchSetOperatorsParams {
|
||||
pool_ids: number[]
|
||||
operator_admin_ids: number[]
|
||||
action: 'grant' | 'revoke'
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchSetOperatorsResult {
|
||||
action: 'grant' | 'revoke'
|
||||
pool_ids: number[]
|
||||
operator_admin_ids: number[]
|
||||
affected: number
|
||||
}
|
||||
|
||||
export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOperatorsParams) {
|
||||
return request.post<WecomPromotionBatchSetOperatorsResult>({
|
||||
url: '/firstvisit.wecomPromotion/batchSetOperators',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolsParams {
|
||||
pool_ids: number[]
|
||||
changes: {
|
||||
skip_verify?: 0 | 1
|
||||
fallback_url?: string
|
||||
status?: 0 | 1
|
||||
automation_config?: Record<string, unknown>
|
||||
member_status?: {
|
||||
member_admin_ids: number[]
|
||||
status: 0 | 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolResult {
|
||||
id: number
|
||||
name: string
|
||||
success: boolean
|
||||
sync_error?: string
|
||||
sync_queued?: boolean
|
||||
member_matched?: number
|
||||
member_updated?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolsResult {
|
||||
pool_ids: number[]
|
||||
updated: number
|
||||
failed: number
|
||||
sync_error_count: number
|
||||
sync_queued_count: number
|
||||
member_matched: number
|
||||
member_updated: number
|
||||
results: WecomPromotionBatchUpdatePoolResult[]
|
||||
}
|
||||
|
||||
export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) {
|
||||
return request.post<WecomPromotionBatchUpdatePoolsResult>({
|
||||
url: '/firstvisit.wecomPromotion/batchUpdatePools',
|
||||
params,
|
||||
timeout: 120000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
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 +377,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 +407,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) {
|
||||
|
||||
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
|
||||
return request.get({ url: '/qywx.customer/lists', params })
|
||||
}
|
||||
|
||||
// 删除一条本地企业微信客户同步记录
|
||||
export function qywxCustomerDelete(params: { id: number }) {
|
||||
return request.post({ url: '/qywx.customer/delete', params })
|
||||
}
|
||||
|
||||
// 同步企业微信客户
|
||||
export function qywxCustomerSync() {
|
||||
return request.post({ url: '/qywx.customer/sync' })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -578,6 +578,11 @@ export function prescriptionOrderAddLog(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||
}
|
||||
|
||||
/** 移除单笔收款关联,总金额不变,同步更新已付金额和需代收 */
|
||||
export function prescriptionOrderUnlinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/unlinkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
|
||||
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
DirectUploadApiError,
|
||||
DirectUploadFallbackError,
|
||||
uploadVideoDirectToCos
|
||||
uploadDirectToCos,
|
||||
type DirectUploadType
|
||||
} from '@/utils/oss-direct-upload'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -83,7 +85,7 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
||||
// 直传到对象存储,绕开服务器中转
|
||||
direct: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -102,8 +104,10 @@ export default defineComponent({
|
||||
const visible = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
|
||||
// 仅 video/voice + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
|
||||
const useDirect = computed(
|
||||
() => props.direct && directTypes.includes(props.type as DirectUploadType)
|
||||
)
|
||||
|
||||
const handleProgress = () => {
|
||||
visible.value = true
|
||||
@@ -131,7 +135,10 @@ export default defineComponent({
|
||||
fileList.value = []
|
||||
emit('allSuccess')
|
||||
}
|
||||
feedback.msgError(`${file.name}文件上传失败`)
|
||||
if (!(event instanceof DirectUploadApiError)) {
|
||||
const message = event instanceof Error ? event.message : ''
|
||||
feedback.msgError(message || `${file.name}文件上传失败`)
|
||||
}
|
||||
uploadRefs.value?.abort(file)
|
||||
visible.value = false
|
||||
emit('change', file)
|
||||
@@ -153,18 +160,20 @@ export default defineComponent({
|
||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||
case 'voice':
|
||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||
case 'desktop_package':
|
||||
return '.exe,.zip'
|
||||
default:
|
||||
return '*'
|
||||
}
|
||||
})
|
||||
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
||||
// 走 COS 直传:成功时模拟老接口的响应 envelope
|
||||
const httpRequest = async (options: UploadRequestOptions) => {
|
||||
visible.value = true
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
const data = await uploadDirectToCos({
|
||||
file: options.file,
|
||||
type: props.type as any,
|
||||
type: props.type as DirectUploadType,
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
@@ -178,6 +187,12 @@ export default defineComponent({
|
||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||
} catch (err: any) {
|
||||
if (err instanceof DirectUploadFallbackError) {
|
||||
if (props.type === 'desktop_package') {
|
||||
;(options as any).onError?.(
|
||||
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
|
||||
)
|
||||
return
|
||||
}
|
||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||
await defaultXhrUpload(options)
|
||||
return
|
||||
|
||||
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
|
||||
import {
|
||||
confirmOssUpload,
|
||||
getOssCredentials,
|
||||
type OssCredentialsResponse
|
||||
type OssCredentialsResponse,
|
||||
type OssDirectUploadType
|
||||
} from '@/api/file'
|
||||
|
||||
export type DirectUploadType = 'video'
|
||||
export type DirectUploadType = OssDirectUploadType
|
||||
|
||||
export interface DirectUploadProgress {
|
||||
/** 0-100 */
|
||||
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
|
||||
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ASYNC_LIMIT = 3
|
||||
|
||||
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await request()
|
||||
} catch (error) {
|
||||
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
|
||||
throw new DirectUploadApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function buildKey(prefix: string, file: File): string {
|
||||
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
|
||||
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
|
||||
const ts = Date.now()
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
return `${prefix}${ts}-${rand}.${ext}`
|
||||
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
|
||||
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
||||
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
||||
*/
|
||||
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
|
||||
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
|
||||
getOssCredentials({
|
||||
type: options.type,
|
||||
name: options.file.name
|
||||
})
|
||||
)
|
||||
|
||||
if (credentials.fallback) {
|
||||
const handled = options.onFallback?.(credentials.provider) ?? false
|
||||
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
|
||||
if (credentials.max_size && options.file.size > credentials.max_size) {
|
||||
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
||||
throw new Error(`视频体积超出上限(${mb}MB)`)
|
||||
throw new Error(`文件体积超出上限(${mb}MB)`)
|
||||
}
|
||||
|
||||
const cred = credentials.credentials
|
||||
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
}
|
||||
})
|
||||
|
||||
const key = buildKey(credentials.key_prefix, options.file)
|
||||
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cos.uploadFile(
|
||||
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
||||
)
|
||||
})
|
||||
|
||||
const confirmed = await confirmOssUpload({
|
||||
type: options.type,
|
||||
key,
|
||||
name: options.file.name,
|
||||
size: options.file.size,
|
||||
content_type: options.file.type || '',
|
||||
cid: options.cid ?? 0
|
||||
})
|
||||
const confirmed = await callDirectUploadApi(() =>
|
||||
confirmOssUpload({
|
||||
type: options.type,
|
||||
key,
|
||||
name: options.file.name,
|
||||
size: options.file.size,
|
||||
content_type: options.file.type || '',
|
||||
cid: options.cid ?? 0
|
||||
})
|
||||
)
|
||||
|
||||
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
||||
|
||||
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
|
||||
this.provider = provider
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
|
||||
export class DirectUploadApiError extends Error {
|
||||
readonly originalError: unknown
|
||||
|
||||
constructor(error: unknown) {
|
||||
super('')
|
||||
this.name = 'DirectUploadApiError'
|
||||
this.originalError = error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +541,19 @@
|
||||
{{ formatOrderTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUnlinkPayOrder" label="操作" width="80" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
:loading="unlinkPayOrderId === Number(row.id)"
|
||||
:disabled="unlinkPayOrderId !== null || ![2, 5].includes(Number(row.status))"
|
||||
:title="Number(row.status) === 4 ? '已退款收款记录不可移除' : '解除关联并更新已付金额,总金额不变'"
|
||||
@click="confirmUnlinkPayOrder(row)"
|
||||
>移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||
</el-card>
|
||||
@@ -1014,6 +1027,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderLogisticsJdUpdate,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderUnlinkPayOrder,
|
||||
prescriptionOrderPatchPrescriptionUsage
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
const detailLogs = ref<any[]>([])
|
||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||
const unlinkPayOrderId = ref<number | null>(null)
|
||||
const canUnlinkPayOrder = computed(() =>
|
||||
!props.readonly && canUpdateAmount(detailData.value) && hasPerm('tcm.prescriptionOrder/unlinkPayOrder')
|
||||
)
|
||||
|
||||
async function confirmUnlinkPayOrder(row: { id: number; order_no?: string; amount: number | string; status: number | string }) {
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const payOrderId = Number(row.id)
|
||||
if (!canUnlinkPayOrder.value || !orderId || !payOrderId || unlinkPayOrderId.value !== null) return
|
||||
if (![2, 5].includes(Number(row.status))) return
|
||||
|
||||
unlinkPayOrderId.value = payOrderId
|
||||
try {
|
||||
await feedback.confirm(
|
||||
`确定移除收款记录「${row.order_no || '#' + payOrderId}」(¥${formatMoney(row.amount)})?` +
|
||||
'仅解除关联,原收款记录保留;订单总金额不变,已付金额与需代收金额按剩余关联收款重新计算。'
|
||||
)
|
||||
if (!detailVisible.value || Number(detailData.value?.id) !== orderId) return
|
||||
const res: any = await prescriptionOrderUnlinkPayOrder({ id: orderId, pay_order_id: payOrderId })
|
||||
const updated = res?.data ?? res
|
||||
if (Number(detailData.value?.id) === orderId && Number(updated?.id) === orderId) {
|
||||
Object.assign(detailData.value!, updated)
|
||||
}
|
||||
feedback.msgSuccess('收款关联已移除,金额已同步更新')
|
||||
emit('detail-changed')
|
||||
await refreshIfCurrent(orderId)
|
||||
} catch {
|
||||
/* 取消不提交;接口错误由拦截器提示,保留当前收款记录 */
|
||||
} finally {
|
||||
unlinkPayOrderId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const detailPrescription = computed(() => {
|
||||
const p = detailData.value?.prescription
|
||||
|
||||
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
unlink_pay_order: '移除收款关联',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -94,15 +94,32 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
placeholder="跟进人姓名(后台姓名或企微账号)"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select
|
||||
v-model="queryParams.add_way"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择或搜索添加渠道"
|
||||
style="width: 240px"
|
||||
@change="resetPage"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in ADD_WAY_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
<el-date-picker
|
||||
v-model="addTimeRange"
|
||||
type="daterange"
|
||||
@@ -235,6 +252,32 @@
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(row).slice(0, 1)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="inline-block max-w-[150px] truncate align-middle">
|
||||
{{ source.label }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="customerAddSources(row).length > 1"
|
||||
:content="remainingAddSourcesTooltip(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="text-primary whitespace-nowrap cursor-help">
|
||||
另 {{ customerAddSources(row).length - 1 }} 条
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未记录</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(firstExternalAddTime(row)) }}
|
||||
@@ -245,9 +288,19 @@
|
||||
{{ formatTime(row.update_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
||||
<el-button
|
||||
v-perms="['qywx.customer/delete']"
|
||||
type="danger"
|
||||
link
|
||||
:loading="deletingCustomerId === Number(row.id)"
|
||||
:disabled="deletingCustomerId !== null"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -500,6 +553,21 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加时间" :span="2">
|
||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加渠道" :span="2">
|
||||
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(currentCustomer)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="info" effect="plain">
|
||||
{{ source.label }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未记录</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatTime(currentCustomer.update_time) }}
|
||||
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxCustomerLists,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerLists,
|
||||
qywxCustomerDelete,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerStats,
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave,
|
||||
@@ -563,6 +632,7 @@ const syncing = ref(false)
|
||||
const showSyncSettings = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const currentCustomer = ref<any>(null)
|
||||
const deletingCustomerId = ref<number | null>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
@@ -591,17 +661,19 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
tag_ids: string[]
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
add_way: number | ''
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
dedupe_mode: 'first' | 'any'
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: [],
|
||||
name: '',
|
||||
follow_user: '',
|
||||
add_way: '',
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: '',
|
||||
dedupe_mode: 'first'
|
||||
@@ -639,6 +711,22 @@ interface TagStatsPayload {
|
||||
groups: TagGroup[]
|
||||
}
|
||||
|
||||
interface AddChannel {
|
||||
state: string
|
||||
label: string
|
||||
source_type: 'promotion_pool' | 'state'
|
||||
pool_id: number
|
||||
user_id: string
|
||||
event_time: number
|
||||
}
|
||||
|
||||
interface AddSource extends AddChannel {
|
||||
key: string
|
||||
add_way: number | null
|
||||
channel_label: string
|
||||
staff_name: string
|
||||
}
|
||||
|
||||
const tagStats = reactive<TagStatsPayload>({
|
||||
total_tags: 0,
|
||||
total_relations: 0,
|
||||
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.add_way = ''
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
queryParams.dedupe_mode = 'first'
|
||||
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: Record<string, any>) {
|
||||
const id = Number(row.id)
|
||||
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
|
||||
|
||||
const customerName = String(row.name || row.external_userid || '该客户')
|
||||
try {
|
||||
await feedback.confirm(
|
||||
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deletingCustomerId.value = id
|
||||
try {
|
||||
await qywxCustomerDelete({ id })
|
||||
if (pager.page > 1 && pager.lists.length === 1) {
|
||||
pager.page -= 1
|
||||
}
|
||||
await Promise.all([getLists(), loadStats(), loadTagStats()])
|
||||
feedback.msgSuccess('删除成功')
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || e?.msg || '删除失败')
|
||||
} finally {
|
||||
deletingCustomerId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
||||
function formatFollowUser(user: Record<string, any>) {
|
||||
const adminName = String(user?.admin_name ?? '').trim()
|
||||
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.add_channels)) {
|
||||
return row.add_channels
|
||||
.map((channel: Record<string, any>): AddChannel => ({
|
||||
state: String(channel?.state ?? '').trim(),
|
||||
label: String(channel?.label ?? channel?.state ?? '').trim(),
|
||||
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
|
||||
pool_id: Number(channel?.pool_id ?? 0),
|
||||
user_id: String(channel?.user_id ?? '').trim(),
|
||||
event_time: Number(channel?.event_time ?? 0)
|
||||
}))
|
||||
.filter((channel: AddChannel) => channel.state !== '')
|
||||
}
|
||||
|
||||
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
|
||||
if (!Array.isArray(row.add_channel_states)) return []
|
||||
return row.add_channel_states
|
||||
.map((state: unknown) => String(state ?? '').trim())
|
||||
.filter((state: string) => state !== '')
|
||||
.map((state: string) => ({
|
||||
state,
|
||||
label: state,
|
||||
source_type: 'state' as const,
|
||||
pool_id: 0,
|
||||
user_id: '',
|
||||
event_time: 0
|
||||
}))
|
||||
}
|
||||
|
||||
const ADD_WAY_LABELS: Record<number, string> = {
|
||||
0: '未知添加方式',
|
||||
1: '通过扫描二维码添加',
|
||||
2: '通过搜索手机号添加',
|
||||
3: '通过名片分享添加',
|
||||
4: '通过群聊添加',
|
||||
5: '通过手机通讯录添加',
|
||||
6: '通过微信联系人添加',
|
||||
8: '安装第三方应用时自动添加',
|
||||
9: '通过搜索邮箱添加',
|
||||
10: '通过视频号添加',
|
||||
11: '通过日程参与人添加',
|
||||
12: '通过会议参与人添加',
|
||||
13: '通过微信好友添加',
|
||||
14: '通过智慧硬件专属客服添加',
|
||||
15: '通过上门服务客服添加',
|
||||
16: '通过获客链接添加',
|
||||
17: '通过定制开发添加',
|
||||
18: '通过需求回复添加',
|
||||
21: '通过第三方售前客服添加',
|
||||
22: '通过可能的商务伙伴添加',
|
||||
24: '通过接受微信好友申请添加',
|
||||
201: '通过内部成员共享添加',
|
||||
202: '通过管理员或负责人分配添加'
|
||||
}
|
||||
|
||||
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label
|
||||
}))
|
||||
|
||||
function normalizeAddWay(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
|
||||
return Number(value.trim())
|
||||
}
|
||||
|
||||
function addWayLabel(addWay: number) {
|
||||
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})`
|
||||
}
|
||||
|
||||
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
|
||||
if (!row) return []
|
||||
|
||||
const channels = customerAddChannels(row)
|
||||
const usedChannelIndexes = new Set<number>()
|
||||
const sources: AddSource[] = []
|
||||
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
|
||||
|
||||
followUsers.forEach((user: Record<string, any>, index: number) => {
|
||||
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
|
||||
const state = String(user?.state ?? user?.State ?? '').trim()
|
||||
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
|
||||
|
||||
let channelIndex = channels.findIndex(
|
||||
(channel, i) =>
|
||||
!usedChannelIndexes.has(i) &&
|
||||
userId !== '' &&
|
||||
state !== '' &&
|
||||
channel.user_id === userId &&
|
||||
channel.state === state
|
||||
)
|
||||
if (channelIndex < 0 && state !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
|
||||
)
|
||||
}
|
||||
if (channelIndex < 0 && userId !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
|
||||
)
|
||||
}
|
||||
|
||||
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
|
||||
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
|
||||
if (addWay === null && state === '' && !channel) return
|
||||
|
||||
const labelFromApi = String(user?.add_way_label ?? '').trim()
|
||||
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
|
||||
const label = labelFromApi || (addWay !== null
|
||||
? addWayLabel(addWay)
|
||||
: sourceType === 'promotion_pool'
|
||||
? '通过获客链接添加'
|
||||
: '通过其他渠道添加')
|
||||
|
||||
sources.push({
|
||||
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
|
||||
add_way: addWay,
|
||||
label,
|
||||
state: state || channel?.state || '',
|
||||
channel_label: channel?.label || '',
|
||||
source_type: sourceType,
|
||||
pool_id: channel?.pool_id || 0,
|
||||
user_id: userId || channel?.user_id || '',
|
||||
staff_name: formatFollowUser(user),
|
||||
event_time: channel?.event_time || Number(user?.createtime ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
|
||||
channels.forEach((channel, index) => {
|
||||
if (usedChannelIndexes.has(index)) return
|
||||
sources.push({
|
||||
...channel,
|
||||
key: `channel:${index}:${channel.user_id}:${channel.state}`,
|
||||
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
|
||||
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
|
||||
channel_label: channel.label,
|
||||
staff_name: channel.user_id || '—'
|
||||
})
|
||||
})
|
||||
|
||||
return sources.sort((a, b) => b.event_time - a.event_time)
|
||||
}
|
||||
|
||||
function addSourceTooltip(source: AddSource) {
|
||||
const parts: string[] = []
|
||||
parts.push(`添加方式:${source.label}`)
|
||||
if (source.source_type === 'promotion_pool' && source.channel_label) {
|
||||
parts.push(`获客助手方案:${source.channel_label}`)
|
||||
}
|
||||
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
|
||||
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
|
||||
if (source.state) parts.push(`渠道参数:${source.state}`)
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function remainingAddSourcesTooltip(row: Record<string, any>) {
|
||||
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
||||
* 再解析 follow_users;最后退回 create_time
|
||||
|
||||
@@ -98,8 +98,17 @@
|
||||
<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>
|
||||
<small>{{ metric.hint }}</small>
|
||||
<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 }}<template v-if="metric.key === 'add_fans_count' && canViewDeletedFans">;(-N)为其中已删除</template></small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -145,7 +154,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -178,7 +187,27 @@
|
||||
</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 }">
|
||||
<button
|
||||
v-if="hasFans(row.add_fans_count)"
|
||||
type="button"
|
||||
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
|
||||
title="查看该行加粉组合明细"
|
||||
@click.stop="openFansDetail(row)"
|
||||
>
|
||||
{{ formatNumber(row.add_fans_count) }}
|
||||
<em
|
||||
v-if="hasDeletedFans(row.deleted_fans_count)"
|
||||
class="deleted-fans-marker"
|
||||
title="加粉总数中已删除"
|
||||
>
|
||||
(-{{ formatNumber(row.deleted_fans_count) }})
|
||||
</em>
|
||||
</button>
|
||||
<span v-else class="fan-count-value is-empty">0</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" />
|
||||
@@ -270,6 +299,75 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="fansDetailVisible"
|
||||
:title="fansDetailTitle"
|
||||
width="920px"
|
||||
top="7vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="fans-detail-dialog"
|
||||
@closed="resetFansDetail"
|
||||
>
|
||||
<div class="fans-detail-context">
|
||||
<span><b>统计区间</b>{{ detailRangeText }}</span>
|
||||
<span><b>媒体渠道</b>{{ detailChannelText }}</span>
|
||||
<span><b>数据范围</b>{{ dashboard.meta.scope_label || '当前权限范围' }}</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="fansDetailLoading"
|
||||
:data="fansDetailRows"
|
||||
:row-class-name="fansDetailRowClassName"
|
||||
height="430"
|
||||
class="fans-detail-table"
|
||||
>
|
||||
<el-table-column label="客户" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<div class="customer-cell">
|
||||
<strong class="customer-name" :class="{ 'is-deleted': isDeletedFan(row) }">
|
||||
{{ fanCustomerName(row) }}
|
||||
</strong>
|
||||
<small v-if="row.customer_name && row.external_userid">{{ row.external_userid }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属员工" min-width="160">
|
||||
<template #default="{ row }">{{ fanOwnerName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" min-width="180">
|
||||
<template #default="{ row }">{{ fanAddTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canViewDeletedFansDetail" label="状态" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="isDeletedFan(row)" class="deleted-status">
|
||||
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
|
||||
<small v-if="row.delete_time">{{ row.delete_time }}</small>
|
||||
</div>
|
||||
<el-tag v-else type="success" effect="plain" size="small">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :image-size="68" description="当前条件下暂无加粉组合明细" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="fans-detail-footer">
|
||||
<span>共 {{ formatNumber(fansDetailPager.total) }} 位客户</span>
|
||||
<el-pagination
|
||||
v-if="fansDetailPager.total > 0"
|
||||
v-model:current-page="fansDetailPager.page_no"
|
||||
v-model:page-size="fansDetailPager.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="fansDetailPager.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadFansDetail"
|
||||
@size-change="handleFansDetailSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -278,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
||||
import {
|
||||
firstVisitConversionFansDetail,
|
||||
firstVisitConversionOverview,
|
||||
type FirstVisitConversionFanDetailRow,
|
||||
type FirstVisitConversionFansDetailParams,
|
||||
type FirstVisitConversionFansDetailResult,
|
||||
type FirstVisitConversionParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'ratio'
|
||||
type MediaChannelOption = {
|
||||
@@ -298,7 +403,7 @@ const emptyDashboard = () => ({
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
||||
can_view_finance: false
|
||||
can_view_finance: false, can_view_deleted_fans: false
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
@@ -324,6 +429,14 @@ const query = reactive<FirstVisitConversionParams>({
|
||||
media_channel_code: ''
|
||||
})
|
||||
const customDateRange = ref<string[]>([])
|
||||
const fansDetailVisible = ref(false)
|
||||
const fansDetailLoading = ref(false)
|
||||
const fansDetailCanViewDeletedFans = ref(false)
|
||||
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
|
||||
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
|
||||
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
|
||||
type: 'dept', id: 0, name: '当前行'
|
||||
})
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
@@ -335,7 +448,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
@@ -353,6 +466,9 @@ const scopeDescription = computed(() => {
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
||||
// 仅服务端按登录账号精确判定为admin时放行;缺失、字符串或角色权限均不能兜底放行。
|
||||
const canViewDeletedFans = computed(() => dashboard.meta.can_view_deleted_fans === true)
|
||||
const canViewDeletedFansDetail = computed(() => canViewDeletedFans.value && fansDetailCanViewDeletedFans.value)
|
||||
const visibleMetricCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? metricCards
|
||||
@@ -402,6 +518,13 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉组合明细`)
|
||||
const detailRangeText = computed(() => {
|
||||
const startDate = dashboard.meta.start_date || query.start_date || ''
|
||||
const endDate = dashboard.meta.end_date || query.end_date || ''
|
||||
return startDate && endDate ? `${startDate} 至 ${endDate}` : (dashboard.meta.time_label || '当前区间')
|
||||
})
|
||||
const detailChannelText = computed(() => dashboard.meta.selected_media_channel_name || '全部渠道')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const targetChartOption = computed(() => ({
|
||||
@@ -418,10 +541,12 @@ const targetChartOption = computed(() => ({
|
||||
}))
|
||||
|
||||
let latestDashboardRequestId = 0
|
||||
let latestFansDetailRequestId = 0
|
||||
|
||||
async function loadDashboard() {
|
||||
const requestId = ++latestDashboardRequestId
|
||||
loading.value = true
|
||||
dashboard.meta.can_view_deleted_fans = false
|
||||
try {
|
||||
const params: FirstVisitConversionParams = { ...query }
|
||||
if (params.time_type === 'custom') {
|
||||
@@ -470,6 +595,92 @@ function handleDeptChange() {
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function openFansDetail(row: any) {
|
||||
fansDetailEntity.value = {
|
||||
type: row.type ? 'member' : 'dept',
|
||||
id: row.id,
|
||||
adminId: Number(row.admin_id || 0) || undefined,
|
||||
name: row.name || '当前行'
|
||||
}
|
||||
fansDetailPager.page_no = 1
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.total = 0
|
||||
fansDetailVisible.value = true
|
||||
loadFansDetail()
|
||||
}
|
||||
|
||||
async function loadFansDetail() {
|
||||
if (!fansDetailVisible.value) return
|
||||
const requestId = ++latestFansDetailRequestId
|
||||
fansDetailLoading.value = true
|
||||
fansDetailCanViewDeletedFans.value = false
|
||||
try {
|
||||
const params: FirstVisitConversionFansDetailParams = {
|
||||
...query,
|
||||
time_type: query.time_type,
|
||||
start_date: dashboard.meta.start_date || query.start_date,
|
||||
end_date: dashboard.meta.end_date || query.end_date,
|
||||
media_channel_code: dashboard.meta.selected_media_channel_code || query.media_channel_code || '',
|
||||
entity_type: fansDetailEntity.value.type,
|
||||
entity_id: fansDetailEntity.value.id,
|
||||
admin_id: fansDetailEntity.value.adminId,
|
||||
page_no: fansDetailPager.page_no,
|
||||
page_size: fansDetailPager.page_size
|
||||
}
|
||||
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
fansDetailCanViewDeletedFans.value = result?.can_view_deleted_fans === true
|
||||
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
|
||||
fansDetailRows.value = rows
|
||||
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
|
||||
} catch (error: any) {
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.total = 0
|
||||
ElMessage.error(error?.message || '加粉组合明细加载失败')
|
||||
} finally {
|
||||
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFansDetailSizeChange() {
|
||||
fansDetailPager.page_no = 1
|
||||
loadFansDetail()
|
||||
}
|
||||
|
||||
function resetFansDetail() {
|
||||
++latestFansDetailRequestId
|
||||
fansDetailLoading.value = false
|
||||
fansDetailCanViewDeletedFans.value = false
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.page_no = 1
|
||||
fansDetailPager.total = 0
|
||||
}
|
||||
|
||||
function hasFans(value: any) {
|
||||
return Math.round(Number(value || 0)) > 0
|
||||
}
|
||||
|
||||
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
|
||||
return canViewDeletedFansDetail.value && Boolean(row.is_deleted)
|
||||
}
|
||||
|
||||
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.customer_name || row.external_userid || '未知客户'
|
||||
}
|
||||
|
||||
function fanOwnerName(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.wecom_staff_name || row.wecom_userid || '未绑定员工'
|
||||
}
|
||||
|
||||
function fanAddTime(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.add_time || '-'
|
||||
}
|
||||
|
||||
function fansDetailRowClassName({ row }: { row: FirstVisitConversionFanDetailRow }) {
|
||||
return isDeletedFan(row) ? 'is-deleted-fan-row' : ''
|
||||
}
|
||||
|
||||
function formatMetric(key: string, type: MetricType) {
|
||||
const value = dashboard.summary[key]
|
||||
if (type === 'money') return formatMoney(value)
|
||||
@@ -481,6 +692,10 @@ function formatNumber(value: any) {
|
||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function hasDeletedFans(value: any) {
|
||||
return canViewDeletedFans.value && Math.round(Number(value || 0)) > 0
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
@@ -621,6 +836,41 @@ 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; }
|
||||
.fan-detail-trigger {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible { outline: 2px solid #2f78df; outline-offset: 3px; border-radius: 3px; }
|
||||
&:disabled { cursor: default; }
|
||||
}
|
||||
.deleted-fans-marker {
|
||||
color: #c65f26;
|
||||
font-size: .58em;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fan-count-value .deleted-fans-marker { font-size: 11px; }
|
||||
.fan-detail-trigger--table { color: #1769aa; font-weight: 650; }
|
||||
.fan-detail-trigger--table:hover { color: #0f4f82; }
|
||||
.fan-count-value.is-empty { color: #98a2b3; }
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -683,6 +933,47 @@ onMounted(loadDashboard)
|
||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||
.target-chart { width: 100%; height: 260px; }
|
||||
|
||||
.fans-detail-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin: -4px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e9ef;
|
||||
border-radius: 7px;
|
||||
color: #5d6b7e;
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
|
||||
span { display: inline-flex; gap: 7px; }
|
||||
b { color: #8a95a5; font-weight: 500; }
|
||||
}
|
||||
.fans-detail-table {
|
||||
width: 100%;
|
||||
|
||||
.customer-cell { display: grid; gap: 3px; }
|
||||
.customer-name { color: #273347; font-weight: 650; }
|
||||
.customer-name.is-deleted { color: #c24137; text-decoration: line-through; text-decoration-color: #e8a39e; }
|
||||
.customer-cell small { overflow: hidden; color: #98a2b3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
:deep(.is-deleted-fan-row > td.el-table__cell) { background: #fff3f2 !important; }
|
||||
}
|
||||
.deleted-status {
|
||||
display: inline-grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
|
||||
small { color: #b14e47; font-size: 10px; }
|
||||
}
|
||||
.fans-detail-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
color: #8491a2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.range-text { margin-left: 0; }
|
||||
@@ -698,5 +989,7 @@ onMounted(loadDashboard)
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
.fans-detail-footer { align-items: flex-start; flex-direction: column; }
|
||||
.fans-detail-footer :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="automation-form">
|
||||
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': receptionDisabled }">
|
||||
<h3 class="form-section-title">接待设置</h3>
|
||||
<el-form-item label="接待模式">
|
||||
<el-radio-group v-model="config.reception_mode" :disabled="receptionDisabled">
|
||||
<el-radio value="always">全天接待</el-radio>
|
||||
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="field-help">成员仍受每日上限、有效期及启用状态限制;时间统一使用北京时间。</p>
|
||||
</el-form-item>
|
||||
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
|
||||
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
|
||||
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="receptionDisabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="receptionDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||
<el-select v-model="slot.member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
|
||||
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
|
||||
</el-select>
|
||||
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员,请重新选择。</p>
|
||||
</div>
|
||||
<el-button :icon="Plus" :disabled="receptionDisabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
|
||||
<p class="field-help">最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。</p>
|
||||
</div>
|
||||
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
|
||||
<el-select v-model="config.backup_member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
|
||||
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="backupExcludedIds.includes(Number(member.id))" />
|
||||
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
|
||||
</el-select>
|
||||
<p class="field-help">备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。</p>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': customerDisabled }">
|
||||
<h3 class="form-section-title">客户设置</h3>
|
||||
<el-form-item label="自动添加客户标签">
|
||||
<el-switch v-model="config.tags_enabled" :disabled="customerDisabled || tagsCreating" />
|
||||
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
|
||||
<p>原方案设置了多个标签:{{ selectedTagNames }}。现在仅支持单选,请重新选择一个标签,或清空原标签。</p>
|
||||
<el-button size="small" :disabled="customerDisabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
|
||||
</div>
|
||||
<div v-if="config.tags_enabled" class="full-width tags-content">
|
||||
<div class="tag-select-row">
|
||||
<el-select v-model="selectedTag" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
|
||||
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
|
||||
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
|
||||
</el-option-group>
|
||||
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
|
||||
</el-select>
|
||||
<el-button :icon="Plus" :disabled="customerDisabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
||||
<el-button :icon="Refresh" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
|
||||
</div>
|
||||
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签,点击“重试”重新加载。</p>
|
||||
<p v-else class="field-help">每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。客户添加成功后由系统调用企微接口打标,不会显示在企微获客链接详情的“客户标签”配置中。</p>
|
||||
<div v-if="showCustomTag" class="custom-tag-editor">
|
||||
<label for="promotion-custom-tag-name">自定义标签名称</label>
|
||||
<div class="custom-tag-row">
|
||||
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="customerDisabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
||||
<el-button type="primary" :disabled="customerDisabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
||||
</div>
|
||||
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
||||
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
||||
</div>
|
||||
<p v-if="customTagSuccess" role="status" class="tag-success">{{ customTagSuccess }}</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动设置客户备注">
|
||||
<el-switch v-model="config.remark_enabled" :disabled="customerDisabled" />
|
||||
<div v-if="config.remark_enabled" class="full-width remark-content">
|
||||
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="customerDisabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
||||
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="customerDisabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
|
||||
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 字</small></div>
|
||||
<p class="field-help">示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动设置客户描述">
|
||||
<el-switch v-model="config.description_enabled" :disabled="customerDisabled" />
|
||||
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="customerDisabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': welcomeDisabled }">
|
||||
<h3 class="form-section-title">欢迎语设置</h3>
|
||||
<el-form-item label="欢迎语模式">
|
||||
<el-radio-group v-model="config.welcome_mode" :disabled="welcomeDisabled || anyUploading">
|
||||
<el-radio value="channel">渠道欢迎语</el-radio>
|
||||
<el-radio value="default">默认欢迎语</el-radio>
|
||||
<el-radio value="none">不发送欢迎语</el-radio>
|
||||
</el-radio-group>
|
||||
<p v-if="config.welcome_mode === 'default'" class="field-help">沿用企业微信后台配置,本系统不发送欢迎语。</p>
|
||||
<p v-else-if="config.welcome_mode === 'none'" class="field-help warning-help">仅关闭本系统欢迎语,无法覆盖或关闭企业微信后台已配置的欢迎语。</p>
|
||||
<p v-else class="field-help warning-help">渠道欢迎语由客户添加回调即时发送,不会显示在企微获客链接详情中。同一接待成员若已配置企微原生欢迎语,企微可能不再下发欢迎码,请先关闭冲突配置。</p>
|
||||
</el-form-item>
|
||||
<template v-if="config.welcome_mode === 'channel'">
|
||||
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语,或新客户添加时间未匹配任何时段时,使用以下内容。</p>
|
||||
<WelcomeMessageEditor v-model="config.welcome" :disabled="welcomeDisabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
|
||||
</div>
|
||||
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="welcomeDisabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配,时段不能重叠</span></el-form-item>
|
||||
<div v-if="config.welcome_schedule_enabled">
|
||||
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
|
||||
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="welcomeDisabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="welcomeDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||
<WelcomeMessageEditor :model-value="slot" :disabled="welcomeDisabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
|
||||
</div>
|
||||
<el-button :icon="Plus" :disabled="welcomeDisabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
||||
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { wecomPromotionCreateTag, wecomPromotionTagOptions } from '@/api/first_visit'
|
||||
import type { WecomPromotionTagGroup } from '@/api/first_visit'
|
||||
import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
|
||||
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
|
||||
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
|
||||
|
||||
type AutomationSection = 'reception' | 'customer' | 'welcome'
|
||||
const props = defineProps<{
|
||||
modelValue: PromotionAutomationConfig
|
||||
mainMemberIds: number[]
|
||||
members: PromotionMemberChoice[]
|
||||
disabled?: boolean
|
||||
disabledSections?: AutomationSection[]
|
||||
backupExcludedMemberIds?: number[]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
||||
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section))
|
||||
const receptionDisabled = computed(() => sectionDisabled('reception'))
|
||||
const customerDisabled = computed(() => sectionDisabled('customer'))
|
||||
const welcomeDisabled = computed(() => sectionDisabled('welcome'))
|
||||
const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds)
|
||||
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
|
||||
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
|
||||
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
|
||||
const remarkPreview = computed(() => previewTemplate(config.value.remark_template, employeeName.value, 20))
|
||||
const remarkInput = ref<{ input?: HTMLInputElement }>()
|
||||
const remarkSelection = ref({ start: 0, end: 0 })
|
||||
const tagGroups = ref<WecomPromotionTagGroup[]>([])
|
||||
const tagsLoading = ref(false)
|
||||
const tagsError = ref('')
|
||||
const tagsLoaded = ref(false)
|
||||
const tagsCreating = ref(false)
|
||||
const showCustomTag = ref(false)
|
||||
const customTagName = ref('')
|
||||
const customTagError = ref('')
|
||||
const customTagSuccess = ref('')
|
||||
const hasMultipleTags = computed(() => config.value.tag_ids.length > 1)
|
||||
const selectedTag = computed({
|
||||
get: () => config.value.tag_ids.length === 1 ? config.value.tag_ids[0] : '',
|
||||
set: (id: string | undefined) => {
|
||||
config.value.tag_ids = id ? [id] : []
|
||||
customTagSuccess.value = ''
|
||||
}
|
||||
})
|
||||
const selectedTagNames = computed(() => {
|
||||
const tags = tagGroups.value.flatMap((group) => group.tag)
|
||||
return config.value.tag_ids.map((id) => tags.find((tag) => tag.id === id)?.name || id).join('、')
|
||||
})
|
||||
const busyEditors = ref(new Set<string>())
|
||||
const anyUploading = computed(() => busyEditors.value.size > 0)
|
||||
watch([tagsCreating, anyUploading], ([creating, uploading]) => emit('busy', creating || uploading), { flush: 'sync' })
|
||||
const unknownTagIds = computed(() => {
|
||||
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
|
||||
return config.value.tag_ids.filter((id) => !ids.has(id))
|
||||
})
|
||||
watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => {
|
||||
if (enabled && !sectionIsDisabled && !tagsLoaded.value && !tagsLoading.value) void loadTags()
|
||||
}, { immediate: true })
|
||||
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
|
||||
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
|
||||
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
|
||||
function removeWelcomeSlot(index: number) { config.value.welcome_schedule.splice(index, 1); busyEditors.value.clear() }
|
||||
function updateBusy(key: string, busy: boolean) { busy ? busyEditors.value.add(key) : busyEditors.value.delete(key) }
|
||||
async function loadTags() {
|
||||
if (tagsCreating.value || tagsLoading.value) return
|
||||
tagsLoading.value = true
|
||||
tagsError.value = ''
|
||||
try {
|
||||
const result = await wecomPromotionTagOptions()
|
||||
if (!Array.isArray(result?.tag_groups)) throw new Error('标签接口未返回有效的标签列表。')
|
||||
tagGroups.value = result.tag_groups.map((group) => ({ ...group, tag: Array.isArray(group.tag) ? group.tag : [] }))
|
||||
tagsLoaded.value = true
|
||||
} catch (error: unknown) {
|
||||
tagsError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '企业微信标签加载失败。'
|
||||
} finally { tagsLoading.value = false }
|
||||
}
|
||||
async function createCustomTag() {
|
||||
if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return
|
||||
customTagError.value = validateCustomTagName(customTagName.value)
|
||||
customTagSuccess.value = ''
|
||||
if (customTagError.value) return
|
||||
tagsCreating.value = true
|
||||
try {
|
||||
const result = await wecomPromotionCreateTag({ name: customTagName.value.trim() })
|
||||
if (!result?.tag?.id || !result.tag.name || !result.group_id) throw new Error('企业微信未返回有效的标签 ID,请刷新标签后确认。')
|
||||
let group = tagGroups.value.find((item) => item.group_id === result.group_id)
|
||||
if (!group) {
|
||||
group = { group_id: result.group_id, group_name: result.group_name, tag: [] }
|
||||
tagGroups.value.push(group)
|
||||
}
|
||||
const existing = group.tag.findIndex((tag) => tag.id === result.tag.id)
|
||||
if (existing >= 0) group.tag[existing] = result.tag
|
||||
else group.tag.push(result.tag)
|
||||
selectedTag.value = result.tag.id
|
||||
customTagSuccess.value = `${result.reused ? '已选用已有' : '已创建并选用'}标签“${result.tag.name}”,保存方案后生效。`
|
||||
customTagName.value = ''
|
||||
showCustomTag.value = false
|
||||
} catch (error: unknown) {
|
||||
customTagError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '自定义标签创建失败,请刷新确认或重试。'
|
||||
} finally { tagsCreating.value = false }
|
||||
}
|
||||
function rememberRemarkSelection() {
|
||||
const input = remarkInput.value?.input
|
||||
if (input) remarkSelection.value = { start: input.selectionStart || 0, end: input.selectionEnd || 0 }
|
||||
}
|
||||
async function insertRemark(token: string) {
|
||||
const { start, end } = remarkSelection.value
|
||||
config.value.remark_template = config.value.remark_template.slice(0, start) + token + config.value.remark_template.slice(end)
|
||||
await nextTick()
|
||||
const cursor = start + token.length
|
||||
remarkInput.value?.input?.focus()
|
||||
remarkInput.value?.input?.setSelectionRange(cursor, cursor)
|
||||
remarkSelection.value = { start: cursor, end: cursor }
|
||||
}
|
||||
onBeforeUnmount(() => emit('busy', false))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
||||
.automation-section { min-width: 0; transition: opacity .2s ease; }.automation-section.is-disabled { opacity: .58; }
|
||||
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
|
||||
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
|
||||
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
|
||||
.tag-select-row .el-button + .el-button { margin-left: 0; }
|
||||
.custom-tag-editor { margin-top: 12px; padding: 14px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
|
||||
.custom-tag-editor label { display: block; font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||
.custom-tag-row { display: flex; align-items: center; gap: 10px; }
|
||||
.custom-tag-row .el-input { flex: 1; min-width: 0; }
|
||||
.legacy-tags-warning { margin-top: 10px; padding: 10px 12px; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; color: #9f6d14; }
|
||||
.legacy-tags-warning p { margin: 0 0 6px; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.tag-success { margin: 8px 0 0; color: #27864c; font-size: 12px; line-height: 1.7; }
|
||||
@media (max-width: 620px) { .tag-select-row { flex-direction: column; }.remark-preview { flex-wrap: wrap; }.automation-form :deep(.el-radio) { margin-right: 14px; }.weekday-select { gap: 0 12px; } }
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="welcome-editor">
|
||||
<div class="welcome-editor__fields">
|
||||
<div class="text-tools">
|
||||
<el-popover placement="bottom-start" trigger="click" :width="240" :disabled="disabled">
|
||||
<template #reference><el-button size="small" :disabled="disabled">☺ 插入表情</el-button></template>
|
||||
<div class="emoji-grid"><button v-for="emoji in emojis" :key="emoji" type="button" @click="insertText(emoji)">{{ emoji }}</button></div>
|
||||
</el-popover>
|
||||
<el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertText(token.value)">{{ token.label }}</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
ref="textInput"
|
||||
:model-value="modelValue.text"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:disabled="disabled"
|
||||
placeholder="请输入欢迎语,也可插入客户昵称、员工昵称和添加日期"
|
||||
@update:model-value="updateText"
|
||||
@select="rememberSelection"
|
||||
@keyup="rememberSelection"
|
||||
@click="rememberSelection"
|
||||
@blur="rememberSelection"
|
||||
/>
|
||||
<div class="text-count" :class="{ 'is-error': textTooLong }">{{ Array.from(modelValue.text).length }}/1200 字 · {{ utf8Length(modelValue.text) }}/4000 字节</div>
|
||||
<div class="attachments-heading">
|
||||
<strong>附件 <span>{{ modelValue.attachments.length }}/9</span></strong>
|
||||
<el-dropdown :disabled="disabled || modelValue.attachments.length >= 9 || uploading" @command="addAttachment">
|
||||
<el-button size="small" :disabled="disabled || modelValue.attachments.length >= 9 || uploading" :icon="Plus">添加附件</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item in attachmentTypes" :key="item.value" :command="item.value">{{ item.label }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div v-if="!modelValue.attachments.length" class="attachment-empty">可添加图片、网页、小程序、视频或文件。</div>
|
||||
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="attachment-card">
|
||||
<div class="attachment-card__heading">
|
||||
<strong>{{ index + 1 }}. {{ attachmentLabel(attachment.msgtype) }}</strong>
|
||||
<div>
|
||||
<el-button text size="small" :disabled="disabled || uploading || index === 0" @click="moveAttachment(index, -1)">上移</el-button>
|
||||
<el-button text size="small" :disabled="disabled || uploading || index === modelValue.attachments.length - 1" @click="moveAttachment(index, 1)">下移</el-button>
|
||||
<el-button text type="danger" size="small" :disabled="disabled || uploading" @click="removeAttachment(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="attachment.msgtype === 'image' || attachment.msgtype === 'video' || attachment.msgtype === 'file'">
|
||||
<div class="upload-field">
|
||||
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, attachment.msgtype)">{{ assetId(attachment) ? '重新上传' : '上传' }}{{ attachmentLabel(attachment.msgtype) }}</el-button>
|
||||
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||
</div>
|
||||
<small class="field-tip">{{ attachment.msgtype === 'image' ? 'JPG / PNG,最多 10MB。' : attachment.msgtype === 'video' ? 'MP4,最多 10MB。' : '文件最多 20MB。' }}上传成功后由服务端保存素材;未上传完成不能提交。</small>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'link'">
|
||||
<label class="attachment-label">网页标题 <span>{{ utf8Length(attachment.link.title) }}/128 字节</span></label>
|
||||
<el-input v-model="attachment.link.title" :disabled="disabled" placeholder="必填:网页标题" />
|
||||
<label class="attachment-label">网页地址</label>
|
||||
<el-input v-model="attachment.link.url" :disabled="disabled" placeholder="https://" />
|
||||
<label class="attachment-label">网页描述 <span>{{ utf8Length(attachment.link.desc) }}/512 字节</span></label>
|
||||
<el-input v-model="attachment.link.desc" :disabled="disabled" type="textarea" :rows="2" placeholder="选填:网页摘要" />
|
||||
<label class="attachment-label">网页封面地址</label>
|
||||
<el-input v-model="attachment.link.picurl" :disabled="disabled" placeholder="选填:公网可访问的 HTTPS 图片地址" />
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||
<label class="attachment-label">小程序标题 <span>{{ utf8Length(attachment.miniprogram.title) }}/64 字节</span></label>
|
||||
<el-input v-model="attachment.miniprogram.title" :disabled="disabled" placeholder="必填:小程序标题" />
|
||||
<label class="attachment-label">AppID</label>
|
||||
<el-input v-model="attachment.miniprogram.appid" :disabled="disabled" placeholder="必填:小程序 AppID" />
|
||||
<label class="attachment-label">页面路径</label>
|
||||
<el-input v-model="attachment.miniprogram.page" :disabled="disabled" placeholder="pages/index/index,可包含查询参数" />
|
||||
<div class="upload-field mini-upload">
|
||||
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, 'image')">{{ attachment.miniprogram.pic_asset_id ? '更换封面' : '上传小程序封面' }}</el-button>
|
||||
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||
</div>
|
||||
<small class="field-tip">JPG / PNG,最多 10MB。</small>
|
||||
</template>
|
||||
<div v-if="uploadErrors[index]" role="alert" class="upload-error">{{ uploadErrors[index] }} 请重试上传,原素材未被替换。</div>
|
||||
</div>
|
||||
<input ref="fileInput" class="file-input" type="file" :accept="fileAccept" @change="uploadSelectedFile" />
|
||||
</div>
|
||||
<aside class="welcome-preview" aria-label="欢迎语手机预览">
|
||||
<div class="phone-heading"><span>‹</span><strong>{{ employeeName || '接待员工' }}</strong><span>···</span></div>
|
||||
<div class="phone-content">
|
||||
<div class="preview-time">添加成功 · 预览示例</div>
|
||||
<div v-if="modelValue.text" class="chat-row"><span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span><div class="chat-bubble">{{ previewTemplate(modelValue.text, employeeName) }}</div></div>
|
||||
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="chat-row">
|
||||
<span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span>
|
||||
<div class="chat-bubble attachment-preview">
|
||||
<template v-if="attachment.msgtype === 'image'">
|
||||
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="欢迎语图片预览" />
|
||||
<div v-else class="media-placeholder"><el-icon><Picture /></el-icon><span>{{ assetId(attachment) ? '已保存的图片素材' : '待上传图片' }}</span></div>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'link'">
|
||||
<strong>{{ attachment.link.title || '网页标题' }}</strong><p>{{ attachment.link.desc || '网页摘要' }}</p><small>网页链接</small>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||
<small>小程序</small><strong>{{ attachment.miniprogram.title || '小程序标题' }}</strong>
|
||||
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="小程序封面预览" />
|
||||
<div v-else class="media-placeholder"><el-icon><Grid /></el-icon><span>小程序封面</span></div>
|
||||
</template>
|
||||
<template v-else><el-icon class="file-icon"><VideoPlay v-if="attachment.msgtype === 'video'" /><Document v-else /></el-icon><strong>{{ assetName(attachment) }}</strong><small>{{ attachmentLabel(attachment.msgtype) }}</small></template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!modelValue.text && !modelValue.attachments.length" class="preview-empty">编辑内容后在这里预览</div>
|
||||
</div>
|
||||
<div class="phone-input"><span>⊕</span><span class="phone-input__blank" /><span>☺</span></div>
|
||||
<p class="preview-note">仅为排版示意,昵称与时间会替换为实际值。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Document, Grid, Picture, Plus, Upload, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { wecomPromotionUploadWelcomeMedia } from '@/api/first_visit'
|
||||
import { previewTemplate, templateTokens, utf8Length } from './promotion-automation'
|
||||
import type { WelcomeAttachment, WelcomeMessage } from './promotion-automation'
|
||||
|
||||
const props = defineProps<{ modelValue: WelcomeMessage; disabled?: boolean; employeeName?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: WelcomeMessage]; busy: [value: boolean] }>()
|
||||
const textInput = ref<{ textarea?: HTMLTextAreaElement }>()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selection = ref({ start: 0, end: 0 })
|
||||
const uploadingIndex = ref(-1)
|
||||
const uploading = computed(() => uploadingIndex.value >= 0)
|
||||
const uploadErrors = ref<Record<number, string>>({})
|
||||
const assetCache = ref<Record<string, { name: string; url?: string }>>({})
|
||||
const selectedUpload = ref<{ index: number; type: 'image' | 'video' | 'file' }>({ index: 0, type: 'image' })
|
||||
const fileAccept = ref('image/jpeg,image/png')
|
||||
const emojis = ['😊', '😀', '👋', '🌹', '❤️', '👍', '🙏', '🎉', '☀️', '✨', '💐', '🤝', '💬', '✅', '🌿', '🍀']
|
||||
const attachmentTypes: Array<{ value: WelcomeAttachment['msgtype']; label: string }> = [
|
||||
{ value: 'image', label: '图片' }, { value: 'link', label: '网页' }, { value: 'miniprogram', label: '小程序' },
|
||||
{ value: 'video', label: '视频' }, { value: 'file', label: '文件' }
|
||||
]
|
||||
const textTooLong = computed(() => Array.from(props.modelValue.text).length > 1200 || utf8Length(props.modelValue.text) > 4000)
|
||||
|
||||
function updateText(text: string) { emit('update:modelValue', { ...props.modelValue, text }) }
|
||||
function rememberSelection() {
|
||||
const textarea = textInput.value?.textarea
|
||||
if (textarea) selection.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
|
||||
}
|
||||
async function insertText(text: string) {
|
||||
if (props.disabled) return
|
||||
const { start, end } = selection.value
|
||||
updateText(props.modelValue.text.slice(0, start) + text + props.modelValue.text.slice(end))
|
||||
await nextTick()
|
||||
const cursor = start + text.length
|
||||
textInput.value?.textarea?.focus()
|
||||
textInput.value?.textarea?.setSelectionRange(cursor, cursor)
|
||||
selection.value = { start: cursor, end: cursor }
|
||||
}
|
||||
function attachmentLabel(type: string) { return attachmentTypes.find((item) => item.value === type)?.label || '附件' }
|
||||
function addAttachment(type: WelcomeAttachment['msgtype']) {
|
||||
if (props.disabled || uploading.value || props.modelValue.attachments.length >= 9) return
|
||||
let attachment: WelcomeAttachment
|
||||
if (type === 'image') attachment = { msgtype: 'image', image: { asset_id: '' } }
|
||||
else if (type === 'video') attachment = { msgtype: 'video', video: { asset_id: '' } }
|
||||
else if (type === 'file') attachment = { msgtype: 'file', file: { asset_id: '' } }
|
||||
else if (type === 'link') attachment = { msgtype: 'link', link: { title: '', url: '', desc: '', picurl: '' } }
|
||||
else attachment = { msgtype: 'miniprogram', miniprogram: { title: '', appid: '', page: '', pic_asset_id: '' } }
|
||||
emit('update:modelValue', { ...props.modelValue, attachments: [...props.modelValue.attachments, attachment] })
|
||||
}
|
||||
function removeAttachment(index: number) {
|
||||
uploadErrors.value = {}
|
||||
emit('update:modelValue', { ...props.modelValue, attachments: props.modelValue.attachments.filter((_, position) => position !== index) })
|
||||
}
|
||||
function moveAttachment(index: number, direction: number) {
|
||||
const attachments = [...props.modelValue.attachments]
|
||||
;[attachments[index], attachments[index + direction]] = [attachments[index + direction], attachments[index]]
|
||||
uploadErrors.value = {}
|
||||
emit('update:modelValue', { ...props.modelValue, attachments })
|
||||
}
|
||||
function assetId(attachment: WelcomeAttachment): string {
|
||||
if (attachment.msgtype === 'image') return attachment.image.asset_id || ''
|
||||
if (attachment.msgtype === 'video') return attachment.video.asset_id
|
||||
if (attachment.msgtype === 'file') return attachment.file.asset_id
|
||||
if (attachment.msgtype === 'miniprogram') return attachment.miniprogram.pic_asset_id
|
||||
return ''
|
||||
}
|
||||
function assetName(attachment: WelcomeAttachment) {
|
||||
const id = assetId(attachment)
|
||||
if (id) return assetCache.value[id]?.name || `已保存素材 ${id.slice(-10)}`
|
||||
return attachment.msgtype === 'image' && attachment.image.pic_url ? '已保存的企微图片' : '尚未上传'
|
||||
}
|
||||
function assetPreview(attachment: WelcomeAttachment) { return assetCache.value[assetId(attachment)]?.url || '' }
|
||||
async function selectFile(index: number, type: 'image' | 'video' | 'file') {
|
||||
selectedUpload.value = { index, type }
|
||||
fileAccept.value = type === 'image' ? '.jpg,.jpeg,.png' : type === 'video' ? '.mp4' : '*'
|
||||
await nextTick()
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function uploadSelectedFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const { index, type } = selectedUpload.value
|
||||
const limit = type === 'file' ? 20 : 10
|
||||
let error = ''
|
||||
if (file.size <= 5 || file.size > limit * 1024 * 1024) error = `文件必须大于 5 字节且不超过 ${limit}MB`
|
||||
else if (type === 'image' && !/\.(jpe?g|png)$/i.test(file.name)) error = '图片仅支持 JPG / PNG 格式'
|
||||
else if (type === 'video' && !/\.mp4$/i.test(file.name)) error = '视频仅支持 MP4 格式'
|
||||
if (error) { uploadErrors.value[index] = error; return }
|
||||
const attachment = props.modelValue.attachments[index]
|
||||
if (!attachment) return
|
||||
uploadingIndex.value = index
|
||||
emit('busy', true)
|
||||
delete uploadErrors.value[index]
|
||||
try {
|
||||
const result = await wecomPromotionUploadWelcomeMedia(file, type)
|
||||
if (!result?.asset_id) throw new Error('上传接口未返回素材 ID')
|
||||
const id = String(result.asset_id)
|
||||
assetCache.value[id] = { name: result.name || file.name, url: type === 'image' ? URL.createObjectURL(file) : undefined }
|
||||
if (attachment.msgtype === 'image') { attachment.image = { asset_id: id } }
|
||||
else if (attachment.msgtype === 'video') attachment.video.asset_id = id
|
||||
else if (attachment.msgtype === 'file') attachment.file.asset_id = id
|
||||
else if (attachment.msgtype === 'miniprogram') attachment.miniprogram.pic_asset_id = id
|
||||
ElMessage.success('素材已上传')
|
||||
} catch (error: unknown) {
|
||||
uploadErrors.value[index] = error instanceof Error ? error.message : typeof error === 'string' ? error : '素材上传失败'
|
||||
} finally {
|
||||
uploadingIndex.value = -1
|
||||
emit('busy', false)
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
Object.values(assetCache.value).forEach((asset) => { if (asset.url) URL.revokeObjectURL(asset.url) })
|
||||
emit('busy', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-editor { display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 22px; width: 100%; }
|
||||
.welcome-editor__fields { min-width: 0; }
|
||||
.text-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.text-tools .el-button + .el-button { margin-left: 0; }
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; }.emoji-grid button { border: 0; background: none; padding: 4px; cursor: pointer; font-size: 20px; }
|
||||
.text-count { text-align: right; font-size: 12px; color: #909399; margin-top: 4px; }.text-count.is-error, .upload-error { color: #d93026; }
|
||||
.attachments-heading, .attachment-card__heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.attachments-heading { margin: 16px 0 10px; }.attachments-heading strong { font-size: 13px; }.attachments-heading strong span { color: #909399; font-weight: 400; }
|
||||
.attachment-empty { padding: 18px 12px; color: #909399; background: #f7f8fa; border: 1px dashed #dcdfe6; border-radius: 4px; font-size: 12px; }
|
||||
.attachment-card { border: 1px solid #e4e7ed; border-radius: 5px; padding: 12px; margin-top: 10px; }.attachment-card__heading { margin-bottom: 10px; }.attachment-card__heading strong { font-size: 13px; }.attachment-card__heading .el-button { padding: 4px; margin: 0; }
|
||||
.attachment-label { display: block; font-size: 12px; color: #606266; margin: 10px 0 4px; }.attachment-label span { color: #909399; float: right; }.upload-field { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; overflow-wrap: anywhere; }.mini-upload { margin-top: 12px; }.asset-ready { color: #178758; }.muted { color: #909399; }.field-tip { display: block; color: #909399; line-height: 1.6; margin-top: 6px; }.upload-error { font-size: 12px; line-height: 1.6; margin-top: 6px; }.file-input { display: none; }
|
||||
.welcome-preview { width: 260px; border: 1px solid #dcdfe6; border-radius: 20px; padding: 7px; background: #fff; overflow: hidden; }
|
||||
.phone-heading { display: flex; justify-content: space-between; align-items: center; padding: 13px 12px; background: #ededed; border-radius: 14px 14px 0 0; font-size: 13px; }.phone-heading > span { font-size: 19px; }
|
||||
.phone-content { min-height: 330px; max-height: 520px; overflow: auto; background: #ededed; padding: 0 10px 18px; }.preview-time { font-size: 10px; text-align: center; color: #999; padding: 12px 0 18px; }.chat-row { display: flex; gap: 7px; margin-bottom: 12px; align-items: flex-start; }.chat-avatar { width: 27px; height: 27px; background: #6e92ae; color: white; flex-shrink: 0; border-radius: 4px; display: grid; place-items: center; font-size: 11px; }.chat-bubble { background: #fff; padding: 9px 10px; border-radius: 4px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-width: 172px; }.attachment-preview { width: 172px; }.attachment-preview strong { display: block; font-weight: 500; font-size: 12px; }.attachment-preview p { color: #909399; font-size: 10px; margin: 6px 0; }.attachment-preview small { display: block; font-size: 9px; color: #909399; margin-top: 7px; }.attachment-preview img { width: 100%; max-height: 160px; object-fit: contain; display: block; }.media-placeholder { background: #f2f5f7; height: 85px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; font-size: 10px; }.media-placeholder .el-icon, .file-icon { font-size: 28px; color: #8babc3; }.preview-empty { text-align: center; color: #aaa; font-size: 12px; margin-top: 100px; }.phone-input { display: flex; gap: 10px; padding: 9px; background: #f6f6f6; border-radius: 0 0 14px 14px; align-items: center; color: #909399; }.phone-input__blank { flex: 1; height: 24px; border-radius: 3px; background: white; }.preview-note { margin: 10px 6px 6px; font-size: 11px; color: #909399; line-height: 1.6; }
|
||||
@media (max-width: 850px) { .welcome-editor { grid-template-columns: 1fr; }.welcome-preview { margin: 8px auto 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
export interface PromotionMemberChoice {
|
||||
id: number
|
||||
name: string
|
||||
userid: string
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
export type WelcomeAttachment =
|
||||
| { msgtype: 'image'; image: { asset_id?: string; pic_url?: string } }
|
||||
| { msgtype: 'video'; video: { asset_id: string } }
|
||||
| { msgtype: 'file'; file: { asset_id: string } }
|
||||
| { msgtype: 'link'; link: { title: string; url: string; desc: string; picurl?: string } }
|
||||
| { msgtype: 'miniprogram'; miniprogram: { title: string; appid: string; page: string; pic_asset_id: string } }
|
||||
|
||||
export interface WelcomeMessage {
|
||||
text: string
|
||||
attachments: WelcomeAttachment[]
|
||||
}
|
||||
|
||||
export interface WeeklySlot {
|
||||
weekdays: number[]
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export interface ReceptionSlot extends WeeklySlot {
|
||||
member_admin_ids: number[]
|
||||
}
|
||||
|
||||
export interface WelcomeSlot extends WeeklySlot, WelcomeMessage {}
|
||||
|
||||
export interface PromotionAutomationConfig {
|
||||
reception_mode: 'always' | 'scheduled'
|
||||
reception_schedule: ReceptionSlot[]
|
||||
backup_member_admin_ids: number[]
|
||||
tags_enabled: boolean
|
||||
tag_ids: string[]
|
||||
remark_enabled: boolean
|
||||
remark_template: string
|
||||
description_enabled: boolean
|
||||
description: string
|
||||
welcome_mode: 'default' | 'channel' | 'none'
|
||||
welcome: WelcomeMessage
|
||||
welcome_schedule_enabled: boolean
|
||||
welcome_schedule: WelcomeSlot[]
|
||||
}
|
||||
|
||||
export type PromotionAutomationSaveConfig = Omit<PromotionAutomationConfig,
|
||||
'tags_enabled' | 'remark_enabled' | 'description_enabled' | 'welcome_schedule_enabled'> & {
|
||||
tags_enabled: 0 | 1
|
||||
remark_enabled: 0 | 1
|
||||
description_enabled: 0 | 1
|
||||
welcome_schedule_enabled: 0 | 1
|
||||
}
|
||||
|
||||
export const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
|
||||
export const templateTokens = [
|
||||
{ label: '客户昵称', value: '{customer_name}' },
|
||||
{ label: '员工昵称', value: '{employee_name}' },
|
||||
{ label: '添加时间', value: '{add_time}' }
|
||||
]
|
||||
|
||||
export const utf8Length = (value: string): number => new TextEncoder().encode(value).length
|
||||
|
||||
export function validateCustomTagName(value: string): string {
|
||||
if (!value.trim()) return '请输入自定义标签名称'
|
||||
if (/[\p{C}\u2028\u2029]/u.test(value)) return '标签名称不能包含换行、控制字符或不可见格式字符'
|
||||
if (Array.from(value.trim()).length > 30) return '标签名称最多 30 个字符'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function defaultAutomationConfig(): PromotionAutomationConfig {
|
||||
return {
|
||||
reception_mode: 'always', reception_schedule: [], backup_member_admin_ids: [],
|
||||
tags_enabled: false, tag_ids: [], remark_enabled: false, remark_template: '{customer_name}',
|
||||
description_enabled: false, description: '', welcome_mode: 'default',
|
||||
welcome: { text: '', attachments: [] }, welcome_schedule_enabled: false, welcome_schedule: []
|
||||
}
|
||||
}
|
||||
|
||||
// Only copy the editable contract. Server-resolved userids never come back in a save payload.
|
||||
export function cloneAutomationConfig(source?: Partial<PromotionAutomationConfig> | null): PromotionAutomationConfig {
|
||||
const defaults = defaultAutomationConfig()
|
||||
if (!source) return defaults
|
||||
const copy = JSON.parse(JSON.stringify(source)) as PromotionAutomationConfig
|
||||
return {
|
||||
reception_mode: copy.reception_mode === 'scheduled' ? 'scheduled' : 'always',
|
||||
reception_schedule: (copy.reception_schedule || []).map(({ weekdays, start, end, member_admin_ids }) => ({
|
||||
weekdays: [...weekdays], start, end, member_admin_ids: member_admin_ids.map(Number)
|
||||
})),
|
||||
backup_member_admin_ids: (copy.backup_member_admin_ids || []).map(Number),
|
||||
tags_enabled: Boolean(copy.tags_enabled), tag_ids: [...(copy.tag_ids || [])],
|
||||
remark_enabled: Boolean(copy.remark_enabled), remark_template: copy.remark_template ?? defaults.remark_template,
|
||||
description_enabled: Boolean(copy.description_enabled), description: copy.description || '',
|
||||
welcome_mode: copy.welcome_mode || 'default',
|
||||
welcome: { text: copy.welcome?.text || '', attachments: copy.welcome?.attachments || [] },
|
||||
welcome_schedule_enabled: Boolean(copy.welcome_schedule_enabled),
|
||||
welcome_schedule: (copy.welcome_schedule || []).map(({ weekdays, start, end, text, attachments }) => ({
|
||||
weekdays: [...weekdays], start, end, text: text || '', attachments: attachments || []
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the save payload compatible with backends and transports that use 0/1 switches. */
|
||||
export function serializeAutomationConfig(config: PromotionAutomationConfig): PromotionAutomationSaveConfig {
|
||||
return {
|
||||
...config,
|
||||
tags_enabled: config.tags_enabled ? 1 : 0,
|
||||
remark_enabled: config.remark_enabled ? 1 : 0,
|
||||
description_enabled: config.description_enabled ? 1 : 0,
|
||||
welcome_schedule_enabled: config.welcome_schedule_enabled ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function previewTemplate(template: string, employee = '小陈', limit?: number): string {
|
||||
const date = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(new Date())
|
||||
const text = template.replace(/\{(customer_name|employee_name|add_time)\}/g, (token) => ({
|
||||
'{customer_name}': '张女士', '{employee_name}': employee, '{add_time}': date
|
||||
}[token] || token))
|
||||
return limit ? Array.from(text).slice(0, limit).join('') : text
|
||||
}
|
||||
|
||||
export function isWebUrl(value: string): boolean {
|
||||
try { return ['https:', 'http:'].includes(new URL(value).protocol) } catch { return false }
|
||||
}
|
||||
|
||||
function validateSlot(slot: WeeklySlot, label: string): string {
|
||||
if (!slot.weekdays.length || slot.weekdays.some((day) => day < 1 || day > 7)) return `${label}请选择星期`
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.start) || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.end)) return `${label}请填写有效的起止时间`
|
||||
if (slot.start === slot.end) return `${label}开始与结束时间不能相同,全天接待请选择全天模式`
|
||||
return ''
|
||||
}
|
||||
|
||||
export function validateWelcomeMessage(message: WelcomeMessage, label: string, required = true): string {
|
||||
if (Array.from(message.text).length > 1200) return `${label}正文不能超过 1200 字`
|
||||
if (utf8Length(message.text) > 4000) return `${label}正文不能超过 4000 UTF-8 字节,请减少表情或文字`
|
||||
if (message.attachments.length > 9) return `${label}最多添加 9 个附件`
|
||||
if (required && !message.text.trim() && !message.attachments.length) return `${label}请填写正文或添加附件`
|
||||
for (const [index, attachment] of message.attachments.entries()) {
|
||||
const prefix = `${label}第 ${index + 1} 个附件:`
|
||||
switch (attachment.msgtype) {
|
||||
case 'image':
|
||||
if (!attachment.image.asset_id && !isWebUrl(attachment.image.pic_url || '')) return prefix + '请上传图片或填写有效图片地址'
|
||||
break
|
||||
case 'video':
|
||||
if (!attachment.video.asset_id) return prefix + '请先上传视频'
|
||||
break
|
||||
case 'file':
|
||||
if (!attachment.file.asset_id) return prefix + '请先上传文件'
|
||||
break
|
||||
case 'link':
|
||||
if (!attachment.link.title.trim() || !isWebUrl(attachment.link.url)) return prefix + '请填写网页标题和有效的 HTTP/HTTPS 链接'
|
||||
if (utf8Length(attachment.link.title) > 128 || utf8Length(attachment.link.desc) > 512) return prefix + '网页标题限 128 字节,描述限 512 字节'
|
||||
if (attachment.link.picurl && !isWebUrl(attachment.link.picurl)) return prefix + '网页封面地址无效'
|
||||
break
|
||||
case 'miniprogram':
|
||||
if (!attachment.miniprogram.title.trim() || !attachment.miniprogram.appid.trim() || !attachment.miniprogram.page.trim() || !attachment.miniprogram.pic_asset_id) return prefix + '请填写小程序标题、AppID、页面路径并上传封面'
|
||||
if (utf8Length(attachment.miniprogram.title) > 64) return prefix + '小程序标题不能超过 64 字节'
|
||||
break
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Weekly minute occupancy also catches Sunday-to-Monday and overnight overlap. */
|
||||
export function welcomeScheduleOverlap(slots: WeeklySlot[]): boolean {
|
||||
const minutes = new Set<number>()
|
||||
for (const slot of slots) {
|
||||
const [sh, sm] = slot.start.split(':').map(Number)
|
||||
const [eh, em] = slot.end.split(':').map(Number)
|
||||
const start = sh * 60 + sm
|
||||
const duration = (eh * 60 + em - start + 1440) % 1440
|
||||
for (const day of new Set(slot.weekdays)) {
|
||||
for (let offset = 0; offset < duration; offset++) {
|
||||
const minute = ((day - 1) * 1440 + start + offset) % 10080
|
||||
if (minutes.has(minute)) return true
|
||||
minutes.add(minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function validateAutomationConfig(config: PromotionAutomationConfig, mainMemberIds: number[]): string {
|
||||
if (config.backup_member_admin_ids.some((id) => mainMemberIds.includes(id))) return '备用成员不能与主接待成员重复'
|
||||
if (config.reception_schedule.length > 30 || config.welcome_schedule.length > 30) return '每类时段最多添加 30 条'
|
||||
if (config.reception_mode === 'scheduled') {
|
||||
if (!config.reception_schedule.length) return '按时段接待至少需要一个接待时段'
|
||||
if (!config.backup_member_admin_ids.length) return '按时段接待至少需要一名备用成员,以承接非接待时段的客户'
|
||||
}
|
||||
for (const [index, slot] of config.reception_schedule.entries()) {
|
||||
const error = validateSlot(slot, `接待时段 ${index + 1}:`)
|
||||
if (error) return error
|
||||
if (!slot.member_admin_ids.length) return `接待时段 ${index + 1}:请至少选择一名成员`
|
||||
if (slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))) return `接待时段 ${index + 1}:成员必须来自主接待成员,请重新选择`
|
||||
}
|
||||
if (config.tags_enabled && !config.tag_ids.length) return '启用客户标签后,请选择一个企业微信标签,或创建自定义标签'
|
||||
if (config.tag_ids.length > 1) return '客户标签只能选择一个,请重新选择或清空原标签'
|
||||
if (config.remark_enabled && !config.remark_template.trim()) return '请填写客户备注模板'
|
||||
if (Array.from(config.remark_template).length > 200) return '客户备注模板不能超过 200 字'
|
||||
if (config.description_enabled && !config.description.trim()) return '请填写客户描述'
|
||||
if (Array.from(config.description).length > 150) return '客户描述不能超过 150 字'
|
||||
const error = validateWelcomeMessage(config.welcome, '基础渠道欢迎语:', config.welcome_mode === 'channel')
|
||||
if (error) return error
|
||||
if (config.welcome_mode === 'channel' && config.welcome_schedule_enabled && !config.welcome_schedule.length) return '请至少添加一个分时欢迎语时段'
|
||||
for (const [index, slot] of config.welcome_schedule.entries()) {
|
||||
const error = validateSlot(slot, `欢迎语时段 ${index + 1}:`) || validateWelcomeMessage(slot, `欢迎语时段 ${index + 1}:`)
|
||||
if (error) return error
|
||||
}
|
||||
if (welcomeScheduleOverlap(config.welcome_schedule)) return '分时欢迎语的时间范围不能重叠(跨午夜时段归属开始日)'
|
||||
return ''
|
||||
}
|
||||
@@ -677,6 +677,32 @@
|
||||
<el-option label="驼奶费用" :value="8" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="canEditOrderTime && isEditPaymentTimeEditable"
|
||||
label="支付时间"
|
||||
prop="payment_time"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="editOrderForm.payment_time"
|
||||
type="datetime"
|
||||
placeholder="请选择支付时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:clearable="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canEditOrderTime" label="创建时间" prop="create_time">
|
||||
<el-date-picker
|
||||
v-model="editOrderForm.create_time"
|
||||
type="datetime"
|
||||
placeholder="请选择创建时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:clearable="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOrderDialogVisible = false">取消</el-button>
|
||||
@@ -782,6 +808,7 @@
|
||||
<script setup lang="ts" name="orderList">
|
||||
import { computed } from 'vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import {
|
||||
orderLists,
|
||||
orderDetail,
|
||||
@@ -1015,9 +1042,33 @@ const editOrderFormRef = ref()
|
||||
const editOrderLoading = ref(false)
|
||||
const editPatientLoading = ref(false)
|
||||
const editPatientList = ref<any[]>([])
|
||||
const editOrderForm = ref<{ id: number; patient_id: number | null; order_type: number } | null>(null)
|
||||
type EditOrderForm = {
|
||||
id: number
|
||||
patient_id: number | null
|
||||
order_type: number
|
||||
status: number
|
||||
payment_time: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
const editOrderForm = ref<EditOrderForm | null>(null)
|
||||
const canEditOrderTime = computed(() => hasPermission(['order.order/editTime']))
|
||||
const isEditPaymentTimeEditable = computed(() => [2, 4].includes(editOrderForm.value?.status ?? 0))
|
||||
const editOrderRules = {
|
||||
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }]
|
||||
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
||||
payment_time: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (isEditPaymentTimeEditable.value && !value) {
|
||||
callback(new Error('请选择支付时间'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 搜索患者
|
||||
@@ -1289,11 +1340,37 @@ const getCreateTypeText = (row: any) => {
|
||||
}
|
||||
|
||||
// 编辑订单
|
||||
const normalizeOrderDateTime = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '' || value === '-') return ''
|
||||
|
||||
const raw = String(value).trim()
|
||||
const canonicalDateTime = raw.match(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/)?.[0]
|
||||
if (canonicalDateTime) return canonicalDateTime.replace('T', ' ')
|
||||
|
||||
const numericTimestamp = /^\d{10,13}$/.test(raw) ? Number(raw) : 0
|
||||
const parsed = new Date(
|
||||
numericTimestamp
|
||||
? numericTimestamp < 1_000_000_000_000
|
||||
? numericTimestamp * 1000
|
||||
: numericTimestamp
|
||||
: raw
|
||||
)
|
||||
if (Number.isNaN(parsed.getTime())) return ''
|
||||
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(
|
||||
parsed.getHours()
|
||||
)}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`
|
||||
}
|
||||
|
||||
const handleEditOrder = (row: any) => {
|
||||
editOrderForm.value = {
|
||||
id: row.id,
|
||||
patient_id: row.patient_id || null,
|
||||
order_type: row.order_type
|
||||
order_type: row.order_type,
|
||||
status: Number(row.status),
|
||||
payment_time: [2, 4].includes(Number(row.status)) ? normalizeOrderDateTime(row.payment_time) : '',
|
||||
create_time: normalizeOrderDateTime(row.create_time)
|
||||
}
|
||||
editPatientList.value = row.patient ? [row.patient] : []
|
||||
editOrderDialogVisible.value = true
|
||||
@@ -1320,11 +1397,18 @@ const submitEditOrder = async () => {
|
||||
try {
|
||||
await editOrderFormRef.value?.validate()
|
||||
editOrderLoading.value = true
|
||||
await orderEdit({
|
||||
const payload: Record<string, unknown> = {
|
||||
id: editOrderForm.value.id,
|
||||
patient_id: editOrderForm.value.patient_id ?? 0,
|
||||
order_type: editOrderForm.value.order_type
|
||||
})
|
||||
}
|
||||
if (canEditOrderTime.value) {
|
||||
payload.create_time = editOrderForm.value.create_time
|
||||
if (isEditPaymentTimeEditable.value) {
|
||||
payload.payment_time = editOrderForm.value.payment_time
|
||||
}
|
||||
}
|
||||
await orderEdit(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
editOrderDialogVisible.value = false
|
||||
getLists()
|
||||
|
||||
@@ -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,352 @@
|
||||
<template>
|
||||
<div class="desktop-workstation">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
class="ls-form"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
scroll-to-error
|
||||
>
|
||||
<el-card shadow="never" class="!border-none">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-5">
|
||||
<template #title>医生工作站升级包</template>
|
||||
<div class="leading-6">
|
||||
桌面端启动后会检测本页发布的版本。请上传或填写与
|
||||
<code>一键打包</code>
|
||||
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
||||
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
||||
安装包通常超过 200MB,本页上传按钮会直传到已配置的腾讯云 COS;也可以
|
||||
自行上传到其他对象存储 / 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
|
||||
v-perms="['setting.desktop_workstation/setConfig']"
|
||||
type="desktop_package"
|
||||
direct
|
||||
: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">
|
||||
安装包将分片直传腾讯云 COS,不经过业务服务器(支持 EXE / ZIP,最大
|
||||
2GB)。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="SHA-256">
|
||||
<div class="w-[640px]">
|
||||
<el-input
|
||||
v-model.trim="formData.packages[item.key].sha256"
|
||||
placeholder="64 位十六进制,来自 SHA256SUMS.txt 或打包生成的 .sha256"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="文件名">
|
||||
<div class="w-[480px]">
|
||||
<el-input
|
||||
v-model.trim="formData.packages[item.key].filename"
|
||||
:placeholder="
|
||||
formData.packages[item.key].type === 'inno_setup'
|
||||
? 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe'
|
||||
: 'DoctorWorkstation-Windows-x64-0.2.0.zip'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="文件大小">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-input
|
||||
v-model.number="formData.packages[item.key].size"
|
||||
class="w-48"
|
||||
placeholder="字节"
|
||||
/>
|
||||
<span class="text-tx-secondary text-sm">{{
|
||||
formatSize(formData.packages[item.key].size)
|
||||
}}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
</el-form>
|
||||
<footer-btns v-perms="['setting.desktop_workstation/setConfig']">
|
||||
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||
</footer-btns>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="desktopWorkstation">
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import {
|
||||
getDesktopWorkstationConfig,
|
||||
setDesktopWorkstationConfig,
|
||||
type DesktopPackage,
|
||||
type DesktopPackageType
|
||||
} from '@/api/setting/desktop_workstation'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
type PlatformKey = 'windows_x64' | 'macos_arm64' | 'macos_x64'
|
||||
|
||||
const emptyPackage = (type: DesktopPackageType = 'archive'): DesktopPackage => ({
|
||||
url: '',
|
||||
sha256: '',
|
||||
size: 0,
|
||||
filename: '',
|
||||
type
|
||||
})
|
||||
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const formData = reactive({
|
||||
enabled: 1,
|
||||
latest_version: '',
|
||||
min_version: '',
|
||||
force_update: 0,
|
||||
title: '',
|
||||
notes: '',
|
||||
packages: {
|
||||
windows_x64: emptyPackage('inno_setup'),
|
||||
macos_arm64: emptyPackage(),
|
||||
macos_x64: emptyPackage()
|
||||
}
|
||||
})
|
||||
|
||||
const platforms: { key: PlatformKey; label: string }[] = [
|
||||
{ key: 'windows_x64', label: 'Windows 64 位安装包' },
|
||||
{ key: 'macos_arm64', label: 'macOS Apple 芯片安装包' },
|
||||
{ key: 'macos_x64', label: 'macOS Intel 安装包' }
|
||||
]
|
||||
|
||||
const rules = {
|
||||
latest_version: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (formData.enabled === 1 && !String(value || '').trim()) {
|
||||
callback(new Error('启用自动检测时请填写最新版本号'))
|
||||
return
|
||||
}
|
||||
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
|
||||
callback(new Error('版本号须为 x.y.z 数字格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
],
|
||||
min_version: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (value && !/^\d+(?:\.\d+){0,3}$/.test(value.trim())) {
|
||||
callback(new Error('版本号须为 x.y.z 数字格式'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: ['blur']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
const bytes = Number(size) || 0
|
||||
if (bytes <= 0) return '未填写'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
const assignPackage = (key: PlatformKey, row: Partial<DesktopPackage> | undefined) => {
|
||||
const current = formData.packages[key]
|
||||
current.url = String(row?.url || '')
|
||||
current.sha256 = String(row?.sha256 || '')
|
||||
current.size = Number(row?.size || 0)
|
||||
current.filename = String(row?.filename || '')
|
||||
current.type =
|
||||
row?.type === 'inno_setup' || (key === 'windows_x64' && !row?.url && !row?.filename)
|
||||
? 'inno_setup'
|
||||
: 'archive'
|
||||
}
|
||||
|
||||
const getData = async () => {
|
||||
const data = await getDesktopWorkstationConfig()
|
||||
formData.enabled = Number(data.enabled ?? 0) ? 1 : 0
|
||||
formData.latest_version = data.latest_version || ''
|
||||
formData.min_version = data.min_version || ''
|
||||
formData.force_update = Number(data.force_update ?? 0) ? 1 : 0
|
||||
formData.title = data.title || ''
|
||||
formData.notes = data.notes || ''
|
||||
assignPackage('windows_x64', data.packages?.windows_x64)
|
||||
assignPackage('macos_arm64', data.packages?.macos_arm64)
|
||||
assignPackage('macos_x64', data.packages?.macos_x64)
|
||||
}
|
||||
|
||||
const sha256File = async (file: File) => {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const handleUploadFile = async (key: PlatformKey, file: any) => {
|
||||
const raw = file?.raw as File | undefined
|
||||
if (!raw) return
|
||||
formData.packages[key].filename = raw.name || formData.packages[key].filename
|
||||
formData.packages[key].size = Number(raw.size || 0)
|
||||
if (key === 'windows_x64' && raw.name?.toLowerCase().endsWith('.exe')) {
|
||||
formData.packages[key].type = 'inno_setup'
|
||||
} else if (raw.name?.toLowerCase().endsWith('.zip')) {
|
||||
formData.packages[key].type = 'archive'
|
||||
}
|
||||
try {
|
||||
formData.packages[key].sha256 = await sha256File(raw)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
feedback.msgWarning('浏览器计算 SHA-256 失败,请手动填写校验值')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (key: PlatformKey, response: any) => {
|
||||
const payload = response?.data || {}
|
||||
if (payload.uri) {
|
||||
formData.packages[key].url = payload.uri
|
||||
} else if (payload.url) {
|
||||
formData.packages[key].url = payload.url
|
||||
}
|
||||
if (payload.name && !formData.packages[key].filename) {
|
||||
formData.packages[key].filename = payload.name
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
await setDesktopWorkstationConfig({
|
||||
enabled: formData.enabled,
|
||||
latest_version: formData.latest_version,
|
||||
min_version: formData.min_version,
|
||||
force_update: formData.force_update,
|
||||
title: formData.title,
|
||||
notes: formData.notes,
|
||||
packages: {
|
||||
windows_x64: { ...formData.packages.windows_x64 },
|
||||
macos_arm64: { ...formData.packages.macos_arm64 },
|
||||
macos_x64: { ...formData.packages.macos_x64 }
|
||||
}
|
||||
})
|
||||
await getData()
|
||||
}
|
||||
|
||||
getData()
|
||||
</script>
|
||||
@@ -996,34 +996,34 @@ const handleEdit = (row: any) => {
|
||||
}
|
||||
|
||||
// 聊天
|
||||
const handleChat = async (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有诊单ID
|
||||
if (!row.diagnosis_id && !row.id) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: row.patient_id,
|
||||
diagnosis_id: row.diagnosis_id || row.id
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
|
||||
// 直接打开聊天对话框,传入必要的参数
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
const handleChat = async (row: any) => {
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取聊天签名信息
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
|
||||
console.log('获取聊天签名成功:', res)
|
||||
|
||||
// 直接打开聊天对话框,传入必要的参数
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res // 传入签名数据
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('获取聊天签名失败:', error)
|
||||
feedback.msgError(error.message || '获取聊天签名失败')
|
||||
|
||||
@@ -642,22 +642,28 @@ const handleEdit = (row: any) => {
|
||||
}
|
||||
editRef.value?.open('edit', row.patient_id)
|
||||
}
|
||||
const handleChat = async (row: any) => {
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: row.patient_id,
|
||||
diagnosis_id: row.diagnosis_id || row.id
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: row.patient_id,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId: row.diagnosis_id || row.id,
|
||||
signatureData: res
|
||||
})
|
||||
const handleChat = async (row: any) => {
|
||||
const sourcePatientId = Number(row.source_patient_id || 0)
|
||||
const diagnosisId = Number(row.diagnosis_id || 0)
|
||||
if (!sourcePatientId) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
if (!diagnosisId) {
|
||||
feedback.msgWarning('预约信息不完整,无法发起聊天')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getCallSignature({
|
||||
patient_id: sourcePatientId,
|
||||
diagnosis_id: diagnosisId
|
||||
})
|
||||
chatDialogRef.value?.open({
|
||||
patientId: sourcePatientId,
|
||||
patientName: row.patient_name,
|
||||
diagnosisId,
|
||||
signatureData: res
|
||||
})
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.msg || e?.message || '获取通话签名失败')
|
||||
}
|
||||
|
||||
@@ -262,9 +262,8 @@ const form = reactive({
|
||||
channel_source_detail: '' as string
|
||||
})
|
||||
|
||||
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
/** 自媒体4H/4Q 无需补充;仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
|
||||
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
|
||||
'自媒体4H',
|
||||
'自媒体3Q',
|
||||
'自媒体3H',
|
||||
'自媒体2H',
|
||||
|
||||
@@ -1775,9 +1775,31 @@ const appointmentCellClasses = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅已预约(1)可进视频/小程序码 */
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 找到当前可用的已预约挂号。
|
||||
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
...row,
|
||||
has_appointment: 1,
|
||||
appointment_id: apt.id,
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
}
|
||||
}
|
||||
|
||||
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
@@ -1962,17 +1984,18 @@ const submitFillIdCard = async () => {
|
||||
}
|
||||
|
||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = row
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1983,11 +2006,11 @@ const handleVideoQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置,请先配置小程序信息')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
diagnosis_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || '',
|
||||
mini_program_path: 'pages/login/login'
|
||||
})
|
||||
@@ -2004,17 +2027,18 @@ const handleVideoQRCode = async (row: any) => {
|
||||
}
|
||||
|
||||
// 生成确认诊单二维码
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = row
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||
return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -2033,11 +2057,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = userStore.userInfo
|
||||
|
||||
// 调用生成二维码接口
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: row.id,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
// 调用生成二维码接口
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: qrcodeRow.id,
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: currentUser?.id || ''
|
||||
})
|
||||
|
||||
|
||||
@@ -946,8 +946,28 @@ const appointmentRowClass = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
...row,
|
||||
has_appointment: 1,
|
||||
appointment_id: apt.id,
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -1241,13 +1261,14 @@ const qrcodeAppointmentTimeText = computed(() => {
|
||||
return p.appointment_time_text || '—'
|
||||
})
|
||||
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||
}
|
||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = row
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1258,11 +1279,11 @@ const handleVideoQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
diagnosis_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || '',
|
||||
mini_program_path: 'pages/login/login'
|
||||
})
|
||||
@@ -1275,13 +1296,14 @@ const handleVideoQRCode = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
||||
}
|
||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = row
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1292,11 +1314,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: row.id,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: qrcodeRow.id,
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || ''
|
||||
})
|
||||
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
.build-tools/
|
||||
.uv-cache/
|
||||
.uv-python/
|
||||
__pycache__/
|
||||
|
||||
@@ -6,12 +6,13 @@ set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
|
||||
set "RESULT=%ERRORLEVEL%"
|
||||
|
||||
if "%RESULT%"=="0" (
|
||||
if "%RESULT%"=="0" if /I "%~1"=="-ValidateOnly" (
|
||||
echo.
|
||||
echo Package ready in: %PROJECT_ROOT%dist
|
||||
if /I not "%~1"=="-ValidateOnly" (
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
)
|
||||
echo Validation complete. No installer was generated.
|
||||
) else if "%RESULT%"=="0" (
|
||||
echo.
|
||||
echo Installer and portable package ready in: %PROJECT_ROOT%dist
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
) else (
|
||||
echo.
|
||||
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
Windows 直接在项目根目录双击:
|
||||
|
||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||
|
||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||
|
||||
发布新版本时,可在管理后台的“医生工作站升级包”中为 Windows 选择 `Inno Setup EXE`,填入一键打包生成的 Setup 地址、SHA-256、文件大小和文件名。客户端启动时自动检测;发现新版本后,用户点击“立即更新”即可完成下载、校验、退出、静默安装和自动重启。生产地址必须使用 HTTPS 并开启证书校验,正式 Setup 还应配置组织的 Authenticode 代码签名。
|
||||
|
||||
macOS 在 Finder 中双击:
|
||||
|
||||
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
|
||||
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
|
||||
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境、锁定依赖和安装器编译器。首次打包需要联网下载依赖,之后会复用本机缓存。当前 Python 3.11+/PySide6/Qt 6 成品的真实最低系统是 Windows 10 1809;Qt 6 不支持 Windows 7,不能只降低安装器版本门槛来伪装兼容。Windows 7 SP1 必须另行维护 Python 3.8.10 + PySide2/Qt 5.15 的遗留构建,并在干净 Win7 虚拟机完成视频、媒体、安装、升级和卸载验收。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
|
||||
## 已实现范围
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
@@ -8,6 +8,21 @@ Both build scripts launch the frozen executable twice. `--media-smoke-test` is h
|
||||
|
||||
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
|
||||
|
||||
## Brand assets
|
||||
|
||||
The approved source artwork is kept byte-for-byte at `resources/branding/brand-master.png`.
|
||||
`app-icon.png`, the multi-size Windows `app-icon.ico`, the macOS `app-icon.icns`,
|
||||
the login-page `brand-lockup.png`, and the video companion favicon are deterministic
|
||||
derivatives of that master. Regenerate them after replacing the approved master:
|
||||
|
||||
```powershell
|
||||
uv run --no-project --with pillow==11.3.0 python scripts/generate_brand_assets.py
|
||||
```
|
||||
|
||||
The full lockup is reserved for large brand placements. Window, taskbar, shortcut,
|
||||
installer, uninstaller, Dock, and browser icons use the text-free pictorial mark so
|
||||
the identity remains legible at 16–64 pixels.
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
@@ -16,7 +31,15 @@ Run the build on the target operating system. PyInstaller cannot cross-build Win
|
||||
|
||||
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
|
||||
|
||||
For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass.
|
||||
For the one-click Windows installer, portable ZIP, and SHA-256 manifest, run `Build_DoctorWorkstation.bat` (or double-click `一键打包_医生工作站.bat`). It prepares locked dependencies, invokes the build/file/smoke gates, then compiles `DoctorWorkstation-Setup-Windows-x64-<version>.exe` with a pinned Inno Setup compiler. The compiler is downloaded from the official release on first use and accepted only after both its pinned SHA-256 and Authenticode signer pass validation. The portable ZIP is retained as a secondary artifact.
|
||||
|
||||
The current Python 3.11+/PySide6/Qt 6 runtime requires Windows 10 version 1809 or newer, so the installer declares `MinVersion=10.0.17763`. Do not lower that installer value to claim Windows 7 compatibility: Qt 6 does not support Windows 7. A real Windows 7 SP1 build requires a separately maintained legacy runtime (Python 3.8.10, PySide2/Qt 5.15, and a compatible freezer), plus clean Windows 7 SP1 VM validation for QtWebEngine, multimedia, installation, upgrade, and uninstall behavior.
|
||||
|
||||
After packaging, `scripts/smoke_windows_installer.ps1` silently installs the newest Setup artifact for the current user into an isolated temporary directory, runs the installed executable's smoke gate, silently uninstalls it, and verifies that the executable was removed. It intentionally retains only its small logs and isolated user-data directory under `%TEMP%` for diagnosis.
|
||||
|
||||
To publish an automatic Windows update, open **System settings → Doctor workstation update** in the admin site and select `Windows installer (Inno Setup EXE)`. Upload the generated `DoctorWorkstation-Setup-Windows-x64-<version>.exe` (or use an HTTPS CDN URL), then copy its SHA-256, byte size, and filename from the packaging output. New clients download and verify the installer, close themselves, run Inno Setup silently, and restart only after the installer succeeds. Existing ZIP metadata remains supported for older releases.
|
||||
|
||||
Automatic installer execution requires an HTTPS download with certificate verification (loopback development URLs are the only exception). Production Setup artifacts should also be Authenticode-signed before publication; the current local build can create an unsigned installer when no organization signing certificate is configured.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ their platform media backends.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,16 +21,59 @@ 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"
|
||||
VERSION_SOURCE = SOURCE_ROOT / "doctor_workstation" / "__init__.py"
|
||||
VERSION_TEMPLATE = PROJECT_ROOT / "packaging" / "windows" / "version_info.template.txt"
|
||||
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||
|
||||
|
||||
def read_application_version():
|
||||
source = VERSION_SOURCE.read_text(encoding="utf-8")
|
||||
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', source, re.MULTILINE)
|
||||
if not match:
|
||||
raise SystemExit(f"Application version is missing from {VERSION_SOURCE}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def windows_version_tuple(version):
|
||||
match = re.match(r"^(\d+(?:\.\d+){0,3})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Application version is invalid for Windows resources: {version}")
|
||||
parts = [int(part) for part in match.group(1).split(".")]
|
||||
return tuple((parts + [0, 0, 0, 0])[:4])
|
||||
|
||||
|
||||
def generate_windows_version_file(version):
|
||||
template = VERSION_TEMPLATE.read_text(encoding="utf-8")
|
||||
version_tuple = ", ".join(str(part) for part in windows_version_tuple(version))
|
||||
rendered = template.replace("@VERSION_TUPLE@", version_tuple)
|
||||
rendered = rendered.replace("@VERSION_STRING@", version)
|
||||
output = PROJECT_ROOT / "build" / "generated" / "version_info.txt"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(rendered, encoding="utf-8")
|
||||
return output
|
||||
|
||||
|
||||
APP_VERSION = read_application_version()
|
||||
if sys.platform == "win32":
|
||||
if not VERSION_TEMPLATE.is_file():
|
||||
raise SystemExit(f"Windows version template is missing: {VERSION_TEMPLATE}")
|
||||
VERSION_FILE = generate_windows_version_file(APP_VERSION)
|
||||
else:
|
||||
VERSION_FILE = None
|
||||
|
||||
if not ENTRY_POINT.is_file():
|
||||
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
|
||||
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,9 +142,10 @@ 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,
|
||||
version=str(VERSION_FILE) if VERSION_FILE else None,
|
||||
)
|
||||
|
||||
collection = COLLECT(
|
||||
@@ -116,7 +161,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,9 +1,10 @@
|
||||
# UTF-8
|
||||
# Example PyInstaller version resource. Update all four version tuples together.
|
||||
# PyInstaller version resource template. Values are generated from
|
||||
# doctor_workstation.__version__ by packaging/doctor_workstation.spec.
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(0, 1, 0, 0),
|
||||
prodvers=(0, 1, 0, 0),
|
||||
filevers=(@VERSION_TUPLE@),
|
||||
prodvers=(@VERSION_TUPLE@),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
@@ -18,11 +19,11 @@ VSVersionInfo(
|
||||
[
|
||||
StringStruct('CompanyName', 'ZYT'),
|
||||
StringStruct('FileDescription', '医生工作台'),
|
||||
StringStruct('FileVersion', '0.1.0.0'),
|
||||
StringStruct('FileVersion', '@VERSION_STRING@'),
|
||||
StringStruct('InternalName', 'DoctorWorkstation'),
|
||||
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
|
||||
StringStruct('ProductName', '医生工作台'),
|
||||
StringStruct('ProductVersion', '0.1.0.0')
|
||||
StringStruct('ProductVersion', '@VERSION_STRING@')
|
||||
]
|
||||
)
|
||||
]),
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "zhenyang-doctor-workstation"
|
||||
version = "0.1.0"
|
||||
dynamic = ["version"]
|
||||
description = "Cross-platform doctor consultation workstation for Windows and macOS"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -31,6 +31,9 @@ build = [
|
||||
[project.scripts]
|
||||
doctor-workstation = "doctor_workstation.app:main"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "src/doctor_workstation/__init__.py"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/doctor_workstation"]
|
||||
|
||||
@@ -46,4 +49,3 @@ target-version = "py311"
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# app AI 入口与患者上下文绑定审计(2026-08-21)
|
||||
|
||||
## 1. 范围与结论
|
||||
|
||||
本次只读审计覆盖当前工作树中的 `app/src/doctor_workstation`,重点追踪所有 AI 对话、结构化分析、诊断报告、患者纵向报告和处方库 AI 解释入口,向下核对到 `DoctorRepository` / `RemoteDoctorRepository` 的实际 HTTP 请求。为判断“服务端全量上下文”是否真实存在,额外只读核对了相应 `server/app/adminapi` 实现;没有修改生产代码或测试。
|
||||
|
||||
结论:
|
||||
|
||||
1. **没有发现生产环境下只携带 `prompt`、不携带任何资源 ID 的 HTTP 请求,也没有发现桌面端直连 OpenAI、千问、Dify 或携带 provider key/base URL 的路径。** 所有患者相关生成请求至少携带 `diagnosis_id`(线上字段名 `id`)或 `patient_id`;处方库解释携带 `template_id`(线上字段名 `id`)。
|
||||
2. **ID 绑定总体正确,但强度不一致。** 患者级报告链路对当前选择、请求和响应中的 `patient_id` 做了最严格的精确校验;AI 完整对话工作区也会用服务端诊单详情反查 `diagnosis_id`/`patient_id` 并过滤错归属数据。诊单报告和诊单结构化分析主要依赖“请求关联 + 服务端授权/DataScope”,桌面端不能从响应再次核对 `diagnosis_id`。
|
||||
3. **并非所有患者 AI 入口都走服务端“患者纵向全量上下文”。** 只有 `patientAiReports` / `generatePatientAiReport` 是服务端按 `patient_id` 聚合历次诊单、医生备注、跟踪、血糖、饮食、运动、IM/微信、通话和转写的纵向链路。`aiAssistant(Stream)`、`aiAnalysis`、诊单 `generateAiReports` 都是按单个 `diagnosis_id` 构造诊单表字段摘要。
|
||||
4. **存在明确的本地拼 prompt 路径。** `AiConsultDialog` 从多个桌面端请求结果中摘取最多 320 字的血糖、舌脉、视频转写、历史 AI 摘要、处方标题,拼到医生问题前,再受 500 字总限制截断。工作区未完成或加载失败时仍可发送,此时退化为“`diagnosis_id` + 原始问题”。该请求仍会进入第一方服务端并由服务端补入单诊单摘要,因此不是无权限的裸模型调用;但它不满足“患者上下文只能由服务端统一、全量组装”的要求。
|
||||
|
||||
## 2. 入口清单
|
||||
|
||||
| # | 可见入口 | 入口代码 | 最终 repository 方法 | 绑定键 | 上下文结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 主壳全局“AI 助手” | `ui/shell.py:1551-1565` → 全局患者诊单选择器 `ui/dialogs/ai_consult_picker.py:390-421` | `list_ai_patient_options` 后进入 `stream_diagnosis_ai` / `analyze_diagnosis_ai` | 选择器独立保存 `diagnosis_id`、`source_patient_id`,不允许 patient ID 回退为 diagnosis ID(`ai_consult_picker.py:72-119`) | 诊单级服务端摘要 + 桌面端局部上下文 |
|
||||
| 2 | “问诊列表/预约”工具栏“AI 分析” | `ui/pages/appointments.py:1578-1598` | 同上 | `diagnosis_id`;展示用 `patient_id` 只取 `source_patient_id` | 同上;存在旧字段兼容回退风险,见缺口 G3 |
|
||||
| 3 | “问诊列表/诊单”行操作“AI 分析” | `ui/pages/consultations.py:2980-3002` | 同上 | `diagnosis_id` 取 `diagnosis_id/id`,`patient_id` 取 `source_patient_id/patient_id` | 同上 |
|
||||
| 4 | “我的患者”行操作/按钮“AI 分析” | `ui/pages/patients.py:2706-2708,2732-2759` | 同上 | 明确禁止从 `patient_id` 回退为诊单;诊单取 `diagnosis_id/id` | 同上 |
|
||||
| 5 | 接诊台“AI 分析”对话工作区 | `ui/pages/reception.py:8650-8668` | 同上 | 从已加载详情的选择上下文取 `diagnosis_id`、`patient_id` | 同上;发送前不要求本地上下文已完成 |
|
||||
| 6 | 接诊台 AI 问诊助手快捷问题/输入框 | `ui/pages/reception.py:8620-8648` → `DiagnosisAiAssistantDialog` | `analyze_diagnosis_ai` | 仅 `diagnosis_id` + `prompt` + `task`;无 `patient_id` | 服务端单诊单摘要;无桌面端患者纵向上下文 |
|
||||
| 7 | 接诊台“AI 智能分析”自动加载、模型切换、重试、重新分析 | `ui/pages/reception.py:5265-6142,6954-6991` | 优先 `list_patient_ai_reports` / `generate_patient_ai_report`;权限/能力不足时回退 `get_diagnosis_ai_analysis` | 优先链路只传 `patient_id`;回退链路只传 `diagnosis_id` | 优先链路是服务端患者纵向全量;回退链路是单诊单摘要 |
|
||||
| 8 | 接诊台“AI 报告” | `ui/pages/reception.py:8680-8721` | `list_diagnosis_ai_reports` / `generate_diagnosis_ai_reports` / `edit_diagnosis_ai_report` | `diagnosis_id` | 服务端单诊单报告,不是患者纵向报告 |
|
||||
| 9 | 预约页“AI 报告” | `ui/pages/appointments.py:1559-1576` | 同上 | 页面先把解析出的诊单号覆盖写入 payload 的 `id` 和 `diagnosis_id` | 服务端单诊单报告 |
|
||||
| 10 | 诊单详情内“AI 报告” | `ui/dialogs/diagnosis.py:1897-1912` | 同上 | 当前详情的 `_diagnosis_id` 同时写入 `id`、`diagnosis_id` | 服务端单诊单报告 |
|
||||
| 11 | 处方库“AI解释” | `ui/pages/prescription_library.py:734-740` | `list_prescription_template_ai_reports` / `generate_prescription_template_ai_reports` / `edit_prescription_template_ai_report` | `template_id`,线上字段 `id` | 非患者入口;服务端只分析模板药材组合 |
|
||||
| 12 | AI 分析历史详情弹窗 | `ui/pages/reception.py:6140-6153` | 不发请求 | 使用已校验缓存 | 纯展示,无新增上下文风险 |
|
||||
|
||||
补充:AI 对话中“开个处方”等明确指令会被 `_handle_local_action` 拦截,重新读取当前诊单详情并打开处方编辑器,不会发 AI 请求(`ui/dialogs/ai_consult.py:4881-4935`)。
|
||||
|
||||
## 3. Repository 请求矩阵
|
||||
|
||||
生产实现集中在 `services/repository.py`,`services/remote_repository.py` 只是兼容导出;未发现其他 AI HTTP 实现。
|
||||
|
||||
| Repository 方法 | HTTP | 请求体/查询 | 患者标识 | 本地校验 |
|
||||
|---|---|---|---|---|
|
||||
| `list_ai_patient_options` | GET `tcm.diagnosis/aiPatientOptions` | `page_no,page_size,keyword` | 返回独立 `diagnosis_id`、`source_patient_id` | DTO 清洗;入口再分离两种 ID(`repository.py:2029-2044`) |
|
||||
| `list_prescription_template_ai_reports` | GET `tcm.prescriptionLibrary/aiReports` | `id=template_id` | 不适用 | repository 未显式正数校验(`repository.py:1267-1277`) |
|
||||
| `generate_prescription_template_ai_reports` | POST `tcm.prescriptionLibrary/generateAiReports` | `id=template_id` | 不适用 | 同上(`repository.py:1279-1289`) |
|
||||
| `edit_prescription_template_ai_report` | POST `tcm.prescriptionLibrary/editAiReport` | `id,report_id,content` | 不适用 | 同上(`repository.py:1291-1307`) |
|
||||
| `list_diagnosis_ai_reports` | GET `tcm.diagnosis/aiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1309-1319`) |
|
||||
| `generate_diagnosis_ai_reports` | POST `tcm.diagnosis/generateAiReports` | `id=diagnosis_id` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1321-1331`) |
|
||||
| `edit_diagnosis_ai_report` | POST `tcm.diagnosis/editAiReport` | `id,report_id,content` | `diagnosis_id` | repository 未显式正数校验(`repository.py:1333-1349`) |
|
||||
| `analyze_diagnosis_ai` | POST `tcm.diagnosis/aiAssistant` | `id, prompt, task` | `diagnosis_id` | 要求正数诊单、非空且 ≤500 字问题、任务白名单(`repository.py:1351-1371,2803-2823`) |
|
||||
| `stream_diagnosis_ai` | SSE POST `tcm.diagnosis/aiAssistantStream` | `id, prompt, task` | `diagnosis_id` | 同上;首个 delta 前失败时最多回退一次非流式助手(`repository.py:1373-1428`) |
|
||||
| `get_diagnosis_ai_analysis` | POST `tcm.diagnosis/aiAnalysis` | `id,model` | `diagnosis_id` | 正数诊单、模型白名单(`repository.py:1430-1450`) |
|
||||
| `list_patient_ai_reports` | GET `tcm.diagnosis/patientAiReports` | `patient_id` | `patient_id` | 正数患者 ID(`repository.py:1452-1464`) |
|
||||
| `generate_patient_ai_report` | POST `tcm.diagnosis/generatePatientAiReport` | `patient_id,model` | `patient_id` | 正数患者 ID、模型白名单(`repository.py:1466-1488`) |
|
||||
|
||||
安全边界:助手请求体只有 `id/prompt/task`,结构化分析只有 `id/model`,患者报告只有 `patient_id/model`;未携带 `key/api_key/base_url/provider/model` 等上游配置(模型键仅出现在固定白名单分析/报告接口)。
|
||||
|
||||
## 4. ID 绑定与归属校验
|
||||
|
||||
### 4.1 AI 完整对话工作区
|
||||
|
||||
- `present_ai_consult` 拒绝非正数 `diagnosis_id`(`ai_consult.py:5353-5378`)。
|
||||
- 打开后先按该诊单请求只读详情,再从详情中解析权威 `patient_id`。如果详情返回的诊单 ID 不完全等于当前诊单,整个详情及关联备注、处方、跟踪资料被过滤;如果入口传入的 patient ID 与详情不一致,停止患者报告请求(`ai_consult.py:3703-3802,3833-3907`)。
|
||||
- IM 消息、备注、处方要求每行明确携带当前 `diagnosis_id`;跟踪记录也必须声明当前诊单归属(`ai_consult.py:1514-1543,3866-3907`)。
|
||||
- 患者报告响应会递归检查所有已声明的 `patient_id`(`ai_consult.py:1546-1564,3769-3781`)。
|
||||
- 每次发送最终都把当前 `self.diagnosis_id` 交给 `_AiStreamWorker`(`ai_consult.py:5171-5230`)。因此即使本地患者上下文为空,也不是 prompt-only 请求。
|
||||
|
||||
### 4.2 接诊台患者级报告
|
||||
|
||||
这是 app 中最强的绑定实现:
|
||||
|
||||
- 请求前同时锁定 generation、appointment 和当前选择的 `patient_id`(`reception.py:5062-5073,5265-5352`)。
|
||||
- GET 响应要求顶层和每条 report 的 `patient_id` 都是精确正数且等于请求值;POST 还要求 `generated_report.id` 为正数、`model_key` 与请求一致(`reception.py:1865-1993`)。
|
||||
- A→B、A→B→A、迟到响应、旧 GET 覆盖新 POST 等并发情况都有单飞、取消和 mutation epoch 保护(`reception.py:5135-5164,5376-5452,5503-5731`)。
|
||||
- 该接口按患者聚合,因此请求不带单一 `diagnosis_id` 是正确契约,不是遗漏。服务端会记录全部来源诊单集合和最新诊单 ID。
|
||||
|
||||
### 4.3 接诊台诊单级回退分析
|
||||
|
||||
- 只有在患者报告权限/方法/患者 ID 条件不满足时才走 `get_diagnosis_ai_analysis`(`reception.py:5788-5853`)。
|
||||
- 请求前后都校验 generation、appointment、当前选择的 diagnosis,且响应模型必须与请求模型相同(`reception.py:5049-5060,5855-6028`)。
|
||||
- 响应契约没有返回 `diagnosis_id`,因此桌面端只能依赖异步请求关联,无法做响应所有者复核。
|
||||
|
||||
### 4.4 报告弹窗
|
||||
|
||||
`PrescriptionAiReportDialog` 通过 `AiReportKind` 把诊单、模板分别路由到正确 repository 方法,生成和读取只使用实体 ID(`prescription_ai.py:453-510,780-828,909-929`)。但 `_apply_reports` 直接接受报告数组和 capabilities,不核对响应中的 `diagnosis_id`/`prescription_id`(`prescription_ai.py:856-891`);该层完全信任服务端返回与请求 ID 对应。
|
||||
|
||||
## 5. 上下文路径判定
|
||||
|
||||
### 5.1 真正的服务端患者纵向全量路径
|
||||
|
||||
接诊台优先使用患者级报告。服务端 `PatientAiReportLogic` 明确按 `patient_id` 查询当前数据域内的全部有效诊单,并聚合诊单、doctor notes、tracking notes、blood、diet、exercise、IM、微信、通话和 transcript segments(`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:17-22,254-297,335-395`)。生成请求只接受 `patient_id` 和固定模型,完整来源快照留在服务端(同文件 `126-218`)。
|
||||
|
||||
### 5.2 诊单级服务端上下文
|
||||
|
||||
`aiAssistant(Stream)`、`aiAnalysis`、诊单报告都先按 `diagnosis_id` 做权限和 DataScope 校验,再由服务端构建脱敏病例摘要;不是裸 prompt 调模型(`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:320-378,470-515,551-590`)。但是其 `buildCaseContext` 只整理当前诊单表中的生命体征和 `CASE_FIELDS`,不会查询 doctor notes、tracking、血糖历史、饮食、运动、IM/微信、通话/转写等关联表(同文件 `211-253,881-953`)。因此它是“单诊单完整字段”,不是患者纵向全量。
|
||||
|
||||
### 5.3 桌面端本地拼 prompt
|
||||
|
||||
`AiConsultDialog` 的本地 envelope 明确存在:
|
||||
|
||||
- 上限 320 字,来源只有每日血糖摘要、舌苔/脉象、第一条视频转写、历史 AI 报告摘要和最多三条处方标题(`ai_consult.py:2687-2694,2703-2898`)。
|
||||
- envelope 与医生问题拼成最多 500 字的 `prompt`,超限时优先保留上下文、截断医生问题(`ai_consult.py:2901-2928`)。
|
||||
- 工作区加载失败时 UI 明示“可先根据已有信息提问”,此时 `_patient_ai_context` 可能为空(`ai_consult.py:3810-3831`);`_compose_ai_prompt` 在上下文为空时直接返回问题(`ai_consult.py:2915-2919`)。
|
||||
- 每次发送仍携带当前 `diagnosis_id`,并由服务端再次加入单诊单摘要(`ai_consult.py:5171-5230`)。
|
||||
|
||||
判定:这不是绕过第一方服务端或无 ID 调用,但**确实绕过了“由服务端作为唯一来源统一组装患者纵向上下文”的架构要求**。本地 envelope 是不完整、截断且可能暂时为空的第二套上下文实现;它还把本可用于医生问题的 500 字预算占掉。
|
||||
|
||||
### 5.4 仅原始问题的 UI 路径
|
||||
|
||||
接诊台轻量 `DiagnosisAiAssistantDialog` 直接发送 `diagnosis_id + prompt + task`,不拼患者报告或桌面工作区上下文(`prescription_ai.py:1427-1454`)。这是“仅原始问题 + diagnosis ID”,不是“仅 prompt”。其安全性依赖服务端按 diagnosis ID 补入单诊单摘要;如果产品要求患者纵向资料,则该路径不达标。
|
||||
|
||||
## 6. 缺口与风险
|
||||
|
||||
### G1 — 高:患者对话上下文存在第二套桌面端拼装,且不是全量
|
||||
|
||||
AI 完整对话把最多 320 字的局部资料拼入问题;快速发送、加载失败或最小病历时可退化为空。本地实现与服务端 `DiagnosisAiLogic::buildCaseContext` 并存,两者字段、更新时机和截断规则不同,容易产生遗漏或矛盾。若目标是“每次患者发消息均由服务端使用完整、权威上下文”,当前实现不满足。
|
||||
|
||||
建议:服务端提供唯一的 diagnosis/patient-scoped assistant context 聚合器;桌面只发送 `diagnosis_id`(必要时另传经验证的 `patient_id`)和原始医生问题。返回可审计的 `context_version/source_summary/source_diagnosis_ids`,UI 展示服务端声明而不是展示客户端自拼文本。
|
||||
|
||||
### G2 — 中高:轻量助手和诊单级分析/报告不是患者纵向全量
|
||||
|
||||
轻量助手、诊单分析、诊单报告都正确绑定 `diagnosis_id`,也经过服务端授权;但上下文只来自单个诊单字段。接诊台有患者报告权限时会优先使用真正的患者纵向报告,缺少该权限时则回退为单诊单分析。产品若把这些入口统称为“患者分析”,应显式区分“本诊单分析”与“患者纵向分析”,或统一到患者级聚合服务。
|
||||
|
||||
### G3 — 中:预约页仍把 `patient_id` 当作诊单 ID 的兼容回退
|
||||
|
||||
`appointments.py:382-394` 在缺少显式 `diagnosis_id` 时把 `patient_id` 作为 diagnosis ID,同时真正患者 ID 只接受 `source_patient_id`。这符合旧 admin 预约行的历史语义,但与规范化 DTO 中 `patient_id` 表示真实患者的常见语义冲突。如果未来接口只返回真实 `patient_id` 而漏掉 `diagnosis_id`,可能把患者 ID 当诊单 ID 发给 AI;若数值恰好命中另一个可访问诊单,仅靠正数/权限校验无法识别语义错绑。
|
||||
|
||||
建议:AI 入口必须要求显式 `diagnosis_id`;旧接口兼容应在 repository DTO 适配层一次性完成,并用契约版本或独立字段证明,不要在 UI 回退。
|
||||
|
||||
### G4 — 中:诊单/处方库 AI 报告弹窗不校验响应所有者
|
||||
|
||||
患者级报告会严格核对响应中的 `patient_id`,AI 工作区也过滤错诊单数据;但通用报告弹窗直接接收 reports。服务端当前会返回 `diagnosis_id`/`prescription_id`,桌面端应拒绝缺失或不匹配的所有者,并在生成、编辑后同样校验,避免代理缓存、服务端回归或测试替身把 A 的报告显示在 B 上。
|
||||
|
||||
### G5 — 低:部分报告 repository 方法缺少一致的正数 ID 前置校验
|
||||
|
||||
助手、结构化分析、患者报告均在 repository 层验证正数 ID;诊单/处方库报告的 list/generate/edit 没有同级校验。UI 通常会拦截无 ID,因此当前主要是防御一致性和未来非 UI 调用风险。
|
||||
|
||||
## 7. 现有测试覆盖与缺口
|
||||
|
||||
### 已覆盖
|
||||
|
||||
- `test_ai_patient_options_repository.py`:专用选择器 endpoint、分页、脱敏 DTO、diagnosis/patient ID 分离。
|
||||
- `test_ai_consult_picker_ui.py`:选择器不自动选中、搜索/迟到响应、接受后才打开、最小脱敏 seed。
|
||||
- `test_ai_consult_workspace_ui.py`:四个资料页均使用选中诊单;seed 不可替换权威 patient ID;错详情/错 patient report/无 owner 的备注、处方、IM、tracking 均 fail closed;A/B 迟到结果隔离。
|
||||
- `test_ai_consult_ui.py`:四个页面入口传递 501/301;完整对话流式顺序、取消;本地上下文的来源过滤、320/500 字截断和拼接;处方本地动作重新核对当前诊单。
|
||||
- `test_patient_ai_report_desktop.py`:patient-only HTTP 契约、精确顶层/行 patient ID 校验、POST 新快照校验、权限组合、A/B/A 单飞与旧 GET/新 POST 并发保护。
|
||||
- `test_prescription_ai_ui.py`:诊单/处方库报告方法路由、权限、生成/编辑;轻量助手精确发送 `diagnosis_id/prompt/task`。
|
||||
- `test_repository_parity.py`:所有 AI endpoint 和 DTO、无 provider 配置、SSE 正常化与单次回退、诊单分析模型白名单。
|
||||
- `test_reception_parity_ui.py`:诊单分析自动加载、Qwen→OpenAI 顺序、迟到结果丢弃、详情失败停止 AI、患者报告完成态和权限回退。
|
||||
- `test_api_client.py`:SSE 请求体、事件顺序、HTTP 行为。
|
||||
|
||||
本次执行:
|
||||
|
||||
```text
|
||||
211 collected tests across the 9 files above
|
||||
211 passed
|
||||
```
|
||||
|
||||
命令使用 `PYTHONDONTWRITEBYTECODE=1` 和 `-p no:cacheprovider`,未写入生产代码或测试。
|
||||
|
||||
### 未覆盖/覆盖不足
|
||||
|
||||
1. 没有测试“工作区仍在加载时立即发送”或“工作区失败后发送”时,断言请求退化为原始问题并验证产品是否允许。
|
||||
2. 当前测试 `test_ask_prepends_patient_context_to_the_ai_prompt` 固化了本地拼 prompt 行为;没有相反的架构契约测试,确保患者上下文只能由服务端组装。
|
||||
3. 没有诊单/处方库报告响应 `diagnosis_id`/`prescription_id` 缺失或错配时 fail closed 的测试。
|
||||
4. 没有预约行“缺少 diagnosis_id、但 patient_id 是真实患者 ID”时拒绝打开 AI 的测试;现有测试只覆盖显式 501/301 分离。
|
||||
5. 诊单级 `aiAnalysis`/assistant 响应本身不返回 `diagnosis_id`,因此目前无法写真正的响应归属断言;只能测试异步请求关联。
|
||||
6. app 测试证明请求 ID 和 UI 并发安全,但没有端到端断言服务器实际采用了患者纵向 source summary。该契约目前只在 server 侧测试/实现中可见。
|
||||
|
||||
## 8. 建议验收标准
|
||||
|
||||
1. 所有患者对话接口只接受 `diagnosis_id`/`patient_id` + 原始用户问题/任务,不接受桌面端病例 envelope。
|
||||
2. 服务端返回 `context_scope`(`diagnosis` 或 `patient_longitudinal`)、`context_version`、`source_diagnosis_ids`、`source_summary`;桌面展示该信息并验证 owner。
|
||||
3. “患者纵向分析”必须传 `patient_id`,服务端按 DataScope 聚合;“本诊单分析”必须显式标注,并只传 `diagnosis_id`。
|
||||
4. 所有 AI response DTO 都回显 owner;app 对 owner 缺失、类型不精确、错配统一 fail closed。
|
||||
5. 预约、诊单、患者三种 ID 在 DTO 层分离;UI 禁止 `patient_id -> diagnosis_id` 语义回退。
|
||||
6. 新增上述六项测试缺口,并保留现有迟到响应、A/B/A 和单飞测试。
|
||||
@@ -0,0 +1,49 @@
|
||||
# AI 全量患者上下文与处方闭环(2026-08-21)
|
||||
|
||||
## 范围
|
||||
|
||||
本次改造覆盖 `app` 医生工作台实际调用的患者级 AI 入口:AI 问诊助手(普通与流式)、AI 智能分析、诊断报告生成、患者纵向报告,以及新增的 AI 处方草稿。客户端不再自行拼装或截断患者资料,统一由服务端在完成诊单权限校验后聚合。
|
||||
|
||||
## 全量上下文覆盖矩阵
|
||||
|
||||
| 必须资料 | 权威数据源 | 发给 AI 的内容 |
|
||||
| --- | --- | --- |
|
||||
| 当前信息、现病史、病例/病历 | `tcm_diagnosis`(同一稳定患者全部未删除诊单) | 结构化字段与完整纵向记录 |
|
||||
| 医生备注 | `tcm_doctor_note` | 备注正文、分类、时间及附件 |
|
||||
| 舌苔/舌象 | `tcm_doctor_note.tongue_images` 等附件字段 | 文字描述与原图文件 |
|
||||
| 报告信息 | 医生备注、诊单等记录中的报告附件 | 元数据与报告原文件 |
|
||||
| 每日视频面诊文字 | `tcm_call_record`、`tcm_call_transcript_segment` | 完整转写文本;有录像地址时同时发送视频文件 |
|
||||
| 日常记录 | `tcm_blood_record`、`patient_diet_record`、`patient_exercise_record` | 血糖/血压/用药、饮食、运动等完整明细 |
|
||||
| 聊天与随访 | IM/微信消息与关联随访记录 | 可用的完整文本和附件 |
|
||||
| 正式处方与记录病历 | `tcm_prescription` | 处方药味、剂量、用法、处方病历、审核状态 |
|
||||
|
||||
聚合结果不再使用客户端 320/500 字符截断,也不再只取当前诊单。超长纵向报告仍可分段送入模型,但不会在分段前丢弃资料。图片、文档、音频和视频通过上游文件参数发送;无法读取的附件要求模型明确说明,不能臆测其内容。
|
||||
|
||||
## AI 处方闭环
|
||||
|
||||
1. 医生在 AI 问诊助手选择“AI 生成处方”。
|
||||
2. 服务端使用同一份全量纵向上下文生成严格 JSON 处方草稿。
|
||||
3. 客户端只接受诊断、药味剂量、用法、辨证与风险说明;患者身份、诊单、预约、医生身份不得由模型覆盖。
|
||||
4. 草稿必须进入现有处方编辑器,由医生复核并签名。
|
||||
5. 保存后始终进入 `audit_status = 0` 的待审核状态,沿用现有审核发布流程;AI 不能伪造签名或绕过审核直接生效。
|
||||
|
||||
## 权限与数据完整性
|
||||
|
||||
- 服务端先按“我的患者”权限校验入口诊单,再按稳定患者 ID 聚合同一患者历史。
|
||||
- 处方新增时再次校验诊单归属,并由服务端回填患者、预约、医生和病例字段,忽略客户端对权威身份字段的伪造。
|
||||
- 处方编辑仅允许创建者,诊单关联不可被改写。
|
||||
- 关键处方写接口在菜单配置缺失时不再默认放行。
|
||||
- 含文档/音视频的请求不会静默降级为不带附件的文本请求。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 桌面端 AI/处方相关回归:244 passed。
|
||||
- 桌面端重点 AI/仓储/处方回归:116 passed。
|
||||
- 服务端 AI 上下文、权限、流式、快照、处方、安全与配置契约测试:通过。
|
||||
- 所有本次涉及的 PHP 与 Python 文件语法检查:通过。
|
||||
|
||||
## 部署验收
|
||||
|
||||
- 配置可接收文件输入的 Dify 工作流,并确保模型/工作流能够读取所需附件类型。
|
||||
- 使用包含舌象、报告、转写、日常记录和历史处方的真实患者,检查 Dify 调用日志中的文本上下文和 `files`。
|
||||
- 让 AI 生成处方,确认医生签名不可省略、保存后为待审核、患者身份不能被请求参数篡改。
|
||||
@@ -0,0 +1,241 @@
|
||||
# AI 对话/报告直接开方链路审计(2026-08-21)
|
||||
|
||||
## 结论
|
||||
|
||||
**当前不能从 AI 的回答或 AI 报告“一键形成并提交处方”。** 当前工作树实现的是另一条链路:医生在 AI 对话框输入一条明确的本地命令(如“开个处方”),桌面端不请求 AI,而是重新核对诊单/患者后打开通用处方编辑器;医生仍需人工填写药材、剂量、用法和手写签名,点击确认后才调用真实创建接口,服务端保存为 `audit_status = 0` 的待审核处方。
|
||||
|
||||
因此应区分三种能力:
|
||||
|
||||
| 能力 | 当前状态 | 判断 |
|
||||
|---|---|---|
|
||||
| AI 输出诊断/风险/用药建议文本 | 已有 | 助手返回 `answer` 文本;结构化分析仅有诊断建议、风险和治疗建议 |
|
||||
| 从 AI 对话入口手工新建并提交待审核处方 | 部分可用 | 输入特定命令可打开编辑器,人工完成后调用 `tcm.prescription/add` |
|
||||
| 把 AI 生成的药味、剂量、用法直接转换成处方草稿/一键提交 | 不存在 | 无处方草稿 schema、无 AI 结果到编辑器的字段映射、无“采用为处方”按钮,也无服务端 AI 开方接口 |
|
||||
| AI 直接生成已审核/生效处方 | 不存在,且不应建设成无人工复核链路 | 创建接口强制待审核;审核由独立权限和角色控制 |
|
||||
|
||||
## 审计范围与验证
|
||||
|
||||
- 审计当前工作树中的桌面端 Python/PySide6、repository/API、PHP controller/logic/validate、权限及数据绑定。
|
||||
- 未修改生产代码或测试,只新增本报告。
|
||||
- 已运行:`uv run pytest tests/test_ai_consult_ui.py tests/test_prescription_ui.py -q`,67 项通过。
|
||||
- 已运行:`DiagnosisAiAssistantContractTest.php`、`DiagnosisAiAssistantStreamContractTest.php`、`DiagnosisWorkspaceRowAuthorizationTest.php`,均通过。
|
||||
- 仓库根目录没有 AGENTS.md 声明的 `.trellis/workflow.md` 和 `.trellis/spec/`,故无法应用缺失的 Trellis 分层规范;本报告按现有实现和测试取证。
|
||||
|
||||
## 端到端链路
|
||||
|
||||
### 1. AI 返回结构:只能给建议,不能形成处方 DTO
|
||||
|
||||
服务端病例助手的返回契约只有 `answer/model_key/model_label/model_name/task`,其中 `answer` 是清洗后的纯文本;没有 `herbs`、`medicine_id`、`dosage`、`usage_*` 或可执行 action。证据:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:276-307`:助手调用上游并交给 `formatAssistantResult()`。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:419-437`:最终响应只有 `answer` 和模型/任务元数据。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:64-104`:`prescription_review` 仅定义为“分析处方/用药并提示复核重点”,不是生成处方。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1084-1106`:提示词要求“简洁、分点的专业回答”和执业医师复核,没有处方 JSON schema。
|
||||
|
||||
另一路结构化 `aiAnalysis` 也只能返回 `diagnosis_advice`、`risk_assessment`、`treatment_advice`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1035-1069`:模型被要求输出的唯一 JSON schema 不包含处方字段。
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1281-1352`:解析器严格只接受上述三类业务字段。
|
||||
|
||||
桌面端流式处理也只拼接 `delta.text` 并渲染答案,不解析处方动作或草稿:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5243-5282`:`start/delta/done` 只更新文本和模型标签。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5284-5289`:流式片段直接拼成 `_stream_text`。
|
||||
|
||||
### 2. 桌面 AI UI:有“命令开编辑器”,没有“AI 结果转处方”
|
||||
|
||||
AI 入口和患者选择已接通:
|
||||
|
||||
- `app/src/doctor_workstation/ui/shell.py:1103-1122,1551-1565`:有全局“AI 助手/开始对话”入口,并先进入患者诊单选择器。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult_picker.py:57-119`:选择对象分别保存 `diagnosis_id` 与 `source_patient_id`,不会把诊单主键误当患者主键,只保留掩码手机号。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult_picker.py:274-298,390-419`:通过专用 repository 拉取权限范围内诊单,再携带诊单/患者上下文打开 AI 工作区。
|
||||
|
||||
所谓“开方”实际是本地意图拦截:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:2931-2967`:仅识别不超过 24 字的明确命令;带“怎么/是否/建议/分析/复核/审核”等词时不会触发。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5171-5179`:本地动作发生在 `_compose_ai_prompt()` 和 AI worker 之前,命中后直接返回,不会调用模型。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:4881-4919`:本地检查 `tcm.diagnosis/chufang` 或 `tcm.diagnosis/kaifang`,再开始诊单核对。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:4921-5001`:重新读取只读诊单详情,严格比对诊单 ID 和当前会话患者 ID。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5003-5044`:仅从权威诊单构造患者/诊断种子,没有从 AI 答案提取药材。
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5055-5105`:打开通用 `PrescriptionEditorDialog`;只有对话框返回 Accepted 后才调用 `create_prescription`。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 输入“怎么开方更合理”会得到 AI 文本建议,但该回答没有“采用为处方”入口。
|
||||
- 输入“开个处方”不会让 AI 开方,只会打开人工编辑器。
|
||||
- 即便在命令里写药名和剂量,当前代码也不会解析或带入编辑器。
|
||||
- UI 没有可发现的“开方”快捷按钮;现有快捷指令都是病情总结、用药建议、检查建议等(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:762-783,3189-3215`)。
|
||||
|
||||
### 3. 人工编辑与提交:接口已复用,但仍是完整人工处方流程
|
||||
|
||||
通用处方编辑器可复用程度较高:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:2652-2765`:完整新增/编辑处方表单。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:2910-3007`:患者、诊断、诊单提示等表单字段。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3015-3042`:可从处方库或文本导入药材;这不是 AI 回答映射。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3205-3227`:医师姓名和手写签名是必填 UI。
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3770-3831`:提交前校验患者、临床诊断、医师、手写签名、至少一味药材、药材主数据选择和正剂量。
|
||||
|
||||
repository/API 已接真实端点:
|
||||
|
||||
- `app/src/doctor_workstation/services/repository.py:1563-1572`:`create_prescription()` POST `tcm.prescription/add`。
|
||||
- `app/src/doctor_workstation/services/repository.py:1574-1593`:`update_prescription()` POST `tcm.prescription/edit`。
|
||||
- `app/src/doctor_workstation/services/repository.py:1643-1649`:按诊单刷新 `tcm.prescription/listByDiagnosis`。
|
||||
- `app/src/doctor_workstation/services/repository.py:3249-3279`:repository 只做浅层 DTO 规范化,不承担患者/诊单一致性校验。
|
||||
|
||||
### 4. 服务端创建与审核:创建即待审,不等于审核通过
|
||||
|
||||
服务端创建流程已有一些正确的安全边界:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:27-35`:控制器忽略客户端 `creator_id`,以当前 `$adminId` 调用创建逻辑。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:220-245`:限制同诊单、同开方人、同日只能有一张未作废处方。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:268-278`:要求药材数组并通过药材主数据解析。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:332-340`:忽略客户端审核状态,强制 `audit_status = 0`,创建人与诊单医助由服务端写入。
|
||||
|
||||
“提交审核”只是保存一条待审核记录;AI 链路不会调用审核接口。真正审核是独立动作:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:102-129`:审核需另行调用 `audit`。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:55-73,796-840`:审核还需允许角色、对象可见性和待审状态;通过后才变为 `audit_status = 1`。
|
||||
|
||||
## 主要缺口与风险
|
||||
|
||||
### P1 / 高:创建接口没有诊单写权限和行级数据域校验
|
||||
|
||||
`PrescriptionLogic::add()` 在有 `diagnosis_id` 时只做 `Diagnosis::find()` 存在性检查,没有复用 `DiagnosisLogic::canManageDiagnosis()` 或 `canViewReadonlyDiagnosis()`,controller 也没有传入 `$adminInfo`:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/PrescriptionController.php:27-35`
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:251-266`
|
||||
- 可复用的写权限入口已经存在于 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4458-4468`。
|
||||
|
||||
桌面 AI 流程虽然会先走只读详情并做客户端 ID 比对,但这不是服务端写操作授权。调用者只要能到达 `tcm.prescription/add`,就可能对一个仅知道 ID、但不在其可管理范围内的诊单创建处方。医疗数据完整性和越权写入风险都应由服务端兜底。
|
||||
|
||||
**建议:** `add()` 接收 `$adminInfo`,在任何读取患者/预约信息和写入前调用 `DiagnosisLogic::canManageDiagnosis($diagnosisId, $adminId, $adminInfo)`;不存在和越权统一报错,避免枚举。AI 页面可继续保留客户端核对作为 UX 防误操作,但不能代替服务端鉴权。
|
||||
|
||||
### P1 / 高:诊单、预约、患者和患者快照没有权威一致性校验
|
||||
|
||||
创建逻辑只验证诊单存在,随后直接信任客户端提交的 `appointment_id`、`patient_id`、`patient_name`、`phone` 和病例快照:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:251-260`:只查诊单是否存在。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:291-315`:诊单、预约、患者 ID 和患者快照直接来自 `$params`。
|
||||
- `server/database/migrations/create_tcm_prescription.sql:5-7,27-31`:三个关系字段只有普通索引,没有外键约束。
|
||||
|
||||
因此直接 API 请求可构造“诊单 A + 预约 B + 患者 C + 姓名 D”的处方。即便 UI 正常使用,也存在下一项实际丢字段问题。
|
||||
|
||||
**建议:** 创建时仅接受 `diagnosis_id` 和处方临床字段;由服务端基于授权诊单解析并写入 `appointment_id/patient_id/patient_name/phone/gender/age`,对病例快照使用服务端当前诊单生成。若必须允许修正患者打印信息,应走现有独立 `patchPatient` 权限和审计链路。
|
||||
|
||||
### P1 / 高:桌面处方编辑器会丢失 `patient_id` 和 `phone`
|
||||
|
||||
AI 入口的种子包含患者 ID 和电话:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5021-5030`
|
||||
|
||||
但 `PrescriptionEditorDialog.payload()` 的保留字段没有 `patient_id`、`appointment_id`、`phone`、`case_record`,表单输出也没有这些字段:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/prescription.py:3692-3758`
|
||||
- 领域模型 `Prescription` 本身也没有 `patient_id` 字段:`app/src/doctor_workstation/core/models.py:577-588`。
|
||||
|
||||
AI 流程在确认后只强制补回 `diagnosis_id`、`appointment_id` 和 `case_record`,没有补回 `patient_id` 或 `phone`:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/ai_consult.py:5078-5093`
|
||||
|
||||
服务端对缺失值使用 `patient_id = 0`、`phone = ''`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:300-307`
|
||||
|
||||
所以从 AI 对话新建的处方虽然能按 `diagnosis_id` 找到,但患者 ID/手机号快照会为空。诊单详情里的既有手工开方流程采用同样的 payload 回填方式,也有同类问题(`app/src/doctor_workstation/ui/dialogs/diagnosis.py:3085-3104,3117-3127`)。
|
||||
|
||||
现有 AI UI 测试只断言强制回填了诊单、预约和病例快照,没有断言 `patient_id/phone`:`app/tests/test_ai_consult_ui.py:293-305`。
|
||||
|
||||
**建议:** 短期在编辑器 DTO 中不可编辑地保留 `patient_id/phone/appointment_id/case_record` 并补测试;最终仍应由服务端从诊单权威派生,避免信任客户端快照。
|
||||
|
||||
### P1 / 高:编辑接口允许关系漂移,且共享处方的对象级编辑边界过宽
|
||||
|
||||
服务端编辑时允许客户端提供新的 `diagnosis_id`,但没有验证新诊单存在、调用者能管理新诊单,也不会同步/校验 `patient_id`、`appointment_id` 和 `phone`:
|
||||
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:388-425`:新诊单 ID 用于唯一性检查,但没有授权/存在性检查。
|
||||
- `server/app/adminapi/logic/tcm/PrescriptionLogic.php:438-485`:保存新 `diagnosis_id`,患者 ID、预约 ID、电话和病例快照不在更新集合中。
|
||||
|
||||
同时对象级编辑规则是“创建者或 `is_shared = 1`”,即任何能到达编辑端点的用户都可编辑共享处方:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:408-412`。这会让处方既可能被重新绑定到其他诊单,又可能保留旧患者关系字段。
|
||||
|
||||
**建议:** 编辑禁止修改关系字段;若确需迁移,使用专用、强审计接口并同时校验新诊单写权限和重建全部患者快照。共享应只扩大读取范围,不应自动扩大编辑权。
|
||||
|
||||
### P1 / 高(部署相关):桌面权限码与真实 API 路由没有统一的服务端别名契约
|
||||
|
||||
AI UI 以 `tcm.diagnosis/chufang` 或 `tcm.diagnosis/kaifang` 判断可开方(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:4891-4901`),独立已开处方页则使用 `cf.prescription/add|edit`(`app/src/doctor_workstation/ui/pages/prescriptions.py:558-565,676-683`),但 repository 最终调用的是 `tcm.prescription/add|edit`。
|
||||
|
||||
`AuthMiddleware` 的通用规则是:如果真实路由不在全量菜单 URI 中就直接放行;若已注册则要求真实路由或显式别名:
|
||||
|
||||
- `server/app/adminapi/http/middleware/AuthMiddleware.php:70-92`
|
||||
- 现有别名仅为“处方库列表导入”覆盖多套权限,不包含 `tcm.prescription/add|edit` 的开方别名:`server/app/adminapi/http/middleware/AuthMiddleware.php:169-202`。
|
||||
|
||||
本仓库没有找到为 `tcm.prescription/add|edit` 注册并分配权限的版本化 SQL,故实际安全性依赖部署数据库里是否已有旧菜单记录:
|
||||
|
||||
- 若未注册,middleware 的第 77-82 行会 fail-open。
|
||||
- 若注册但未分配真实路由,拥有 `chufang/kaifang/cf.prescription/add` 的桌面用户可能被 403。
|
||||
|
||||
**建议:** 选择一套 canonical 权限,版本化注册真实路由,并在 middleware 对兼容码做双向、可测试的精确别名;未知业务写路由应 fail-closed,而不是因为未注册就绕过鉴权。
|
||||
|
||||
### P2 / 中:AI “开方命令”不可发现,且会丢弃命令中的处方内容
|
||||
|
||||
界面没有开方快捷按钮或“采用为处方”CTA;只有文本意图正则。命中后清空输入并进入本地编辑流程(`app/src/doctor_workstation/ui/dialogs/ai_consult.py:4881-4889`),未保存原命令里的药味、剂量或 AI 建议。用户容易误解为 AI 已生成处方,实际看到的是诊单预填的空药材表单。
|
||||
|
||||
**建议:** AI 答案与本地命令分离。提供明确的“生成处方草稿”与“采用草稿”按钮,展示字段来源、缺失项和风险提示;任何药材/剂量进入正式表单前都需医生逐项确认。
|
||||
|
||||
### P2 / 中:医师显示名和签名缺少服务端身份约束
|
||||
|
||||
编辑器预填当前用户姓名,但姓名仍可编辑,签名由客户端 data URL 提交(`app/src/doctor_workstation/ui/dialogs/prescription.py:3205-3227,3349-3355,3740-3741`);服务端虽然强制 `creator_id = $adminId`,却直接保存客户端 `doctor_name/doctor_signature`(`server/app/adminapi/logic/tcm/PrescriptionLogic.php:327-339`)。待审核机制降低了风险,但不能防止错误/冒用的签名快照进入系统。
|
||||
|
||||
**建议:** 医师显示名由 authenticated profile 派生;签名使用账号绑定的签名资产或至少保存签名来源、哈希、提交人、时间和确认事件,不接受 AI 生成签名。
|
||||
|
||||
### P2 / 中:删除、作废的对象级服务端授权也不完整
|
||||
|
||||
虽不是 AI 新建的主路径,但同一处方生命周期中:
|
||||
|
||||
- `delete()` 未接收当前管理员,也没有创建者/共享/可见性检查:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:636-675`。
|
||||
- `void()` 接收管理员仅用于记录作废人,没有对象级授权:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:1137-1178`。
|
||||
|
||||
如果路由权限配置漂移或范围过宽,可能修改他人处方。建议所有写操作统一通过同一个处方对象授权策略。
|
||||
|
||||
## 可直接复用的接口和组件
|
||||
|
||||
| 层 | 可复用能力 | 证据 / 用途 |
|
||||
|---|---|---|
|
||||
| AI 患者选择 | `list_ai_patient_options` / `tcm.diagnosis/aiPatientOptions` | `app/src/doctor_workstation/services/repository.py:2029-2044`;用于只暴露数据域内、脱敏的诊单目标 |
|
||||
| 诊单权威读取 | `get_diagnosis_detail(..., readonly=True)` / `readonlyDetail` | `app/src/doctor_workstation/services/repository.py:2046-2060`;服务端在 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4362-4412` 做行级只读授权 |
|
||||
| AI 问答 | `stream_diagnosis_ai` / `aiAssistantStream` | `app/src/doctor_workstation/services/repository.py:1373-1405`;适合继续提供解释,不应直接作为可执行处方 DTO |
|
||||
| 人工处方编辑 | `PrescriptionEditorDialog` | 已有主数据药材选择、剂量、用法、签名和本地校验,可作为 AI 草稿的人工复核容器 |
|
||||
| 药材主数据 | `doctor.medicine/lists` + `RemoteMedicineComboBox` | `app/src/doctor_workstation/ui/dialogs/prescription.py:1425-1536`;AI 草稿必须解析为有效 `medicine_id` |
|
||||
| 处方提交 | `create_prescription` / `tcm.prescription/add` | 可复用,但应先补服务端诊单写授权和权威关系派生 |
|
||||
| 处方刷新 | `list_prescriptions_by_diagnosis` | 创建成功后已能按当前诊单刷新并过滤归属 |
|
||||
| 服务端药材校验 | `normalizeHerbIdentities()` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:165-175`;AI 草稿落表前必须复用 |
|
||||
| 服务端重复控制 | `assertUniquePrescriptionPerDiagnosisDay()` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:220-245` |
|
||||
| 诊单写授权 | `DiagnosisLogic::canManageDiagnosis()` | `server/app/adminapi/logic/tcm/DiagnosisLogic.php:4458-4468`;应接入处方 add/edit |
|
||||
| 审核 | `PrescriptionLogic::audit()` | 保留独立人工审核,不与 AI 生成合并 |
|
||||
|
||||
## 推荐目标链路
|
||||
|
||||
不建议把“AI 可以直接给患者开方”实现为模型静默调用创建接口。更安全且可交付的目标是“AI 生成结构化草稿,医生确认并签名,服务端权威绑定,进入独立审核”。
|
||||
|
||||
1. 新增只读草稿能力:AI 返回 `prescription_draft`,至少包含 `clinical_diagnosis`、主/辅方药材(`medicine_id/name/dosage/formula_type`)、剂数、用法、禁忌、生成依据、缺失信息和风险警示;不得包含可自行决定的患者/诊单/医生身份字段。
|
||||
2. 服务端严格解析草稿:使用药材主数据解析、剂量范围、重复药名、特殊人群/相互作用规则;无法解析的草稿只作为文本展示。
|
||||
3. UI 显示“采用为处方草稿”,而不是“直接提交”;逐字段标记“AI 建议/诊单原值/医生修改”,打开现有 `PrescriptionEditorDialog`。
|
||||
4. 医生必须人工复核、补齐必填项并手写/绑定签名;确认页明确显示“将创建待审核处方”。
|
||||
5. 创建接口仅接收授权 `diagnosis_id` 和临床处方字段;患者、预约、医生身份、病例快照全部由服务端权威派生,并在事务内校验诊单写权限与唯一性。
|
||||
6. 审核继续使用独立角色/权限;AI 生成标记、模型、prompt 版本、草稿哈希、采用人和修改差异写审计日志。
|
||||
|
||||
## 建议补充的最小测试集
|
||||
|
||||
1. AI `done` 事件的合法/非法 `prescription_draft` schema、超量药材、无 `medicine_id`、负剂量和重复药名。
|
||||
2. “AI 建议 → 采用草稿 → 人工修改 → 签名 → 创建待审”端到端桌面测试。
|
||||
3. AI 创建 payload 必须包含当前诊单,并由服务端返回的处方断言 `diagnosis_id/appointment_id/patient_id` 一致。
|
||||
4. 任意其他诊单 ID、跨数据域诊单、错配预约/患者 ID 的创建请求必须失败。
|
||||
5. 编辑请求尝试修改 `diagnosis_id/patient_id/appointment_id` 必须失败。
|
||||
6. 只有读取权限、只有 AI 权限、只有 `cf.prescription/add`、只有 `chufang/kaifang` 的权限矩阵测试,并覆盖 middleware 菜单已注册/未注册两种状态。
|
||||
7. 创建后必须仍为待审核;没有审核权限的创建者不能把处方变为已通过。
|
||||
8. 共享处方仅扩大读取范围,非创建者不能编辑、删除或作废。
|
||||
|
||||
## 最终判断
|
||||
|
||||
- **问:目前能否从 AI 对话/报告一键形成并提交处方?答:不能。**
|
||||
- **问:能否在 AI 对话窗口里用一句“开方”命令进入处方流程,并在人工填写/签名后创建待审核处方?答:当前工作树可以。**
|
||||
- **问:这条半自动链路是否已达到可安全上线的端到端闭环?答:尚未。** 服务端诊单写授权、患者/预约权威绑定、权限 canonical 化和患者字段丢失问题应先修复;之后再建设“AI 结构化草稿 → 医生确认 → 待审核”的链路。
|
||||
@@ -0,0 +1,176 @@
|
||||
# Server AI/LLM 患者上下文与接口安全审计
|
||||
|
||||
审计日期:2026-08-21
|
||||
审计范围:`server/app`、`server/config` 及与真实请求拼装直接相关的 `app/src/doctor_workstation`。
|
||||
方法:只读静态审计;未连接生产数据库、未请求任何模型服务、未修改生产代码或测试。行号以本次工作区内容为准。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前不存在一套被所有患者 AI 入口复用的统一上下文。实际有三套相互独立的患者上下文:
|
||||
|
||||
1. `DiagnosisAiLogic`:诊单 AI 助手、诊单智能分析、诊单 AI 报告共用 `buildCaseContext()`,但它只基于**当前一张诊单详情**,并不聚合医生备注文字、跟踪记录、视频转写、聊天、已开处方或处方病历快照。证据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:349`、`:492`、`:564` 均调用 `buildCaseContext()`;该方法仅遍历诊单及附件数量,见 `:875-947`。
|
||||
2. `PatientAiReportLogic`:患者纵向 AI 报告明确与 `DiagnosisAiLogic` 独立,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:18-22`。它覆盖历次诊单、备注、日常记录、聊天及视频转写,是当前最完整的一套,但**仍不读取 `tcm_prescription` 已开处方、药味和 `case_record`**。
|
||||
3. `DailyDietAiLogic`:患者端饮食建议另建一套上下文,只含姓名、性别、年龄和近 30 天血糖统计/近 7 条明细,见 `server/app/api/logic/tcm/DailyDietAiLogic.php:279-339`。
|
||||
|
||||
此外,桌面端给诊单 AI 助手补上下文时存在两个确定的契约错误:跟踪接口返回 `blood_records`,客户端却读取 `blood_sugar`;处方接口返回 `Prescription` 数据类,客户端只接受 `Mapping`。因此界面声称附带的“每日血糖/处方记录”在真实远端数据形态下会缺失。证据见第 4 节。
|
||||
|
||||
最高优先级安全问题是 `AiChatService` 在阻塞与流式请求中都关闭 TLS 证书和主机名校验,会让患者姓名、血糖及问题内容面临中间人窃取或篡改风险:`server/app/common/service/AiChatService.php:48-59`、`:144-160`。
|
||||
|
||||
## 2. AI/LLM 入口清单
|
||||
|
||||
| 入口 | 是否调用 LLM | 上下文构造 | 备注 |
|
||||
|---|---:|---|---|
|
||||
| `tcm.diagnosis/aiAssistant`、`aiAssistantStream` | 是 | `DiagnosisAiLogic::buildCaseContext()` + 客户端把额外资料塞入 `prompt` | 控制器入口:`server/app/adminapi/controller/tcm/DiagnosisController.php:913-1020`;阻塞/流式最终都用同一 prepared context:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:316-397` |
|
||||
| `tcm.diagnosis/aiAnalysis` | 是 | `DiagnosisAiLogic::buildCaseContext()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1034-1047`;`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:481-516` |
|
||||
| `tcm.diagnosis/generateAiReports` | 是,每次固定生成 qwen/openai 两份 | `DiagnosisAiLogic::buildCaseContext()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1087-1098`;`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:553-618` |
|
||||
| `patientAiReports` | 否,只读历史 | 不组装上游请求 | `server/app/adminapi/controller/tcm/DiagnosisController.php:1052-1064` |
|
||||
| `generatePatientAiReport` | 是 | `PatientAiReportLogic::buildSourceSnapshot()` | `server/app/adminapi/controller/tcm/DiagnosisController.php:1069-1082`;全量聚合见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:335-398` |
|
||||
| `aiReports`、`editAiReport`、`aiPatientOptions` | 否,只读/编辑/选择 | 不调用模型 | 控制器见 `server/app/adminapi/controller/tcm/DiagnosisController.php:879-926`、`:1104-1115` |
|
||||
| `dailyDietAiRecommend`、`dailyDietAiAsk` 及两条 Stream | 是 | `DailyDietAiLogic::buildPatientContext()` | `server/app/api/controller/TcmController.php:900-979`;上下文见 `server/app/api/logic/tcm/DailyDietAiLogic.php:279-339` |
|
||||
| 处方库 `generateAiReports` | 是,但不是患者入口 | 处方库名称、类型、最多 80 味有效药材 | `server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:145-222`、`:400-426`;不应强行复用患者上下文 |
|
||||
| `DailyBloodCareAiLogic` | 当前不可达 | 计划复用 DailyDiet | 全仓只有类自身引用,没有控制器/路由;且调用不存在的公开方法 `DailyDietAiLogic::getPatientContext()`,见 `server/app/api/logic/tcm/DailyBloodCareAiLogic.php:161-176`,实际方法是私有 `buildPatientContext()`:`server/app/api/logic/tcm/DailyDietAiLogic.php:279` |
|
||||
|
||||
全仓 PHP 搜索只发现两种上游客户端:`DifyChatService` 和 `AiChatService`。其调用者分别是 `DiagnosisAiLogic`、`PatientAiReportLogic`、`PrescriptionLibraryAiLogic`,以及 `DailyDietAiLogic`、未接线的 `DailyBloodCareAiLogic`。
|
||||
|
||||
## 3. “患者发给 AI”时各类资料的真实覆盖
|
||||
|
||||
符号:✅ 文字进入上游;△ 只有部分字段/数量元数据/依赖客户端;❌ 未进入。
|
||||
|
||||
| 资料类型 | 诊单 AI 助手/分析/诊单报告 | 患者纵向 AI 报告 | 患者端饮食 AI |
|
||||
|---|---|---|---|
|
||||
| 患者基本信息 | △ 性别、年龄、身高、体重、婚姻等;服务端不主动发送姓名/电话/身份证 | ✅ 最新诊单基本信息;上游发送前 `_id`、`_name` 等键脱敏 | △ 姓名、性别、年龄;姓名被直接发送 |
|
||||
| 当前/现病信息 | △ 当前诊单的症状、既往史、当前用药、舌脉、治则等;仓库字段漂移导致实际 `prescription`、`doctor_advice` 漏掉 | ✅ 所有授权诊单的广泛字段,包含诊单上的 `prescription`、`doctor_advice` | ❌ 除血糖外不含症状、当前用药、过敏、肝肾风险、医嘱等 |
|
||||
| 每日视频面诊转写 | ❌ 服务端不查;桌面端最多把一条转写塞入 320 字上下文 | ✅ 所有授权诊单通话及转写段,段落可重建 transcript | ❌ |
|
||||
| 病例与记录病历 | △ 当前诊单字段;不含处方 `case_record` | △ 历次诊单完整,但不含已开处方的 `case_record` | ❌ |
|
||||
| 医生备注/跟踪备注 | ❌ 只聚合备注里的图片,不聚合备注文字 | ✅ 全部医生备注和跟踪备注 | ❌ |
|
||||
| 舌苔/舌象 | △ 舌苔/舌象文字 + 图片数量;不读取图片内容 | △ 文字 + 附件数量元数据;明确禁止声称做视觉识别 | ❌ |
|
||||
| 检查报告 | △ 只发送附件数量和“未提供附件内容” | △ 只发送附件数量元数据,无 OCR/报告正文 | ❌ |
|
||||
| 日常记录 | ❌ 服务端不查;桌面端本想补血糖但字段名错误,饮食/运动也未拼入 | ✅ 血糖血压、饮食、运动全量 | △ 近 30 天血糖统计、近 7 条逐日明细;不含饮食/运动记录 |
|
||||
| 既往处方 | ❌ `tcm_diagnosis.prescription` 被字段白名单漏掉;不查 `tcm_prescription`;桌面端又因类型判断漏掉远端处方 | △ 有历次诊单的自由文本 `prescription`,但没有正式 `tcm_prescription`、药味、服法、`case_record` | ❌ |
|
||||
| IM/企微聊天 | ❌ | ✅ 腾讯 IM 与企微聊天全量 | ❌ |
|
||||
|
||||
### 3.1 诊单 AI 共用的是“窄上下文”
|
||||
|
||||
`DiagnosisAiLogic::CASE_FIELDS` 定义于 `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:210-252`。它会加入血压、空腹血糖、身高体重、舌象/报告附件数量,再遍历白名单字段,见 `:875-923`。`DiagnosisLogic::detail()` 本身只从医生备注聚合舌象与报告图片,未加载备注 `content`,见 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:274-352` 与 `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:140-164`。
|
||||
|
||||
字段白名单存在明确漂移:仓库诊单表包含 `prescription` 和 `doctor_advice`,见 `server/sql/tcm_diagnosis.sql:13-21`;新增/编辑验证器也使用这两个名称,见 `server/app/adminapi/validate/tcm/DiagnosisValidate.php:111`。但 AI 白名单只找 `prescription_opinion`、`prescription_advice`,没有 `prescription`、`doctor_advice`,见 `server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:244-251`。相反,白名单中的 `chief_complaint`、`present_illness` 等名称在本仓库 SQL 定义中未找到,应以生产表 `SHOW COLUMNS` 再核实;无论如何,这已说明上下文与真实字段没有单一契约。
|
||||
|
||||
舌象和检查报告只有数量元数据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:905-916` 明确写入“未提供附件内容”。医生备注实际保存 `content`、`tongue_images`、`report_files`,见 `server/app/adminapi/logic/doctor/DoctorNoteLogic.php:12-68`,但诊单 AI 没有读取 `content`。
|
||||
|
||||
### 3.2 患者纵向报告覆盖广,但漏正式处方
|
||||
|
||||
纵向报告先按 `patient_id` 和 `MyPatientLogic` 数据域查出全部有效诊单,不设日期或条数上限:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:254-296`。随后按这些诊单 ID 全量查询:
|
||||
|
||||
- 医生备注、跟踪备注、血糖血压、饮食、运动:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:342-366`;
|
||||
- 腾讯 IM、企微聊天、视频通话:`:367-380`;
|
||||
- 视频转写段:`:382-392`,并在 `:450-472` 重建每次通话的 `transcript_text`;
|
||||
- 汇总对象确实包含 `doctor_notes`、`tracking_notes`、三类 daily records、两类 chat records 和 `video_calls`:`:495-520`。
|
||||
|
||||
但 `buildSourceSnapshot()` 完全没有查询 `tcm_prescription`。正式处方模型把 `herbs`、`case_record`、`aux_usage` 声明为 JSON 字段:`server/app/common/model/tcm/Prescription.php:14-21`;`case_record` 又是明确的“详细病历(诊单快照 JSON)”:`server/database/migrations/2026_03_19_add_prescription_case_record.sql:2`。已有按诊单、逐条可见性过滤的安全入口可复用:`server/app/adminapi/logic/tcm/PrescriptionLogic.php:937-963`。
|
||||
|
||||
上游脱敏总体正确:患者纵向报告会把 ID、`*_id`、`*_name`、账户标识替换为脱敏占位,并把附件 URL 替换成数量,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:820-848`;手机号、身份证、邮箱、URL 正则见 `:851-860`。提示词也明确禁止对附件和视频画面作视觉推断,只能使用文字、转写和元数据,见 `:739-744`。
|
||||
|
||||
### 3.3 患者端饮食 AI 的上下文过窄且发送姓名
|
||||
|
||||
`DailyDietAiLogic` 查询近 30 天血糖、不限显式行数,筛近 7 天后只取 7 条明细,见 `server/app/api/logic/tcm/DailyDietAiLogic.php:291-326`。返回上下文只有 `patient_name`、`age`、`gender_text`、血糖明细和 7/30 天统计,见 `:328-337`。推荐与问答提示词都将患者姓名直接发往模型:`:508-519`、`:560-569`、`:706-712`、`:795-803`。
|
||||
|
||||
对于会给出个体化饮食建议的糖尿病入口,未纳入诊单中的当前用药、过敏、肾脏/肝脏状况、医生医嘱、现有饮食/运动记录和正式处方,可能使建议与真实禁忌或治疗方案冲突。
|
||||
|
||||
## 4. 桌面端实际问答链路的两个确定缺口
|
||||
|
||||
诊单 AI 助手不是只用服务端诊单摘要。桌面端先把额外资料拼成最多 320 字的“患者综合资料”,再与医生问题合成最多 500 字的 `prompt`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2682-2694`、`:2835-2928`。服务端把整个 `prompt` 当作 `<USER_QUESTION>`,同时另放自己构造的 `<CASE_DATA>`:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1052-1078`。真实流式请求体只有 `id`、`prompt`、`task`:`app/src/doctor_workstation/services/repository.py:1373-1391`。
|
||||
|
||||
确定缺口如下:
|
||||
|
||||
1. **每日血糖字段名不一致。** 客户端先读取 `tracking["blood_sugar"]`,再找 `entries|records|items`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2710-2742`。但服务端跟踪接口返回的是 `blood_records`、`diet_records`、`exercise_records`:`server/app/adminapi/logic/tcm/DiagnosisLogic.php:4473-4502`;仓库层原样返回:`app/src/doctor_workstation/services/repository.py:2170-2191`。结果是逐日血糖不会进入 prompt,通常只剩诊单上的一次空腹血糖。
|
||||
2. **远端处方对象类型不一致。** 客户端只处理 `Mapping`,否则 `continue`:`app/src/doctor_workstation/ui/dialogs/ai_consult.py:2789-2806`。远端仓库却把列表解析为 `Prescription` 数据类:`app/src/doctor_workstation/services/repository.py:1643-1649`;该类定义于 `app/src/doctor_workstation/core/models.py:577-620`。结果是正式环境返回的处方不会进入 prompt。当前脚本 `app/scripts/check_ai_context.py:252-267` 用字典模拟处方,无法覆盖此契约错误。
|
||||
|
||||
另有三个设计缺口:
|
||||
|
||||
- 工作区已加载医生备注 `notes`,见 `app/src/doctor_workstation/ui/dialogs/ai_consult.py:3782-3801`,但调用 `build_patient_ai_context()` 时没有 notes 参数,见 `:3928-3951`。
|
||||
- 上下文顺序固定为血糖、舌脉、视频、历史 AI 报告、处方,并共享 320 字预算,见 `:2855-2898`;后面的处方更容易被预算耗尽,没有逐段保底或截断清单。
|
||||
- “历史AI报告”会作为新一轮模型输入,见 `:2769-2786`,存在把旧模型推断当作新证据反复强化的来源污染;应至少明确标记为模型生成内容并默认不作为临床事实。
|
||||
|
||||
## 5. 鉴权与接口安全
|
||||
|
||||
### 5.1 已有的有效控制
|
||||
|
||||
- 管理端经过登录、权限认证等中间件:`server/app/adminapi/config/route.php:15-27`。诊单 AI 逻辑又独立校验精确权限和 `MyPatientLogic::canAccessDiagnosis()`,再读详情:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:842-869`;权限匹配是精确小写 URI 白名单:`:744-755`。
|
||||
- 患者纵向报告同时要求查看权限和生成权限,见 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:126-149`,并通过 `MyPatientLogic::applyScope()` 限定医生/医助/部门数据域,且对不存在和越权返回同一错误,见 `:254-296`。
|
||||
- 患者端饮食四个入口不在 `notNeedLogin` 中:`server/app/api/controller/TcmController.php:36-42`;每个入口在调用 AI 前执行 `ensurePatientOwnsDiagnosis()`,通过当前用户的 `diagnosis_view_records` 验证归属:`:495-525`、`:904-977`。
|
||||
- `aiAnalysis`、患者报告读/生成使用严格字段白名单,拒绝客户端传 provider、BASE_URL、凭据和自由来源正文:`server/app/adminapi/validate/tcm/DiagnosisValidate.php:196-213`、`:269-327`。AI 助手 task 受枚举限制,prompt 最长 500:`:27-53`。
|
||||
- `DifyChatService` 只允许 qwen/openai profile、超时 1–300 秒,且启用 TLS 校验:`server/app/common/service/DifyChatService.php:13-50`、`:305-307`、`:329-343`、`:422-440`。它也不会把供应商原始错误正文和密钥回传给控制器。
|
||||
|
||||
### 5.2 缺口与风险等级
|
||||
|
||||
#### P0:患者端 AI 上游关闭 TLS 校验
|
||||
|
||||
`AiChatService` 阻塞和流式路径均设置 `CURLOPT_SSL_VERIFYPEER=false`、`CURLOPT_SSL_VERIFYHOST=false`:`server/app/common/service/AiChatService.php:48-59`、`:144-160`。该服务承载姓名、血糖和自由问题,风险为患者隐私泄露、模型响应被篡改以及服务端密钥被中间人获取。
|
||||
|
||||
最小修复:两处改为 `true`/`2`;对 `base_url` 做与 `DifyChatService::isValidBaseUrl()` 同等级校验;生产只允许 HTTPS。若企业内网使用私有 CA,应配置 CA bundle,不能关闭校验。
|
||||
|
||||
#### P1:上下文不统一且诊单助手缺失关键临床资料
|
||||
|
||||
诊单助手、分析和诊单报告虽然内部共用一份 builder,但这份 builder 没有患者级聚合能力;纵向报告和饮食 AI 又各自维护字段。这会造成同一患者在三个入口得到基于不同事实集的答案。
|
||||
|
||||
最小修复:新增服务端只读 `PatientAiContextBuilder`,先接收已鉴权的 diagnosis/patient scope,再用 profile 决定范围:
|
||||
|
||||
- `assistant`:当前诊单 + 最近 30/90 天日常记录 + 最近 N 条医生/跟踪备注 + 最近 N 次已完成视频转写 + 最近 N 张可见正式处方;
|
||||
- `diagnosis_report`:当前诊单的完整字段、备注、处方及附件元数据;
|
||||
- `longitudinal_report`:现有全病程分片策略,但补正式处方和来源清单;
|
||||
- `daily_diet`:只取饮食决策所需的最小临床子集,避免姓名。
|
||||
|
||||
所有 profile 应返回 `source_manifest`、每类记录数量、时间窗和 `truncated_sections`,使 UI 与审计日志能准确说明模型看到了什么。
|
||||
|
||||
#### P1:患者纵向报告无限查询/无限调用成本
|
||||
|
||||
诊单查询和九类来源查询均未设置日期或行数上限:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:270-283`、`:342-392`。代码会按 120,000 字节切片、逐片调用模型,再最多做 8 轮归并,确保不静默截断:`:44-51`、`:630-729`。完整性设计是优点,但攻击者或异常大患者记录可触发大量数据库内存和模型请求,当前 AI 路径也未发现用户级/患者级频率限制或并发去重。
|
||||
|
||||
最小修复:在查询层分页/游标读取;设置总来源字节、最大 chunk 数和最大上游调用数;生成任务使用 `(patient_id, model, source_hash)` 幂等锁;按管理员/患者限流。达到上限时返回显式“资料过多,需要缩小时间范围”,不可仍标记 `snapshot_complete=true`。
|
||||
|
||||
#### P1:原始患者快照落库,保留范围过大
|
||||
|
||||
上游发送前会脱敏,但落库的 `source_snapshot` 是脱敏前的 `$sourceJson`:快照先在 `server/app/adminapi/logic/tcm/PatientAiReportLogic.php:163-166` 构造,脱敏只在 `generateUpstreamReport()` 的 `:638-640` 执行,而原始 JSON 在 `:203-225` 直接写入 `source_snapshot`。它包含姓名、内部 ID、聊天/转写正文和附件 URL。历史接口有意识地不读取这个大字段,见 `:1012-1025`,但数据库静态泄露与过度保留风险仍存在。
|
||||
|
||||
最小修复:若无需法律审计复现,仅存脱敏快照 + 哈希 + source manifest;若必须保存原文,则字段级加密、独立访问权限、明确 TTL/删除策略,并记录谁读取过原始快照。
|
||||
|
||||
#### P1:患者端饮食 AI 暴露不必要姓名且临床约束不足
|
||||
|
||||
姓名对 GI 推荐没有必要,但当前四套 prompt 均发送姓名,证据见第 3.3 节。与此同时,可能直接影响饮食安全的过敏、肾病/肾功能、当前用药和医生医嘱未进入模型。
|
||||
|
||||
最小修复:删除 `patient_name`;加入最小化的风险字段并设置“若过敏/肝肾/用药信息缺失,不给个体化禁忌结论”;将问答文本放进明确的不可信数据边界,防止把用户问题中的指令当系统指令。
|
||||
|
||||
#### P2:上游 URL/响应大小与中间件防线仍可加强
|
||||
|
||||
- `DifyChatService` 的 URL 校验允许 `http` 和任意主机/IP:`server/app/common/service/DifyChatService.php:287-303`。虽然 URL 只来自服务端配置,不是请求参数,因此不是直接请求型 SSRF,但生产误配会把患者资料发往明文或非批准主机。建议 HTTPS-only + 域名 allowlist;若确有内网模型,使用独立显式配置开关并拒绝重定向到私网/环回地址。
|
||||
- 两个上游客户端都没有在传输阶段设置响应体最大字节数。`DifyChatService` 阻塞请求先整段 `RETURNTRANSFER`:`:329-354`,解析器的 32/64 KiB 限制发生在收完之后;流式路径也持续累积内容。建议在 write callback 中按字节中止,并区分“响应过大”错误。
|
||||
- `AuthMiddleware` 对不在全局菜单 URI 集合中的路由直接放行:`server/app/adminapi/http/middleware/AuthMiddleware.php:73-83`。当前诊单/患者 AI 逻辑自带权限校验,故没有形成直接越权;但新 AI 路由若忘记逻辑层校验会失守。最小修复是对 `/ai*` 或配置的敏感控制器 fail-closed,并保留逻辑层二次校验。
|
||||
|
||||
#### P2:未接线的 DailyBloodCare 代码会在接线后立即失败
|
||||
|
||||
`DailyBloodCareAiLogic` 调用不存在的 `DailyDietAiLogic::getPatientContext()`,且自身没有患者归属校验。当前无路由所以不构成线上入口;未来若启用,应先改为共享的公开 context builder,并在控制器进入逻辑前复用 `ensurePatientOwnsDiagnosis()`。不要只修方法名后直接暴露。
|
||||
|
||||
## 6. 字段、时间范围和条数限制清单
|
||||
|
||||
| 路径 | 当前限制 | 审计判断 |
|
||||
|---|---|---|
|
||||
| 诊单 AI 助手 | task 枚举;医生 prompt 500 字;上下文字段逐项最多 800 字 | 输入边界明确,但 500 字中还混入客户端上下文,医生问题会被截短;字段截断无 manifest。证据:`server/app/adminapi/validate/tcm/DiagnosisValidate.php:49-53`、`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1653-1661` |
|
||||
| 诊单 AI 分析 | case 最多 16,000 字,响应最多 32,768 bytes,建议字段/风险条数均有限 | 输出校验较好;仍只看当前诊单且截断不告知模型/UI。证据:`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:21-58`、`:1025-1048` |
|
||||
| 患者纵向报告 | 数据库查询全历史、无行数上限;每片 120,000 bytes;综合 180,000 bytes;最多 8 轮归并;响应 65,536 bytes | 不静默漏源,但数据库内存、延迟和费用无硬上限。证据:`server/app/adminapi/logic/tcm/PatientAiReportLogic.php:36-51`、`:630-729` |
|
||||
| 患者端饮食推荐 | 血糖 30 天;近 7 天最多 7 条明细;问答 80 字;缓存推荐 1 天、问答 1 小时 | 时间窗合理,但 `refresh=1` 可反复绕过推荐缓存,未见限流;30 天查询无显式行数 cap。证据:`server/app/api/logic/tcm/DailyDietAiLogic.php:28-109`、`:291-326` |
|
||||
| 处方库 AI | 最多 80 味有效药材;待生成列表最多 500 条 | 属于非患者入口,边界基本清晰。证据:`server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:83-120`、`:604-625` |
|
||||
| Dify 上游 | profile 仅 qwen/openai;timeout 1–300 秒 | 正向控制;缺传输级响应大小上限与生产域名 allowlist。证据:`server/app/common/service/DifyChatService.php:16-20`、`:305-307` |
|
||||
|
||||
## 7. 建议的最小修复顺序
|
||||
|
||||
1. **当天可改**:恢复 `AiChatService` TLS 校验;饮食 prompt 去掉患者姓名;为上游响应设置硬字节上限。
|
||||
2. **第一批契约修复**:桌面端读取 `blood_records`;处方 helper 同时支持 `Prescription` 对象;把 notes 显式加入或由服务端统一拼装;补覆盖真实远端类型/字段名的测试。
|
||||
3. **服务端临床完整性**:给 `DiagnosisAiLogic::CASE_FIELDS` 补真实 `prescription`、`doctor_advice`;接入最近医生/跟踪备注、视频转写、日常记录和经 `canViewPrescription()` 过滤的正式处方;不要把附件 URL 当内容,若有 OCR 则以独立、带来源的文本字段加入。
|
||||
4. **统一 builder**:让 DiagnosisAi、PatientAiReport、DailyDiet 复用同一数据访问/脱敏/来源清单层,只在 profile 的时间窗和字段最小化上不同。
|
||||
5. **资源与审计**:患者报告增加查询/调用/总字节上限、幂等锁和限流;调整原始 `source_snapshot` 的加密与保留策略;所有结果返回上下文版本、来源数量和截断信息。
|
||||
|
||||
## 8. 最终判断
|
||||
|
||||
目前“诊单 AI 助手会自动拿到患者完整资料”的说法不成立。服务端只自动拿当前诊单摘要;桌面端确实尝试补视频、血糖、处方等,但 320 字预算及两个契约错误使其远达不到完整上下文。患者纵向 AI 报告是唯一真正覆盖视频转写、备注、聊天和日常记录的入口,却没有正式处方/处方病历,并且全历史、无限行的实现带来显著资源与隐私保留风险。
|
||||
|
||||
建议把“统一上下文”定义为**统一的数据访问、鉴权、脱敏、来源清单和截断协议**,而不是要求所有入口发送同样多的数据。饮食问答应最小化;纵向报告可更完整;诊单助手应在可控时间窗内补齐临床关键项。这样才能同时解决答案一致性、隐私最小化和成本边界。
|
||||
@@ -0,0 +1,158 @@
|
||||
# Windows x64 客户端更新 `can_install=False` 诊断
|
||||
|
||||
## 结论
|
||||
|
||||
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
|
||||
|
||||
对标准 Windows x64 客户端,最值得按以下顺序检查:
|
||||
|
||||
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True` 但 `can_install=False`。
|
||||
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
|
||||
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
|
||||
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
|
||||
|
||||
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()` 在 `offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
|
||||
|
||||
## 端到端链路与证据
|
||||
|
||||
### 1. 客户端发送的身份
|
||||
|
||||
- Windows 被映射为 `windows`(`app/src/doctor_workstation/services/app_update.py:78-83`)。
|
||||
- `AMD64`、`x86_64`、`x64` 都被映射为 `x64`(`app/src/doctor_workstation/services/app_update.py:86-92`)。
|
||||
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version`、`platform`、`arch`(`app/src/doctor_workstation/services/app_update.py:218-243`)。
|
||||
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
|
||||
|
||||
因此标准 64 位 Windows 的请求应为:
|
||||
|
||||
```text
|
||||
GET /adminapi/setting.desktop_workstation/check
|
||||
?current_version=<当前版本>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
### 2. 服务端先决定是否有对应平台安装包
|
||||
|
||||
- 服务端只声明三个包槽位:`windows_x64`、`macos_arm64`、`macos_x64`(`server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
|
||||
- `windows`/`win32`/`win64` 会归一为 `windows`,`x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
|
||||
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
|
||||
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall`(`:90-103`)。
|
||||
|
||||
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url` 或 `packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true`、`package: null`、`can_install: false`。
|
||||
|
||||
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}`(`admin/src/api/setting/desktop_workstation.ts:5-24`、`admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`(Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256(`:292-315`)。
|
||||
|
||||
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||
|
||||
### 3. Python 客户端会再做一轮更严格的判定
|
||||
|
||||
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`:
|
||||
|
||||
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
|
||||
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup`;`inno_setup` 只允许 Windows(`:158-167`)。
|
||||
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update`、`enabled`、合法且更高的版本(`:175-184`)。
|
||||
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
|
||||
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback(`:190-194`,具体 URL 规则在 `:371-376`)。
|
||||
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false(`:202-215`)。
|
||||
|
||||
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
|
||||
|
||||
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|
||||
|---|---|---|
|
||||
| `package` | `null`、数组、字符串等非对象 | 是 |
|
||||
| `package.url` | 空字符串 | 是 |
|
||||
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
|
||||
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
|
||||
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
|
||||
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
|
||||
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
|
||||
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**;EXE 被误当 archive 会在稍后解压失败 |
|
||||
|
||||
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
|
||||
|
||||
### 4. UI session 还会因本机 TLS 设置二次降级
|
||||
|
||||
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True`,`AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False`、`package=None`、`can_install=False`(`app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
|
||||
|
||||
本机配置默认 `verify_ssl=True`(`app/src/doctor_workstation/config.py:88-97`、`:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844`、`:937-944`、`:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL(`app/src/doctor_workstation/services/app_update.py:379-385`)。
|
||||
|
||||
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
|
||||
|
||||
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`;
|
||||
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
|
||||
|
||||
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`。
|
||||
|
||||
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`;UI `:200-206`、`:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
|
||||
|
||||
### 5. 为什么平台或版本通常不是这个弹窗的根因
|
||||
|
||||
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False`(`app_update.py:175-184`)。
|
||||
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
|
||||
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64`(`DesktopWorkstationLogic.php:125-153`)。
|
||||
|
||||
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64` 与 `x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
|
||||
|
||||
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394`、`:401-410`)。
|
||||
|
||||
## 最短现场排查路径
|
||||
|
||||
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`:
|
||||
|
||||
```text
|
||||
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
|
||||
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno Setup,URL 应为 HTTPS。
|
||||
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
|
||||
5. 对 Windows 安装程序,期望响应至少应类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": true,
|
||||
"enabled": true,
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"can_install": true,
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||
"sha256": "<64 lowercase hex chars>",
|
||||
"size": 123456789,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 现有测试覆盖与缺口
|
||||
|
||||
已有客户端测试覆盖:
|
||||
|
||||
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
|
||||
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
|
||||
- HTTP Inno Setup 会拒绝(`:92-115`)。
|
||||
- 未知类型会拒绝(`:118-139`)。
|
||||
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
|
||||
- 检查接口会发送 `platform=windows`、`arch=x64`(`:166-208`)。
|
||||
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
|
||||
|
||||
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
|
||||
|
||||
建议新增以下测试:
|
||||
|
||||
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
|
||||
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
|
||||
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`;true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
|
||||
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
|
||||
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
|
||||
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
|
||||
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
|
||||
|
||||
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
|
||||
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
|
||||
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
|
||||
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# DEBUG_MODE 与线上 API 固定策略分析
|
||||
|
||||
## 结论
|
||||
|
||||
当前启动配置不是单一的“环境变量 -> `AppConfig`”链路,而是四层覆盖:
|
||||
|
||||
1. `python-dotenv` 先加载 `.env`,且 `override=False`,所以进程环境变量优先于 `.env`(`app/src/doctor_workstation/config.py:113-118`)。
|
||||
2. `DOCTOR_API_BASE_URL` 被规范化并传入 `AppConfig`(`config.py:118-132`)。
|
||||
3. 用户目录中的 `preferences.json` 再覆盖环境配置,因此当前实际上是 `进程环境/.env < preferences.json`(`config.py:133-153`)。
|
||||
4. 登录页另有一套 Qt `QSettings`:`server/base_url` 会覆盖已经合并好的 `config.api_base_url`,保存或正式登录时再通过 `config_changed` 写回 `AppConfig`,控制器随后保存 `preferences.json` 并重建远端仓库(`app/src/doctor_workstation/ui/login.py:917-927, 1026-1054, 1067-1077, 1079-1103`;`app/src/doctor_workstation/app.py:438-504`)。
|
||||
|
||||
所以,只在 `AppConfig.load()` 里把环境变量替换成线上域名是不完整的。`DEBUG_MODE=False` 时必须同时封住:
|
||||
|
||||
- 环境变量 / `.env`;
|
||||
- `preferences.json`;
|
||||
- Qt `QSettings` 的 `server/base_url`;
|
||||
- 登录页运行期 `with_updates(api_base_url=...)`。
|
||||
|
||||
建议目标契约为:
|
||||
|
||||
| 模式 | 最终 `AppConfig.api_base_url` | 本地地址设置 |
|
||||
| --- | --- | --- |
|
||||
| `DEBUG_MODE=False` | 始终为 `https://admin.zhenyangtang.com.cn/adminapi` | 环境、JSON preference、Qt `QSettings`、登录页编辑均不得改变 |
|
||||
| `DEBUG_MODE=True` | 保留当前规则:环境/.env 初始化,`preferences.json` 覆盖,登录页可再次编辑 | 完全保留现有可配置行为 |
|
||||
|
||||
仓库内线上地址最直接的证据是 `admin/.env.production:1-3`,生产管理端使用 `https://admin.zhenyangtang.com.cn/`;`admin/vite.config.ts:54-64` 的开发代理也指向同一主机。桌面端的 `normalize_api_base_url()` 会追加 `/adminapi`(`config.py:67-85`),因此建议常量保存主机根地址,最终有效地址由同一个规范化函数产生。`TongjiUniApp/main.js:3` 当前使用的是 `https://xt.zhenyangtang.com.cn/`,它属于另一客户端,不能替代管理 API 地址。
|
||||
|
||||
## 精确修改建议
|
||||
|
||||
### 1. 包级发布策略常量
|
||||
|
||||
在 `app/src/doctor_workstation/__init__.py:1-6` 添加两个普通源码常量,并更新 `__all__`。当前该文件已有未提交的版本升级 `1.1.0 -> 1.2.0`,实现时必须保留它,只做增量编辑。
|
||||
|
||||
建议名称和取值:
|
||||
|
||||
```python
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
__all__ = ["__version__", "DEBUG_MODE", "ONLINE_API_BASE_URL"]
|
||||
```
|
||||
|
||||
`DEBUG_MODE` 不应来自 `DOCTOR_DEBUG_MODE` 或其他环境变量,否则已安装程序仍可被本地环境切回调试地址,直接违反需求。它应是发布代码/构建产物内的策略开关。线上常量不要带查询参数、凭据或 fragment;是否在常量里带 `/adminapi` 均可,但建议只放域名根地址,让 `normalize_api_base_url()` 保持路径的唯一规范化入口。
|
||||
|
||||
`config.py` 从包根导入这两个常量不会形成循环:包 `__init__.py` 不导入 `config.py`;现有 `services/app_update.py:21` 也已经用相同方式从包根导入 `__version__`。
|
||||
|
||||
### 2. 在 AppConfig 的两个入口执行同一发布策略
|
||||
|
||||
涉及 `app/src/doctor_workstation/config.py:17-30, 67-85, 113-153, 167-176`。
|
||||
|
||||
建议在 `normalize_api_base_url()` 之后增加一个小型策略函数(名称可调整):
|
||||
|
||||
```python
|
||||
def effective_api_base_url(candidate: str) -> str:
|
||||
if not DEBUG_MODE:
|
||||
# 源码常量无效属于发布错误,应显式失败,不要静默退回空地址。
|
||||
return normalize_api_base_url(ONLINE_API_BASE_URL)
|
||||
try:
|
||||
return normalize_api_base_url(candidate)
|
||||
except ValueError:
|
||||
return ""
|
||||
```
|
||||
|
||||
然后在两个入口复用:
|
||||
|
||||
1. `AppConfig.load()` 的 `api_url` 必须由该函数生成。这样进程环境和 `.env` 在 release 模式下即使含 `http://127.0.0.1` 也只会被读取而不会成为有效 API 地址。
|
||||
2. `_merge_preferences()` 在 `DEBUG_MODE=False` 时必须忽略 JSON 中的 `api_base_url`;也可以允许读取后在 `replace()` 前强制写回 `effective_api_base_url(...)`。关键是**发布策略必须在 preference 合并之后生效**。
|
||||
3. `with_updates()` 在 `DEBUG_MODE=False` 时必须把任何传入的 `api_base_url` 强制改为线上值,而不是仅做 URL 规范化。登录页运行期正是通过此入口更新配置。
|
||||
|
||||
实现上可选择“每个入口调用 `effective_api_base_url()`”,也可选择一个 `_apply_runtime_policy()` 在 `_merge_preferences()` 和 `with_updates()` 的 `replace()` 之后统一执行。后者更不容易遗漏,但需要保证两条返回路径都调用它。
|
||||
|
||||
不建议用 `AppConfig.__post_init__()` 强制改写所有直接构造的实例。仓库中大量单元/UI 测试直接构造带 `.test` 域名的 `AppConfig`(例如 `app/tests/test_ui_contract.py:408-412`);发布要求针对真实运行配置入口,没必要破坏依赖注入式测试。若希望更强的防御,可提供显式 `apply_runtime_policy()`,由 `load()`、`with_updates()` 和控制器接收外部 `AppConfig` 时调用。
|
||||
|
||||
### 3. 封住 Qt QSettings 的第二套本地 preference
|
||||
|
||||
涉及 `app/src/doctor_workstation/ui/login.py:451-468, 788-851, 917-950, 956-969, 1026-1054`。
|
||||
|
||||
这是满足“本地 preference 不应把它改回调试地址”的必需修改,不是纯 UI 优化:
|
||||
|
||||
- `_restore_settings()`:`DEBUG_MODE=False` 时,`server_url_edit` 只能显示 `config.api_base_url`,不得读取 `self.settings.value("server/base_url", ...)`;`DEBUG_MODE=True` 时保持现有读取逻辑。
|
||||
- `_apply_server_settings()`:`DEBUG_MODE=False` 时使用 `config.api_base_url` 作为 `base_url`,不得信任编辑框或旧 QSettings;也不要把旧调试地址重新写入 `server/base_url`。`DEBUG_MODE=True` 时保持现状。
|
||||
- 发布模式下至少将 `server_url_edit` 设为只读。也可隐藏地址编辑入口,但不要无意中一起删除超时设置;是否同时禁止“信任自签名证书”属于另一项发布安全策略。
|
||||
- `_credential_scope()` 当前优先使用地址编辑框(`login.py:956-969`)。因此必须先确保发布模式下编辑框显示线上地址,否则实际请求虽已被 `with_updates()` 锁到线上,密码却可能错误地按旧调试地址做凭据 scope,造成跨环境凭据恢复混乱。
|
||||
|
||||
不建议启动时删除旧的 `server/base_url` QSettings。发布模式忽略它即可,这样将来显式切到 `DEBUG_MODE=True` 时仍能保留既有调试配置,也避免无必要的数据清理。
|
||||
|
||||
### 4. bootstrap / repository 侧无需另建域名来源
|
||||
|
||||
实际启动入口是 `app/src/doctor_workstation/__main__.py:5-19 -> app.py:1166-1182`。`main()` 只调用一次 `AppConfig.load()`,随后 `ApplicationController.__init__()` 立即执行 `_rebuild_remote_repository()`(`app.py:402-424`);后者把 `self.config.api_base_url` 原样传给 `build_repository()`(`app.py:513-525`),再由 `ApiClient` 规范化为带结尾斜杠的 `/adminapi/` 地址(`services/factory.py:13-45`;`services/api_client.py:133-148`)。
|
||||
|
||||
因此 `app.py`、`factory.py`、`api_client.py` 不应复制线上域名常量。只要 `AppConfig` 在完成所有合并后保持不变量,这一段无需修改。
|
||||
|
||||
可选的纵深防御:`ApplicationController._on_config_changed()` 在收到一个完整 `AppConfig` payload 时目前直接接受(`app.py:460-489`),只有 dict payload 才经过 `self.config.with_updates()`。若未来可能有第二个发信者,建议让完整 `AppConfig` 同样经过显式 runtime policy;当前唯一连接来自 `LoginWindow`(`app.py:447`),且其正常路径会先调用 `with_updates()`,所以这不是本次最小改动的阻塞项。
|
||||
|
||||
## 当前覆盖顺序与修改后顺序
|
||||
|
||||
当前:
|
||||
|
||||
```text
|
||||
.env --(override=False)--> os.environ
|
||||
|
|
||||
v
|
||||
AppConfig(env)
|
||||
|
|
||||
v
|
||||
preferences.json 覆盖 env
|
||||
|
|
||||
v
|
||||
LoginWindow 的 QSettings/server/base_url 覆盖 config
|
||||
|
|
||||
v
|
||||
with_updates -> save_preferences -> rebuild repository
|
||||
```
|
||||
|
||||
建议修改后:
|
||||
|
||||
```text
|
||||
DEBUG_MODE=True : 保持上面的完整可配置链路
|
||||
|
||||
DEBUG_MODE=False:
|
||||
env/.env ----------- ignored for api_base_url ---+
|
||||
preferences.json --- ignored for api_base_url ---+--> ONLINE_API_BASE_URL
|
||||
QSettings ---------- ignored for api_base_url ---+ |
|
||||
runtime update ------ clamped for api_base_url ---+ v
|
||||
build_repository
|
||||
```
|
||||
|
||||
## 兼容风险与边界
|
||||
|
||||
1. **Demo 模式仍可覆盖“是否使用远端仓库”。** 当前 `demo_mode` 默认 `True`,且仍可被环境和 `preferences.json` 覆盖(`config.py:93, 126, 140-153`);登录页也会保存它(`login.py:1002-1006`)。本需求只要求固定 API 域名,所以不应顺手强制 `demo_mode=False`。如果产品语义其实是“发布版必须始终连接线上、不能进入 Demo”,需要单独明确并给 `demo_mode` 增加相同发布策略。
|
||||
2. **TLS 校验仍可被本地 preference 关闭。** `verify_ssl` 当前可由环境、JSON preference 和 QSettings 改为 `False`(`config.py:129, 151-152, 174-175`;`login.py:937-944, 1038-1048`)。固定线上域名但允许关闭证书校验仍有中间人风险。建议产品确认是否在 `DEBUG_MODE=False` 时也强制 `verify_ssl=True`,但它超出“域名不可改”的最小范围。
|
||||
3. **调试启动脚本不会自动打开源码 DEBUG_MODE。** `app/Debug_DoctorWorkstation.bat:16-23` 只设置独立配置目录、Demo 和日志级别,没有能力改变源码布尔常量。若常量提交为 `False`,脚本仍能跑 Demo,但不能用本地 URL。不要为方便而从环境读取 `DEBUG_MODE`;更安全的方案是开发者本地改为 `True`(不提交),或由明确区分的 debug 构建生成非发布模块。
|
||||
4. **已有 preference 不需要迁移或删除。** 发布模式会忽略旧调试 URL;切回 debug 后仍按现有优先级恢复。`save_preferences()` 当前把完整 dataclass 写入 JSON(`config.py:155-165`),发布运行后可能把线上 URL写回文件,这是可接受的,但测试应覆盖“旧文件存在时首次启动仍直接得到线上 URL”。
|
||||
5. **凭据按 URL scope 隔离。** `LoginWindow._credential_scope()` 和 `TokenStore` 使用 API scope。切到线上后旧调试 token/password 不应被用于线上,这是正确行为;但若只锁 `AppConfig` 而不锁登录页 QSettings,可能出现“请求发往线上、密码却按调试 URL scope 保存/恢复”的错配,因此第 3 节不能省略。
|
||||
6. **构建 smoke 环境目前注入 loopback API。** Windows/macOS 构建与安装 smoke 分别在 `app/scripts/build_windows.ps1:28-47`、`build_macos.sh:105-123`、`smoke_windows_installer.ps1:101-109` 注入 `https://127.0.0.1:9`。发布策略生效后该变量会被忽略。正常 smoke 不应访问线上:更新检查被 `DOCTOR_SMOKE_TEST`/`--smoke-test` 阻断(`ui/dialogs/app_update.py:332-340`),且这些脚本设置 `DOCTOR_DEMO_MODE=true`,会阻断 session restore(`app.py:530-540`)。仍建议增加“smoke 期间没有发起线上请求”的断言,避免未来启动流程变化造成生产流量。
|
||||
7. **不要把线上常量的错误静默转为空地址。** 调试环境输入无效时保持当前的空地址降级合理;源码内线上常量无效则应让测试/构建立即失败,否则发布包只会落入 `_UnconfiguredRepository`(`app.py:435-436, 513-525`),错误会拖到登录时才暴露。
|
||||
8. **版本读取兼容。** PyInstaller spec 用正则只读取 `__version__` 行(`app/packaging/doctor_workstation.spec:27-37`)。只要保留当前独立的 `__version__ = "1.2.0"` 赋值,新增常量与 `__all__` 不影响版本生成。
|
||||
|
||||
## 建议测试
|
||||
|
||||
优先在 `app/tests/test_config.py` 增加以下矩阵:
|
||||
|
||||
1. `DEBUG_MODE=False`,环境 `DOCTOR_API_BASE_URL=http://127.0.0.1:8000`,无 preference:`AppConfig.load().api_base_url == "https://admin.zhenyangtang.com.cn/adminapi"`。
|
||||
2. `DEBUG_MODE=False`,环境为线上、`preferences.json` 保存调试 URL:最终仍为线上。
|
||||
3. `DEBUG_MODE=False`,先 `AppConfig.load()`,再 `with_updates(api_base_url="http://localhost:8000")`:最终仍为线上。
|
||||
4. `DEBUG_MODE=True`,环境提供 A、preference 提供 B:最终仍为 B,证明现有“preference 覆盖 env”行为未回归。
|
||||
5. `DEBUG_MODE=True`,无 preference,仅环境提供 URL:继续规范化并自动追加 `/adminapi`。
|
||||
6. 将 `ONLINE_API_BASE_URL` 临时 monkeypatch 为非法值且 `DEBUG_MODE=False`:应显式抛错,避免发布误配置静默降级。
|
||||
|
||||
在 `app/tests/test_ui_contract.py` 增加:
|
||||
|
||||
1. 发布模式的 QSettings 预置 `server/base_url=http://127.0.0.1:8000`,创建 `LoginWindow` 后地址框显示线上 URL且不可编辑。
|
||||
2. 发布模式调用 `_save_server_settings()` / 非 Demo `submit()`,`config_changed` payload 的 `api_base_url` 仍为线上,远端仓库不会以 QSettings 地址重建。
|
||||
3. 上述场景下 `_credential_scope()` 返回线上 scope,防止凭据落在旧调试 scope。
|
||||
4. `DEBUG_MODE=True` 重跑同类场景,确认地址框仍从 QSettings 恢复、保存后仍能切换服务器。
|
||||
|
||||
在 bootstrap/构建层增加或保留以下回归:
|
||||
|
||||
1. `ApplicationController` 用 `AppConfig.load()` 启动时,传给 `build_repository()` 的 release base URL 精确为 `https://admin.zhenyangtang.com.cn/adminapi`。
|
||||
2. `--smoke-test` 和 `DOCTOR_SMOKE_TEST=1` 下,无论 release URL 是否存在,都不执行更新请求或 token restore 网络调用。
|
||||
3. 冻结包 smoke 继续通过;原 smoke 脚本中的 loopback `DOCTOR_API_BASE_URL` 被忽略是预期行为,不应把断言写成“最终 URL 等于 127.0.0.1”。
|
||||
|
||||
建议验证命令:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\web\zyt\app
|
||||
uv run pytest tests/test_config.py tests/test_ui_contract.py -q
|
||||
uv run ruff check src/doctor_workstation/__init__.py src/doctor_workstation/config.py src/doctor_workstation/ui/login.py tests/test_config.py tests/test_ui_contract.py
|
||||
```
|
||||
|
||||
若还修改了 bootstrap 防御或 smoke 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 登录页 DEBUG_MODE 门禁分析
|
||||
|
||||
## 结论
|
||||
|
||||
当前实现不存在 `DEBUG_MODE`(项目内唯一含 `debug_mode` 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;`AppConfig.demo_mode` 默认又是 `True`,且 `preferences.json` 会覆盖环境配置。因此,仅对两个控件调用 `hide()` 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 `QSettings`,控制器也会接受伪造或残留的 demo 状态。
|
||||
|
||||
建议把 `DEBUG_MODE` 设计成**非用户偏好、不可由 `QSettings` 或 `preferences.json` 覆盖的单一运行时门禁**,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
|
||||
|
||||
- `DEBUG_MODE=True`:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。
|
||||
- `DEBUG_MODE=False`:隐藏完整 UI 区块,强制 effective demo 为 `False`,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
|
||||
|
||||
`demo_mode` 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
|
||||
|
||||
## 当前实现与风险点
|
||||
|
||||
### 1. 配置与持久化
|
||||
|
||||
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
|
||||
| --- | --- | --- |
|
||||
| `src/doctor_workstation/config.py:88-99` | `AppConfig.demo_mode` 默认 `True`,没有 debug gate | 直接构造 `AppConfig()` 就默认允许 demo |
|
||||
| `config.py:113-133` | `DOCTOR_DEMO_MODE` 未设置时也按 `True` 加载,然后调用 `_merge_preferences()` | 生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
|
||||
| `config.py:135-153` | `preferences.json` 中所有 dataclass 字段均会合并,包括 `demo_mode`、`api_base_url`、`request_timeout`、`verify_ssl` | 旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
|
||||
| `config.py:155-165` | `save_preferences()` 用 `asdict(self)` 保存完整配置 | demo 切换和服务器设置会持续残留在 JSON 中 |
|
||||
| `config.py:167-176` | `with_updates()` 可随时把 demo/服务器字段改回调试值 | UI 隐藏后仍可从信号/直接调用修改 |
|
||||
|
||||
需要特别区分两套持久化:demo 当前**不写 QSettings**,它通过 `config_changed -> ApplicationController._on_config_changed -> save_preferences()` 写入 `preferences.json`;服务器地址、超时和证书校验先写 `QSettings`,随后同一信号链又写入 `preferences.json`。相关位置是 `ui/login.py:1038-1054` 和 `app.py:460-491`。
|
||||
|
||||
当前 `Debug_DoctorWorkstation.bat:16-24` 只是设置 `DOCTOR_DEMO_MODE=true` 和 `DOCTOR_LOG_LEVEL=DEBUG`,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 `DOCTOR_DEBUG_MODE=true`);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 `.env` 参与。无论采用哪种来源,都不要把它作为普通 `AppConfig` dataclass 字段写入 `preferences.json`。
|
||||
|
||||
### 2. LoginWindow 组件和信号
|
||||
|
||||
| 位置 | 组件/信号链 | 当前行为与缺口 |
|
||||
| --- | --- | --- |
|
||||
| `ui/login.py:444-449` | `server_settings_changed(dict)`、`config_changed(object)`、`demo_mode_changed(bool)` | `server_settings_changed` 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
|
||||
| `login.py:451-468` | 构造参数、`demo_repository`、`active_repository` | 只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
|
||||
| `login.py:739-755` | “记住密码”行和 `demo_check` | checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
|
||||
| `login.py:770-855` | “或”分隔线、`server_toggle`、`server_panel` 及 URL/timeout/self-signed/save 子控件 | toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
|
||||
| `login.py:917-948` | `_restore_settings()` | 始终从 QSettings 恢复三项 server 值;只看 `config.demo_mode` 就勾选 demo。`setChecked(True)` 会触发已连接的 `_on_demo_toggled()` |
|
||||
| `login.py:956-969` | `_credential_scope()` | 优先读取 `server_url_edit`;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
|
||||
| `login.py:1002-1013` | `demo_check.toggled -> _on_demo_toggled()` | 切换 `active_repository`,发射 `demo_mode_changed`,再经 `_emit_config_update` 发射 `config_changed`;没有权限判断 |
|
||||
| `login.py:1015-1027` | `server_toggle.clicked`、save button | 方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
|
||||
| `login.py:1029-1054` | `_apply_server_settings()` | 会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
|
||||
| `login.py:1079-1126` | `submit()` | 直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会**无条件自动应用服务器控件当前值**,所以旧 QSettings 即便不展开 panel 也会生效 |
|
||||
| `login.py:1140-1159` | `_set_loading()` | loading 结束会按 `demo_repository is not None` 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
|
||||
| `login.py:1177-1216` | 登录成功与凭据保存 | `payload["demo_mode"]` 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
|
||||
| `login.py:1224-1234` | 证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
|
||||
|
||||
证书错误文案还在 `ui/widgets.py:376-383` 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
|
||||
|
||||
### 3. ApplicationController 与登录可信边界
|
||||
|
||||
| 位置 | 当前行为 | 需要的防线 |
|
||||
| --- | --- | --- |
|
||||
| `app.py:402-423` | 总是实例化 `DemoDoctorRepository()`;`current_demo_mode=config.demo_mode` | 非 debug 不创建/不暴露 demo repository,并强制 current demo false |
|
||||
| `app.py:438-455` | 总把 demo repository 传给 LoginWindow;复用窗口时信任 `demo_check` | 传递显式 gate;非 debug 复用时重置 checkbox/active repository |
|
||||
| `app.py:460-506` | 接受 `demo_mode` 及全部 server 字段,保存 preferences 并重建 repository | 非 debug 拒绝 debug-only changes,避免伪造 `config_changed` 绕过 UI |
|
||||
| `app.py:508-511` | 任意 `demo_mode_changed(True)` 都会设置 current demo 并取消 session restore | 非 debug 忽略/纠正 true |
|
||||
| `app.py:530-540` | `config.demo_mode=True` 会跳过生产 token restore | 必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
|
||||
| `app.py:653-681` | 信任成功 payload 中的 `demo_mode` 与 repository | 非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
|
||||
| `app.py:782-792` | `current_demo_mode` 决定是否打开离线 demo 视频窗 | 前述边界不收口时,伪造状态还会扩散到登录后的功能 |
|
||||
|
||||
## 精确修改建议
|
||||
|
||||
### A. 建立单一、不可持久化的 capability
|
||||
|
||||
在 `src/doctor_workstation/config.py` 定义唯一 `DEBUG_MODE`(或等价只读函数),由受控构建/专用调试启动器决定。不要从 `QSettings` 读取,不要让 `preferences.json` 覆盖,也不要随 `asdict(AppConfig)` 保存。
|
||||
|
||||
配置加载完成后必须做一次最终归一化:`effective_demo_mode = DEBUG_MODE and requested_demo_mode`。在 `DEBUG_MODE=False` 时,`_merge_preferences()` 至少忽略 `demo_mode`;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 `api_base_url`、`request_timeout`、`verify_ssl`,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。`with_updates()` 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
|
||||
|
||||
推荐把 debug capability 显式传给 `ApplicationController`/`LoginWindow` 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
|
||||
|
||||
### B. LoginWindow:可见性和行为同时门禁
|
||||
|
||||
在 `ui/login.py:451-468` 记录 `self.debug_mode`,并把 `self.demo_repository` 设为 `demo_repository if debug_mode else None`。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 `self.debug_mode` 判断。
|
||||
|
||||
UI 结构建议:
|
||||
|
||||
1. `demo_check` 仅在 debug 时 visible,并且 enabled 条件为 `debug_mode and demo_repository is not None and not loading`。
|
||||
2. 把 `login.py:770-855` 的“或”分隔线、server toggle、panel 和上下 spacing 包进一个 `self.debug_server_section` QWidget;整个 section 仅在 debug 时 visible。单独隐藏 `server_toggle` 会留下“或”和空白。
|
||||
3. panel 初始仍折叠;debug true 时保持现有 toggle 行为。
|
||||
|
||||
行为防线建议:
|
||||
|
||||
1. `_restore_settings()`:非 debug 不读取 `server/*` QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控 `config` 填充。是否删除旧键是迁移策略,**忽略它们才是安全要求**。
|
||||
2. `_credential_scope()`:非 debug 始终从受控 config URL 取 scope,不读取隐藏的 `server_url_edit`。
|
||||
3. `_on_demo_toggled(True)`:非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发 `demo_mode_changed` 或 `config_changed`。
|
||||
4. `_toggle_server_panel()`、`_save_server_settings()`、`_apply_server_settings()`:非 debug 强制 panel 关闭且不写 QSettings、不发 `server_settings_changed/config_changed`。直接调用也必须无效。
|
||||
5. `submit()`:用 `demo_mode = self.debug_mode and self.demo_check.isChecked()`,并从这个 effective 值选择 repository。非 debug 跳过 `_apply_server_settings()`,只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。
|
||||
6. `_set_loading()`:所有 demo/server enabled 状态与 `self.debug_mode` 做 AND,防止 loading 完成后重新激活。
|
||||
7. `_on_login_error()`:仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
|
||||
|
||||
### C. Controller:不要信任 UI 状态或 payload
|
||||
|
||||
在 `app.py:402-423` 以同一 capability 计算 effective state;非 debug 最好根本不实例化 `DemoDoctorRepository`。`_show_login()` 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
|
||||
|
||||
`_on_config_changed()` 必须再次过滤 demo/server debug-only 字段;`_on_demo_mode_changed()` 非 debug 不接受 true;`_begin_session_restore()` 不得因未经门禁的旧 `config.demo_mode` 跳过;`_on_login_succeeded()` 应把 demo capability 作为可信边界,非 debug 收到 `demo_mode=True` 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
|
||||
|
||||
## `tests/test_ui_contract.py` 现状与调整
|
||||
|
||||
实际文件是 `app/tests/test_ui_contract.py`,`app/tests` 下没有 `conftest.py`;这里使用的 `tmp_path`/`monkeypatch` 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
|
||||
|
||||
现有相关契约:
|
||||
|
||||
- `test_ui_contract.py:230-273`:真实 demo 登录,证明 `config.demo_mode=True` 会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。
|
||||
- `test_ui_contract.py:276-337`:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。
|
||||
- `test_ui_contract.py:340-375`:服务器 panel 在最小窗口的布局。
|
||||
- `test_ui_contract.py:378-399`:函数名虽含 `debug_mode`,实际仅验证 self-signed 值写入 QSettings,没有任何 `DEBUG_MODE` 判断。
|
||||
- `test_ui_contract.py:402-455`:普通登录前自动应用 server 值、经 `config_changed` 换成新 repository。
|
||||
- `test_ui_contract.py:458-463`:证书错误文案指向服务器设置。
|
||||
- `test_ui_contract.py:495-513`:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
|
||||
|
||||
引入 gate 后,`230`、`340`、`378`、`402`、`495` 这几组依赖 demo/server 的测试都应显式运行在 `DEBUG_MODE=True`,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
|
||||
|
||||
## 建议回归测试矩阵
|
||||
|
||||
### `tests/test_ui_contract.py`
|
||||
|
||||
1. **debug true 可见且可用**:show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
|
||||
2. **debug false 无视觉残件**:断言 demo checkbox、`debug_server_section`(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked,`active_repository is remote_repository`。
|
||||
3. **残留 QSettings 不生效**:预写 `server/base_url=旧地址`、`server/read_timeout`、`server/verify_ssl=false`,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 config,QSettings 值未被 `_apply_server_settings()` 写回或发射成配置更新。
|
||||
4. **直接调用不能绕过**:debug false 下程序化 `demo_check.setChecked(True)`、`_on_demo_toggled(True)`、`_toggle_server_panel(True)`、`_save_server_settings()`;断言仍 unchecked、production repository、panel hidden,`demo_mode_changed`、`server_settings_changed`、`config_changed` 均无 debug 更新。
|
||||
5. **提交强制 production**:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的 `demo_mode=True`;输入账号密码后立即执行 worker,断言只有 remote `login()` 被调用,payload `demo_mode=False`。
|
||||
6. **证书错误分模式**:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
|
||||
7. **loading 不重启入口**:debug false 执行 `_set_loading(True)` 再 `_set_loading(False)`,断言 demo/server 控件持续 hidden + disabled。
|
||||
|
||||
### `tests/test_config.py`
|
||||
|
||||
1. 在隔离 `DOCTOR_CONFIG_DIR` 写入旧 `preferences.json`(至少 `demo_mode:true`);DEBUG false 加载后必须 `demo_mode is False`,即使 `DOCTOR_DEMO_MODE=true` 也不能越权。
|
||||
2. DEBUG true 时确认 `DOCTOR_DEMO_MODE`/允许的 demo preference 仍能选择默认 demo 状态。
|
||||
3. 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
|
||||
4. `with_updates(demo_mode=True)` 在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
|
||||
|
||||
### `tests/test_ui_contract.py` 中的 controller 边界(或拆到 controller 专属测试)
|
||||
|
||||
1. DEBUG false 时 `_begin_session_restore()` 不因 stale `config.demo_mode=True` 而跳过远程恢复。
|
||||
2. DEBUG false 时直接调用 `_on_demo_mode_changed(True)` 不改变 `current_demo_mode`。
|
||||
3. DEBUG false 时把 `demo_mode=True`/demo repository 的伪造 payload 传给 `_on_login_succeeded()`,断言不能创建 ShellWindow。
|
||||
|
||||
若采用专用 `DOCTOR_DEBUG_MODE` 环境变量,还应在 `tests/test_one_click_entrypoints.py:102-108` 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 `.env.example:8-17` 与 `README.md:56-72`,避免继续把 `DOCTOR_DEMO_MODE=true` 描述成足以启用演示能力。
|
||||
|
||||
## 最小验收标准
|
||||
|
||||
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。
|
||||
@@ -0,0 +1,79 @@
|
||||
# Windows 1.2.0 正式包重建结果(DEBUG_MODE=False)
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 总体结果:成功
|
||||
- 生产源码修改:无(本次仅重建产物并新增本记录)
|
||||
|
||||
## 发布配置核对
|
||||
|
||||
打包前解析 `src/doctor_workstation/__init__.py`,确认:
|
||||
|
||||
- `__version__ = "1.2.0"`
|
||||
- `DEBUG_MODE = False`
|
||||
- `ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"`
|
||||
|
||||
打包完成后,使用 PyInstaller 的归档读取器打开
|
||||
`dist/DoctorWorkstation/DoctorWorkstation.exe` 内嵌的 `PYZ.pyz`,提取
|
||||
`doctor_workstation` 模块并检查其顶层字节码常量,得到:
|
||||
|
||||
- `STORE_NAME __version__` 前的常量为 `"1.2.0"`
|
||||
- `STORE_NAME DEBUG_MODE` 前的常量为 `False`
|
||||
- `STORE_NAME ONLINE_API_BASE_URL` 前的常量为
|
||||
`"https://admin.zhenyangtang.com.cn"`
|
||||
|
||||
因此本次重建的 EXE 已包含正式模式和线上 API 域名配置。
|
||||
Windows 版本资源也核对为:应用 EXE 的 `FileVersion` / `ProductVersion`
|
||||
均为 `1.2.0`,安装器的 `ProductVersion` 为 `1.2.0`。
|
||||
|
||||
## 打包
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- Vue/Vite 前端构建:通过(43 个模块)
|
||||
- PyInstaller 6.22.0 / Python 3.12.12:通过
|
||||
- Frozen Qt multimedia file gate:通过
|
||||
- Frozen Qt multimedia smoke gate:通过
|
||||
- Frozen application entry smoke gate:通过
|
||||
- 7-Zip ZIP 创建:通过(`Everything is Ok`)
|
||||
- Inno Setup 6.7.3:通过(`Successful compile (175.078 sec)`)
|
||||
|
||||
构建过程出现 Vite 大 chunk 提示、一个可选 Qt QML 插件缺失提示以及
|
||||
Windows 系统 DLL 解析警告;它们均未阻断构建,且上述冻结产物门禁全部通过。
|
||||
|
||||
## 安装器烟测
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离测试目录:
|
||||
`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-4988cc3cc71346e49925445b9ae57fb9`
|
||||
- 覆盖项:安装器图标、静默安装、已安装 EXE 启动、静默卸载及卸载残留检查
|
||||
|
||||
## 产物与校验
|
||||
|
||||
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,912,808 | 155.366 | `D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A` |
|
||||
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,092 | 220.118 | `9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B` |
|
||||
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `2DA56F76458ACE040597916C68B265C737D270B335EA73F21C4A248A2FA41B78` |
|
||||
|
||||
`dist/SHA256SUMS.txt` 内容:
|
||||
|
||||
```text
|
||||
D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||
```
|
||||
|
||||
独立使用 `Get-FileHash -Algorithm SHA256` 重算 EXE 和 ZIP 后,两项均与
|
||||
`SHA256SUMS.txt` 逐字符匹配;清单恰好包含两条记录。
|
||||
@@ -0,0 +1,57 @@
|
||||
# Debug mode full-suite verification
|
||||
|
||||
Verification date: 2026-08-28 (Asia/Shanghai)
|
||||
|
||||
Scope: read-only verification of the current shared worktree under `D:\web\zyt\app`. No production source was modified.
|
||||
|
||||
## Pytest
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m pytest
|
||||
```
|
||||
|
||||
- Exit code: `1`
|
||||
- Result: `2 failed, 684 passed`
|
||||
- Total collected/executed: `686`
|
||||
- Duration: `3195.85s` (`0:53:15`)
|
||||
|
||||
Failures:
|
||||
|
||||
1. `tests/test_diagnosis_order_video_visual.py::test_video_table_embeds_player_and_preserves_row_bound_upload`
|
||||
- Assertion location: `tests/test_diagnosis_order_video_visual.py:429`
|
||||
- Assertion: `table.rowHeight(0) >= playback.required_table_row_height()`
|
||||
- Actual: row height `246`; required row height `250`.
|
||||
|
||||
2. `tests/test_reception_parity_ui.py::test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geometry`
|
||||
- Assertion location: `tests/test_reception_parity_ui.py:1575`
|
||||
- Assertion: `expand_button.size().width() == expand_button.size().height() == 28`
|
||||
- Actual: `QSize(28, 34)`; expected `QSize(28, 28)`.
|
||||
|
||||
## Ruff
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m ruff check src tests
|
||||
```
|
||||
|
||||
- Exit code: `1`
|
||||
- Result: `9` errors, all `F401` unused imports and all reported as fixable with `--fix`.
|
||||
|
||||
Findings:
|
||||
|
||||
1. `src/doctor_workstation/ui/diagnosis_index_widgets.py:37:5` - unused `PySide6.QtGui.QPixmap`.
|
||||
2. `src/doctor_workstation/ui/dialogs/ai_consult.py:18:5` - unused `PySide6.QtGui.QFont`.
|
||||
3. `src/doctor_workstation/ui/dialogs/ai_consult.py:24:5` - unused `PySide6.QtGui.QPixmap`.
|
||||
4. `src/doctor_workstation/ui/dialogs/ai_consult.py:26:5` - unused `PySide6.QtGui.QTextBlockFormat`.
|
||||
5. `src/doctor_workstation/ui/dialogs/ai_consult.py:27:5` - unused `PySide6.QtGui.QTextCharFormat`.
|
||||
6. `src/doctor_workstation/ui/dialogs/ai_consult.py:28:5` - unused `PySide6.QtGui.QTextCursor`.
|
||||
7. `src/doctor_workstation/ui/dialogs/prescription.py:19:28` - unused `datetime.datetime`.
|
||||
8. `src/doctor_workstation/ui/pages/patients.py:11:75` - unused `PySide6.QtGui.QPixmap`.
|
||||
9. `src/doctor_workstation/ui/pages/prescriptions.py:10:65` - unused `PySide6.QtGui.QPixmap`.
|
||||
|
||||
## Overall result
|
||||
|
||||
The full verification gate is failing: both pytest and ruff returned exit code `1`.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 强制更新对话框“退出软件”安全实现分析
|
||||
|
||||
## 结论
|
||||
|
||||
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `dialog.close()`、`reject()` 或 `QApplication.quit()`。当前更新下载由全局 `QThreadPool` 中的 `QRunnable` 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
|
||||
|
||||
1. GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
|
||||
2. 更新任务通过既有 `finished` 信号确认已结束后,再由 `ApplicationController` 调用 `application.quit()`;
|
||||
3. `aboutToQuit` 中的 `ApplicationController.shutdown()` 只做最终、幂等的资源清理,不能承担异步等待任务结束的职责。
|
||||
|
||||
这条顺序保证:用户选择“退出软件”后不会又启动更新助手;`.part` 文件能走现有异常清理;Qt 事件循环在工作线程仍可能发信号时不会提前消失。
|
||||
|
||||
## 当前实现与证据
|
||||
|
||||
### 1. 强制对话框目前没有退出路径
|
||||
|
||||
- `AppUpdateDialog` 对强制更新移除关闭按钮并设为应用级模态(`app/src/doctor_workstation/ui/dialogs/app_update.py:112-121`)。
|
||||
- 按钮区只有“稍后提醒”“取消下载”“立即更新”;“稍后提醒”在强制更新时隐藏(`:179-197`)。
|
||||
- `set_busy()` 只在“忙且非强制”时显示取消下载,所以强制更新下载过程中没有任何停止入口(`:208-213`)。
|
||||
- 强制更新或任意下载忙状态都会忽略窗口关闭事件,强制更新还会忽略 Escape(`:265-275`)。
|
||||
|
||||
因此新增能力应是独立的 `exit_requested` 语义,而不是复用 `download_cancelled`。后者当前在 session 中明确拒绝强制更新(`:411-415`),且它的既有语义只是“取消后留在应用内”。
|
||||
|
||||
### 2. session 没有“请求取消 -> 已经停稳”的闭环
|
||||
|
||||
- `_TaskSignals` 已声明 `finished`,`_Task.run()` 也一定会在 `finally` 发出它(`app_update.py:67-90`),但 `AppUpdateSession` 没有连接该信号。
|
||||
- session 只保留一个跨线程共享的 `_cancel: bool` 和最近一次 `_signals`,没有活动 worker/token、退出状态或完成回调(`:285-292`)。
|
||||
- 检查任务和安装准备任务均直接提交到 `QThreadPool.globalInstance()`(`:316-330`、`:467-472`);局部 `worker` 没被 session 用来跟踪生命周期。
|
||||
- 安装准备任务直接把进度/状态连到 dialog,把结果连到 `_finish_install()`(`:467-471`)。关闭事件循环前没有撤销或门控这些回调。
|
||||
- 新 offer 到达时,session 会对旧 dialog 调用 `close()` 后立刻 `deleteLater()`(`:397-405`)。如果旧 dialog 正在强制更新/下载,它的 `closeEvent()` 会拒绝关闭,但 `deleteLater()` 仍会排队;与此同时旧 worker 仍持有连接和捕获该 dialog 的 lambda。这也是需要用“活动操作 token”阻止重入/替换的理由。
|
||||
|
||||
### 3. 当前取消只能在收到下载分块以后生效
|
||||
|
||||
- `download_package()` 把 HTTP read timeout 设为 `None`(`app/src/doctor_workstation/services/app_update.py:311`)。服务器建立连接后若不再发送数据,worker 可以无限阻塞在读取中,GUI 写入 `_cancel=True` 也不能唤醒 socket。
|
||||
- 取消回调只在 `iter_bytes()` 产出一个 chunk 后检查(`:334-337`)。取消被观察到时会抛出 `AppUpdateError`,现有异常分支会删除 `.part` 文件(`:349-351`),这一清理机制可以继续复用。
|
||||
- 下载返回以后没有再次检查取消状态;job 会继续校验安装器,或调用不可取消的 `safe_extract_zip()`(UI `app_update.py:439-465`;service `app_update.py:247-260`)。
|
||||
- 下载全部完成后,文件在 `os.replace()` 前也没有最后一次取消检查(service `app_update.py:358-367`)。即使退出请求恰好到达末尾,job 仍可能返回 `_PreparedUpdate`。
|
||||
- 工作目录在下一次同版本尝试开始时会整体删除重建(service `app_update.py:722-727`),所以取消发生在下载完成或解压阶段时,保留完整 zip/部分解压目录不会污染下一次尝试;关键仍是不能继续提交安装。
|
||||
|
||||
### 4. 直接 `quit()` 存在安装竞态
|
||||
|
||||
当前 `_finish_install()` 收到任何合法 `_PreparedUpdate` 就先启动外部更新助手,再用 300 ms 定时器调用 `application.quit()`(UI `app_update.py:483-503`)。更新助手按设计等待当前 PID 消失后才覆盖/安装并重启:archive 路径见 service `app_update.py:517-525`,Inno Setup 路径见 `:594-625`。
|
||||
|
||||
若下载中“退出软件”直接调用 `quit()`,存在以下时序:
|
||||
|
||||
1. worker 已完成最后一个 chunk,并已把 `result` 排进 GUI 事件队列;
|
||||
2. 用户的退出点击与该 queued result 先后到达 GUI 线程;
|
||||
3. 若 result 仍被处理,当前 `_finish_install()` 没有“退出已请求”门禁,会启动更新助手;
|
||||
4. 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
|
||||
|
||||
反向时序也不安全:如果 `quit()` 先结束事件循环,worker 仍可能在独立 `httpx.Client` 中写 `.part`、解压或发射 Qt 信号。`QApplication.quit()` 是退出事件循环的请求,不是 `QRunnable` 的 cancel/join。进程最终可能等待 Qt 线程池析构、遗留中间文件,或丢弃已经排队的结果;不能把这些析构时机当成生命周期协议。
|
||||
|
||||
### 5. `ApplicationController.shutdown()` 目前不管理 updater
|
||||
|
||||
- `aboutToQuit` 在控制器构造时连接到 `shutdown()`(`app/src/doctor_workstation/app.py:402-427`)。
|
||||
- `shutdown()` 只置 `_shutting_down`、失效 session restore、关闭视频和远端 API client;没有调用 `self.app_updater.shutdown()`(`:1081-1097`)。
|
||||
- Qt 配置了 `setQuitOnLastWindowClosed(True)`(`:1147-1162`),因此单纯关闭/拒绝 dialog 也不是统一的退出协议:父 login/shell 仍存在时未必退出,最后窗口意外关闭时又会绕过 updater 的准备阶段。
|
||||
- `ApiClient.close()` 会无超时地等待所有活跃短请求归还连接(`app/src/doctor_workstation/services/api_client.py:414-450`,尤其 `:427-432`)。更新检查使用的正是共享 remote client(UI `app_update.py:310-330`),所以若退出恰逢检查请求,`aboutToQuit -> shutdown -> client.close()` 可能在 GUI 线程等待请求超时/重试结束。强制对话框的原始检查通常已经返回,但 session 仍应在最终 shutdown 时先递增 generation,使迟到的检查结果绝不能再创建窗口。
|
||||
|
||||
`aboutToQuit` 已经处于事件循环退出阶段,不适合再启动“取消后等 finished signal”的异步流程;finished queued signal 可能已没有下一轮事件可处理。因此必须在点击“退出软件”时先完成 quiesce,再真正调用 `quit()`。
|
||||
|
||||
## 建议的最小实现
|
||||
|
||||
### A. 对话框只发意图,不自行退出
|
||||
|
||||
在 `AppUpdateDialog` 增加独立信号 `exit_requested = Signal()` 和按钮:
|
||||
|
||||
- 文案为“退出软件”,仅 `offer.force` 时显示;非强制更新继续使用“稍后提醒/取消下载”。
|
||||
- 强制更新即使 `_busy=True` 也保持该按钮可用,因为这正是下载中唯一的离开路径。
|
||||
- 点击后只 emit;session 接管状态转换。对话框增加 `set_exiting()`,禁用所有按钮、显示“正在停止更新并退出…”,防止双击。
|
||||
- `closeEvent()` 和 Escape 的现有强制拦截继续保留。不要让窗口标题栏关闭绕开协调器。
|
||||
- 一旦外部安装助手已经成功启动,进入不可逆的 `APPLY_COMMITTED` 状态,禁用“退出软件”;此后退出必然表示“退出并安装”。
|
||||
|
||||
### B. 用 `threading.Event` 和活动操作身份建立闭环
|
||||
|
||||
`AppUpdateSession` 最少需要以下 GUI 线程状态:
|
||||
|
||||
```python
|
||||
self._cancel_event = Event()
|
||||
self._active_install_signals: _TaskSignals | None = None
|
||||
self._exit_requested = False
|
||||
self._quit_when_idle: Callable[[], None] | None = None
|
||||
self._apply_committed = False
|
||||
```
|
||||
|
||||
开始安装准备时 `clear()` event,保存本次 `signals`,并把 `signals.finished` 连到带 `signals` 身份参数的 `_on_install_finished()`。进度、状态、result、error 也不要再直接连接 dialog 方法;统一经过 session handler,并同时验证:
|
||||
|
||||
- `signals is self._active_install_signals`;
|
||||
- dialog 仍是 `self.dialog`;
|
||||
- 未处于 `_exit_requested`(finished handler 除外)。
|
||||
|
||||
这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 `_cancel` 状态的问题。活动安装存在时,`check()`/`_present()` 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。
|
||||
|
||||
退出请求的最小状态机是:
|
||||
|
||||
```text
|
||||
IDLE/PREPARING --点击退出--> EXIT_PENDING
|
||||
EXIT_PENDING --cancel_event.set()--> 等待当前 install signals.finished
|
||||
无活动任务或 finished 到达 --> ApplicationController.request_quit()
|
||||
aboutToQuit --> ApplicationController.shutdown() 最终幂等清理
|
||||
```
|
||||
|
||||
`_finish_install()` 的第一条业务门禁必须是“如果退出已请求、event 已 set、或 signals 已不是当前操作,则直接返回,不调用 `apply_downloaded_update()`”。这是防止“退出反而安装”的关键断言。
|
||||
|
||||
### C. 让 job 在阶段边界观察取消,并给网络读取有限上界
|
||||
|
||||
现有 `download_package(cancelled=...)` 接口无需改变,改传 `self._cancel_event.is_set`。job 至少在以下边界调用统一的 `_raise_if_cancelled()`:
|
||||
|
||||
1. 创建 workspace 前;
|
||||
2. `download_package()` 返回后;
|
||||
3. 安装器校验/zip 解压前;
|
||||
4. 校验/解压后、构造 `_PreparedUpdate` 前。
|
||||
|
||||
同时把 `download_package()` 的 `read=None` 改成有限的“单次读空闲超时”,建议沿用配置的 `request_timeout` 或默认 30 秒。这个 timeout 不是总下载时长:只要持续收到 chunk,大文件仍可继续;服务器停止发数据后,退出等待则有确定上界。
|
||||
|
||||
如果希望解压中点击退出也能很快响应,可把 `safe_extract_zip()` 从一次性 `extractall()` 改为逐 member 提取并在每个 member 前检查同一个 cancel callback。若坚持最小改动,也可以让退出等待当前 `extractall()` 完成,但必须保持 event loop 和 dialog 存活,并在解压后门禁掉安装,不能先 `quit()`。
|
||||
|
||||
### D. 由控制器统一发起真正退出
|
||||
|
||||
在 `ApplicationController` 增加与 `_shutting_down` 分离的 `_quit_requested`,以及幂等 `request_quit()`:
|
||||
|
||||
1. 首次调用时设置 `_quit_requested`;
|
||||
2. 调用 `app_updater.prepare_to_quit(self.application.quit)`;
|
||||
3. updater 无活动安装时立即以 `QTimer.singleShot(0, callback)` 完成;有任务时保存 callback,待该任务 `finished` 后完成;
|
||||
4. 重复调用不做任何事。
|
||||
|
||||
不要提前设置 `_shutting_down`,否则真正触发 `aboutToQuit` 时现有 `shutdown()` 会在 `:1084-1086` 直接返回,跳过资源释放。
|
||||
|
||||
`ApplicationController.shutdown()` 中应在 `_cancel_session_restore()` 之后、关闭视频和 remote client 之前调用幂等的 `self.app_updater.shutdown()`。该方法应:递增 `_generation`、设置 cancel event、清除退出 callback、使所有迟到 callback 失效;它是兜底,不再等待 worker。正常的强制对话框退出路径到这里时,安装准备 worker 已经 finished。
|
||||
|
||||
成功更新也应复用同一完成门:`_finish_install()` 成功启动 helper 后只记录 `_apply_committed=True` 和“任务结束后退出”;由本次 `signals.finished` 再调用控制器的 `request_quit()`。这样可以删除当前依赖经验值的 300 ms 定时退出(UI `app_update.py:501-503`),并明确保证 worker 已离开 `run()`。
|
||||
|
||||
### E. 不建议的实现
|
||||
|
||||
- 不要在退出按钮中调用 `os._exit()`、`terminate()` 或强杀线程;这会绕过 controller 的视频/API 清理,并可能截断 `.part`/日志写入。
|
||||
- 不要在 `aboutToQuit` 中调用 `QThreadPool.globalInstance().waitForDone()`;它会等待整个应用的全局线程池,而不只是更新任务,当前无限 read timeout 还可能让 GUI 永久卡住。
|
||||
- 不要用循环 `processEvents()` 等待 worker;这会允许更新按钮、窗口关闭和 queued result 重入。
|
||||
- 不要只设置现有 `_cancel=True` 后立即 `quit()`;设置取消只是请求,`finished` 才是可退出的确认。
|
||||
|
||||
## 建议补测
|
||||
|
||||
在 `app/tests/test_app_update_ui.py` 现有强制对话框测试(`:68-83`)基础上补:
|
||||
|
||||
1. 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
|
||||
2. 空闲时点击退出只调用一次 controller `request_quit()`。
|
||||
3. 下载中点击退出会 set event、保持应用运行且不立即调用 `application.quit()`;手工 emit 当前 signals 的 `finished` 后才调用一次。
|
||||
4. 退出请求后再投递 `progress/status/result/error` 均不更新旧 dialog;特别断言 `_finish_install()` 不调用 `apply_downloaded_update()`。
|
||||
5. 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
|
||||
6. 新 offer 在活动安装期间不会 `deleteLater()` 当前 dialog,也不会启动第二个 workspace job。
|
||||
7. `ApplicationController.shutdown()` 调用 updater shutdown 早于 `remote_repository.client.close()`,并保持二次调用幂等。
|
||||
|
||||
在 `app/tests/test_app_update.py` 的下载测试(现有 `:235-293`)基础上补:
|
||||
|
||||
8. 流式响应在若干 chunk 后设置 `Event`,断言抛取消错误、目标文件和 `.part` 都不存在。
|
||||
9. 下载最后一个 chunk 后、`os.replace()`/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
|
||||
10. 读空闲超时为有限值,并被转换为 `AppUpdateError`;避免退出永久等待。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
最小、低风险的提交顺序是:先加入 session 的 operation token、`Event`、finished 门和 controller `request_quit()`;再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 `apply_downloaded_update()`”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
|
||||
|
||||
## 审阅说明
|
||||
|
||||
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
|
||||
- 根目录 `AGENTS.md` 已读取;仓库当前不存在其中提到的 `.trellis/` 目录,因此没有额外的 workflow/spec 文件可读。
|
||||
- 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 强制更新“退出软件”Windows 正式包重建与验证结果
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 总体结果:成功
|
||||
- 生产源码/测试修改:无(本次仅重建发布产物并新增本报告)
|
||||
- 工作树说明:执行前 `src/doctor_workstation/__init__.py` 及其他生产/测试文件已有用户修改;本次全部保留,未覆盖或回退
|
||||
- Trellis:仓库根目录不存在 `.trellis/`,因此无 Trellis 工作流文件可继续读取
|
||||
|
||||
## 发布配置核对
|
||||
|
||||
重建前后均读取 `src/doctor_workstation/__init__.py`,确认:
|
||||
|
||||
```powershell
|
||||
rg -n "(__version__|DEBUG_MODE)" src/doctor_workstation/__init__.py
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 输出:`6:__version__ = "1.2.0"`、`10:DEBUG_MODE = False`
|
||||
|
||||
- `__version__ = "1.2.0"`
|
||||
- `DEBUG_MODE = False`
|
||||
|
||||
最终 frozen 主程序的 Windows 版本资源也复核为:
|
||||
|
||||
- `FileVersion = 1.2.0`
|
||||
- `ProductVersion = 1.2.0`
|
||||
|
||||
最终安装器的 `ProductVersion = 1.2.0`。
|
||||
|
||||
## 正式包重建
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Windows package complete.`
|
||||
- Python 构建环境:PyInstaller 6.22.0 / Python 3.12.12
|
||||
- 视频伴侣构建:通过,Vite 转换 43 个模块
|
||||
- frozen Qt 多媒体文件门禁:通过
|
||||
- frozen Qt 多媒体 smoke(`--media-smoke-test`,隔离 offscreen 环境):通过
|
||||
- frozen 应用入口 smoke(`--smoke-test`,隔离 offscreen 环境):通过
|
||||
- ZIP:7-Zip 报告 `Everything is Ok`
|
||||
- 安装器:Inno Setup 6.7.3 编译成功,`Successful compile (140.609 sec)`
|
||||
|
||||
构建期间有非阻断警告:Vite 报告单个压缩后 chunk 超过 500 kB;PyInstaller 报告一个 Qt QML 插件二进制缺失及若干 Windows 系统 DLL 解析警告。脚本内置的 frozen 文件门禁与两个 smoke gate 均通过,构建最终退出码为 `0`。
|
||||
|
||||
## 最终安装器冒烟验证
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离验证目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-55b6feebaa134d21ba0637549d55355b`
|
||||
- 覆盖范围:安装器品牌图标、静默安装、已安装 EXE 存在性与启动 smoke、静默卸载、卸载后主程序残留检查
|
||||
|
||||
## 产物与独立校验
|
||||
|
||||
使用以下命令模式逐项独立复核,退出码为 `0`:
|
||||
|
||||
```powershell
|
||||
Get-Item -LiteralPath <产物绝对路径>
|
||||
Get-FileHash -LiteralPath <产物绝对路径> -Algorithm SHA256
|
||||
```
|
||||
|
||||
| 产物 | 绝对路径 | 字节数 | SHA-256 |
|
||||
| --- | --- | ---: | --- |
|
||||
| 正式安装器 EXE | `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,907,874 | `71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9` |
|
||||
| 发布 ZIP | `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,817,779 | `D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604` |
|
||||
| frozen 主程序 EXE | `D:\web\zyt\app\dist\DoctorWorkstation\DoctorWorkstation.exe` | 6,300,842 | `550BF4434C6B2BEEB9DDA5A78107FB392C4FF442A6D62D01803A51197E59A893` |
|
||||
| 校验清单 | `D:\web\zyt\app\dist\SHA256SUMS.txt` | 220 | `9BD4CB7CD0A1FA7FBBED0E6D38BE2A4F98898224A6B97E0E38F35701CB8B8A20` |
|
||||
|
||||
`dist\SHA256SUMS.txt` 内容:
|
||||
|
||||
```text
|
||||
71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9 DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604 DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||
```
|
||||
|
||||
独立复算的安装器与 ZIP 哈希均与脚本末尾输出及 `SHA256SUMS.txt` 逐字符一致。
|
||||
@@ -0,0 +1,182 @@
|
||||
# 强制更新“退出软件”回归测试设计
|
||||
|
||||
## 结论
|
||||
|
||||
建议在 `app/tests/test_app_update_ui.py` 把“退出软件”作为强制更新对话框的独立显式动作测试,不把它等同于关闭窗口或 `reject()`:
|
||||
|
||||
- 强制更新在初始状态和下载中状态都显示且启用“退出软件”。
|
||||
- 点击只发出一次专用信号(下文假定为 `exit_requested`);对话框自身不静默 `reject()`。
|
||||
- 普通更新仍显示“稍后提醒”,不显示“退出软件”,原有 `update_deferred` + `reject()` 行为不变。
|
||||
- 强制更新无论初始还是下载中,标题栏关闭和 Escape 都继续被拦截;用户只能通过明确的“退出软件”动作退出。
|
||||
|
||||
生产实现若采用独立控件,建议公开 `exit_button`;这比把强制退出语义塞进现有 `later_button` 更容易测试,也避免 `_defer()` 同时承担“稍后”和“退出”两种相反行为。若实现选择复用 `later_button`,下述断言可把 `exit_button` 替换为该控件,但至少应保留独立的 `exit_requested` 信号。
|
||||
|
||||
## 当前覆盖缺口
|
||||
|
||||
当前 `test_app_update_ui.py` 有以下相关覆盖:
|
||||
|
||||
- `test_optional_update_dialog_allows_later` 只断言普通更新的稍后按钮可见和更新文案,未点击按钮,也未验证 `update_deferred`。
|
||||
- `test_forced_update_dialog_hides_defer_and_blocks_escape` 断言稍后按钮隐藏,并用 `dialog.close()` 验证强更无法关闭;尽管测试名写有 `blocks_escape`,测试体没有发送 Escape。
|
||||
- 没有覆盖 `set_busy(True)`。当前 `set_busy()` 会禁用 `later_button`,因此若复用该按钮显示“退出软件”,下载中会直接回归为不可退出。
|
||||
- 没有覆盖退出信号的次数,也没有证明显式退出动作不会被当成普通 `reject()`。
|
||||
|
||||
现有 service 测试 `app/tests/test_app_update.py` 主要覆盖 offer 解析、下载、校验与更新应用,不适合承载 Qt 按钮和键盘行为;这些回归应继续留在 `test_app_update_ui.py`。
|
||||
|
||||
## 建议测试矩阵
|
||||
|
||||
| offer | 对话框状态 | 稍后按钮 | 退出按钮 | 更新按钮 | 取消下载 | 关闭 / Escape |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 强制 | 初始 | 隐藏 | 显示、启用 | 启用 | 隐藏 | 均拦截 |
|
||||
| 强制 | 下载中 | 隐藏 | 显示、启用 | 禁用 | 隐藏 | 均拦截 |
|
||||
| 普通 | 初始 | 显示、启用,文案“稍后提醒” | 隐藏 | 启用 | 隐藏 | 允许 |
|
||||
| 普通 | 下载中 | 保持现有禁用语义 | 隐藏 | 禁用 | 显示 | `closeEvent` 目前拦截;本次不要顺带定义 Escape 新语义 |
|
||||
|
||||
最后一格存在现有 Qt 行为不对称:普通更新下载中时 `closeEvent()` 会拦截标题栏关闭,但 `keyPressEvent()` 仅专门拦截强制更新的 Escape。除非产品需求明确要求调整普通更新下载中的 Escape,否则本次回归不要无意固化或改变该行为。
|
||||
|
||||
## 推荐测试拆分
|
||||
|
||||
测试文件增加:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
```
|
||||
|
||||
### 1. 强制更新在初始和下载中均可显式退出
|
||||
|
||||
用参数化覆盖两个状态,避免只测初始渲染:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||
def test_forced_update_exit_action_stays_available(
|
||||
busy: bool,
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
dialog.show()
|
||||
if busy:
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(256, 1024)
|
||||
app.processEvents()
|
||||
|
||||
assert not dialog.later_button.isVisible()
|
||||
assert dialog.exit_button.isVisible()
|
||||
assert dialog.exit_button.isEnabled()
|
||||
assert dialog.exit_button.text() == "退出软件"
|
||||
assert dialog.update_button.isEnabled() is (not busy)
|
||||
assert not dialog.cancel_button.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
这里必须在 `set_busy(True)` 后断言,才能捕获“统一禁用底部按钮”导致强制更新无法退出的回归。调用 `show_download_progress()` 同时让测试更贴近真实 `_start_install()` 顺序:先 `set_busy(True)`,再进入下载进度态。
|
||||
|
||||
### 2. 点击退出按钮只发一次专用信号
|
||||
|
||||
```python
|
||||
def test_forced_update_exit_button_emits_request(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
requested: list[bool] = []
|
||||
dialog.exit_requested.connect(lambda: requested.append(True))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
dialog.exit_button.click()
|
||||
|
||||
assert requested == [True]
|
||||
assert dialog.isVisible()
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
`assert dialog.isVisible()` 有意证明按钮在 dialog 层只表达“请求退出软件”,而不是绕开应用级清理流程直接 `reject()`。真正退出应由 `AppUpdateSession`/应用层的 slot 完成。若最终设计明确由 dialog 自身关闭,则删除这一条,但仍要保留信号次数断言。
|
||||
|
||||
还可把该测试参数化为初始/下载中并在两种状态点击;若测试数量需要控制,则第一个参数化测试负责可用性,第二个测试负责一次信号已足够定位大部分回归。
|
||||
|
||||
### 3. 普通更新仍是“稍后提醒”
|
||||
|
||||
建议增强现有 optional 测试,而不是只检查可见性:
|
||||
|
||||
```python
|
||||
def test_optional_update_dialog_keeps_defer_action(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=False))
|
||||
deferred: list[bool] = []
|
||||
dialog.update_deferred.connect(lambda: deferred.append(True))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
assert dialog.later_button.isVisible()
|
||||
assert dialog.later_button.isEnabled()
|
||||
assert dialog.later_button.text() == "稍后提醒"
|
||||
assert not dialog.exit_button.isVisible()
|
||||
|
||||
dialog.later_button.click()
|
||||
|
||||
assert deferred == [True]
|
||||
assert not dialog.isVisible()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
这条会防止实现“退出软件”时误把普通更新的次按钮文案、信号或关闭行为一起改掉。
|
||||
|
||||
### 4. 强制更新明确拦截关闭与 Escape
|
||||
|
||||
把当前名不副实的测试改成真实事件测试,并参数化初始/下载中:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||
def test_forced_update_only_allows_explicit_exit(
|
||||
busy: bool,
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
dialog.show()
|
||||
if busy:
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(256, 1024)
|
||||
app.processEvents()
|
||||
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
行为断言比检查 window flags 更稳定:不同平台可能规范化窗口标志,但 `closeEvent()`/`keyPressEvent()` 是否真正保留对话框才是用户可观察契约。
|
||||
|
||||
## 会话层边界
|
||||
|
||||
dialog 信号测试只能证明点击请求已发出,不能证明应用最终退出。生产接线还应满足:
|
||||
|
||||
- `AppUpdateSession._present()` 连接 `exit_requested` 到一个应用级退出入口。
|
||||
- 下载中退出时先设置取消标志,使 `download_package(..., cancelled=...)` 尽快结束并清理 `.part` 文件,再请求 `QApplication.quit()`;否则全局线程池任务可能拖延进程退出。
|
||||
- 应用级退出必须走既有 `QApplication.aboutToQuit -> ApplicationController.shutdown`,不要从 dialog 直接调用 `sys.exit()` 或跳过资源清理。
|
||||
|
||||
若实现为可替换的 session 方法(例如 `_request_exit()`),可另补一个 session 单测,mock/monkeypatch 该方法后验证 dialog 信号接线;不要在 pytest 共享的真实 `QApplication` 上直接调用 `quit()`,以免污染同进程后续 UI 测试。本次题目明确要求的四项回归,以上 dialog 测试已经可以独立、稳定覆盖。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 已读取根 `AGENTS.md`;工作树中不存在 `.trellis/workflow.md` 和 `.trellis/spec/`,因此无法应用额外 Trellis 分层规范。
|
||||
- 只读运行现有基线:`app/.venv/Scripts/python.exe -m pytest tests/test_app_update_ui.py -q`,结果 `4 passed`。
|
||||
- 本文之外未修改生产代码或测试代码;工作树中原有的 `app_update.py` 与 `test_app_update_ui.py` 未提交改动均已保留。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Windows 1.2.0 正式打包结果
|
||||
|
||||
- 打包日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 版本源:`src/doctor_workstation/__init__.py`
|
||||
- 确认版本:`1.2.0`
|
||||
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1`
|
||||
- 脚本退出码:`0`
|
||||
- 总体结果:成功
|
||||
|
||||
## 发布产物核对
|
||||
|
||||
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,914,042 | 155.367 | `1D74966B73005B30ECB4EB7E2101BECB57BB8C91D0981392ACD00F6800D37E7D` |
|
||||
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,707 | 220.118 | `A56057D95EFCE70E58FA54264CAC17A16E2F5BDB842FE515A5A153B073A4AFE9` |
|
||||
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `08BD69A21BACEC86B8269ED665C50CDAF4D3ED6D252CEC4DD7BEE30E73573175` |
|
||||
|
||||
以上三个文件均存在。独立使用 `Get-FileHash -Algorithm SHA256` 重新计算 EXE 和 ZIP 哈希,结果与打包脚本末尾输出及 `SHA256SUMS.txt` 中的两条记录逐项一致。
|
||||
|
||||
## 脚本验证结果
|
||||
|
||||
- 锁定的 Python 构建依赖检查通过。
|
||||
- 视频伴侣依赖安装成功,`vue-tsc --noEmit && vite build` 成功;Vite 共转换 43 个模块。
|
||||
- PyInstaller 6.22.0 / Python 3.12.12 构建成功,输出 `dist/DoctorWorkstation`。
|
||||
- `Frozen Qt multimedia file gate passed.`
|
||||
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||
- 7-Zip 创建 ZIP 成功,输出 `Everything is Ok`;归档包含 180 个目录、3,011 个文件。
|
||||
- Inno Setup 6.7.3 编译成功,输出 `Successful compile (165.062 sec)`。
|
||||
- 正式安装包、ZIP 和校验清单均生成,脚本最终退出码为 0。
|
||||
|
||||
## 非阻断警告
|
||||
|
||||
- Vite 报告单个压缩后 chunk 超过 500 kB,仅为体积优化提示。
|
||||
- PyInstaller 报告一个 Qt QML 插件二进制缺失,以及若干 Windows 系统 DLL 解析警告;这些警告未阻断构建,且脚本内置的多媒体文件门禁、多媒体离屏 smoke test 和应用入口 smoke test 全部通过。
|
||||
|
||||
## 真实安装器冒烟测试
|
||||
|
||||
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'`
|
||||
- 脚本退出码:`0`
|
||||
- 脚本最终结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离测试目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-ac84581babe94e28a0d0ffab63dd0b5f`
|
||||
|
||||
### 分阶段核对
|
||||
|
||||
- 静默安装:通过。安装器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER` 运行到隔离目录;`setup.log` 记录 `Installation process succeeded.` 和 `Need to restart Windows? No`。
|
||||
- 安装内容:通过。脚本在启动前确认 `DoctorWorkstation.exe` 与 `unins000.exe` 均存在。
|
||||
- 图标:通过。脚本分别提取安装器、已安装主程序及卸载器的 32×32 关联图标并计算 SHA-256;主程序与卸载器图标均和安装器品牌图标一致,否则脚本会失败。
|
||||
- 启动 smoke:通过。已安装主程序在隔离配置、日志目录与 `QT_QPA_PLATFORM=offscreen` 环境下执行 `--smoke-test`,退出码为 0;应用日志记录 `doctor workstation starting`,未记录异常堆栈。
|
||||
- 静默卸载:通过。卸载器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART` 运行,退出码为 0;`uninstall.log` 记录 `Uninstallation process succeeded.`、`Removed all? Yes` 和 `Need to restart Windows? No`。
|
||||
- 残留检查:通过。卸载完成后独立确认隔离安装目录、`DoctorWorkstation.exe`、`unins000.exe`、当前用户开始菜单快捷方式及本次当前用户卸载注册表键均不存在。隔离测试根目录按设计保留,仅包含安装/卸载日志和隔离应用日志,便于审计。
|
||||
@@ -0,0 +1,233 @@
|
||||
# 医生工作站版本发布配置与更新 API 诊断
|
||||
|
||||
## 结论
|
||||
|
||||
当前仓库中,admin、server、app 三端的**现行契约是一致的**,但字段名不是扁平的
|
||||
`package_type` / `download_url`。正式 wire contract 是:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"has_update": true,
|
||||
"force": false,
|
||||
"enabled": true,
|
||||
"current_version": "1.1.0",
|
||||
"latest_version": "1.2.0",
|
||||
"min_version": "",
|
||||
"title": "...",
|
||||
"notes": "...",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"package": {
|
||||
"url": "https://.../DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"sha256": "64 位十六进制值",
|
||||
"size": 123,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
},
|
||||
"can_install": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- `latest_version` 是 `data` 下的顶层字段。
|
||||
- `platform`、`arch` 是检测请求参数,同时在 `data` 下回显规范化后的值;它们不保存在发布配置中。
|
||||
- 安装包对象叫 `package`。
|
||||
- 安装包类型叫 `package.type`,不是顶层或同级的 `package_type`。
|
||||
- 下载地址叫 `package.url`,不是 `download_url`。
|
||||
- 哈希叫 `package.sha256`。
|
||||
- 如果截图或线上响应实际出现的是扁平 `package_type`、`download_url`,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。
|
||||
|
||||
当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 `1.2.0`(`app/src/doctor_workstation/__init__.py:5-6`),但 `app/dist/SHA256SUMS.txt:1-2` 只登记了 `1.1.0` 的 EXE/ZIP,且本地 `DoctorWorkstation.exe` 文件版本也是 `1.1.0`。如果管理端把 `latest_version` 设为 `1.2.0`,却填入当前 `1.1.0` 安装包,客户端安装后仍会报告 `1.1.0`,下次启动会再次发现 `1.2.0`,形成重复升级提示。打包脚本明确从同一个 `__version__` 读取版本(`app/scripts/package_windows.ps1:68-76`),并用它生成 EXE/ZIP 名称(`:169-174`)及两者哈希(`:234-244`);发布前必须重新生成 `1.2.0` 产物。
|
||||
|
||||
> 本轮只读诊断没有修改生产代码。根目录 `AGENTS.md` 已读取;仓库中没有 `.trellis/` 目录。
|
||||
|
||||
## 1. Windows 64 位安装包字段如何进入保存请求
|
||||
|
||||
管理端类型定义把 Windows 包放在 `packages.windows_x64`,每个包固定包含
|
||||
`url`、`sha256`、`size`、`filename`、`type`;其中 `type` 只允许
|
||||
`archive | inno_setup`(`admin/src/api/setting/desktop_workstation.ts:3-24`)。保存接口是
|
||||
`POST /setting.desktop_workstation/setConfig`(同文件 `:27-34`)。
|
||||
|
||||
页面的 Windows 区块来自平台键 `windows_x64`(`admin/src/views/setting/desktop_workstation/index.vue:196-224`),默认类型是 `inno_setup`(`:198-218`)。UI 字段与请求体的对应关系如下:
|
||||
|
||||
| 截图/UI 字段 | 保存请求字段 | 证据 |
|
||||
|---|---|---|
|
||||
| Windows 64 位安装包 | `packages.windows_x64` | `index.vue:221-224, 339-343` |
|
||||
| 安装包类型 | `packages.windows_x64.type`,EXE 为 `inno_setup` | `index.vue:96-104, 305-309` |
|
||||
| 安装包地址 | `packages.windows_x64.url` | `index.vue:113-123, 318-324` |
|
||||
| SHA-256 | `packages.windows_x64.sha256` | `index.vue:144-150, 292-315` |
|
||||
| 文件名 | `packages.windows_x64.filename` | `index.vue:152-162, 300-304` |
|
||||
| 文件大小(字节) | `packages.windows_x64.size` | `index.vue:164-174, 300-304` |
|
||||
| 最新版本号 | 顶层 `latest_version` | `index.vue:34-42, 330-344` |
|
||||
|
||||
选择文件后,页面在浏览器本地读取原始文件名和字节数,`.exe` 自动切换为
|
||||
`inno_setup`,并用 Web Crypto 计算 SHA-256(`index.vue:292-315`)。上传成功后只把上传接口返回的 `data.uri`(次选 `data.url`)写入包的 `url`(`:318-328`)。最终点击保存时,页面显式组装三个平台的完整 `packages` 对象,而不是上传后自动发布(`:330-345`)。
|
||||
|
||||
虽然 API 封装调用写成 `request.post({ params })`,拦截器会在 POST 且没有 `data` 时把
|
||||
`params` 移入 JSON body(`admin/src/utils/request/index.ts:20-38`),所以 PHP 收到的是上述嵌套 JSON,而不是查询字符串。
|
||||
|
||||
## 2. 后端如何校验和持久化
|
||||
|
||||
控制器用 POST 校验器接收请求,再交给逻辑层保存(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:38-46`)。
|
||||
|
||||
正常管理端保存时的关键约束:
|
||||
|
||||
- 版本号需是纯数字分段格式(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:48-57`)。
|
||||
- 包类型只允许 `archive` / `inno_setup`,且 `inno_setup` 只允许 Windows x64(`:118-137`)。
|
||||
- Inno Setup 若填写文件名,必须以 `.exe` 结尾(`:138-140`)。
|
||||
- 显式 `http://` 的 Inno Setup 地址会被拒绝(`:141-143`)。
|
||||
- 外部 http(s) 地址必须带 SHA-256,SHA-256 若非空必须是 64 位十六进制(`:144-152`)。
|
||||
- 文件名最长 180 字节,size 必须是非负数(`:153-158`)。
|
||||
|
||||
逻辑层将标量分别保存为配置项,把所有平台包作为一个 `packages` 配置项保存:
|
||||
|
||||
- `enabled`
|
||||
- `latest_version`
|
||||
- `min_version`
|
||||
- `force_update`
|
||||
- `title`
|
||||
- `notes`
|
||||
- `packages`
|
||||
|
||||
证据为 `server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:45-55`。其中
|
||||
`latest_version` / `min_version` 会被正规化为三段版本,包则逐平台正规化
|
||||
`url/sha256/size/filename/type`(`:180-205, 226-254`)。
|
||||
|
||||
`ConfigService::set()` 对数组执行 `json_encode(..., JSON_UNESCAPED_UNICODE)` 后写入 Config 模型的 `value` 字段;标量直接写入(`server/app/common/service/ConfigService.php:32-50`)。因此数据库中的逻辑形态是:
|
||||
|
||||
```text
|
||||
type = desktop_workstation, name = latest_version, value = "1.2.0"
|
||||
type = desktop_workstation, name = packages, value =
|
||||
{"windows_x64":{"url":"...","sha256":"...","size":...,"filename":"...","type":"inno_setup"},...}
|
||||
```
|
||||
|
||||
读取时,`ConfigService::get()` 会对合法 JSON 自动 `json_decode(..., true)`(同文件
|
||||
`:65-85`),所以 `packages` 回到 PHP 数组。保存 URL 时会去掉当前站点/当前存储域名,读取给 API 时再补回绝对域名(`DesktopWorkstationLogic.php:231-252, 261-280`;`server/app/common/service/FileService.php:42-59, 69-78`)。本地 `uploads/...` 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||
|
||||
## 3. 检测 API 如何选择包和序列化响应
|
||||
|
||||
app 请求的端点是 `setting.desktop_workstation/check`(`app/src/doctor_workstation/services/app_update.py:29-35`)。控制器把 `check` 放进免登录列表(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:26-29`),并把 GET 参数直接交给逻辑层(`:48-55`)。
|
||||
|
||||
客户端发送:
|
||||
|
||||
```text
|
||||
current_version=<当前运行时版本>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
证据为 `app_update.py:217-243`。server 将 `windows/win/win32/win64` 统一成
|
||||
`windows`,把 `amd64/x86_64/x64` 统一成 `x64`,拼成配置键
|
||||
`windows_x64`(`DesktopWorkstationLogic.php:125-153`)。也就是说,`platform` / `arch`
|
||||
不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择
|
||||
`packages.windows_x64` 的请求维度。
|
||||
|
||||
server 的检测响应由 `evaluate()` 直接组成(`DesktopWorkstationLogic.php:75-103`):
|
||||
|
||||
- `latest_version` 来自已保存的配置并正规化。
|
||||
- `platform`、`arch` 是请求值正规化后的回显。
|
||||
- `package` 是匹配平台的单个包,只有 `url` 和 `sha256` 都非空才返回,否则为 `null`。
|
||||
- 包对象的键为 `url/sha256/size/filename/type`(`:272-280`)。
|
||||
- `can_install = has_update && url 非空 && sha256 非空`。
|
||||
- `force` 只有存在更新、命中强制策略并且有可安装包时才为 true。
|
||||
|
||||
控制器的 `data()` 最终封装为 `{code, show, msg, data}`(`server/app/common/controller/BaseLikeAdminController.php:50-60`;`server/app/common/service/JsonService.php:71-91`)。app 的 `ApiClient` 对 `code == 1` 返回 envelope 中的 `data`(`app/src/doctor_workstation/services/api_client.py:504-542`),因此 `parse_update_offer()` 收到的就是上面列出的 `data` 对象,而不是整个 envelope。
|
||||
|
||||
## 4. 与 app 客户端契约逐字段对照
|
||||
|
||||
| 语义 | server 实际输出 | app 实际读取 | 是否一致 |
|
||||
|---|---|---|---|
|
||||
| 最新版本 | `latest_version` | `data.get("latest_version")` | 一致(`DesktopWorkstationLogic.php:95`; `app_update.py:174-181`) |
|
||||
| 平台 | `platform` | `data.get("platform")` | 一致(server `:99`; app `:147-150, 176-183`) |
|
||||
| 架构 | `arch` | `data.get("arch")` | 一致(server `:100`; app `:147-150, 176-183`) |
|
||||
| 安装包 | `package` object/null | `data.get("package")` | 一致(server `:101`; app `:151-153`) |
|
||||
| 下载地址 | `package.url` | `package_payload.get("url")` | 一致(server `:275`; app `:154, 166-173`) |
|
||||
| SHA-256 | `package.sha256` | `package_payload.get("sha256")` | 一致(server `:276`; app `:155, 184-188`) |
|
||||
| 包类型 | `package.type` | `package_payload.get("type")` | 一致(server `:279`; app `:157-173`) |
|
||||
| 文件大小 | `package.size` | `package_payload.get("size")` | 一致(server `:277`; app `:158-173`) |
|
||||
| 文件名 | `package.filename` | `package_payload.get("filename")` | 一致(server `:278`; app `:156-173`) |
|
||||
| 可安装 | `can_install` | `data.get("can_install")` + 客户端二次校验 | 一致但客户端更严格(server `:85-102`; app `:184-200`) |
|
||||
|
||||
客户端只认可 `archive` / `inno_setup`,且 Inno 只允许 Windows;它还要求 SHA-256
|
||||
严格为 64 位小写十六进制、响应平台/架构必须与请求一致(`app_update.py:162-200`)。对于
|
||||
`inno_setup`,下载 URL 还必须是 HTTPS(localhost 调试例外),随后下载内容要通过 SHA-256、size、`.exe` 后缀和 PE `MZ` 头校验(`:287-300, 360-397`)。UI 根据 `package.type` 分流:`inno_setup` 直接走 Windows 安装器,`archive` 则按 ZIP 解压(`app/src/doctor_workstation/ui/dialogs/app_update.py:423-449`)。
|
||||
|
||||
现有自动化也明确锁定了这个嵌套契约:server contract test 要求 Windows 包返回
|
||||
`package.type == inno_setup`(`server/tests/DesktopWorkstationUpdateContractTest.php:34-62`);app test 用
|
||||
`package.{url,sha256,size,filename,type}` 构造响应并验证接收(`app/tests/test_app_update.py:66-90`)。本轮实跑:
|
||||
|
||||
```text
|
||||
php server/tests/DesktopWorkstationUpdateContractTest.php PASS
|
||||
uv run pytest app/tests/test_app_update.py -q PASS (21 tests)
|
||||
```
|
||||
|
||||
## 5. 根因候选(按优先级)
|
||||
|
||||
### A. `latest_version` 与实际安装包版本不一致(当前工作区已有直接证据)
|
||||
|
||||
当前版本源是 `1.2.0`,但现有 EXE/ZIP、SHA256SUMS 和冻结 exe 都是 `1.1.0`。如果截图中的管理端配置已经把最新版本发布为 `1.2.0`,当前 `1.1.0` 包不能作为它的安装包。表现为下载、安装可能成功,但应用重启后仍是旧版本并再次提示更新。
|
||||
|
||||
### B. 线上响应使用 `package_type` / `download_url` 扁平字段
|
||||
|
||||
当前 app 没有这两个 wire key 的兼容读取,仓库内也没有生成它们的 server 代码。如果截图中的实际网络响应是例如:
|
||||
|
||||
```json
|
||||
{"latest_version":"1.2.0","package_type":"inno_setup","download_url":"...","sha256":"..."}
|
||||
```
|
||||
|
||||
app 会因为没有 `package.url` 而得到 `package=None`,最终 `can_install=false`;即使把包放在
|
||||
`package` 中但只给 `package_type`,客户端也会默认当成 `archive`,对 EXE 执行 ZIP 解压并失败。该情形应视为明确的协议不一致。
|
||||
|
||||
### C. admin / server / app 部署版本分叉,或 PHP OPcache 未刷新
|
||||
|
||||
Git 历史显示提交 `43e5411b6a8d2e625140c5dca8ddeb8492ba7daa` 才同步把
|
||||
`type=inno_setup` 加入 admin、server 和 app。它之前的 server 会在保存/读取包时丢掉 `type`。
|
||||
因此“管理页面已有 Inno Setup 下拉框,但 check 响应没有 `package.type`”最符合部分部署或旧 PHP 代码仍在运行,而不是当前源码的逻辑错误。
|
||||
|
||||
### D. SHA-256 非空但无效,server 与 app 的可安装判定强度不同
|
||||
|
||||
`evaluate()` 只检查 URL/哈希非空;app 要求恰好 64 位十六进制。通过正常管理端保存不会发生,因为 validator 会拦截;但旧数据、手工改库、另一服务写入配置时,可能出现 server 返回 `can_install=true`、app 最终降级为不可安装。
|
||||
|
||||
### E. 相对上传路径在 server 输出时被扩成 HTTP
|
||||
|
||||
validator 只对输入字符串显式以 `http://` 开头的 Inno URL 拒绝;`uploads/...` 相对路径可通过。响应时 `FileService::getFileUrl()` 按 `request()->domain()` 补域名。如果生产位于 HTTPS 反向代理后但 PHP 未正确识别代理协议,响应可能变成 `http://...`。app 会安全地拒绝自动执行这个 EXE。若截图中的 `package.url` 为 HTTP,应核对反向代理的 forwarded proto / trusted proxy 配置,而不是放宽客户端安全校验。
|
||||
|
||||
## 6. 建议修复与验证顺序
|
||||
|
||||
1. **先重新打 1.2.0 正式包再发布。** 保持 `app/src/doctor_workstation/__init__.py`、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端 `latest_version` 四者全部为 `1.2.0`;从新生成的 `SHA256SUMS.txt` 复制 EXE 对应哈希,不要复用 1.1.0 的值。
|
||||
2. **直接抓线上 check 响应。** 用与 app 一样的参数请求:
|
||||
`GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64`。确认有效数据位于 `data`,并且字段精确为 `data.package.url/type/sha256`。
|
||||
3. **如果看到 `download_url/package_type`,统一契约。** 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有 `package.{url,type,...}`,并补契约测试。
|
||||
4. **如果 `package.type` 缺失,做完整部署并清 OPcache。** 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。
|
||||
5. **核验 URL 与哈希。** `package.url` 必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与 `package.sha256` 完全一致,size 若填写也必须一致。
|
||||
6. **补一条跨端端到端 fixture。** 固化一个 Windows `inno_setup` 响应,既让 PHP `evaluate()` 产出 JSON,也让 Python `parse_update_offer()` 消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
|
||||
|
||||
## 最小正确发布样例
|
||||
|
||||
管理端保存体中的 Windows 部分:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": 1,
|
||||
"latest_version": "1.2.0",
|
||||
"min_version": "",
|
||||
"force_update": 0,
|
||||
"title": "医生工作站 1.2.0",
|
||||
"notes": "...",
|
||||
"packages": {
|
||||
"windows_x64": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"sha256": "<新 1.2.0 EXE 的 64 位 SHA-256>",
|
||||
"size": 0,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
},
|
||||
"macos_arm64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"},
|
||||
"macos_x64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不要把同一内容改名为顶层 `download_url` / `package_type`;当前 app 不消费该形态。
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Read-only-ish timing probe for the desktop update commit sequence.
|
||||
|
||||
The probe imports production code and replaces only its external download/apply
|
||||
edges in memory. It does not modify production sources or existing tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThreadPool, QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import app_update as update_service
|
||||
from doctor_workstation.services.app_update import (
|
||||
PACKAGE_TYPE_INNO_SETUP,
|
||||
UpdateOffer,
|
||||
UpdatePackage,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import app_update as update_ui
|
||||
|
||||
|
||||
def _record(events: list[dict[str, Any]], name: str, started: float) -> None:
|
||||
events.append(
|
||||
{
|
||||
"event": name,
|
||||
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
"thread_id": threading.get_ident(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def exercise_session(iterations: int = 25) -> dict[str, Any]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
main_thread = threading.get_ident()
|
||||
original_edges = {
|
||||
"is_frozen_install": update_ui.is_frozen_install,
|
||||
"frozen_install_root": update_ui.frozen_install_root,
|
||||
"download_package": update_ui.download_package,
|
||||
"apply_downloaded_update": update_ui.apply_downloaded_update,
|
||||
}
|
||||
failures: list[dict[str, Any]] = []
|
||||
samples: list[list[dict[str, Any]]] = []
|
||||
collected_signals = 0
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-commit-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
install_root = temp_root / "installed"
|
||||
install_root.mkdir()
|
||||
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||
update_ui.is_frozen_install = lambda: True
|
||||
update_ui.frozen_install_root = lambda: install_root
|
||||
|
||||
for index in range(iterations):
|
||||
run_root = temp_root / f"run-{index}"
|
||||
run_root.mkdir()
|
||||
events: list[dict[str, Any]] = []
|
||||
started = time.perf_counter()
|
||||
|
||||
def fake_download(
|
||||
_url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
_record(_events, "download_enter", _started)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(b"MZ" + b"probe")
|
||||
progress = kwargs.get("progress")
|
||||
if callable(progress):
|
||||
progress(7, 7)
|
||||
_record(_events, "download_return", _started)
|
||||
return destination
|
||||
|
||||
def fake_apply(
|
||||
_payload: Path,
|
||||
*,
|
||||
package_type: str,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
assert package_type == PACKAGE_TYPE_INNO_SETUP
|
||||
_record(_events, "apply_enter", _started)
|
||||
_record(_events, "apply_return", _started)
|
||||
|
||||
update_ui.download_package = fake_download
|
||||
update_ui.apply_downloaded_update = fake_apply
|
||||
|
||||
host = QObject()
|
||||
host.config = SimpleNamespace( # type: ignore[attr-defined]
|
||||
config_dir=run_root,
|
||||
verify_ssl=True,
|
||||
)
|
||||
|
||||
def request_quit(
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
_record(_events, "request_quit", _started)
|
||||
|
||||
host.request_quit = request_quit # type: ignore[attr-defined]
|
||||
session = update_ui.AppUpdateSession(host)
|
||||
offer = UpdateOffer(
|
||||
has_update=True,
|
||||
force=True,
|
||||
enabled=True,
|
||||
current_version="1.0.0",
|
||||
latest_version=f"1.0.{index + 1}",
|
||||
min_version="",
|
||||
title="probe",
|
||||
notes="probe",
|
||||
platform="windows",
|
||||
arch="x64",
|
||||
package=UpdatePackage(
|
||||
url="https://example.invalid/DoctorWorkstation-Setup.exe",
|
||||
sha256="a" * 64,
|
||||
size=7,
|
||||
filename="DoctorWorkstation-Setup.exe",
|
||||
type=PACKAGE_TYPE_INNO_SETUP,
|
||||
),
|
||||
can_install=True,
|
||||
)
|
||||
dialog = update_ui.AppUpdateDialog(offer)
|
||||
session.dialog = dialog
|
||||
|
||||
original_finish = session._finish_install
|
||||
original_finished = session._on_install_finished
|
||||
|
||||
def finish_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finish,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "result_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "result_slot_return", _started)
|
||||
|
||||
def finished_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finished,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "finished_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "finished_slot_return", _started)
|
||||
|
||||
session._finish_install = finish_probe # type: ignore[method-assign]
|
||||
session._on_install_finished = finished_probe # type: ignore[method-assign]
|
||||
session._start_install(dialog, offer)
|
||||
signal_ref = weakref.ref(session._active_install_signals)
|
||||
|
||||
deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < deadline:
|
||||
app.processEvents()
|
||||
if any(item["event"] == "request_quit" for item in events):
|
||||
break
|
||||
time.sleep(0.001)
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
|
||||
names = [item["event"] for item in events]
|
||||
expected = [
|
||||
"download_enter",
|
||||
"download_return",
|
||||
"result_slot_enter",
|
||||
"apply_enter",
|
||||
"apply_return",
|
||||
"result_slot_return",
|
||||
"finished_slot_enter",
|
||||
"request_quit",
|
||||
"finished_slot_return",
|
||||
]
|
||||
slot_threads = {
|
||||
item["thread_id"]
|
||||
for item in events
|
||||
if item["event"] in {"result_slot_enter", "finished_slot_enter", "request_quit"}
|
||||
}
|
||||
if names != expected or slot_threads != {main_thread}:
|
||||
failures.append(
|
||||
{
|
||||
"iteration": index,
|
||||
"events": events,
|
||||
"main_thread_id": main_thread,
|
||||
}
|
||||
)
|
||||
if index < 3:
|
||||
samples.append(events)
|
||||
|
||||
session.deleteLater()
|
||||
dialog.deleteLater()
|
||||
del session, dialog, host
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
app.processEvents()
|
||||
gc.collect()
|
||||
app.processEvents()
|
||||
if signal_ref() is None:
|
||||
collected_signals += 1
|
||||
finally:
|
||||
update_ui.is_frozen_install = original_edges["is_frozen_install"]
|
||||
update_ui.frozen_install_root = original_edges["frozen_install_root"]
|
||||
update_ui.download_package = original_edges["download_package"]
|
||||
update_ui.apply_downloaded_update = original_edges["apply_downloaded_update"]
|
||||
|
||||
return {
|
||||
"iterations": iterations,
|
||||
"failures": failures,
|
||||
"signals_collected_after_iteration": collected_signals,
|
||||
"main_thread_id": main_thread,
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def exercise_real_popen() -> dict[str, Any]:
|
||||
if sys.platform != "win32":
|
||||
return {"skipped": f"requires win32, got {sys.platform}"}
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-helper-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
script = temp_root / "probe_helper.ps1"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"param(",
|
||||
" [int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||
" [string]$HelperLogFile, [string]$InstallerLogFile",
|
||||
")",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Start-Sleep -Milliseconds 1200",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-complete'",
|
||||
]
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
installer = temp_root / "Setup.exe"
|
||||
installer.write_bytes(b"MZ")
|
||||
restart = temp_root / "DoctorWorkstation.exe"
|
||||
restart.write_bytes(b"MZ")
|
||||
helper_log = temp_root / "helper.log"
|
||||
fixed_log = temp_root / "fixed.log"
|
||||
installer_log = temp_root / "installer.log"
|
||||
original_script = script.read_text(encoding="utf-8-sig")
|
||||
fixed_log_literal = str(fixed_log).replace("'", "''")
|
||||
script.write_text(
|
||||
original_script.replace(
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Set-Content -LiteralPath '"
|
||||
+ fixed_log_literal
|
||||
+ "' -Value (\"helper=<{0}> args=<{1}>\" -f $HelperLogFile, ($args -join '|'))\n"
|
||||
+ "Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
captured: list[subprocess.Popen[Any]] = []
|
||||
captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
real_popen = update_service.subprocess.Popen
|
||||
|
||||
def capture_popen(*args: Any, **kwargs: Any) -> subprocess.Popen[Any]:
|
||||
captured_calls.append((args, kwargs.copy()))
|
||||
process = real_popen(*args, **kwargs)
|
||||
captured.append(process)
|
||||
return process
|
||||
|
||||
update_service.subprocess.Popen = capture_popen
|
||||
try:
|
||||
started = time.perf_counter()
|
||||
update_service._spawn_inno_setup_applier(
|
||||
script,
|
||||
installer=installer,
|
||||
restart_exe=restart,
|
||||
helper_log_file=helper_log,
|
||||
installer_log_file=installer_log,
|
||||
)
|
||||
returned_ms = round((time.perf_counter() - started) * 1000, 3)
|
||||
finally:
|
||||
update_service.subprocess.Popen = real_popen
|
||||
deadline = time.perf_counter() + 2.5
|
||||
child_log = ""
|
||||
while time.perf_counter() < deadline:
|
||||
if helper_log.exists():
|
||||
child_log = helper_log.read_text(encoding="utf-8").strip()
|
||||
if child_log == "child-complete":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return_code = captured[0].poll() if captured else None
|
||||
if captured and return_code is None:
|
||||
return_code = captured[0].wait(timeout=2.0)
|
||||
matrix: dict[str, Any] = {}
|
||||
if captured_calls:
|
||||
command = list(captured_calls[0][0][0])
|
||||
flag_cases = {
|
||||
"zero": 0,
|
||||
"detached": getattr(subprocess, "DETACHED_PROCESS", 0),
|
||||
"new_process_group": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"no_window": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"detached_new_group": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"detached_no_window": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"new_group_no_window": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"production_all": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
}
|
||||
running: dict[str, tuple[subprocess.Popen[Any], Path]] = {}
|
||||
helper_parameter = command.index("-HelperLogFile") + 1
|
||||
for name, flags in flag_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
running[name] = (process, case_log)
|
||||
production_flags = flag_cases["production_all"]
|
||||
detached_flags = flag_cases["detached"]
|
||||
extra_cases = {
|
||||
"production_close_false": (production_flags, False, False),
|
||||
"production_all_devnull": (production_flags, True, True),
|
||||
"detached_close_false": (detached_flags, False, False),
|
||||
"detached_all_devnull": (detached_flags, True, True),
|
||||
}
|
||||
for name, (flags, close_fds, all_devnull) in extra_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
stream_kwargs = (
|
||||
{
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if all_devnull
|
||||
else {}
|
||||
)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=close_fds,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
**stream_kwargs,
|
||||
)
|
||||
flag_cases[name] = flags
|
||||
running[name] = (process, case_log)
|
||||
matrix_deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < matrix_deadline:
|
||||
if all(case_log.exists() for _, case_log in running.values()):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
for name, (process, case_log) in running.items():
|
||||
matrix[name] = {
|
||||
"flags": flag_cases[name],
|
||||
"log_created": case_log.exists(),
|
||||
"return_code": process.poll(),
|
||||
}
|
||||
control_return_code = None
|
||||
control_stdout = ""
|
||||
control_stderr = ""
|
||||
if not fixed_log.exists() and captured_calls:
|
||||
call_args, call_kwargs = captured_calls[0]
|
||||
call_kwargs.update(
|
||||
creationflags=0,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
control = real_popen(*call_args, **call_kwargs)
|
||||
control_stdout, control_stderr = control.communicate(timeout=5.0)
|
||||
control_return_code = control.returncode
|
||||
return {
|
||||
"command": captured_calls[0][0][0] if captured_calls else [],
|
||||
"spawn_returned_ms": returned_ms,
|
||||
"child_completed": child_log == "child-complete",
|
||||
"child_log": child_log,
|
||||
"fixed_log": fixed_log.read_text(encoding="utf-8").strip()
|
||||
if fixed_log.exists()
|
||||
else "",
|
||||
"child_return_code": return_code,
|
||||
"flag_matrix": matrix,
|
||||
"control_return_code": control_return_code,
|
||||
"control_stdout": control_stdout,
|
||||
"control_stderr": control_stderr,
|
||||
}
|
||||
|
||||
|
||||
def exercise_controller_quit() -> dict[str, Any]:
|
||||
"""Run the production request_quit method against a real Qt event loop."""
|
||||
|
||||
from doctor_workstation.app import ApplicationController
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
events: list[str] = []
|
||||
holder = SimpleNamespace(application=app, _shutting_down=False)
|
||||
app.aboutToQuit.connect(lambda: events.append("aboutToQuit"))
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: (
|
||||
events.append("request_quit_enter"),
|
||||
ApplicationController.request_quit(holder),
|
||||
events.append("request_quit_return"),
|
||||
),
|
||||
)
|
||||
watchdog = QTimer()
|
||||
watchdog.setSingleShot(True)
|
||||
watchdog.timeout.connect(lambda: (events.append("watchdog"), app.quit()))
|
||||
watchdog.start(1000)
|
||||
started = time.perf_counter()
|
||||
return_code = app.exec()
|
||||
return {
|
||||
"events": events,
|
||||
"return_code": return_code,
|
||||
"returned_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
iteration_count = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||
output = {
|
||||
"session": exercise_session(iteration_count),
|
||||
"real_popen": exercise_real_popen(),
|
||||
"controller_quit": exercise_controller_quit(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,200 @@
|
||||
# AppUpdateSession 更新提交时序复现报告
|
||||
|
||||
日期:2026-08-28
|
||||
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
|
||||
范围:只读检查生产源码和现有测试;新增的唯一测试资产是
|
||||
`app/research/update_commit_repro.py`,未修改生产源码和既有测试。
|
||||
|
||||
## 结论
|
||||
|
||||
1. **当前工作树中的 `result -> finished -> request_quit` 时序可以稳定复现为正确。**
|
||||
100 次真实 `QThreadPool` 跨线程循环没有一次乱序或丢失:下载/prepare 在 worker
|
||||
线程,`_finish_install()`、`_on_install_finished()` 和 `request_quit()` 都在 GUI 主线程。
|
||||
2. **`_TaskSignals` 不会因局部变量释放而提前消失。** 安装期间它同时被 `_Task`、
|
||||
`AppUpdateSession._signals` 和 `_active_install_signals` 强引用;`finished` 到达后才清空
|
||||
session 引用。探针在主动 `gc.collect()` 后仍观察到 wrapper 存活,风险方向是残留/泄漏,
|
||||
不是过早 GC 导致信号丢失。
|
||||
3. **`ApplicationController.request_quit()` 本身有效。** 在真实 Qt 事件循环里,调用顺序是
|
||||
`request_quit_enter -> request_quit_return -> aboutToQuit`,没有触发 1 秒 watchdog;本次
|
||||
测量从进入事件循环到退出约 0.1 ms。
|
||||
4. **`apply_downloaded_update()` 的 `Popen` 调用不阻塞 GUI。** 生产参数下
|
||||
`_spawn_inno_setup_applier()` 约 2.7--4.7 ms 返回。
|
||||
5. **真正可复现的安装失败位于 Windows helper 启动。** 当前代码组合
|
||||
`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 启动 Windows
|
||||
PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志
|
||||
命令。标志矩阵表明:本机上任何包含 `DETACHED_PROCESS` 的组合都失败;去掉它后,
|
||||
`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW` 以及两者组合均能执行脚本。
|
||||
6. **本机真实 18:08 更新尝试与复现完全一致。** 安装包和 `install_update.ps1` 都在
|
||||
18:08:37 生成,证明 Qt `result` 已投递且 `_finish_install()` 已进入
|
||||
`apply_downloaded_update()`;但同目录没有 `update_helper.log` 和 `inno_setup.log`,说明
|
||||
helper 没有运行到脚本第 20 行的第一条 `Write-Log`。
|
||||
|
||||
因此,“下载完成后没有进入安装”的首要根因不是 `finished` 信号丢失,也不是
|
||||
`request_quit()` 失效,而是 **`DETACHED_PROCESS` 令 PowerShell helper 静默不执行**。
|
||||
当前源码仍会在 `finished` 后请求退出,所以如果现场描述为“窗口也一直不关闭”,这部分在当前
|
||||
工作树中未能复现;现有日志更符合“应用已走到提交/退出路径,但安装器从未启动,因此没有安装
|
||||
和重启”的用户观感。成功路径没有阶段日志,无法仅凭旧日志证明窗口具体关闭时刻。
|
||||
|
||||
## 源码时序
|
||||
|
||||
相关位置:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:68-91`:`_Task.run()` 在同一个
|
||||
`try/else/finally` 中先 `result.emit(result)`,再 `finished.emit()`。
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:503-555`:安装任务创建
|
||||
`_TaskSignals`,保存到 `_signals` 和 `_active_install_signals`,然后连接
|
||||
`progress/status/result/error/finished`。
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:601-641`:`result` 槽先设置
|
||||
`_apply_committed=True` 并同步调用 `apply_downloaded_update()`;`finished` 槽随后清理活动
|
||||
signals,并在 `_apply_committed` 或 `_exit_requested` 时调用 `_complete_quit()`。
|
||||
- `app/src/doctor_workstation/app.py:1081-1086`:controller 通过
|
||||
`QTimer.singleShot(0, application.quit)` 请求正常退出。
|
||||
- `app/src/doctor_workstation/app.py:427,1088-1105`:`aboutToQuit` 同步进入幂等
|
||||
`shutdown()`,更新 session 先被 invalidated,再清理视频和 remote client。
|
||||
- `app/src/doctor_workstation/services/app_update.py:637-672`:Inno helper 的 Windows
|
||||
`Popen` 和三个 creation flags。
|
||||
|
||||
必须注意一个 Qt 细节:worker 发出 `result` 后不会等待 GUI 槽执行,紧接着就发出
|
||||
`finished`;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:
|
||||
|
||||
```text
|
||||
worker: download/prepare return
|
||||
-> GUI: result slot enter
|
||||
-> GUI: apply_downloaded_update enter/return
|
||||
-> GUI: result slot return
|
||||
-> GUI: finished slot enter
|
||||
-> GUI: request_quit
|
||||
```
|
||||
|
||||
这也意味着:如果 `apply_downloaded_update()` 真正阻塞,排在它后面的 `finished` 和 quit 会一起
|
||||
延迟。但本机真实 `Popen` 返回只需数毫秒,未观察到阻塞。
|
||||
|
||||
## 复现结果
|
||||
|
||||
### 1. 现有测试
|
||||
|
||||
```powershell
|
||||
$env:QT_QPA_PLATFORM='offscreen'
|
||||
uv run --project app pytest app/tests/test_app_update_ui.py app/tests/test_app_update.py -q
|
||||
```
|
||||
|
||||
结果:`28 passed`。
|
||||
|
||||
现有 `test_session_waits_for_update_worker_before_quitting` 是直接调用私有槽的同步单元测试,能够
|
||||
检查状态门禁,但没有经过 `QThreadPool`/Qt queued delivery;这正是独立探针需要补足的部分。
|
||||
|
||||
### 2. 独立跨线程与 helper 探针
|
||||
|
||||
```powershell
|
||||
$env:QT_QPA_PLATFORM='offscreen'
|
||||
uv run --project app python app/research/update_commit_repro.py 100
|
||||
```
|
||||
|
||||
关键结果:
|
||||
|
||||
```text
|
||||
session.iterations = 100
|
||||
session.failures = []
|
||||
|
||||
worker thread != GUI thread
|
||||
result_slot_enter.thread_id == finished_slot_enter.thread_id
|
||||
request_quit.thread_id == GUI main thread
|
||||
|
||||
_spawn_inno_setup_applier return = 约 3 ms
|
||||
production flags script log created = false
|
||||
production PowerShell return code = 0
|
||||
|
||||
controller quit events =
|
||||
request_quit_enter, request_quit_return, aboutToQuit
|
||||
```
|
||||
|
||||
Windows 创建标志矩阵:
|
||||
|
||||
| creation flags | 脚本是否执行 |
|
||||
|---|---:|
|
||||
| `0` | 是 |
|
||||
| `CREATE_NEW_PROCESS_GROUP` | 是 |
|
||||
| `CREATE_NO_WINDOW` | 是 |
|
||||
| `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` | 是 |
|
||||
| `DETACHED_PROCESS` | 否 |
|
||||
| `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` | 否 |
|
||||
| `DETACHED_PROCESS | CREATE_NO_WINDOW` | 否 |
|
||||
| 当前生产三标志组合 | 否 |
|
||||
|
||||
将 `close_fds` 改为 false,或给 stdin/stdout/stderr 全部接 `DEVNULL`,均不能挽救包含
|
||||
`DETACHED_PROCESS` 的组合。
|
||||
|
||||
### 3. 真实运行残留
|
||||
|
||||
本机目录:
|
||||
|
||||
```text
|
||||
C:\Users\pc\AppData\Local\ZhenYangTang\ZhenyangDoctor\updates\1_3_0\
|
||||
```
|
||||
|
||||
18:08:37 已有:
|
||||
|
||||
- `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe`
|
||||
- `install_update.ps1`
|
||||
|
||||
不存在:
|
||||
|
||||
- `update_helper.log`
|
||||
- `inno_setup.log`
|
||||
|
||||
`install_update.ps1` 只会在 `_finish_install() -> apply_downloaded_update() ->
|
||||
apply_inno_setup_update()` 中生成,所以这组残留直接排除了“result 未投递”和
|
||||
“`_TaskSignals` 被提前回收”。脚本第一项业务动作就是写 `waiting for pid ...`;没有 helper log
|
||||
则失败发生在脚本业务逻辑之前。
|
||||
|
||||
另有一个独立的发布数据风险:workspace 名为 `1_3_0`,下载文件名却是 `1.1.0`,而请求中的
|
||||
当前版本是 `1.2.0`。即使 helper 启动成功,也可能尝试降级安装。服务端 offer 的
|
||||
`latest_version`、package filename、安装器 FileVersion/产品版本需要在发布端和客户端都做一致性
|
||||
校验。这不是本次 helper 不启动的直接原因,但上线前必须处理。
|
||||
|
||||
## 建议修复方向
|
||||
|
||||
1. Windows helper 不使用 `DETACHED_PROCESS`;先验证保留
|
||||
`CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 时,父进程结束后 helper 仍能存活并运行。
|
||||
2. 不把 `Popen()` 成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个
|
||||
`ready`/`waiting` 标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示
|
||||
明确错误。
|
||||
3. 给成功路径补结构化日志:`prepare_result_received`、`helper_spawn_requested`、
|
||||
`helper_ready`、`worker_finished`、`request_quit`、`about_to_quit`。当前只有异常日志,现场无法
|
||||
区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。
|
||||
4. 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于
|
||||
`latest_version` 的安装器。
|
||||
|
||||
## 建议回归测试
|
||||
|
||||
### Qt/session 测试
|
||||
|
||||
1. **真实 queued delivery 成功路径**:用 `QThreadPool` 启动 `_Task`,以 event loop 等待,断言
|
||||
`result slot -> apply return -> finished slot -> request_quit` 严格顺序,并断言所有 UI/controller
|
||||
槽都在 GUI 线程。
|
||||
2. **signals 生命周期**:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和
|
||||
finished;finished 后断开连接并 `deleteLater()`,最终 weakref 应释放,避免长期检查导致残留。
|
||||
3. **apply 门控**:用 `Event` 暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发
|
||||
一次 quit。
|
||||
4. **apply 失败**:`apply_downloaded_update()` 抛 `AppUpdateError` 时不 quit、
|
||||
`_apply_committed` 恢复 false;另外补非 `AppUpdateError` 异常,避免意外异常留下 commit=true 后
|
||||
仍被 finished 退出。
|
||||
5. **controller 集成**:在真实 `QApplication.exec()` 中调用 controller `request_quit()`,spy
|
||||
`aboutToQuit`、`app_updater.shutdown()` 和远程 client close,断言各一次且总时长有上界。
|
||||
|
||||
### Windows helper 测试
|
||||
|
||||
1. **sentinel 启动测试(当前代码应失败)**:生成只写 sentinel 的 PowerShell 文件,使用生产
|
||||
`_spawn_inno_setup_applier()` 启动,2 秒内必须看到 sentinel;不能只断言 `Popen` 被调用。
|
||||
2. **父进程退出测试**:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready,
|
||||
再观察父 PID 消失并写第二个 sentinel,证明去掉 `DETACHED_PROCESS` 后不会被父退出连带杀死。
|
||||
3. **完整握手测试**:应用只有在 helper ready 后才调用 quit;helper 未 ready、提前退出或无法写
|
||||
日志时,应用保留并展示可重试错误。
|
||||
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
|
||||
mock `subprocess.Popen`。
|
||||
|
||||
## 仓库说明
|
||||
|
||||
根 `AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
|
||||
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
|
||||
修改,本次没有改动其中任何生产源码或既有测试。
|
||||
@@ -0,0 +1,134 @@
|
||||
# Doctor Workstation 1.3.0 Windows 正式包构建与安全验证结果
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 结论:通过。1.3.0 Windows 安装器与 ZIP 已重新构建;冻结媒体/入口门禁、隔离安装/启动/卸载冒烟和真实 Windows helper bootstrap 专项测试全部通过。
|
||||
- 安全边界:未修改生产源码或既有测试,未执行 reset/checkout,未访问现场服务端,也未用 `dist` 中旧的 1.1.0 安装器覆盖真实安装。安装器冒烟显式指定 1.3.0 文件并仅安装到随机临时目录。
|
||||
|
||||
## 最终产物
|
||||
|
||||
| 产物 | 字节数 | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe` | 162917310 | `E1734C7B5E1619951AF81082FC4578D50BD881B5CD8C7AC7F4E658E8E28B74A7` |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.3.0.zip` | 230820285 | `FAF2D4A68065C1ED528A1D887B5210B44FACBEBBC1CB89729FA3B690D7995CEF` |
|
||||
|
||||
`dist\SHA256SUMS.txt` 与重新计算的两个 SHA-256 完全一致。
|
||||
|
||||
## 命令、退出码与结果
|
||||
|
||||
以下命令均从 PowerShell 执行;未特别注明时工作目录为 `D:\web\zyt\app`。
|
||||
|
||||
### 1. 项目约束与 Trellis 检查
|
||||
|
||||
```powershell
|
||||
Get-Content -LiteralPath 'D:\web\zyt\AGENTS.md' -Raw
|
||||
if (Test-Path -LiteralPath 'D:\web\zyt\.trellis') { Get-ChildItem ... } else { 'NO_TRELLIS' }
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:根 `AGENTS.md` 已完整读取;`D:\web\zyt\.trellis` 不存在,因此没有可继续读取的 `.trellis/workflow.md` 或分层 spec。
|
||||
|
||||
### 2. 初始共享工作树与构建输入只读检查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-Content src\doctor_workstation\__init__.py
|
||||
Get-Content scripts\package_windows.ps1
|
||||
Get-Content scripts\smoke_windows_installer.ps1
|
||||
rg -n "bootstrap|ready|helper|windows" tests\test_app_update.py
|
||||
Get-ChildItem dist -File
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:开始时共享工作树已有大量未提交源码/测试变更和研究文件,均视为他人工作并保留;确认构建、安装器冒烟与 helper 测试入口存在。`dist` 内旧 1.1.0/1.2.0 包仅被列出,没有被安装或复制到真实安装位置。
|
||||
|
||||
### 3. 版本源逐行及实际导入检查
|
||||
|
||||
```powershell
|
||||
[System.IO.File]::ReadAllLines((Resolve-Path 'src\doctor_workstation\__init__.py'))
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; print(repr(doctor_workstation.__version__)); print(repr(doctor_workstation.DEBUG_MODE)); print(repr(doctor_workstation.ONLINE_API_BASE_URL))"
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`__version__ == '1.3.0'`;`DEBUG_MODE is False`;线上 API 基址为 `https://admin.zhenyangtang.com.cn`。
|
||||
|
||||
### 4. 打包脚本内置冻结门禁确认
|
||||
|
||||
```powershell
|
||||
rg -n "runtime_media_smoke|smoke|entry|frozen|media|DoctorWorkstation.exe|--smoke" scripts\package_windows.ps1 scripts\build_windows.ps1 packaging\runtime_media_smoke.py
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:确认 `build_windows.ps1` 在构建后依次执行冻结 Qt 多媒体文件门禁、`--media-smoke-test` 冻结进程门禁和 `--smoke-test` 应用入口门禁。
|
||||
|
||||
### 5. 1.3.0 Windows 正式包重建
|
||||
|
||||
```powershell
|
||||
& .\scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 关键结果:
|
||||
- `uv sync --frozen --extra build` 成功;构建环境使用 Python 3.12.12、PyInstaller 6.22.0。
|
||||
- `npm ci` 成功,视频伴侣生产构建成功(Vite 6.1.1,43 modules transformed)。
|
||||
- PyInstaller onedir 冻结成功,输出 `dist\DoctorWorkstation\DoctorWorkstation.exe`。
|
||||
- `Frozen Qt multimedia file gate passed.`
|
||||
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||
- 7-Zip 创建 1.3.0 ZIP 成功;Inno Setup 6.7.3 编译 1.3.0 安装器成功。
|
||||
- 脚本打印的最终哈希与本报告“最终产物”一致。
|
||||
- 非阻塞警告:Vite 报告单个 JS chunk 大于 500 kB;PyInstaller 报告一个可选 QML asset downloader 插件二进制不存在及若干 Windows 系统 DLL 静态解析警告。它们未阻断构建,且后续冻结媒体实际进程、应用入口及安装后启动门禁全部通过。
|
||||
|
||||
### 6. 新安装器隔离安装/启动/卸载冒烟
|
||||
|
||||
```powershell
|
||||
& .\scripts\smoke_windows_installer.ps1 -Installer (Resolve-Path -LiteralPath '.\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe').Path
|
||||
```
|
||||
|
||||
- 退出码:`0`(PowerShell 进程退出码)
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-14b72f4137814eea8ede6bc48920f5f5`
|
||||
- 覆盖项:安装器/主程序/卸载器图标一致;安装目录内主程序和卸载器存在;安装后主程序 `--smoke-test` 返回 0;卸载返回 0;卸载后主程序不再存在。
|
||||
- 安全说明:脚本使用随机 `%LOCALAPPDATA%\Temp\doctor-workstation-installer-smoke-*\install`,环境变量、配置与日志均隔离;命令显式锁定 1.3.0 安装器,没有调用 1.1.0 包或现场服务端。
|
||||
|
||||
### 7. 真实 Windows helper bootstrap 专项测试
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest 'tests\test_app_update.py::test_inno_helper_executes_bootstrap_with_production_flags' -q
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`1 passed`(单点输出为 `.`)。该测试未 mock `subprocess.Popen`,会真实启动 PowerShell helper,并由测试生成的 helper 脚本在收到生产参数集后写入 ready 文件;最后断言 ready 文件内容为 `ready`,因此确认生产 flags 下 ready 握手实际产生。
|
||||
|
||||
### 8. 最终版本/生产 flag 复核及独立哈希核验
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; assert doctor_workstation.__version__ == '1.3.0'; assert doctor_workstation.DEBUG_MODE is False"
|
||||
Get-Item .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe
|
||||
Get-FileHash .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe -Algorithm SHA256
|
||||
Get-Item .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip
|
||||
Get-FileHash .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip -Algorithm SHA256
|
||||
Compare-Object <重新计算的两行> (Get-Content .\dist\SHA256SUMS.txt)
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:版本和 `DEBUG_MODE` 断言通过;EXE/ZIP 字节数和哈希如“最终产物”所列;`Compare-Object` 无差异。
|
||||
|
||||
### 9. 完成前共享工作树/产物复查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-ChildItem .\dist -File | Where-Object { $_.Name -match '1\.3\.0|SHA256SUMS' } | Select-Object Name,Length,LastWriteTime
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:1.3.0 EXE、ZIP 和新 `SHA256SUMS.txt` 均存在;本任务未编辑生产源码或测试。完成复查时仍可见共享工作树中的既有 `src/doctor_workstation/services/app_update.py` 修改,未回退或覆盖。
|
||||
|
||||
## 验收结论
|
||||
|
||||
1. 版本/发布开关正确:`1.3.0`、`DEBUG_MODE=False`。
|
||||
2. 正式 EXE/ZIP 重建成功,内置三项冻结门禁全部通过。
|
||||
3. 1.3.0 安装器隔离安装、启动、卸载和残留检查通过。
|
||||
4. 真实 Windows helper bootstrap 在生产 flags 下成功生成 ready 文件。
|
||||
5. 最终大小与 SHA-256 已独立复核,且与 `SHA256SUMS.txt` 一致。
|
||||
6. 未触碰真实安装或现场服务端 1.1.0 包。
|
||||
@@ -0,0 +1,115 @@
|
||||
# Windows 更新安装交接故障诊断
|
||||
|
||||
## 结论
|
||||
|
||||
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
|
||||
|
||||
1. **确定性根因:强制更新对话框拒绝了 `QApplication.quit()` 触发的关闭事件。** 下载 job 完成后,session 启动 helper、设置 `_apply_committed=True`,随后在 `finished` 回调中调用应用级 `request_quit()`;但对话框此时仍是 `offer.force=True` 且 `_busy=True`,其 `closeEvent()` 无条件 `ignore()`。Qt 明确允许窗口通过 Close event 阻止 `quit()`,所以事件循环不退出,`aboutToQuit`/`ApplicationController.shutdown()` 也不会发生。helper 又先等待当前 PID 消失,于是交接形成闭环等待。
|
||||
2. **独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。** 现场有精确的 PowerShell 启动事件,但没有 `update_helper.log`、没有 `inno_setup.log`、没有存活 helper/installer 进程。代码丢弃 `Popen` 句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。
|
||||
3. **发布配置错误:服务端宣称最新/最低版本为 `1.3.0`,实际下发的安装器却是 `1.1.0`。** 本机当前运行 `1.2.0`,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到 `1.3.0`。修复客户端前应先停止这条强制更新配置。
|
||||
|
||||
## 代码交接链路与根因证据
|
||||
|
||||
### 1. UI 已经请求退出,但强制对话框否决退出
|
||||
|
||||
当前交接顺序是:
|
||||
|
||||
1. `_start_install()` 设置 busy、启动 QThreadPool 下载 job(`app/src/doctor_workstation/ui/dialogs/app_update.py:481-555`)。
|
||||
2. job 下载并校验 Inno Setup EXE,返回 `_PreparedUpdate`(`:511-545`)。
|
||||
3. `_finish_install()` 先显示“即将关闭程序并自动安装”,再调用 `dialog.set_apply_committed()`;该方法把 `_busy` 保持为 true(`:616-628`、`:275-283`)。
|
||||
4. `apply_downloaded_update()` 进入 `apply_inno_setup_update()`,写脚本并 `Popen` PowerShell helper(`app/src/doctor_workstation/services/app_update.py:429-472`)。
|
||||
5. QRunnable 随后发送 `finished`,`_on_install_finished()` 清理 active signals 并调用 `_complete_quit()`(UI `:634-641`)。
|
||||
6. `_complete_quit()` 调用 controller `request_quit()`;controller 用 `QTimer.singleShot(0, self.application.quit)` 请求正常退出(UI `:472-479`;`app/src/doctor_workstation/app.py:1081-1086`)。
|
||||
7. 但是更新对话框的 `closeEvent()` 在 `offer.force` **或** `_busy` 为真时执行 `event.ignore()`(UI `:296-300`)。此时两个条件都为真。
|
||||
|
||||
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 `closeEvent()` 执行 `ignore()`,50 ms 后调用 `QApplication.quit()`,并设置 2 秒进程级 watchdog。输出为:
|
||||
|
||||
```text
|
||||
calling quit
|
||||
closeEvent ignored
|
||||
CODE=9
|
||||
```
|
||||
|
||||
也就是 `quit()` 确实到达了窗口,但被 Close event 否决,事件循环直到 watchdog 才终止。Qt 官方文档同样说明 `QCoreApplication.quit()` 可能被仍未关闭的窗口或被忽略的 Quit/Close event 阻止:<https://doc.qt.io/qt-6/qcoreapplication.html#quit>。
|
||||
|
||||
这也解释了为何 controller 的 `aboutToQuit -> shutdown()` 接线本身没有帮助:`aboutToQuit` 只有在退出请求未被阻止时才会发出(`app.py:425-427`、`:1088-1105`)。
|
||||
|
||||
### 2. Inno helper 的等待设计放大了 UI 退出缺陷
|
||||
|
||||
生成的 `install_update.ps1` 首先写 `waiting for pid ...`,然后无限轮询 `Get-Process -Id $TargetPid`;只有目标 PID 消失后才启动安装器(service `:566-627`)。父进程传入的是 `os.getpid()`(`:637-672`)。因此,只要 Qt 主进程被对话框留下,安装器就不可能启动。
|
||||
|
||||
安装器参数本身与 Inno 静默更新意图一致:`/VERYSILENT`、`/SUPPRESSMSGBOXES`、`/NORESTART`、`/CLOSEAPPLICATIONS`、`/NOFORCECLOSEAPPLICATIONS`、`/NORESTARTAPPLICATIONS`、`/LOG=...`;返回 `0`/`3010` 后才重启已安装的 `DoctorWorkstation.exe`,其他返回码重启旧程序(`:599-627`)。`powershell.exe -File <script> <script args>` 的排列也符合 Windows PowerShell 5.1 的 `-File` 契约:<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1>。
|
||||
|
||||
不过当前等待还有两个健壮性问题:
|
||||
|
||||
- 循环无超时、只按整数 PID 查询;极端情况下 PID 被复用会继续等待无关进程。更安全的是先取得特定 `Process` 对象/句柄,再等待该对象退出,而不是每 400 ms 重新按 ID 查找。
|
||||
- helper 的成功标准只是 `subprocess.Popen(...)` 没有同步抛出 `OSError`。`Popen` 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake(`:637-672`)。
|
||||
|
||||
当前还同时设置 `DETACHED_PROCESS`、`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW`(`:645-670`)。Microsoft 文档指出 `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` 同用时会被忽略,因此这组 flag 至少是冗余且不能证明 helper 已进入脚本:<https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>。本次现场只能确认 PowerShell 在脚本体前早退,不能仅凭事件日志断言具体是 flag、stdio、执行策略还是主机初始化中的哪一个原因;修复应以“可确认接管”为契约,而不是猜一个退出原因。
|
||||
|
||||
### 3. `apply_downloaded_update()` 的路由本身正确
|
||||
|
||||
`package_type=inno_setup` 会进入 `apply_inno_setup_update()`;后者要求 Windows、可解析的 frozen install root、存在的已安装 EXE,以及扩展名为 `.exe` 且 DOS header 为 `MZ` 的安装器(service `:390-401`、`:429-483`)。现场已经生成 `install_update.ps1`,证明路由、install root、EXE/PE 基本校验均已通过;故障发生在 helper 启动及应用退出交接之后。
|
||||
|
||||
## 本机现场证据(2026-08-28,Asia/Shanghai)
|
||||
|
||||
- 运行进程:PID `36304`,`C:\Program Files\ZYT\DoctorWorkstation\DoctorWorkstation.exe`,启动于 `18:08:13`;检查时仍 `Responding=True`、主窗口可见,文件 `ProductVersion=1.2.0`。
|
||||
- 应用日志:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\Logs\doctor-workstation.log`。
|
||||
- `18:08:13`:应用启动。
|
||||
- `18:08:15`:以 `current_version=1.2.0&platform=windows&arch=x64` 检查更新成功。
|
||||
- `18:08:32`:下载 `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe` 返回 HTTP 200。
|
||||
- 此后没有安装/helper/退出阶段日志,也没有 Python 异常。
|
||||
- 工作区:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\updates\1_3_0`。
|
||||
- 安装器大小 `162,873,123` 字节,SHA-256 `0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789`,`ProductVersion=1.1.0`,未签名。
|
||||
- `install_update.ps1` 在 `18:08:37` 生成,PowerShell AST 解析无语法错误。
|
||||
- `update_helper.log` 不存在;`inno_setup.log` 不存在。
|
||||
- Windows PowerShell Operational 日志:`18:08:37.828` 有 Event `40961`“Powershell 控制台正在启动”,没有配对的 ready `40962`;检查时也没有命令行指向 `install_update.ps1` 的 PowerShell 或 Inno Setup 进程。这证明子进程被创建过,但没有进入脚本首条 `Write-Log`。
|
||||
- crash log 只有各次进程启动标记,本次没有崩溃堆栈。
|
||||
- 实时只读请求更新接口得到:`latest_version=1.3.0`、`min_version=1.3.0`、`force=true`,但 URL、filename、size、sha256 全都对应上述 `1.1.0` 安装器。本机实测文件 hash 与接口 SHA 一致,说明下载正确,错误在发布元数据/产物绑定。
|
||||
|
||||
## 最小安全修复
|
||||
|
||||
### P0:先修发布配置
|
||||
|
||||
在正确的 `1.3.0` 安装器上传并核对 `ProductVersion`、filename、size、SHA-256 前,立即关闭这条强制更新或将其设为不可安装。不要让 `latest_version=1.3.0` 继续绑定 `1.1.0` 安装器。客户端后续应增加“offer 版本与包版本”的发布门禁;仅校验 HTTPS、SHA 和 `MZ` 不能防止签名正确的旧包被错误发布。
|
||||
|
||||
### P0:让应用级退出能够越过“用户不可关闭”的对话框门禁
|
||||
|
||||
不要把“禁止用户关闭强制更新弹窗”和“禁止应用已提交后的受控退出”共用同一个 `closeEvent` 条件。建议增加明确的 `_allow_application_exit` 状态:
|
||||
|
||||
- 用户点击标题栏关闭/Escape 时仍然拒绝;
|
||||
- helper 已确认接管,或用户点击专用“退出软件”且更新 worker 已安全结束时,session 先设置 allow 状态并关闭/隐藏该 dialog,再调用 controller `request_quit()`;
|
||||
- `closeEvent()` 仅在 `not _allow_application_exit and (offer.force or _busy)` 时 ignore。
|
||||
|
||||
直接在主线程调用 `QCoreApplication.exit(0)`也能绕过 Close event,但会绕过其他窗口的正常 close 协议;相比之下,显式放行本更新对话框后继续走既有 `QApplication.quit -> aboutToQuit -> shutdown` 更小、更安全。
|
||||
|
||||
### P0:把 helper“已接管”变成可验证状态
|
||||
|
||||
`_spawn_inno_setup_applier()` 应返回并保留 `Popen`/child PID,且 helper 在做任何 PID 等待前原子写入 ready 标记(或首条结构化 bootstrap log)。父进程应异步等待一个很短且有界的 ready 窗口:
|
||||
|
||||
- ready 到达且 child 仍存活后,才设置 `_apply_committed=True`、放行 dialog close、请求主程序退出;
|
||||
- child 在 ready 前退出时,读取退出码/bootstrap stderr,留在当前 UI 显示错误,不退出主程序;
|
||||
- 使用 `-NonInteractive`,明确重定向 stdin/stdout/stderr 到日志或 `DEVNULL`;规范化 creation flags,不同时依赖会被忽略的 `DETACHED_PROCESS + CREATE_NO_WINDOW`;
|
||||
- helper 顶层捕获脚本初始化、首条日志、PID wait、安装器启动等所有异常,并写明阶段和退出码。
|
||||
|
||||
这样即使本次 PowerShell 早退的底层原因在另一台机器上不同,也不会再出现“UI 已锁死并宣称即将关闭,但其实无人接管”的假成功。
|
||||
|
||||
## 建议测试
|
||||
|
||||
现有 `app/tests/test_app_update.py` 与 `app/tests/test_app_update_ui.py` 共 `28 passed`,但没有覆盖真实交接:service 测试只截获 `_spawn_inno_setup_applier` 并检查脚本文本;UI 测试只断言 mock `host.request_quit` 被调用,没有运行 `QApplication` 事件循环,也没有验证强制 dialog 是否会否决 quit。现有 installer smoke 直接执行安装器,也绕过了应用退出与 helper。
|
||||
|
||||
建议至少增加:
|
||||
|
||||
1. **Qt 子进程回归(必须)**:显示 `force=True` 且 busy/apply committed 的真实 `AppUpdateDialog`,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回 `0`、`aboutToQuit` 发生,而不是被 `closeEvent` 卡住。
|
||||
2. **显式退出路径**:强制更新下载中点击“退出软件”,worker `finished` 后同样能退出;result 已排队但退出先处理时不得启动 helper。
|
||||
3. **helper ready 成功**:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
|
||||
4. **helper 早退**:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
|
||||
5. **PID 身份**:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
|
||||
6. **隔离端到端 Inno 更新**:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
|
||||
7. **发布契约**:服务端 `latest_version`、package filename/manifest `ProductVersion`、SHA/size 必须属于同一版本;构造 `latest=1.3.0 + package=1.1.0` 时发布或客户端安装必须失败。
|
||||
|
||||
## 审阅边界
|
||||
|
||||
- 已读取根 `AGENTS.md`;仓库不存在 `.trellis/`,因此没有额外 workflow/spec 可读取。
|
||||
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
|
||||
- 除新增本文档外,没有修改生产代码或测试。
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 886 KiB |
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Bundled Noto Sans SC
|
||||
|
||||
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
|
||||
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
|
||||
resource lookup. No font bytes were changed or subsetted locally.
|
||||
|
||||
- Qt family: `Noto Sans SC`
|
||||
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
|
||||
- Variable axis: `wght`, 100–900; named instances at every 100, including 600.
|
||||
- Size: 17,772,300 bytes.
|
||||
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
|
||||
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
|
||||
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
|
||||
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
|
||||
|
||||
The font is distributed under the SIL Open Font License 1.1. Retain
|
||||
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
|
||||
font with the application. The license applies to the font, independently of
|
||||
the application's license.
|
||||
|
||||
Load this local resource through `QFontDatabase.addApplicationFont` after
|
||||
creating `QApplication`, then use the returned family name. The application
|
||||
must not fetch fonts at runtime. The PyInstaller spec already copies the
|
||||
entire `resources` directory, including this directory and its license.
|
||||
|
||||
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
|
||||
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
|
||||
may select Medium for a plain `font-weight: 600` request; this distribution
|
||||
preserves distinct results with the application's normal QSS font weights.
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="真阳医生工作台">
|
||||
<rect width="64" height="64" rx="16" fill="#123F3A"/>
|
||||
<path d="M29 14h6a3 3 0 0 1 3 3v10h9a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-9v9a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3v-9h-9a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3h9V17a3 3 0 0 1 3-3Z" fill="#F6F4EE"/>
|
||||
<path d="M42 16c-1 8-5 13-12 15 2-7 6-12 12-15Z" fill="#79B9A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 418 B |
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
project_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
python_bin="${PYINSTALLER_PYTHON:-$project_root/.venv-build/bin/python}"
|
||||
companion_root="$project_root/video_companion"
|
||||
macos_icon="$project_root/resources/branding/app-icon.icns"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "The macOS bundle must be built on macOS." >&2
|
||||
@@ -13,6 +14,10 @@ if [[ ! -x "$python_bin" ]]; then
|
||||
echo "Build Python was not found: $python_bin" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$macos_icon" ]]; then
|
||||
echo "macOS application icon was not found: $macos_icon" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
|
||||
npm ci --prefix "$companion_root" --no-audit --no-fund
|
||||
|
||||
@@ -8,6 +8,7 @@ $ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
|
||||
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
|
||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||
|
||||
function Invoke-FrozenGate {
|
||||
param(
|
||||
@@ -153,6 +154,9 @@ if (-not [System.IO.Path]::IsPathRooted($Python)) {
|
||||
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Build Python was not found: $Python"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $WindowsIcon -PathType Leaf)) {
|
||||
throw "Windows application icon was not found: $WindowsIcon"
|
||||
}
|
||||
|
||||
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
|
||||
Push-Location $ProjectRoot
|
||||
@@ -165,8 +169,30 @@ try {
|
||||
& $Npm run build --prefix $CompanionRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
||||
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
|
||||
$BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $BuildPythonBase) {
|
||||
throw "Unable to resolve the build Python runtime directory"
|
||||
}
|
||||
# Dependency scanning must not collect unrelated ICU/OpenSSL libraries from
|
||||
# an editor's helper tools (for example Poppler) ahead of Windows libraries.
|
||||
$PreviousBuildPath = $env:PATH
|
||||
$BuildRuntimePaths = @(
|
||||
(Split-Path -Parent $Python),
|
||||
$BuildPythonBase,
|
||||
(Join-Path $BuildPythonBase "DLLs"),
|
||||
(Join-Path $env:SystemRoot "System32"),
|
||||
$env:SystemRoot,
|
||||
(Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0")
|
||||
)
|
||||
try {
|
||||
$env:PATH = ($BuildRuntimePaths | Select-Object -Unique) -join [System.IO.Path]::PathSeparator
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
$PyInstallerExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$env:PATH = $PreviousBuildPath
|
||||
}
|
||||
if ($PyInstallerExitCode -ne 0) { throw "PyInstaller build failed" }
|
||||
|
||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Smoke test for the AI context builder helpers in ai_consult.py.
|
||||
|
||||
This avoids importing PySide6-bound modules by extracting only the pure
|
||||
helper functions we want to verify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
AI_CONSULT_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "src", "doctor_workstation", "ui", "dialogs",
|
||||
"ai_consult.py",
|
||||
)
|
||||
|
||||
|
||||
def _make_helpers() -> dict[str, object]:
|
||||
"""Pull the pure helpers out of ai_consult.py without importing PySide6."""
|
||||
|
||||
with open(AI_CONSULT_PATH, encoding="utf-8") as stream:
|
||||
source = stream.read()
|
||||
tree = ast.parse(source)
|
||||
wanted_names = {
|
||||
"AI_CONTEXT_MAX_CHARS",
|
||||
"AI_PROMPT_LIMIT",
|
||||
"AI_CONTEXT_SEPARATOR",
|
||||
"_truncate_for_context",
|
||||
"_patient_context_blood_sugar",
|
||||
"_patient_context_tongue",
|
||||
"_patient_context_reports",
|
||||
"_patient_context_prescriptions",
|
||||
"_patient_context_videos",
|
||||
"build_patient_ai_context",
|
||||
"_compose_ai_prompt",
|
||||
}
|
||||
selected: list[ast.stmt] = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name in wanted_names:
|
||||
selected.append(node)
|
||||
continue
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in wanted_names:
|
||||
selected.append(node)
|
||||
break
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
|
||||
def _as_mapping(value: object) -> dict[str, object]:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
raw = getattr(value, "raw", None)
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
def first_value(value: object, *keys: str, default: object = None) -> object:
|
||||
"""Return the first present, non-empty value from ``keys``.
|
||||
|
||||
Mirrors ``widgets.first_value``:
|
||||
``first_value(mapping, "k1", "k2", default=...)``.
|
||||
"""
|
||||
|
||||
for key in keys:
|
||||
if not isinstance(value, dict):
|
||||
break
|
||||
if key in value and value[key] not in (None, "", "—"):
|
||||
return value[key]
|
||||
return default
|
||||
|
||||
def get_value(source: object, key: str, default: object = None) -> object:
|
||||
if isinstance(source, dict) and key in source:
|
||||
return source[key]
|
||||
return default
|
||||
|
||||
def _human_value(value: object, *, empty: str = "未记录") -> str:
|
||||
if value in (None, "", "—"):
|
||||
return empty
|
||||
if isinstance(value, str):
|
||||
return value.strip() or empty
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for key, nested in value.items():
|
||||
rendered = _human_value(nested, empty="")
|
||||
if rendered:
|
||||
parts.append(f"{key}:{rendered}")
|
||||
return ";".join(parts) or empty
|
||||
if isinstance(value, list):
|
||||
parts = [_human_value(item, empty="") for item in value]
|
||||
return "、".join(part for part in parts if part) or empty
|
||||
return str(value).strip() or empty
|
||||
|
||||
def display_text(value: object, *, default: str = "") -> str:
|
||||
if value in (None, "", "—"):
|
||||
return default
|
||||
return str(value).strip() or default
|
||||
|
||||
def _exact_positive_id(value: object, expected: int) -> bool:
|
||||
if value in (None, ""):
|
||||
return False
|
||||
try:
|
||||
return int(value) == expected
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
# Provide fallback names for ``collections.abc`` symbols referenced by
|
||||
# the helpers without forcing the real module imports on this stub box.
|
||||
import collections.abc as _abc
|
||||
Sequence = _abc.Sequence # type: ignore[attr-defined]
|
||||
Mapping = _abc.Mapping # type: ignore[attr-defined]
|
||||
Any = object # type: ignore[assignment]
|
||||
|
||||
namespace.update(
|
||||
{
|
||||
"_as_mapping": _as_mapping,
|
||||
"first_value": first_value,
|
||||
"get_value": get_value,
|
||||
"_human_value": _human_value,
|
||||
"display_text": display_text,
|
||||
"_exact_positive_id": _exact_positive_id,
|
||||
"Sequence": Sequence,
|
||||
"Mapping": Mapping,
|
||||
"Any": Any,
|
||||
}
|
||||
)
|
||||
|
||||
module_ast = ast.Module(body=selected, type_ignores=[])
|
||||
ast.fix_missing_locations(module_ast)
|
||||
exec(compile(module_ast, AI_CONSULT_PATH, "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
def main() -> None:
|
||||
helpers = _make_helpers()
|
||||
AI_CONTEXT_MAX_CHARS = helpers["AI_CONTEXT_MAX_CHARS"]
|
||||
AI_PROMPT_LIMIT = helpers["AI_PROMPT_LIMIT"]
|
||||
_truncate_for_context = helpers["_truncate_for_context"]
|
||||
_patient_context_blood_sugar = helpers["_patient_context_blood_sugar"]
|
||||
_patient_context_tongue = helpers["_patient_context_tongue"]
|
||||
_patient_context_reports = helpers["_patient_context_reports"]
|
||||
_patient_context_videos = helpers["_patient_context_videos"]
|
||||
build_patient_ai_context = helpers["build_patient_ai_context"]
|
||||
_compose_ai_prompt = helpers["_compose_ai_prompt"]
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise AssertionError(message)
|
||||
|
||||
def assertEqual(actual: object, expected: object, message: str) -> None:
|
||||
if actual != expected:
|
||||
fail(f"{message}: expected {expected!r}, got {actual!r}")
|
||||
|
||||
def assertContains(container: object, needle: str, message: str) -> None:
|
||||
if not isinstance(container, str) or needle not in container:
|
||||
fail(f"{message}: {needle!r} missing in output")
|
||||
|
||||
# 1. _truncate_for_context
|
||||
short = _truncate_for_context("hello", max_chars=10)
|
||||
assertEqual(short, "hello", "short text should pass through unchanged")
|
||||
|
||||
long_text = _truncate_for_context(
|
||||
"诊断:血糖偏高,建议调整饮食结构,配合运动每周三次以上。",
|
||||
max_chars=12,
|
||||
)
|
||||
assertContains(long_text, "…", "long text should end with ellipsis")
|
||||
assertEqual(len(long_text), 12, "truncated text should respect max_chars")
|
||||
|
||||
# 2. _patient_context_tongue
|
||||
tongue = _patient_context_tongue(
|
||||
{
|
||||
"diagnosis": {
|
||||
"tongue": "舌红苔黄腻",
|
||||
"tongue_coating": "黄腻",
|
||||
"pulse": "弦滑",
|
||||
"tongue_images": ["url1", "url2", "url3"],
|
||||
}
|
||||
}
|
||||
)
|
||||
assertContains(tongue, "舌红苔黄腻", "tongue text missing")
|
||||
assertContains(tongue, "弦滑", "pulse text missing")
|
||||
assertContains(tongue, "舌苔图片 3 张", "tongue image count missing")
|
||||
|
||||
# 3. _patient_context_blood_sugar
|
||||
blood_sugar = _patient_context_blood_sugar(
|
||||
{"diagnosis": {"fasting_blood_sugar": "7.8"}},
|
||||
{
|
||||
"blood_sugar": {
|
||||
"entries": [
|
||||
{"date": "2026-08-15", "value": "6.2", "period": "空腹"},
|
||||
{"date": "2026-08-14", "value": "9.1", "period": "餐后"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assertContains(blood_sugar, "7.8", "fasting reading missing")
|
||||
assertContains(blood_sugar, "每日血糖", "tracking summary missing")
|
||||
|
||||
# 4. _patient_context_videos: filters by current diagnosis id.
|
||||
videos = _patient_context_videos(
|
||||
[
|
||||
{
|
||||
"diagnosis_id": 99,
|
||||
"transcript_text": "其他诊单",
|
||||
"start_time_text": "今天",
|
||||
},
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"transcript_text": "医生:请问您最近睡眠如何;患者:经常失眠。",
|
||||
"start_time_text": "2026-08-15 10:30",
|
||||
},
|
||||
],
|
||||
diagnosis_id=501,
|
||||
)
|
||||
assertContains(videos, "医生", "transcript text missing")
|
||||
assertContains(videos, "2026-08-15", "transcript timestamp missing")
|
||||
|
||||
# 5. _patient_context_reports
|
||||
reports = _patient_context_reports(
|
||||
{
|
||||
"summary": "近期血糖偏高",
|
||||
"diagnosis_advice": "建议控制饮食",
|
||||
"risk_assessment": ["心血管风险升高", "肾功负担加重"],
|
||||
}
|
||||
)
|
||||
assertContains(reports, "既往AI摘要", "summary missing")
|
||||
assertContains(reports, "诊断建议", "advice missing")
|
||||
assertContains(reports, "心血管风险", "risk bullet missing")
|
||||
|
||||
# 6. build_patient_ai_context: full envelope assembly.
|
||||
detail = {
|
||||
"diagnosis": {
|
||||
"tongue": "舌淡苔白",
|
||||
"pulse": "细弱",
|
||||
"fasting_blood_sugar": "8.0",
|
||||
},
|
||||
"tongue_images": ["a", "b"],
|
||||
}
|
||||
tracking = {
|
||||
"blood_sugar": {
|
||||
"entries": [
|
||||
{"date": "2026-08-19", "value": "6.1", "period": "空腹"},
|
||||
]
|
||||
}
|
||||
}
|
||||
analysis = {
|
||||
"summary": "控制尚可",
|
||||
"diagnosis_advice": "调整饮食",
|
||||
"risk_assessment": ["肾功"],
|
||||
}
|
||||
prescriptions = [
|
||||
{"prescription_name": "六味地黄丸", "prescription_remark": "调理方"},
|
||||
]
|
||||
call_records = [
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"transcript_text": "对话内容:患者表述近期乏力。",
|
||||
"start_time_text": "2026-08-18 14:00",
|
||||
}
|
||||
]
|
||||
context_text, present = build_patient_ai_context(
|
||||
detail=detail,
|
||||
tracking=tracking,
|
||||
analysis=analysis,
|
||||
prescriptions=prescriptions,
|
||||
call_records=call_records,
|
||||
diagnosis_id=501,
|
||||
)
|
||||
for label in (
|
||||
"每日血糖",
|
||||
"舌苔/脉象",
|
||||
"视频问诊文字",
|
||||
"历史AI报告",
|
||||
"处方记录",
|
||||
):
|
||||
assertContains(context_text, label, f"section {label} missing in envelope")
|
||||
if label not in present:
|
||||
fail(f"label {label} not in present labels")
|
||||
if len(context_text) > AI_CONTEXT_MAX_CHARS + 12:
|
||||
fail(f"context exceeds {AI_CONTEXT_MAX_CHARS} chars: {len(context_text)}")
|
||||
|
||||
# 7. _compose_ai_prompt: short answer stays untouched.
|
||||
short_prompt = _compose_ai_prompt("血糖如何?", context_text)
|
||||
assertContains(short_prompt, context_text, "short prompt loses context")
|
||||
assertContains(short_prompt, "血糖如何?", "short prompt loses question")
|
||||
if len(short_prompt) > AI_PROMPT_LIMIT:
|
||||
fail("short prompt exceeds limit")
|
||||
|
||||
# 8. Long answer is truncated with ellipsis.
|
||||
long_question = (
|
||||
"请结合患者既往糖尿病史、家族史以及服用的多种药物,给出一份详尽的"
|
||||
"个性化治疗方案,并解释每一步的理由,最终输出一份结构化报告,"
|
||||
"包括风险评估、用药合理性、并发症筛查和分级随访计划。"
|
||||
) * 6
|
||||
long_prompt = _compose_ai_prompt(long_question, context_text)
|
||||
if len(long_prompt) > AI_PROMPT_LIMIT:
|
||||
fail(f"long prompt exceeds limit: {len(long_prompt)}")
|
||||
if context_text not in long_prompt:
|
||||
fail("long prompt loses context")
|
||||
assertContains(long_prompt, "…", "long prompt should end with ellipsis")
|
||||
|
||||
# 9. Empty context returns bare question.
|
||||
bare = _compose_ai_prompt("血糖?", "")
|
||||
assertEqual(bare, "血糖?", "empty context should drop the envelope entirely")
|
||||
|
||||
# 10. Empty question returns empty string.
|
||||
empty = _compose_ai_prompt("", context_text)
|
||||
assertEqual(empty, "", "empty question returns empty string")
|
||||
|
||||
print("OK: all AI context helper assertions passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$ToolVersion = "6.7.3"
|
||||
$ToolRoot = Join-Path $ProjectRoot ".build-tools\inno-setup-$ToolVersion"
|
||||
$Compiler = Join-Path $ToolRoot "ISCC.exe"
|
||||
$DownloadRoot = Join-Path $ProjectRoot ".build-tools\downloads"
|
||||
$Installer = Join-Path $DownloadRoot "innosetup-$ToolVersion.exe"
|
||||
$InstallerUrl = (
|
||||
"https://github.com/jrsoftware/issrc/releases/download/" +
|
||||
"is-6_7_3/innosetup-$ToolVersion.exe"
|
||||
)
|
||||
$InstallerSha256 = "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732"
|
||||
$LanguageRoot = Join-Path $ProjectRoot ".build-tools\inno-languages"
|
||||
$ChineseMessages = Join-Path $LanguageRoot "ChineseSimplified.isl"
|
||||
$ChineseMessagesUrl = (
|
||||
"https://raw.githubusercontent.com/jrsoftware/issrc/" +
|
||||
"6ef32198ef1f7b7b375cd4b6b90896c2a58eb4c2/Files/Languages/ChineseSimplified.isl"
|
||||
)
|
||||
$ChineseMessagesSha256 = "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D"
|
||||
|
||||
function Test-Compiler {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseNotes = Join-Path (Split-Path -Parent $Candidate) "whatsnew.htm"
|
||||
if (-not (Test-Path -LiteralPath $ReleaseNotes -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseText = [System.IO.File]::ReadAllText($ReleaseNotes)
|
||||
$VersionMatch = [regex]::Match($ReleaseText, '<span class="ver">([0-9.]+)')
|
||||
return $VersionMatch.Success -and $VersionMatch.Groups[1].Value -eq $ToolVersion
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $LanguageRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $ChineseMessages -PathType Leaf) {
|
||||
$ExistingLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ExistingLanguageHash -ne $ChineseMessagesSha256) {
|
||||
Remove-Item -LiteralPath $ChineseMessages -Force
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ChineseMessages -PathType Leaf)) {
|
||||
Write-Host "Downloading the pinned Simplified Chinese installer messages..."
|
||||
Invoke-WebRequest -Uri $ChineseMessagesUrl -OutFile $ChineseMessages -UseBasicParsing
|
||||
}
|
||||
$ActualLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ActualLanguageHash -ne $ChineseMessagesSha256) {
|
||||
throw "Inno Setup language checksum mismatch. Expected $ChineseMessagesSha256; found $ActualLanguageHash"
|
||||
}
|
||||
|
||||
foreach ($Candidate in @(
|
||||
$Compiler,
|
||||
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
|
||||
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
|
||||
)) {
|
||||
if ($Candidate -and (Test-Compiler -Candidate $Candidate)) {
|
||||
Write-Output (Resolve-Path -LiteralPath $Candidate).Path
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DownloadRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $Installer -PathType Leaf) {
|
||||
$ExistingHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ExistingHash -ne $InstallerSha256) {
|
||||
Remove-Item -LiteralPath $Installer -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
|
||||
Write-Host "Downloading pinned Inno Setup $ToolVersion compiler..."
|
||||
Invoke-WebRequest -Uri $InstallerUrl -OutFile $Installer -UseBasicParsing
|
||||
}
|
||||
|
||||
$ActualHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ActualHash -ne $InstallerSha256) {
|
||||
throw "Inno Setup download checksum mismatch. Expected $InstallerSha256; found $ActualHash"
|
||||
}
|
||||
$Signature = Get-AuthenticodeSignature -LiteralPath $Installer
|
||||
if ($Signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
|
||||
throw "Inno Setup download does not have a valid Authenticode signature: $($Signature.Status)"
|
||||
}
|
||||
if (-not $Signature.SignerCertificate -or
|
||||
$Signature.SignerCertificate.Subject -notmatch "O=Pyrsys B\.V\.") {
|
||||
throw "Inno Setup download has an unexpected signer"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $ToolRoot -Force | Out-Null
|
||||
Write-Host "Installing the pinned Inno Setup compiler into $ToolRoot ..."
|
||||
& $Installer /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER "/DIR=$ToolRoot"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Inno Setup compiler installation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Compiler -Candidate $Compiler)) {
|
||||
throw "Inno Setup installation completed without ISCC.exe: $Compiler"
|
||||
}
|
||||
|
||||
Write-Output $Compiler
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Generate deterministic application-brand assets from the approved master PNG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MASTER = PROJECT_ROOT / "resources" / "branding" / "brand-master.png"
|
||||
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
|
||||
VIDEO_PUBLIC_ROOT = PROJECT_ROOT / "video_companion" / "public"
|
||||
ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
|
||||
def _transparent_connected_background(image: Image.Image) -> Image.Image:
|
||||
"""Remove only near-white pixels connected to the crop boundary.
|
||||
|
||||
The logo contains intentional white ECG strokes. A global color-key would
|
||||
erase them, whereas a connected-background mask preserves enclosed whites.
|
||||
"""
|
||||
|
||||
rgb = image.convert("RGB")
|
||||
candidates = Image.new("L", rgb.size)
|
||||
candidates.putdata(
|
||||
[
|
||||
255
|
||||
if min(pixel) >= 185 and max(pixel) - min(pixel) <= 70
|
||||
else 0
|
||||
for pixel in rgb.getdata()
|
||||
]
|
||||
)
|
||||
ImageDraw.floodfill(candidates, (0, 0), 128, thresh=0)
|
||||
alpha = candidates.point(lambda value: 0 if value == 128 else 255)
|
||||
rgba = rgb.convert("RGBA")
|
||||
rgba.putalpha(alpha)
|
||||
return rgba
|
||||
|
||||
|
||||
def _square_icon(image: Image.Image, size: int = 1024, padding: int = 72) -> Image.Image:
|
||||
available = size - padding * 2
|
||||
scale = min(available / image.width, available / image.height)
|
||||
rendered = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
tile_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(tile_mask).rounded_rectangle(
|
||||
(24, 24, size - 24, size - 24),
|
||||
radius=190,
|
||||
fill=255,
|
||||
)
|
||||
white_tile = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
tile.paste(white_tile, mask=tile_mask)
|
||||
tile.alpha_composite(
|
||||
rendered.convert("RGBA"),
|
||||
((size - rendered.width) // 2, (size - rendered.height) // 2),
|
||||
)
|
||||
return tile
|
||||
|
||||
|
||||
def generate(master_path: Path) -> tuple[Path, ...]:
|
||||
master = Image.open(master_path).convert("RGB")
|
||||
if master.size != (1254, 1254):
|
||||
raise ValueError(f"expected a 1254x1254 brand master, got {master.size}")
|
||||
|
||||
BRANDING_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
VIDEO_PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lockup_bbox = _transparent_connected_background(master).getchannel("A").getbbox()
|
||||
if lockup_bbox is None:
|
||||
raise ValueError("brand lockup extraction produced an empty image")
|
||||
left, top, right, bottom = lockup_bbox
|
||||
full_lockup = master.crop(
|
||||
(
|
||||
max(0, left - 28),
|
||||
max(0, top - 28),
|
||||
min(master.width, right + 28),
|
||||
min(master.height, bottom + 28),
|
||||
)
|
||||
)
|
||||
|
||||
# The supplied artwork places the pictorial mark wholly above y=720. The
|
||||
# crop intentionally excludes the Chinese and English lockup for legible
|
||||
# Windows/macOS small icons.
|
||||
mark_crop = master.crop((300, 110, 980, 720))
|
||||
mark_bbox = _transparent_connected_background(mark_crop).getchannel("A").getbbox()
|
||||
if mark_bbox is None:
|
||||
raise ValueError("application icon extraction produced an empty image")
|
||||
left, top, right, bottom = mark_bbox
|
||||
app_icon = _square_icon(
|
||||
mark_crop.crop(
|
||||
(
|
||||
max(0, left - 12),
|
||||
max(0, top - 12),
|
||||
min(mark_crop.width, right + 12),
|
||||
min(mark_crop.height, bottom + 12),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
lockup_path = BRANDING_ROOT / "brand-lockup.png"
|
||||
icon_png_path = BRANDING_ROOT / "app-icon.png"
|
||||
icon_ico_path = BRANDING_ROOT / "app-icon.ico"
|
||||
icon_icns_path = BRANDING_ROOT / "app-icon.icns"
|
||||
favicon_path = VIDEO_PUBLIC_ROOT / "favicon.png"
|
||||
|
||||
full_lockup.save(lockup_path, optimize=True)
|
||||
app_icon.save(icon_png_path, optimize=True)
|
||||
app_icon.save(
|
||||
icon_ico_path,
|
||||
format="ICO",
|
||||
sizes=[(size, size) for size in ICON_SIZES],
|
||||
)
|
||||
app_icon.save(icon_icns_path, format="ICNS")
|
||||
app_icon.resize((64, 64), Image.Resampling.LANCZOS).save(
|
||||
favicon_path,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
return lockup_path, icon_png_path, icon_ico_path, icon_icns_path, favicon_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--master", type=Path, default=DEFAULT_MASTER)
|
||||
args = parser.parse_args()
|
||||
master_path = args.master.resolve()
|
||||
if not master_path.is_file():
|
||||
parser.error(f"brand master is missing: {master_path}")
|
||||
for output in generate(master_path):
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -131,9 +131,9 @@ PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
|
||||
/bin/bash "$script_dir/build_macos.sh"
|
||||
|
||||
artifact="$project_root/dist/DoctorWorkstation.app"
|
||||
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
|
||||
"$project_root/pyproject.toml")"
|
||||
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
|
||||
project_version="$(/usr/bin/awk -F '"' '/^__version__ = "/ { print $2; exit }' \
|
||||
"$project_root/src/doctor_workstation/__init__.py")"
|
||||
[[ -n "$project_version" ]] || macos_die "无法从 doctor_workstation.__version__ 读取版本号。"
|
||||
case "$(uname -m)" in
|
||||
arm64) release_arch="arm64" ;;
|
||||
x86_64) release_arch="x64" ;;
|
||||
|
||||
@@ -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"
|
||||
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
|
||||
$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"
|
||||
$VersionSource = Join-Path $ProjectRoot "src\doctor_workstation\__init__.py"
|
||||
$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)
|
||||
@@ -49,15 +53,28 @@ try {
|
||||
$BuildScript,
|
||||
$PackageLock,
|
||||
(Join-Path $ProjectRoot "uv.lock"),
|
||||
$ProjectMetadata,
|
||||
$VersionSource,
|
||||
$MediaSmokeHook,
|
||||
$ReleaseLauncherTemplate
|
||||
$WindowsIcon,
|
||||
$ReleaseLauncherTemplate,
|
||||
$InstallerDefinition,
|
||||
$EnsureInstallerCompiler
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
|
||||
throw "Required build file is missing: $RequiredFile"
|
||||
}
|
||||
}
|
||||
|
||||
$VersionText = [System.IO.File]::ReadAllText($VersionSource)
|
||||
$VersionMatch = [regex]::Match(
|
||||
$VersionText,
|
||||
'(?m)^\s*__version__\s*=\s*"([^"]+)"'
|
||||
)
|
||||
if (-not $VersionMatch.Success) {
|
||||
throw "Unable to read the application version from $VersionSource"
|
||||
}
|
||||
$ProjectVersion = $VersionMatch.Groups[1].Value
|
||||
|
||||
$Npm = Find-Application -Name "npm.cmd"
|
||||
$Node = Find-Application -Name "node.exe"
|
||||
if (-not $Npm -or -not $Node) {
|
||||
@@ -76,7 +93,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
|
||||
}
|
||||
|
||||
@@ -149,18 +166,11 @@ try {
|
||||
throw "Build completed without the expected executable: $Executable"
|
||||
}
|
||||
|
||||
$ProjectText = [System.IO.File]::ReadAllText($ProjectMetadata)
|
||||
$VersionMatch = [regex]::Match(
|
||||
$ProjectText,
|
||||
'(?m)^\s*version\s*=\s*"([^"]+)"'
|
||||
)
|
||||
if (-not $VersionMatch.Success) {
|
||||
throw "Unable to read the project version from $ProjectMetadata"
|
||||
}
|
||||
$ProjectVersion = $VersionMatch.Groups[1].Value
|
||||
$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 +202,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 {
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
|
||||
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
@@ -480,12 +480,7 @@ def _run_immediately(
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
diagnosis_module.run_async = _run_immediately
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
@@ -19,13 +18,6 @@ def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -9,10 +9,11 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
|
||||
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
|
||||
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
||||
# the same CJK face used by the production QSS when it is available.
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
|
||||
def main() -> int:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Render the clinical reading surfaces with demo data and production fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
|
||||
|
||||
def _settle(app: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
QTest.qWait(100)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
|
||||
parser.add_argument("--width", type=int, default=1536)
|
||||
parser.add_argument("--height", type=int, default=912)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(
|
||||
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
|
||||
)
|
||||
shell.resize(args.width, args.height)
|
||||
shell.show()
|
||||
try:
|
||||
shell.navigate("reception")
|
||||
_settle(app)
|
||||
page = shell.pages["reception"]
|
||||
page._set_queue_filter(None)
|
||||
_settle(app)
|
||||
# A synthetic multiline case tests paragraph rhythm without capturing
|
||||
# a live patient or connecting to a production service.
|
||||
page.case_labels["present"].setText(
|
||||
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||
)
|
||||
app.processEvents()
|
||||
if not shell.grab().save(str(args.output / "reception.png")):
|
||||
raise RuntimeError("Could not save reception preview")
|
||||
daily = next(
|
||||
index
|
||||
for index in range(page.detail_tabs.count())
|
||||
if page.detail_tabs.tabText(index) == "日常记录"
|
||||
)
|
||||
page.detail_tabs.setCurrentIndex(daily)
|
||||
_settle(app)
|
||||
if not shell.grab().save(str(args.output / "daily_records.png")):
|
||||
raise RuntimeError("Could not save daily-record preview")
|
||||
metrics = {
|
||||
"family": QFontInfo(app.font()).family(),
|
||||
"pixel_size": app.font().pixelSize(),
|
||||
"font_strategy": app.font().styleStrategy().value,
|
||||
"font_hinting": app.font().hintingPreference().name,
|
||||
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
|
||||
"device_pixel_ratio": shell.devicePixelRatioF(),
|
||||
"window": [shell.width(), shell.height()],
|
||||
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
|
||||
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
|
||||
}
|
||||
(args.output / "render.json").write_text(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(args.output)
|
||||
finally:
|
||||
_settle(app)
|
||||
shell.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"
|
||||
@@ -1,5 +1,11 @@
|
||||
"""Zhenyang doctor workstation."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.4.1"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
@@ -26,7 +27,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,8 +35,12 @@ 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 (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
set_authentication_expired_handler,
|
||||
show_toast,
|
||||
@@ -46,6 +51,165 @@ from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
|
||||
|
||||
class _ChineseQtTranslator(QTranslator):
|
||||
"""Guarantee Chinese labels for common Qt standard buttons.
|
||||
|
||||
@@ -149,7 +313,7 @@ class DemoVideoDialog(QDialog):
|
||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
|
||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||
)
|
||||
|
||||
@@ -162,7 +326,7 @@ class DemoVideoDialog(QDialog):
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
demo.setStyleSheet("color:#707584;font-size:12px;")
|
||||
header.addWidget(demo)
|
||||
self.duration_label = QLabel("00:00")
|
||||
self.duration_label.setStyleSheet("font-weight:700;")
|
||||
@@ -239,22 +403,26 @@ class ApplicationController(QObject):
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||
self.demo_repository = DemoDoctorRepository()
|
||||
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||
self.remote_repository: RemoteDoctorRepository | None = None
|
||||
self.login_window: LoginWindow | None = None
|
||||
self.shell_window: ShellWindow | None = None
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = config.demo_mode
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self._restore_generation = 0
|
||||
self._restore_in_progress = False
|
||||
self._restore_worker: Any = None
|
||||
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 +431,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,12 +502,14 @@ 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
|
||||
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
self.current_demo_mode = enabled
|
||||
if enabled:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
self._cancel_session_restore()
|
||||
|
||||
def _rebuild_remote_repository(self) -> None:
|
||||
@@ -499,7 +670,8 @@ class ApplicationController(QObject):
|
||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||
return
|
||||
|
||||
demo_mode = bool(payload.get("demo_mode"))
|
||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
payload["demo_mode"] = demo_mode
|
||||
if not demo_mode and not session.menu:
|
||||
with suppress(Exception):
|
||||
repository.logout()
|
||||
@@ -512,12 +684,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:
|
||||
@@ -542,6 +718,7 @@ class ApplicationController(QObject):
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
@@ -572,6 +749,7 @@ class ApplicationController(QObject):
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
@@ -625,19 +803,40 @@ class ApplicationController(QObject):
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
|
||||
def get_ticket() -> Any:
|
||||
return repository.get_call_ticket(
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
|
||||
def request_ticket() -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
run_async(
|
||||
get_ticket,
|
||||
on_success=lambda ticket: self._launch_video(
|
||||
ticket,
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
@@ -645,6 +844,7 @@ class ApplicationController(QObject):
|
||||
marker=marker,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
@@ -690,6 +890,7 @@ class ApplicationController(QObject):
|
||||
marker: object,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
@@ -717,6 +918,10 @@ class ApplicationController(QObject):
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
LOGGER.exception("video call could not be launched")
|
||||
@@ -737,7 +942,110 @@ class ApplicationController(QObject):
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
|
||||
@@ -766,10 +1074,17 @@ class ApplicationController(QObject):
|
||||
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = resource_path("icon.svg")
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Queue a normal application exit so owned resources are released."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
QTimer.singleShot(0, self.application.quit)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate asynchronous restoration and release owned resources."""
|
||||
|
||||
@@ -777,7 +1092,9 @@ class ApplicationController(QObject):
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
@@ -850,7 +1167,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)
|
||||
|
||||
@@ -14,6 +14,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from doctor_workstation import DEBUG_MODE, ONLINE_API_BASE_URL
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError: # pragma: no cover - optional during pure unit tests
|
||||
@@ -90,7 +92,8 @@ class AppConfig:
|
||||
"""Runtime configuration loaded from environment and user preferences."""
|
||||
|
||||
api_base_url: str = ""
|
||||
demo_mode: bool = True
|
||||
demo_mode: bool = False
|
||||
debug_mode: bool = DEBUG_MODE
|
||||
video_mode: str = "embedded"
|
||||
video_web_url: str = ""
|
||||
verify_ssl: bool = True
|
||||
@@ -115,18 +118,28 @@ class AppConfig:
|
||||
if load_dotenv is not None:
|
||||
load_dotenv(dotenv_path=env_file, override=False)
|
||||
|
||||
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
|
||||
debug_mode = bool(DEBUG_MODE)
|
||||
raw_url = (
|
||||
os.getenv("DOCTOR_API_BASE_URL", "") if debug_mode else ONLINE_API_BASE_URL
|
||||
)
|
||||
try:
|
||||
api_url = normalize_api_base_url(raw_url)
|
||||
except ValueError:
|
||||
except ValueError as error:
|
||||
if not debug_mode:
|
||||
raise ValueError("ONLINE_API_BASE_URL 必须是有效的 HTTP(S) 域名") from error
|
||||
api_url = ""
|
||||
if not debug_mode and not api_url:
|
||||
raise ValueError("正式模式下 ONLINE_API_BASE_URL 不能为空")
|
||||
|
||||
config = cls(
|
||||
api_base_url=api_url,
|
||||
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
|
||||
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), False) if debug_mode else False,
|
||||
debug_mode=debug_mode,
|
||||
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
|
||||
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
|
||||
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
|
||||
verify_ssl=(
|
||||
_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True) if debug_mode else True
|
||||
),
|
||||
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
|
||||
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
|
||||
)
|
||||
@@ -137,7 +150,9 @@ class AppConfig:
|
||||
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return self
|
||||
allowed = {item.name for item in fields(self)}
|
||||
allowed = {item.name for item in fields(self)} - {"debug_mode"}
|
||||
if not self.debug_mode:
|
||||
allowed -= {"api_base_url", "demo_mode", "verify_ssl"}
|
||||
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
|
||||
if "api_base_url" in clean:
|
||||
try:
|
||||
@@ -159,12 +174,18 @@ class AppConfig:
|
||||
target = self.preferences_file
|
||||
temporary = target.with_suffix(".tmp")
|
||||
payload = asdict(self)
|
||||
payload.pop("debug_mode", None)
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
with suppress(OSError):
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(target)
|
||||
|
||||
def with_updates(self, **changes: Any) -> AppConfig:
|
||||
changes.pop("debug_mode", None)
|
||||
if not self.debug_mode:
|
||||
changes.pop("api_base_url", None)
|
||||
changes.pop("demo_mode", None)
|
||||
changes.pop("verify_ssl", None)
|
||||
if "api_base_url" in changes:
|
||||
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
|
||||
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,785 @@
|
||||
"""Detect, download and apply doctor-workstation desktop updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
install_unavailable_reason: str = ""
|
||||
|
||||
|
||||
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 package_declared_version(package: UpdatePackage | None) -> str:
|
||||
"""Return a semantic version embedded in a package filename, when present."""
|
||||
|
||||
if package is None:
|
||||
return ""
|
||||
name = Path(package.filename or urlsplit(package.url).path).name
|
||||
match = re.search(r"(?<!\d)(\d+(?:\.\d+){1,3})(?!\d)", name)
|
||||
return normalize_version(match.group(1)) if match is not None else ""
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
declared_package_version = package_declared_version(package)
|
||||
package_version_matches = bool(
|
||||
not declared_package_version or declared_package_version == latest_version
|
||||
)
|
||||
can_install = (
|
||||
bool(data.get("can_install"))
|
||||
and package is not None
|
||||
and digest_is_valid
|
||||
and installer_transport_is_valid
|
||||
and package_version_matches
|
||||
and has_update
|
||||
)
|
||||
install_unavailable_reason = ""
|
||||
if has_update and declared_package_version and not package_version_matches:
|
||||
install_unavailable_reason = (
|
||||
f"安装包版本 {declared_package_version} 与发布版本 {latest_version} 不一致,"
|
||||
"请管理员重新上传正确版本的安装包。"
|
||||
)
|
||||
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,
|
||||
install_unavailable_reason=install_unavailable_reason,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
# Keep each network read bounded so an explicit application exit cannot be
|
||||
# held forever by a server that accepted the connection and then stalled.
|
||||
timeout = httpx.Timeout(connect=30.0, read=30.0, 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"
|
||||
ready_file = installer.parent / "update_helper.ready"
|
||||
try:
|
||||
ready_file.unlink(missing_ok=True)
|
||||
script = _write_inno_setup_script(
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
ready_file=ready_file,
|
||||
)
|
||||
_spawn_inno_setup_applier(
|
||||
script,
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
ready_file=ready_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,
|
||||
ready_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,",
|
||||
" [Parameter(Mandatory=$true)][string]$ReadyFile",
|
||||
")",
|
||||
'$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"',
|
||||
" }",
|
||||
"}",
|
||||
'Set-Content -LiteralPath $ReadyFile -Value ("ready target_pid={0} helper_pid={1}" -f $TargetPid, $PID) -Encoding UTF8',
|
||||
'Write-Log "helper ready; 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, ready_file
|
||||
return script
|
||||
|
||||
|
||||
def _spawn_inno_setup_applier(
|
||||
script: Path,
|
||||
*,
|
||||
installer: Path,
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
ready_file: Path,
|
||||
) -> None:
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
process = 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),
|
||||
"-ReadyFile",
|
||||
str(ready_file),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(script.parent),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
if ready_file.is_file():
|
||||
return
|
||||
exit_code = process.poll()
|
||||
if exit_code is not None:
|
||||
raise OSError(f"更新助手启动后提前退出(代码 {exit_code})")
|
||||
time.sleep(0.05)
|
||||
try: # noqa: SIM105 - best-effort cleanup before reporting the handshake timeout
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
raise OSError(f"更新助手启动超时,未生成就绪标记:{ready_file}")
|
||||
|
||||
|
||||
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, "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
|
||||