This commit is contained in:
gr
2026-10-30 13:43:29 +08:00
144 changed files with 16467 additions and 2457 deletions
+1
View File
@@ -1,5 +1,6 @@
.env
.local-workstation/
resources/local-asr/
.venv/
.venv-build/
.venv-win7-check/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+16 -4
View File
@@ -10,11 +10,14 @@ modules, Qt6Multimedia shared libraries/frameworks, multimedia plugins, and
their platform media backends.
"""
import importlib.util
import os
import re
import sys
from pathlib import Path
from PyInstaller.utils.hooks import collect_all
PROJECT_ROOT = Path(SPEC).resolve().parent.parent
SOURCE_ROOT = PROJECT_ROOT / "src"
@@ -108,15 +111,24 @@ qt_multimedia_hiddenimports = [
"PySide6.QtMultimediaWidgets",
]
# Cloud transcription only needs offline character conversion on the client.
if importlib.util.find_spec("opencc") is None:
raise SystemExit("Install the project dependencies before packaging (missing opencc)")
transcript_datas, transcript_binaries, transcript_hiddenimports = collect_all("opencc")
client_resources = [
(str(entry), f"resources/{entry.name}" if entry.is_dir() else "resources")
for entry in RESOURCES.iterdir()
if entry.name != "local-asr"
]
analysis = Analysis(
[str(ENTRY_POINT)],
pathex=[str(SOURCE_ROOT)],
binaries=python_runtime_binaries,
binaries=python_runtime_binaries + transcript_binaries,
datas=[
(str(VIDEO_DIST), "video_companion_dist"),
(str(RESOURCES), "resources"),
],
hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports,
] + client_resources + transcript_datas,
hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports + transcript_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[str(MEDIA_SMOKE_HOOK)],
+4
View File
@@ -13,12 +13,16 @@ authors = [{ name = "Zhenyangtang" }]
dependencies = [
"httpx>=0.27.2,<1",
"keyring>=25.5,<26",
"opencc-python-reimplemented==0.1.7",
"platformdirs>=4.3,<5",
"PySide6>=6.8.2,<7",
"python-dotenv>=1.0.1,<2",
]
[project.optional-dependencies]
local-asr = [
"faster-whisper==1.2.1",
]
dev = [
"pytest>=8.3,<9",
"pytest-cov>=6,<7",
+72
View File
@@ -0,0 +1,72 @@
# 医生工作站客户端设计规范
本轮覆盖现有 PySide6 客户端与 Vue 视频伴随窗口。按 `redesign-existing-projects` 审查现有页面,结合 `minimalist-ui` 的扁平化原则实施;医疗工作台保留信息密度,不套用营销页面的大留白、装饰动效或布局模板。
## 审查结论
- 主框架与业务页面原先并存科技蓝、紫蓝及多套中性色;登录、AI、诊单的控件也各自定义配色。
- 页面内圆角、边框和层级不统一,部分阴影、悬浮位移和过渡使操作显得跳动。
- 小窗口的表格、诊单与视频操作需要分别验证,不能依靠整体缩小解决。
- 键盘焦点、禁用与选择状态应始终可识别,不通过改变控件几何位置表达状态。
## 视觉系统
设计变量以 `src/doctor_workstation/ui/design_system.py` 为准。旧页面通过 `clinical_stylesheet` 迁移已知的浅色主题颜色,新样式使用命名变量。医学数据曲线、模型标识色和深色主题不进行全局替色。
| 角色 | 值 | 使用场景 |
| --- | --- | --- |
| 主色 | `#4267B2` | 主操作、选中项、重要链接 |
| 主色浅底 | `#EAF0FB` | 选中背景、轻量状态 |
| 画布 | `#F5F7FA` | 主窗口背景 |
| 表面 | `#FFFFFF` | 内容区、对话框 |
| 次级表面 | `#F7F9FC` | 筛选栏、分组、只读区域 |
| 侧栏 | `#F0F3F8` | 导航区域 |
| 标题 / 正文 | `#223047` / `#344258` | 文字层级 |
| 次级文字 | `#647287` | 说明、辅助信息 |
| 边框 / 强边框 | `#E6EAF0` / `#D4DCE8` | 低对比结构线、输入边界 |
| 成功 / 警示 / 危险 | `#287B65` / `#96601D` / `#B54A59` | 保留明确语义,不只依赖颜色 |
- 圆角:标签 6 px、输入与按钮 10 px、内容面板 16 px、独立窗口 20 px。
- 间距:8 / 12 / 16 / 24 px,根据现有表格密度调整,不整体放大行高。
- 字体:继续使用打包的中文字体与现有可读字号;标题层级通过字重、字号和间距体现。
- 表面:纯色为主,轻边框划分区域,减少嵌套卡片与装饰阴影。
- 按钮:每个操作组主次清晰;次要操作使用浅底或边框;终止、删除等危险操作保留语义色。
## 交互规范
- 悬浮、按下和焦点反馈不移动布局、不改变控件尺寸。
- 动画短而克制;滚动可被键盘、拖动、程序定位与反向滚动即时打断;尊重减少动画设置。
- 切换页面保留原有路由、权限和数据加载机制。
- 登录表单保留密码存储、服务器设置与提交保护;组合密码框使用实际 Qt 焦点状态。
- 大小窗口都必须能找到主要操作,优先局部滚动而非把全部内容压小。
- 视频保留原有截图捕获、确认上传、大预览和小窗关闭返回大窗逻辑;视觉样式不改变媒体内容或尺寸计算。
## 覆盖范围
1. 登录与品牌区:扁平品牌面板、简洁功能说明、清晰表单与焦点。
2. 主框架:导航、顶部搜索、工具按钮、标签与 AI 入口。
3. 列表:挂号、接诊记录、患者、处方库、已开处方、业务订单及患者进度。
4. 诊疗:接诊台、预约抽屉、诊单编辑与只读、指标和保存状态。
5. 辅助窗口:AI 问诊、处方编辑、处方解读与报告控制台。
6. 视频:通话控制、聊天、录制提示与截图确认。
## 验收方式
`scripts/render_client_design.py` 使用演示仓库生成 1440 × 900、1024 × 640 的真实页面,以及 1280 × 800、860 × 640 的登录页;输出 `artifacts/client-design/index.html` 与原尺寸 PNG。诊疗和 AI 弹窗使用现有专用渲染脚本验证。
检查重点:小窗操作可达、中文可读、表格信息密度、长文本换行、键盘焦点、布局稳定、登录与页面路由契约、视频截图与窗口状态回归。生产服务与真实患者资料不用于视觉验收。
本轮共享组件还覆盖筛选展开按钮、列表加载状态、聊天通知、更新提示、本机录音队列、媒体预览和上传进度。860 × 640 登录表单完整显示,不需要为纯留白滚动。
## 验证记录与边界
- 主框架、登录、遮罩与动效首轮 100 项通过;新增组合密码框键盘焦点检查通过。
- 小窗登录与焦点补测 2 项、最终登录及主框架复查 68 项通过。
- 筛选、无限列表、通知、更新窗口与本机录音队列 37 项通过。
- 原生视频截图、截图预览尺寸、小窗关闭与视频契约 199 项通过。
- 视频前端 26 项检查通过,生产 dist 已重新构建。
- 当前视频 dist 在 1120 × 760、540 × 356、440 × 300 的合成画面中完成 27 项布局检查,截图与关闭事件正常,未访问外部服务。
- 临床页面、AI、处方及图表分别完成专项测试和真实控件渲染,详细记录位于 `artifacts/client-design/*-report.md`。
- 最终修改文件的 Ruff 与 `git diff --check` 均通过;AI 工作区及快捷操作权限最后复查 24 项通过。
此记录不表示整仓所有历史测试全部通过。扩展测试中存在已用原始 HEAD 复现的 AI 流式/延迟生命周期失败、共享 QApplication 样式引发的图表测试顺序依赖,以及 Windows Qt 网络模块异常;本轮没有改变相关业务行为。未连接真实面诊服务,也未重新打包或安装 Windows 客户端。
@@ -0,0 +1,21 @@
# 腾讯云实时转写恢复(2026-10-30)
用户明确选择“恢复腾讯云实时转写,保留简体中文”。本次仅替换识别通路,保留 IM AI、实时 AI 辅助、复盘、本机录音、视频录制、截图和窗口交互。
## 当前行为
- 使用当前固定 SDK 的 `getAITranscriberManager()` 和 `startRealtimeTranscriber({sourceLanguage: 'zh'})`。不再启动本地 PCM 识别或加载 Whisper/Nano 模型,本机语音录音保持独立。
- 最终转写片段先经过原生可信页面及会话校验,以宿主的医生/患者用户 ID 确定角色,再用本机 OpenCC 转为简体并保存。仅持久化成功的确认回传简体文字,字幕和本次 AI 引用使用同一保存原文;历史记录查看仍显示简体。
- 同一通话的云端重试复用原转写会话,保留已保存字幕和 AI 结果;云端中断不提前封存存储,真正挂断时才排空并归档。出错期间可能漏记的通话最终标记 partial。
- 云端启动成功通过 `transcription-ready` 更新原生状态,避免重试成功仍显示失败;旧页面、旧会话、旧监听器及本地 ASR 命令不能混入当前会话。
- 启动/打包脚本不再默认启用 `local-asr` 额外依赖;仍打包简体转换字典。`resources/local-asr` 从发布资源中排除。已有开发模型和独立测试模块保留,不默认使用。
## 验证及生效
仅用合成身份/文字与 SDK 替身验证启停、重试、尾段排空、成功保存后的六参数 ACK、简体转换、说话人归属、AI 更新和隔离边界。截图、大小窗及录音回归继续通过,前端类型检查和生产资源重建完成。
详细结果见 `app/artifacts/im-ai/cloud-transcription-restore-frontend.md`、`cloud-transcription-restore-native-tests.md`。没有发起真实患者通话或验证线上腾讯云识别效果,没有生成新的完整安装包。
最终整合验证:338 项 Python 回归、128 项前端测试通过,Vue 类型检查及生产构建通过。已核对真实 Qt 六参数转写信号,以及 PyInstaller 资源展开结果:图标、字体和简体词典保留,本地识别模型不进入发布资源。
源码客户端需要在结束当前通话后退出并重新启动整个客户端,原生 Python 代码与新前端资源才能一起生效。已冻结安装包需重新构建,不能只更换网页资源。
+75
View File
@@ -0,0 +1,75 @@
# IM 问诊 AI 辅助
用户确认范围:在现有 IM 问诊窗口内增加「总结病情」「建议追问」「起草回复」。结果供医生审阅,医生主动填入输入框后编辑并手动发送。
## 集成契约
- 复用 `stream_diagnosis_ai`:总结使用 `summary`,追问和回复使用 `custom`。不新增模型配置、不把凭据传到浏览器、不新增服务端路由。
- 每次生成前,通过 `sync_im_chat_messages(scope="current", sync_token=...)` 完成当前账号对话归档;响应 `completed=true` 且没有错误后才能调用 AI。准备失败明确报错,不静默降级为旧聊天。服务器按授权诊单聚合临床资料与归档 IM,提示词限定本次医生账号的近期对话。
- 三项提示词保持 500 字以内;不在提示词中挤入、截断完整聊天或未发送草稿。
- AI 独立后台 worker,不占用通话、录音和消息发送的队列。
原生配置 `DoctorCallConfig.imAi`:`{version:1, enabled:boolean, sessionId:string, reason?:string}`。宿主生成 sessionId,每个新窗口独立。上层将 AI 权限布尔值贯穿 launcher;默认拒绝。
浏览器到原生:
```json
{"source":"doctor-call","event":"im-ai-request","sessionId":"host-uuid","requestId":"uuid","contextKey":"frontend-context","diagnosisId":"501","appointmentId":101,"action":"summary"}
```
取消使用同一组身份字段及 `event="im-ai-cancel"`。只允许 `summary | followup | reply`。宿主核验可信文档、窗口未关闭、AI 权限、会话、诊单、挂号和请求格式;实际 repository 调用始终使用宿主绑定诊单。
原生通过 `window.doctorConsultation.imAiResult(payload)` 返回相同身份字段与:
```text
status: loading | streaming | success | error | cancelled
text?: 累积正文(流式及最终)
message?: 简短进度或错误
modelLabel?: 服务端模型标签
warnings?: string[]
```
所有数据使用 JSON 序列化,桥接正文仍为原始文本。页面通过受限 Markdown 渲染器显示标题、重点、列表和表格;原始 HTML 被转义,链接和图片只保留文字,不执行内容或加载外部资源。客户端截止时间 180 秒,超时发送取消;原生聊天同步有独立有界预算,AI 沿用现有 105 秒传输预算。取消请求由独立线程发送,GUI 不等待网络。停止只是停止本次接收,不冒称上游工作已确认停止。
## 界面与状态
- 宽屏右侧约 330 px 辅助区;窄屏可收起抽屉,独立滚动,操作和关闭可达。沿用现有蓝色、圆角和轻边框。
- AI loading 不阻塞 IM 输入、发送、加载更多和视频控制。
- 只有完整成功结果允许填入;追问与回复可填入,病情总结默认只读。非空草稿明确追加,超过 3000 字拒绝追加而不截断原文。
- 切换会话清空 AI 状态和草稿;迟到、重复、身份不匹配结果全部忽略。
- 异步手动发送成功仅清空当时提交且没有继续修改的草稿。
- 结果注明医生复核,展示服务端临床复核提示;不会自动发送、写病历或开处方。
## 验证要求
离线 fake repository 验证同步续拉、失败不生成、取消、终态、身份隔离、权限与关闭;Node 测试验证桥接状态/计时器、草稿追加和发送隔离;使用真实构建产物和合成消息验证宽屏与紧凑窗口。回归截图、录音收尾、通话权限与 IM 流程。验证不得访问真实患者、IM 或 AI 服务。
## 交付与验证结果(2026-10-29)
- `app.py` 在实际打开窗口时读取当前账号的 `tcm.diagnosis/aiAssistant` 权限,经 launcher 传入原生窗口;未配置权限时默认关闭,不采用聊天载荷中的权限标记。
- 三项功能已接入真实 Qt bridge 和现有流式服务;宽屏常驻侧栏,980 px 及以下窗口使用可收起抽屉。中文输入法选词时按 Enter 不发送,异步发送不会清除医生随后编辑的草稿。
- 合并后 Python 回归 **364 项通过**:包括新增 AI 服务/仓库/窗口、权限入口、IM、视频契约、截图、小窗关闭及大预览等。运行使用独立 `artifacts/im-ai/final-integration-tmp`,未访问外部服务。
- 前端 **45 项 Node 行为测试通过**,`vue-tsc` 与 Vite 构建通过;实际 dist 在 1120×760、980×640、540×356、440×300 下 **42 项界面检查通过**。离线夹具只使用合成消息和结果,外部请求尝试为 0。
- Ruff 与 `git diff --check` 通过。首次交付构建入口:`index-CnqWQu6O.js`、`index-BwArpbVK.css`。
- 截图、可复现夹具和详细报告存放在 `app/artifacts/im-ai/`。未验证真实账号的模型响应;未生成 Windows 安装包。源码运行需重启客户端,已安装版本需重新打包后安装。
停止接收后,原生端会独立请求取消;在旧请求实际退出前重试会提示稍后再试,避免重复模型请求。聊天同步失败时不使用旧归档静默生成,助手显示错误并允许重试。
## 结果排版与切换改进(2026-10-29)
- 模型输出的 Markdown 标题、加粗、列表、引用、代码和简单表格转换为安全的结构化展示;重点以加粗和浅蓝底色标记,结构标题使用蓝色引导线。不依据医学关键词自行推断重点。未闭合的流式片段保持可读原文。
- 三项操作在当前窗口会话内分别保留状态、正文、模型标签和复核提示。顶部按钮首次点击生成,之后只切换查看;底部“重新生成/重试”才会再发请求。生成中可以阅读其他已完成结果,后台更新不能覆盖当前阅读页。
- 每项保留独立滚动位置,换患者、关闭或重开会话时清空。结果仅保存在内存中,不增加本地患者内容持久化。
- 填入草稿前去除 Markdown 排版符号,保留原文、数值和列表含义,再执行 3000 字长度检查;仍不覆盖原稿、不自动发送。
- 本次前端 **61 项行为测试通过**,包含安全渲染、草稿转换、缓存、显式重生成、后台流式隔离及现有截图/通话守卫;TypeScript 与 Vite 构建通过。
- 真实 dist 配合生产 AI controller 源码和离线宿主替身,在四种窗口尺寸完成 **20 项可点击/布局检查**,以及切换不发请求、显式刷新、后台生成、独立阅读位置、原稿追加及注入防护断言。外部请求为 0。
- 证据:`artifacts/im-ai/refinements-visual-checks.json`、`render_im_ai_refinements.py` 与 `im-ai-styled-cached-*.png`。未打包安装程序。
## 可调宽度、首次总结与指标强调(2026-10-29)
- 聊天与助手之间增加可左右拖动的分隔线,宽屏保留至少 480 px 聊天区域;窄屏抽屉也可调整,最大宽度仍留出可操作的边缘。支持方向键、Home/End、Enter 或双击恢复默认,Escape 取消本次拖动。窗口变更、失焦与关闭释放指针捕获。宽屏和抽屉宽度分别记忆在当前窗口内。
- 进入有权限的 IM 会话后,首次总结等待当前登录、聊天、Qt 通道及原生 `imAiReady(sessionId)` 确认;准备等待最多 30 秒。每个会话只自动尝试一次,不因重连、标签切换、展开面板或失败自动重试。医生提前手动操作会取消待启动的自动总结。关闭和换会话清理计时器及旧结果。
- 普通文本中的血糖、血压、糖化血红蛋白、体重等指标名称及带单位的数值自动强调,支持范围、小数和血压组合。名称加深,数值加粗并显示浅蓝底色,不依赖模型输出 Markdown。原始数值、单位、否定和不确定表述保持不变;没有新增医学阈值判断或自动风险分级。
- **97 项前端测试、318 项客户端回归通过**,TypeScript、Vite 和 Ruff 检查通过,`git diff --check` 无问题。当前构建:`index-C_tMrPfb.js`、`index-CMN1CXTr.css`。
- 实际构建页面在 1634×1008、1120×760、980×640、540×356、440×300 下完成真实 Qt 鼠标拖动、键盘调整、取消、跨断点释放及指标样式检查,**15 项布局/点击检查通过**,自动选择与请求次数断言通过,外部请求为 0。
- 证据:`artifacts/im-ai/render_im_ai_resizing.py`、`resizing-visual-checks.json`、`im-ai-metrics-resizable-*.png`。本轮未生成安装包;原生就绪握手需重启使用最新源码的客户端。
@@ -0,0 +1,72 @@
# 实时问诊 AI:响应与建议质量改进
日期:2026-10-30。最新范围调整:仅视频接通后的实时 AI 使用本次通话文字;IM、病历分析与通话后复盘保持原范围。见末尾“纯对话实时模式”。
## 问题证据
从本机服务端当天运行日志中仅提取错误码和耗时,共发现 10 条 `LIVE_RESULT_INVALID` 记录:最短 12,833 ms,中位 15,002 ms,最长 29,465 ms。这说明部分等待最终消耗在无效结果上;原日志没有校验细项,不能断言具体是哪一种格式或引用错误。
代码另有两处可确认的问题:有结果后的新增对话要等待 8 秒才发起下一轮;“症状要点”允许放入医生的完整问句。模型还会重复询问已有年龄,且只有整份 JSON 通过后才显示结果。
## 已实现
- 首次已保存转写后等待约 0.8 秒发起分析,后续更新等待改为 2 秒。等待从第一段新增内容起算,持续说话不会无限推迟。每个窗口仍只有一个在途分析,期间新内容合并,已到期的下一轮不会在上一轮结束后再次等待。
- 服务端完整验证一条带合法分隔符的结果后,通过 `live_update` 提前交付累计内容,不再等待整个数组闭合。按实际可展示条目增加发送,实时最多 12 次、复盘最多 15 次,最终仍需 `done`。原始 JSON 片段不展示;客户端同样验证诊单、通话、转写会话、版本及所有引用。失败保留已经可核对的内容,并标明本轮尚未完成。
- 优先生成最多三条“下一步追问”,给出简短理由;实时辅助依据完整本次对话减少重复,通话后复盘仍结合授权病历。保留发病年龄、历史年龄和家属信息等必要的追问空间。
- 患者摘要由已校验的引用 ID 还原完整原话,不采用模型改写的事实。省略医生、未知说话人和单纯问题选段。合法 JSON 代码块包装可以解析;引用、数据范围和内容校验继续执行。
- 侧栏首问突出显示,再展示患者关键信息、待核实及复盘。显示实际已用时间、待处理分段和内容依据时间;区分连接、超时、格式、引用、权限等失败,不再统一提示升级服务端。
- 去除模型输入中当前转写和授权历史的重复副本,保留完整来源、资料权限、附件清单和现有超时预算。增加首条有效建议及整轮耗时的安全日志,不记录患者原文或模型正文。
- 沟通追问可帮助了解患者诉求、理解困难和执行顾虑,不预设需要新增治疗、检查或购买项目。腾讯云实时转写、简体中文及现有通话流程保持原配置。
## 验证与构建
- 客户端 Python 相关回归:277 项通过;Ruff 通过。
- 前端组件、通话和布局等测试:136 项通过;Vue 类型检查、生产构建通过。
- 服务端质量测试:421 个累计断言;流式集成:851 个累计断言;真实请求构造的响应契约测试:167 个累计检查。它们包含各自复用的基础断言,不能简单相加视为独立用例数。前一轮 SSE 协议、错误回执、心跳、计时、请求生命周期回归通过。
- `git diff --check` 通过;未提交或撤销其他开发中的功能。
- 构建产物:`app/video_companion/dist/index.html`,脚本 `index-DZWRwUZ-.js`,样式 `index-BbhpEZgS.css`。Vite 仍提示现有视频 SDK 主包体积较大;构建成功。
测试使用合成资料,未发送真实患者资料,未发起真实通话。第二轮另做了下述一次成功的纯文字在线合成验收。浏览器工具未完成本次视觉验收,组件与布局验证已完成。未重启医生客户端或部署远程服务端。
## 针对 16 秒反馈的第二轮定位与处理
11:13 的成功请求在客户端耗时 16,922 ms,首条有效内容 14,952 ms。对应服务端聚合仅 79 ms,未做历史压缩;附件处理 2,800 ms,其中 OCR 2,233 ms;Dify 传输 13,630 ms,其中模型节点开始到首正文约 8,098 ms。这些是相互包含的阶段,不能把嵌套指标重复相加。客户端与服务端按时间及状态关联,不是通过同一 trace ID 直接关联。
此次新增处理:
1. 修复公共 Dify 传输给实时任务附加长报告诊疗规则的冲突。仅服务端选定 `response_contract=live_consultation` 的问诊/复盘使用简短的实时结构化契约,普通诊疗报告保持原规则。当前完整资料、附件、否定/日期/角色及“不采脉”边界均保留。
2. 逐条交付第一条已校验内容,不等后续问题和整组输出。医生/未知来源不能被理由改写成“患者自述”,明确的无患者依据事实前提会省略。
3. 增加 300 秒 OCR 私有缓存,按凭据、应用端点、用户、原图内容、识别配置和运行环境绑定。每次仍重新校验当前来源;失败或识别预算耗尽不缓存。正常无文字图片可按原 unreadable 状态复用,原图仍发送,未减少医学资料。缓存上限 64 个槽位、32 MiB;实际图片内容及模型身份变化即失效。
4. 客户端分别显示首条有效结果和整轮完成时间,未知首条时间不显示为零。
OCR 新增 45 个离线断言通过;原桥接 25、预算 13、上传复用 62 项检查通过。两项外围测试仍失败,已用 Git HEAD 对应实现复现相同失败,未掩盖或计入通过:`DifyImageTextEvidenceTest.php:30` 的既有索引文案断言,以及 `DifyRequiredAttachmentUploadTest.php:519` 的既有“全缺失附件 + 无效配置”断言。后者在沙箱内先受回环测试服务器限制,在正常回环环境下才到达该断言。
在线合成验收使用现有 Qwen/Dify、无图片、无真实患者资料。一次成功请求的首条有效结果为 **1,306 ms**、整轮 **2,181 ms**,JSON 与引用校验通过。沙箱内两次连接立即失败,随后允许网络的同一合成脚本成功;不把失败连接算作模型速度样本。该结果只证明轻量文字请求可以快速且按契约返回,不能证明原来带图病例的 16.9 秒已降到 2.2 秒,也不是控制变量前后对比。实际带图问诊的缓存收益、首条和整轮耗时仍待部署后的相同场景验证。
## 生效与验收
客户端需要加载本次构建并重新启动;使用远程 PHP 服务时需同步服务端改动。旧服务端仍可通过最终 `done` 返回结果,但不能提前交付建议。
下一次实际使用应比较“已保存转写至首条有效建议”的耗时和校验失败率,同时由医生检查追问相关性、信息遗漏及患者理解情况。0.8 秒、2 秒仅为发起请求前的等待,不是模型响应承诺。本轮不声称已经验证诊断准确率或成单率提升。
## 纯对话实时模式
根据用户“仅限视频接通后的 AI 那一块”的明确限定,只有 `live_consultation` 改为 `current_call_only`。视频中的自动更新与重新生成只提交本次会话已保存的全部转写;不读取诊单详情、历史病历,不做病历压缩、OCR 或附件上传。权限、有效诊单、通话归属、会话身份与完整转写快照的校验仍在;原有 1 MiB 容量超限会明确拒绝,不截断早期文字。
`live_review` 保持 `authorized_case`,继续结合授权病历与图片;IM 总结、追问、起草回复及普通病历 AI 未改。实时提示词不再包含未提供的病历块,明确只依照本次对话、跳过已答问题。代价是没有在对话提及的历史过敏、用药、检查等资料不会纳入实时建议,不能将建议视作完整病历评估。
服务端在 start、live_update、done 返回可信分析范围。客户端只在结果校验通过后更新“当前内容仅依据本次通话文字,不含历史病历和图片”的说明;复盘等待或失败时不会把旧结果标成另一范围。旧服务端未提供范围时也不会误标为纯对话。同一请求中范围不一致会拒绝更新。
验证:纯对话服务端专测 328 个累计断言,包括 220 个完整分段、超过旧历史压缩阈值的长文本逐字一致、拒绝任何病历详情/历史读取、零附件,以及普通 summary 和完整复盘隔离。真实 Dify 构包测试 203 个累计检查;进度与质量回归仍为 851、421 个累计断言。客户端 277 项、前端 136 项均通过。服务端完整记录在 `app/artifacts/im-ai/dialogue-only-server.md`;其中两组普通临床策略测试的额外既有失败已隔离复现,没有将它们计入通过或改变其行为。
在线测速仅使用 6 段完全合成的对话,经真实请求构造器及 `streamPreparedAssistant` 的完整流式校验路径请求现有 Qwen/Dify。没有查询真实诊单、患者转写或图片。三轮结果:
| 轮次 | 首条已校验结果 | 整轮完成 | 结果 |
| --- | --- | --- | --- |
| 1 | 859 ms | 2,130 ms | 引用与结构校验通过 |
| 2 | 785 ms | 2,001 ms | 引用与结构校验通过 |
| 3 | 806 ms | 2,021 ms | 引用与结构校验通过 |
计时包括纯请求构造与真实流式模型处理,不含语音转写、保存分段、触发等待和客户端渲染。每轮 query 为 5,526 bytes、6 个分段、零附件,首轮与后续轮次均使用独立合成上游用户标识。这不是实际长通话延迟保证,也不是与原带图病例的控制变量对照。数据记录在 `app/artifacts/im-ai/live-dialogue-only-online-timing-20261030.json`。
尚未部署远程服务端或重启正在运行的医生客户端。需要服务端加载最新代码、客户端加载本次构建后才会生效;腾讯云实时转写与简体中文配置保持原样。
+92
View File
@@ -0,0 +1,92 @@
# 本地离线语音转写:安装、打包与验证
> 当前状态(2026-10-30):用户已明确要求恢复腾讯云实时转写、保留简体中文。客户端默认通路已经恢复腾讯云,启动/打包不再自动安装本地 ASR,也不把本地模型加入发布资源。本文后续内容保留为此前本地方案的开发、安装与评估记录,并非当前默认行为。当前通路见 `cloud-transcription-restore.md`。
运行时使用 faster-whisper 1.2.1,在医生电脑的 CPU 上以 int8 加载本地 Whisper 模型。音频为浏览器桥接传入的单声道、16 kHz、有符号 PCM16 小端字节流;不写临时音频、不发送至 ASR 服务。模型安装和下载只能通过下面的显式命令执行,视频通话不会触发安装、下载或腾讯云语音识别。
## 开发环境安装
在已安装医生工作站依赖的 `app` 目录运行:
```powershell
.venv\Scripts\python.exe scripts/setup_local_transcription.py --install-dependencies --download --model-size small --check
```
此命令明确允许联网安装 `faster-whisper==1.2.1` 及其传递依赖,并从官方 `Systran/faster-whisper-small` 仓库下载模型,然后禁用网络验证 CPU/int8 模型和 Silero VAD 的加载。`tiny`、`base`、`small`、`medium`、`large-v3` 均可选择;中文场景不要使用英文专用 `.en` 模型。开发默认选择 `small`,实际识别准确率和延迟仍需在目标电脑与实际通话音频上验收。
`--install-dependencies` 仅安装至执行命令的 Python 环境;`--download` 才下载模型。不传操作参数只显示帮助。可用 `--revision <官方仓库提交哈希>` 固定下载版本。
模型查找顺序为 `DOCTOR_ASR_MODEL_PATH`、开发目录或安装包内的 `resources/local-asr/model`、最后为 `platformdirs.user_data_path("ZhenyangDoctorWorkstation", appauthor="ZhenYangTang") / "local-asr" / "model"`。可以指定独立目录:
```powershell
$env:DOCTOR_ASR_MODEL_PATH = 'D:\DoctorModels\whisper-small'
.venv\Scripts\python.exe scripts/setup_local_transcription.py --download --model-size small --check
```
`--model-path` 只覆盖本次安装/检查命令;工作站仍按上述顺序查找。下载后会记录 `doctor-asr-install.json`,包含仓库、请求的版本、运行库版本和时间,不包含音频。运行时要求目录存在,且 `model.bin`、`config.json`、`tokenizer.json`、`vocabulary.txt` 或 `vocabulary.json` 非空。缺失时直接报错。`tokenizer.json` 必须随模型提供,以阻止上游缺失 tokenizer 时的自动获取回退。
离线电脑可通过离线安装包安装运行库,再复制完整模型目录;不要在通话过程中初始化下载。离线检查:
```powershell
.venv\Scripts\python.exe scripts/setup_local_transcription.py --check
```
## 桌面安装包
为应用配置 `local-asr` 可选依赖(`faster-whisper==1.2.1`),并在实际 PyInstaller 构建环境安装该额外依赖。运行库需要 NumPy、CTranslate2、tokenizers、ONNX Runtime、PyAV 等上游依赖。不能仅在开发 `.venv` 安装后假设 `.venv-build` 或已冻结的 exe 自动拥有这些依赖。
PyInstaller spec 已加入 `faster_whisper` 数据文件(包括 Silero VAD)及 `ctranslate2`、`onnxruntime`、`av`、`tokenizers` 的 `collect_all`,缺少运行库时构建会明确失败。Windows/macOS 运行环境准备与打包脚本已加入 `--extra local-asr`,依赖锁文件同步更新。构建前可把完整模型放在 `app/resources/local-asr/model`,随现有 resources 打入安装包;也可在目标电脑独立安装到应用数据目录或管理员指定目录。模型不纳入 Git。该模块导入依赖是延迟执行的,没有安装本地 ASR 运行库也不会阻止工作站、视频和录音模块启动。
本轮未生成新 exe 或安装包。发布前仍需验证运行库、模型和 VAD 在成品内真实可用,不能把开发环境通过的单元测试作为成品 ASR 可用性的证明。旧系统兼容性取决于这些原生依赖的官方 wheel 和操作系统支持,不自动沿用工作站其他部分的兼容性声明。
## 验证
```powershell
.venv\Scripts\python.exe -m pytest tests/test_local_transcription.py
.venv\Scripts\python.exe -m ruff check src/doctor_workstation/video/local_transcription.py tests/test_local_transcription.py scripts/setup_local_transcription.py
.venv\Scripts\python.exe scripts/setup_local_transcription.py --check --smoke-wav D:\ASR-Test\non-sensitive-16k-mono.wav
```
smoke WAV 必须是单声道 16 kHz PCM16,最长 12 秒。该命令阻止网络连接并输出识别结果;请使用无患者信息的人工测试音频。建议覆盖短回答“有”“不”、否定句、药物名称、0.5/5、剂量单位、静音、背景噪声,以及医生和患者同时说话。录音通道的角色来源于浏览器端音轨身份,模型不做声纹识别。
单元测试使用替代引擎,验证会话隔离、异步回调、队列上限、序号验证、排空停止、错误脱敏、静音和数字/否定词的原样输出。替代引擎测试不证明实际模型准确率。启动和推理只能在后台执行;Qt 接收方通过 signal 转交主线程。`close()` 立即取消回调,正在执行的原生推理可能在守护线程中完成,不能在 UI 线程等待它。
2026-10-29 已在本仓库 `.venv` 安装运行库,并在 `app/resources/local-asr/model` 安装官方 `Systran/faster-whisper-small`。CPU/int8 模型和 VAD 加载检查通过;使用 [FunASR 官方示例](https://github.com/modelscope/FunASR)提供的[公开中文音频](https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav)完成真实识别,检查及推理期间阻断网络。输出为“欢迎大家来体验打磨院推出的语音识别模型”,出现“达摩院”的同音误识;这验证了本机推理链路,不能代替医疗词汇、噪声、双向通话及目标电脑负载验收。没有发送真实患者资料,尚未测试真实 AI 服务响应。
## 视频 AI 接入与验收
现有腾讯音视频通信、IM 和云端录制继续使用;语音转写改由本地音轨 PCM 和本机模型完成。完成分段保存成功后才触发 AI,约八秒合并更新一次,无新增原文时不重复请求。使用现有 `tcm.diagnosis/aiAssistantStream` 的 `live_consultation`/`live_review` 任务;服务端按医生、诊单、通话及转写会话读取完整原文,再连同授权病历交给已授权的现有 Dify 服务。AI 不发送消息或自动写入病历。
客户端新增 AI 辅助、对话原文、患者资料标签、可调侧栏宽度和窄屏抽屉。挂断后在 IM 顶部打开“本次通话复盘”;点击复盘按钮请求复盘草稿。每条辅助结果可以查看完整引用原话,转写未完整保存时明确显示状态。模型缺失、AI 服务未更新或没有权限时显示具体状态,视频通话可继续。
生效需要部署本轮服务端代码,并重启使用新构建资源的客户端。开发验证只使用合成通话数据及公开音频,不连接真实患者。上线验收需在医生测试账号和合成患者下确认双方音轨归属、转写时延、挂断排空、服务端 AI JSON 格式、取消与复盘。
模型/存储准备阶段先采集到最多 12 个 PCM 分段的内存缓冲;超限明确标记转写不完整。挂断后等待原生识别线程排空才封存文字,15 秒仅提示等待,不提前关闭文字存储。关闭应用时沿用 190 秒异常收尾上限,强制退出会把未完成转写标记为 partial。同会话的存储启动临时失败可以显式重试,正在执行或已成功的启动请求仍复用。
音轨接入适配当前锁定的 CallEngine 3.5.9 / TRTCCloud 2.10.19:`getTRTCCloudInstance()` 返回包装对象,音轨 API 来自其 `_trtc`。`resolveTrtcAudioSource` 先验证实际能力,并保留方法所属实例;升级 SDK 时需重新运行真实包装对象的隔离验证。前端采集失败优先覆盖旧的 native running 状态,并保留独立错误文字和重试入口。持续声音或非零底噪的 PCM 提交窗口为约 4 秒,之后才排队识别,因此字幕按分段追加,不是逐字流式识别。无需提高音量来触发分段,轻声和短否定词不会因前端音量阈值被丢弃。
该音轨修复后,118 项前端测试和 63 项转写桥接/通话回归通过,生产资源已重建。公开语音在隔离 Qt 中贯通了实际 SDK 包装适配、生产音频采集、真实 WebChannel、本地 faster-whisper、Qt 信号及实际 App 字幕 DOM,双方共 8 段均显示;首段实测 6.494 秒。SDK 实例的媒体获取替换为公开音频双轨,存储使用内存,因此仍不代表真实 RTC 发布/订阅与在线持久化已验收。详细材料为 `app/artifacts/im-ai/asr-fixed-end-to-end.md`。已打开的通话页面不会自动载入新脚本,结束当前通话后需关闭并重新打开 IM/视频窗口。
本轮回归:384 项 Python 用例、113 项前端用例通过;PHP 新流式链路 440 项合成断言及 13 组既有回归通过。另有两个既有 PHP 套件在未修改的 HEAD 上同样失败,详见 `app/artifacts/im-ai/live-ai-server-implementation.md`,未将它们计入通过数。Vue 类型检查、前端生产构建、Python Ruff 和 diff 空白检查通过。隔离 QtWebEngine 加载本地合成页面,验证挂断入口、打开不重新生成、完整引用、弹窗 Tab 焦点循环、Escape 返回焦点和 440px 下按钮可见,截图位于 `app/artifacts/im-ai/video-ai-final-*.png`。未执行 macOS 真机构建或冻结安装包验收。
## 核对的官方来源(2026-10-29)
- [faster-whisper v1.2.1 模型和转写接口](https://github.com/SYSTRAN/faster-whisper/blob/v1.2.1/faster_whisper/transcribe.py):本地目录、`local_files_only`、CPU/int8 参数、数组音频输入、惰性 segments、tokenizer 回退。
- [faster-whisper v1.2.1 本地 VAD](https://github.com/SYSTRAN/faster-whisper/blob/v1.2.1/faster_whisper/vad.py):Silero ONNX 资产和 VAD 参数。保留 `min_speech_duration_ms=0`,避免额外丢弃短否定词。
- [faster-whisper 下载实现](https://github.com/SYSTRAN/faster-whisper/blob/master/faster_whisper/utils.py):官方模型映射与模型文件列表。
- [Systran/faster-whisper-small 文件清单](https://huggingface.co/Systran/faster-whisper-small/tree/main):本地模型、tokenizer 和词表文件。
## 简体输出与多方言边界(2026-10-30)
`opencc-python-reimplemented==0.1.7` 是客户端基础依赖。使用随包配置/词典进行本地 `t2s` 字形转换,在原生转写 controller 发出字幕/保存事件前统一为简体;实时字幕、存储和新通话 AI 引用使用同一份文字。已有通话记录打开“面诊对话文字”时以简体显示,不批量改写历史数据库。数字、剂量、否定词、标点和方言词语保留,不使用大模型改写原话。字形转换不能修复误听,也不把粤语词汇翻译成普通话。
安装工具已包含该依赖,PyInstaller 收集 `opencc` 配置和词典;模型启动时预加载转换资源,缺失则明确报错。不要同时安装同样提供 `opencc` 导入名的原生 `opencc` 分发。基础依赖与锁文件更新后,源码运行需要结束通话并重启客户端;单独重新打开网页不会重载原生 Python 代码。已冻结的安装包需要重新构建。
当前通用 Whisper small 不等于多方言专项模型。7 类方言/26 种地域口音的 Fun-ASR-Nano 是本地替代候选,具体能力范围、CPU 成本和离线测试方案见 `app/artifacts/im-ai/dialect-asr-research.md`;未通过目标电脑的双路时延及对应方言准确率验收前,不应声称“所有方言准确识别”。
本机已在隔离环境完成 Nano CPU float32 公开普通话/粤语测试,固定官方权重通过 SHA-256 校验,加载/推理均禁网。Nano 可以保留这条粤语样本词形,但普通话“开放时间”误成“开饭时间”,没有 VAD 的粤语尾段生成额外“对。”。4 秒双路负载预算也缺乏余量;这些结果不支持默认切换。详细输出、耗时和内存见 `app/artifacts/im-ai/dialect-asr-benchmark.md`。该评估没有修改应用运行环境,没有宣称四川/湖南已验收。
后续同机 VAD + 动态 int8 有界实测完成 24 个案例,15 次正常生成、9 次无语音输入被 VAD 跳过;短样本平均耗时下降,但量化期间峰值工作集约 5.7 GiB,且输出词形改变。未验证四川/湖南、医疗数字/否定语音和持续视频负载,因此保持默认引擎。完整结果为 `app/artifacts/im-ai/dialect-asr-quantized-benchmark.md`。评估环境、公开样本及权重保存在 `app/artifacts/im-ai/nano-*`,不会随客户端 resources 打包。
- [转换库发布者文档与固定版本](https://pypi.org/project/opencc-python-reimplemented/0.1.7/):纯 Python、离线词典、t2s 配置。
- [Fun-ASR-Nano 官方模型卡](https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-2512):明确列出的多方言/地域口音范围。
+143
View File
@@ -0,0 +1,143 @@
"""Render the real native client with synthetic data for UI design review."""
from __future__ import annotations
import argparse
import html
import json
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("DOCTOR_MOTION", "off")
from PySide6.QtCore import QSettings, QThreadPool
from PySide6.QtGui import QImage
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
from doctor_workstation.ui.login import LoginWindow
ROUTES = {
"appointments": "挂号列表",
"consultations": "问诊列表",
"patients": "我的患者",
"prescription_library": "我的处方库",
"prescriptions": "已开处方",
"reception": "接诊台",
}
def settle(app: QApplication) -> None:
for _ in range(4):
QThreadPool.globalInstance().waitForDone(3000)
app.processEvents()
QTest.qWait(75)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=Path("artifacts/client-design"))
args = parser.parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
app = QApplication.instance() or QApplication([])
apply_theme(app)
repo = DemoDoctorRepository()
records = []
def capture(window, name: str, title: str) -> None:
settle(app)
filename = f"{name}.png"
if not window.grab().save(str(output / filename)):
raise RuntimeError(f"Could not save {filename}")
records.append({
"title": title, "file": filename,
"width": window.width(), "height": window.height(),
})
# Isolate preview settings from the user's saved account and credentials.
settings = QSettings(str(output / "preview-login.ini"), QSettings.Format.IniFormat)
settings.clear()
login = LoginWindow(repo, settings=settings)
login.show()
for width, height in ((1280, 800), (860, 640)):
login.resize(width, height)
capture(login, f"login-{width}", "登录工作站")
login.close()
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
shell = ShellWindow(
repo, {"session": session, "demo_mode": True}, permissions=session.permissions,
)
shell.show()
try:
for width, height in ((1440, 900), (1024, 640)):
shell.resize(width, height)
for route, title in ROUTES.items():
shell.navigate(route)
capture(shell, f"{route}-{width}", title)
finally:
settle(app)
shell.close()
# Include independently rendered dialog/media fixtures when available.
supplementary = [
("patient_orders_1366x768.png", "患者业务订单"),
("patient_progress_1366x768.png", "患者进度"),
("clinical-after/appointment_drawer_1440x900.png", "预约编辑"),
("clinical-after/diagnosis_edit_1440x900.png", "诊单编辑"),
("clinical-after/diagnosis_edit_1024x640.png", "诊单编辑 · 紧凑窗口"),
("clinical-after/diagnosis_index_1440x900.png", "诊单列表"),
("assistant-1440.png", "AI 问诊"),
("assistant-1080.png", "AI 问诊 · 紧凑窗口"),
("../subwindow_exact/prescription_editor_860x900.png", "处方编辑"),
("../subwindow_exact/prescription_ai_report_920x760.png", "处方报告"),
("video-call-1120x760.png", "视频面诊"),
("video-call-440x300.png", "视频面诊 · 小窗"),
("video-chat-440x300.png", "视频聊天 · 小窗"),
("video-preview-1120x760.png", "视频截图确认"),
]
for filename, title in supplementary:
preview = QImage(str(output / filename))
if not preview.isNull():
records.append({
"title": title, "file": filename,
"width": preview.width(), "height": preview.height(),
})
(output / "gallery.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8",
)
cards = []
for record in records:
filename = html.escape(record["file"])
title = html.escape(record["title"])
cards.append(
f'<section><h2>{title} <small>{record["width"]} × {record["height"]}</small>'
f'</h2><a href="{filename}"><img src="{filename}" alt="{title}" loading="lazy">'
'</a></section>'
)
(output / "index.html").write_text(
'<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>医生工作站 · 客户端设计验收</title><style>'
'body{margin:0;padding:32px;background:#F5F7FA;color:#223047;'
'font-family:"Microsoft YaHei",sans-serif}main{max-width:1440px;margin:auto}'
'h1{font-size:28px}p,small{color:#647287}small{font-weight:400;font-size:14px}'
'section{margin:32px 0 48px}h2{font-size:20px;display:flex;gap:20px;align-items:baseline}'
'img{width:100%;display:block;border:1px solid #E6EAF0;border-radius:16px}'
'a:focus-visible{outline:3px solid #6D8CCB}footer{padding:24px 0}'
'</style><main><h1>医生工作站 · 客户端设计验收</h1>'
'<p>使用演示数据渲染的真实客户端。点击图片查看原始尺寸。</p>'
+ "".join(cards)
+ '<footer>主窗口与紧凑窗口均按实际控件排版生成。</footer></main></html>',
encoding="utf-8",
)
print(output / "index.html")
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
"""Explicit installation/download/check CLI; never invoked by a live call.
Run with --help for examples. With no action flags this only prints help.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import wave
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import patch
APP_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP_ROOT / "src"))
MODEL_REPOS = {
size: f"Systran/faster-whisper-{size}"
for size in ("tiny", "base", "small", "medium", "large-v3")
}
RUNTIME_REQUIREMENT = "faster-whisper==1.2.1"
TEXT_REQUIREMENT = "opencc-python-reimplemented==0.1.7"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Install or verify local CPU/int8 speech recognition before making calls.",
epilog=(
"Example: python scripts/setup_local_transcription.py "
"--install-dependencies --download --model-size small --check. "
"Offline check: python scripts/setup_local_transcription.py --check. "
"A packaged app must ship the runtime; installing into another Python "
"does not alter a frozen executable."
),
)
parser.add_argument(
"--install-dependencies",
action="store_true",
help="Explicitly run pip in this Python environment.",
)
parser.add_argument(
"--download",
action="store_true",
help="Explicitly download the selected official model from Hugging Face.",
)
parser.add_argument("--model-size", choices=tuple(MODEL_REPOS), default="small")
parser.add_argument(
"--model-path",
type=Path,
help="Override DOCTOR_ASR_MODEL_PATH or the app data model directory.",
)
parser.add_argument(
"--revision", default="main", help="Official repository revision/commit for model download."
)
parser.add_argument(
"--check",
action="store_true",
help="Load the local model and VAD on CPU with networking disabled.",
)
parser.add_argument(
"--smoke-wav",
type=Path,
help="Optional non-sensitive mono 16 kHz PCM16 WAV, at most 12 seconds; recognized text is printed.",
)
args = parser.parse_args(argv)
if not (args.install_dependencies or args.download or args.check or args.smoke_wav):
parser.print_help()
return args
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if not (args.install_dependencies or args.download or args.check or args.smoke_wav):
return 0
if args.install_dependencies:
installer = [sys.executable, "-m", "pip", "install"]
if importlib.util.find_spec("pip") is None:
uv = shutil.which("uv")
if not uv:
print(
"This environment has no pip. Install with uv pip install --python <python> "
f"{RUNTIME_REQUIREMENT} {TEXT_REQUIREMENT}",
file=sys.stderr,
)
return 1
installer = [uv, "pip", "install", "--python", sys.executable]
subprocess.run(
[*installer, RUNTIME_REQUIREMENT, TEXT_REQUIREMENT, "platformdirs>=4.3,<5"],
check=True,
)
from doctor_workstation.core.transcript_text import simplified_transcript
from doctor_workstation.video.local_transcription import (
MAX_PCM_BYTES,
SAMPLE_RATE,
FasterWhisperEngine,
LocalTranscriptionError,
default_model_path,
local_transcription_readiness,
)
path = args.model_path.expanduser().resolve() if args.model_path else default_model_path()
try:
if args.download:
from huggingface_hub import snapshot_download
path.mkdir(parents=True, exist_ok=True)
repo = MODEL_REPOS[args.model_size]
print(f"Downloading {repo} into {path}")
snapshot_download(
repo_id=repo,
revision=args.revision,
local_dir=str(path),
allow_patterns=[
"config.json",
"preprocessor_config.json",
"model.bin",
"tokenizer.json",
"vocabulary.*",
"README.md",
"LICENSE*",
],
token=False,
)
(path / "doctor-asr-install.json").write_text(
json.dumps(
{
"repository": repo,
"requestedRevision": args.revision,
"runtimeRequirement": RUNTIME_REQUIREMENT,
"textRequirement": TEXT_REQUIREMENT,
"installedAt": datetime.now(UTC).isoformat(),
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
if args.check or args.smoke_wav or args.download:
# This affects only the explicit setup/check process. The runtime
# already requires complete local files and local_files_only=True.
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
print(json.dumps(local_transcription_readiness(path), ensure_ascii=False, indent=2))
# The hub may already have been imported during --download, so its
# offline environment flag alone is not a sufficient verification.
# Block sockets in this standalone check process as well.
blocked = RuntimeError("Network access is disabled during local ASR verification")
with (
patch("socket.socket.connect", side_effect=blocked),
patch("socket.socket.connect_ex", side_effect=blocked),
patch("socket.getaddrinfo", side_effect=blocked),
):
engine = FasterWhisperEngine(path)
print("Local model and VAD loaded successfully (CPU/int8; network blocked).")
if args.smoke_wav:
with wave.open(str(args.smoke_wav), "rb") as audio:
if (
audio.getnchannels(),
audio.getsampwidth(),
audio.getframerate(),
audio.getcomptype(),
) != (1, 2, SAMPLE_RATE, "NONE"):
raise ValueError("Smoke WAV must be mono 16 kHz uncompressed PCM16.")
if not 0 < audio.getnframes() <= MAX_PCM_BYTES // 2:
raise ValueError(
"Smoke WAV must contain between one sample and 12 seconds."
)
pcm = audio.readframes(audio.getnframes())
print(f"Recognized text: {simplified_transcript(engine.transcribe(pcm))}")
return 0
except LocalTranscriptionError as error:
print(f"Local ASR setup failed ({error.code}): {error}", file=sys.stderr)
except ImportError:
print(
"Required packages are missing; run with --install-dependencies first.", file=sys.stderr
)
except (OSError, ValueError, RuntimeError) as error:
print(
f"Local ASR setup failed: {type(error).__name__}. Check model files, WAV format, and connectivity for explicit downloads.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.5.0"
__version__ = "1.5.1"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
+16 -1
View File
@@ -41,6 +41,7 @@ from doctor_workstation.ui.widgets import (
friendly_error,
gender_text,
get_value,
has_permission,
run_async,
set_authentication_expired_handler,
show_toast,
@@ -939,6 +940,11 @@ class ApplicationController(QObject):
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
can_ai_assistant=has_permission(
getattr(self.shell_window, "permissions", None),
"tcm.diagnosis/aiAssistant",
default=False,
),
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
self._open_video_diagnosis(current_id, call_key=current_key)
),
@@ -1024,11 +1030,14 @@ class ApplicationController(QObject):
lambda _result, expected=generation: self._restore_video_preview(expected)
)
try:
set_close_handler = getattr(video_window, "set_compact_close_handler", None)
if callable(set_close_handler):
set_close_handler(lambda expected=generation: self._restore_video_preview(expected))
video_window.raise_()
dialog.raise_()
dialog.activateWindow()
except RuntimeError:
self._video_preview_state = None
self._restore_video_preview(generation, activate=False)
def _restore_video_preview(
self,
@@ -1045,6 +1054,12 @@ class ApplicationController(QObject):
self._video_preview_generation += 1
window = state.get("window")
try:
set_close_handler = getattr(window, "set_compact_close_handler", None)
if callable(set_close_handler):
set_close_handler(None)
restore_screenshot = getattr(window, "restore_screenshot_preview", None)
if callable(restore_screenshot):
restore_screenshot()
window.setWindowFlag(
Qt.WindowType.WindowStaysOnTopHint,
bool(state.get("stays_on_top")),
@@ -0,0 +1,23 @@
"""Offline script conversion for speech transcripts, without paraphrasing."""
from __future__ import annotations
from threading import local
_converters = local()
def simplified_transcript(text: str) -> str:
"""Convert character forms only; retain dialect words, numbers and negations.
Each thread owns its converter. Cache dictionaries, never patient text.
The t2s configuration deliberately excludes regional vocabulary rewriting.
Missing package data is an error, rather than silently mixing scripts.
"""
converter = getattr(_converters, "simplified", None)
if converter is None:
from opencc import OpenCC
converter = OpenCC("t2s")
_converters.simplified = converter
return converter.convert(text)
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import re
import struct
import zlib
from collections.abc import Callable, Mapping, Sequence
@@ -2846,11 +2847,22 @@ class DemoDoctorRepository:
}
)
def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]:
"""Acknowledge the same asynchronous sync contract in demo mode."""
def sync_im_chat_messages(
self, diagnosis_id: int, *, scope: str = "", sync_token: str = ""
) -> dict[str, Any]:
"""Complete synthetic archive sync while retaining the legacy receipt."""
self._find_consultation(diagnosis_id)
return {"queued": True, "diagnosis_id": diagnosis_id}
if scope not in {"", "current"}:
raise ValueError("IM sync scope must be empty or current")
if not isinstance(sync_token, str) or (
sync_token and re.fullmatch(r"[a-f0-9]{48}", sync_token) is None
):
raise ValueError("IM sync token is invalid")
return {
"queued": True, "diagnosis_id": diagnosis_id,
"completed": True, "phase": "completed", "errors": [],
}
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Build demo audit rows from the diagnosis appointments."""
@@ -214,6 +214,13 @@ class DoctorRepository(Protocol):
def cancel_diagnosis_ai(self, diagnosis_id: int, request_id: str) -> dict[str, Any]:
"""Request server/upstream cancellation of one correlated stream."""
def stream_video_ai(
self, diagnosis_id: int, *, call_record_id: int,
transcription_session_id: str, task: str = "live_consultation",
request_id: str, cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Analyze the server-owned snapshot of this exact video call."""
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
"""Generate a server-validated prescription draft for doctor review."""
@@ -692,8 +699,10 @@ class DoctorRepository(Protocol):
) -> dict[str, Any]:
"""Return diagnosis IM messages, defaulting to the fast archive-only path."""
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the server-side IM archive synchronisation job."""
def sync_im_chat_messages(
self, diagnosis_id: int, *, scope: str = "", sync_token: str = ""
) -> Any:
"""Advance one server-side archive page; inspect completed before using it."""
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return per-diagnosis registration / cancellation audit rows."""
@@ -1476,6 +1485,42 @@ class RemoteDoctorRepository:
yield {"event": "delta", "text": answer, "fallback": True}
yield {**result, "event": "done", "fallback": True}
def stream_video_ai(
self, diagnosis_id: int, *, call_record_id: int,
transcription_session_id: str, task: str = "live_consultation",
request_id: str, cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""No legacy fallback: an older route cannot bind a live call safely."""
if (isinstance(diagnosis_id, bool) or not isinstance(diagnosis_id, int)
or diagnosis_id <= 0 or isinstance(call_record_id, bool)
or not isinstance(call_record_id, int) or call_record_id <= 0):
raise ValueError("diagnosis and call_record_id must be positive integers")
if (not isinstance(transcription_session_id, str)
or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", transcription_session_id) is None):
raise ValueError("invalid transcription session")
if task not in {"live_consultation", "live_review"}:
raise ValueError("unsupported video AI task")
body = {
"id": diagnosis_id, "task": task,
"prompt": "请依据本次通话已保存的原文整理症状、追问和待核实信息,供医生复核。",
"call_record_id": call_record_id,
"transcription_session_id": transcription_session_id,
"request_id": request_id,
}
for raw in self.client.post_event_stream(
"tcm.diagnosis/aiAssistantStream", body, timeout=105.0, cancelled=cancelled,
):
if cancelled is not None and cancelled():
return
event = _normalise_diagnosis_ai_event(raw)
if event is not None:
yield event
if event["event"] == "done":
return
if cancelled is None or not cancelled():
raise ApiProtocolError("video AI stream ended before done")
def cancel_diagnosis_ai(self, diagnosis_id: int, request_id: str) -> dict[str, Any]:
"""Use an independent short request, even while the stream is idle."""
@@ -2602,12 +2647,27 @@ class RemoteDoctorRepository:
)
return dict(_require_mapping(payload, "tcm.diagnosis/getImChatMessages"))
def sync_im_chat_messages(self, diagnosis_id: int) -> Any:
"""Queue the confirmed shutdown-function archive sync endpoint."""
def sync_im_chat_messages(
self, diagnosis_id: int, *, scope: str = "", sync_token: str = ""
) -> Any:
"""Advance a bounded archive page, preserving legacy no-option callers."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
return self.client.post("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": diagnosis_id})
if scope not in {"", "current"}:
raise ValueError("IM sync scope must be empty or current")
if not isinstance(sync_token, str) or (
sync_token and re.fullmatch(r"[a-f0-9]{48}", sync_token) is None
):
raise ValueError("IM sync token is invalid")
payload: dict[str, Any] = {"diagnosis_id": diagnosis_id}
if scope:
payload["scope"] = scope
if sync_token:
payload["sync_token"] = sync_token
return _client_request(
self.client, "post", "tcm.diagnosis/triggerImChatSync", payload, timeout=15.0
)
def list_diagnosis_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
"""Return registration/cancellation logs from ``guahaoLogList``."""
@@ -3043,10 +3103,10 @@ def _normalise_diagnosis_ai_event(raw: Mapping[str, Any]) -> dict[str, Any] | No
if not isinstance(text, str):
raise ApiProtocolError("AI assistant delta content must be text", data=data)
return {"event": "delta", "text": text}
if kind == "done":
if kind in {"live_update", "done"}:
payload.pop("event", None)
payload.pop("type", None)
return {**payload, "event": "done"}
return {**payload, "event": kind}
if kind == "error":
message = (
data
@@ -23,7 +23,6 @@ from PySide6.QtWidgets import (
QComboBox,
QDialog,
QFrame,
QGraphicsDropShadowEffect,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
@@ -43,6 +42,7 @@ from PySide6.QtWidgets import (
)
from ..core.appointment_modes import APPOINTMENT_MODES
from .design_system import clinical_stylesheet
from .theme import main_window_target_rect
from .widgets import (
display_text,
@@ -84,8 +84,8 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
min-height: 32px;
max-height: 32px;
padding: 0;
border: 0;
border-radius: 4px;
border: 1px solid transparent;
border-radius: $ds_radius_control;
background-color: transparent;
color: #606163;
font-size: 22px;
@@ -95,6 +95,10 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
color: #1A1C1F;
background-color: #F0F0F0;
}
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:focus {
border-color: $ds_focus;
background-color: $ds_accent_soft;
}
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
@@ -110,7 +114,7 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
}
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
color: #1A1C1F;
color: $ds_muted;
font-size: 14px;
font-weight: 500;
}
@@ -125,7 +129,7 @@ QDialog#AppointmentDrawerOverlay QPlainTextEdit {
min-height: 30px;
padding: 0 11px;
border: 1px solid #EDEDEE;
border-radius: 9px;
border-radius: 10px;
background-color: #FFFFFF;
color: #1A1C1F;
selection-background-color: #EEF1FA;
@@ -145,7 +149,14 @@ QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
QDialog#AppointmentDrawerOverlay QComboBox:focus,
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
border: 2px solid #8B9AD9;
border: 1px solid #8B9AD9;
}
QDialog#AppointmentDrawerOverlay QComboBox:disabled,
QDialog#AppointmentDrawerOverlay QLineEdit:disabled,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:disabled {
color: $ds_disabled;
background-color: $ds_disabled_surface;
border-color: $ds_line;
}
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
@@ -197,7 +208,7 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
max-height: 38px;
padding: 0;
border: 1px solid #EDEDEE;
border-radius: 8px;
border-radius: 10px;
background-color: #FFFFFF;
color: #1A1C1F;
font-size: 14px;
@@ -221,15 +232,15 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
background-color: #F7F7F7;
background-color: $ds_surface_alt;
border: 0;
border-radius: 8px;
border-radius: $ds_radius_panel;
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
color: #1A1C1F;
font-size: 15px;
font-weight: 500;
font-weight: 600;
}
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
@@ -247,13 +258,17 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
background-color: #EEF1FA;
}
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:focus {
background-color: $ds_accent_soft;
border: 1px solid $ds_focus;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
min-width: 110px;
min-height: 70px;
padding: 0 8px;
border: 2px solid #EDEDEE;
border-radius: 8px;
border-radius: 10px;
background-color: #FFFFFF;
color: #1A1C1F;
}
@@ -330,25 +345,25 @@ QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
background-color: #EEF1FA;
border: 1px solid #EDEDEE;
border-radius: 9px;
border-radius: 10px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] {
background-color: #FFF5E6;
border: 1px solid #F6E3C4;
border-radius: 9px;
border-radius: 10px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] {
background-color: #FFF1F3;
border: 1px solid #F7D7DC;
border-radius: 9px;
border-radius: 10px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
background-color: #EAF9F3;
border: 1px solid #D4F0E5;
border-radius: 9px;
border-radius: 10px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
@@ -378,7 +393,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
max-height: 30px;
padding: 0 15px;
border: 1px solid #EDEDEE;
border-radius: 9px;
border-radius: 10px;
background-color: #FFFFFF;
color: #1A1C1F;
font-size: 13px;
@@ -408,9 +423,14 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[prim
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
color: #FFFFFF;
border-color: #EDEDEE;
background-color: #8E8F90;
color: $ds_disabled;
border-color: $ds_line;
background-color: $ds_disabled_surface;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:pressed {
color: $ds_surface;
background-color: $ds_accent_pressed;
border-color: $ds_accent_pressed;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
@@ -487,6 +507,7 @@ _APPOINTMENT_DARK_REPLACEMENTS = (
)
# Light is the default application theme. Keep the replacement table available
# for a future explicit dark-mode switch, but do not mutate the light source QSS.
APPOINTMENT_DRAWER_QSS = clinical_stylesheet(APPOINTMENT_DRAWER_QSS)
def _as_int(value: Any, default: int = 0) -> int:
@@ -733,35 +754,7 @@ class _EmptyIllustration(QWidget):
painter.end()
class _HoverLiftButton(QPushButton):
"""Button with a subtle two-pixel lift and blue-tinted shadow on hover."""
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
super().__init__(text, parent)
self._lifted = False
self._base_position = QPoint()
def enterEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
if self.isEnabled() and not self._lifted:
self._base_position = self.pos()
self.move(self.x(), self.y() - 2)
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(12)
shadow.setOffset(0, 4)
shadow.setColor(QColor(26, 28, 31, 72))
self.setGraphicsEffect(shadow)
self._lifted = True
super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
if self._lifted:
self.move(self._base_position)
self.setGraphicsEffect(None)
self._lifted = False
super().leaveEvent(event)
class _SlotCard(_HoverLiftButton):
class _SlotCard(QPushButton):
"""Two-line time card matching Element's independent time/status typography."""
def __init__(self, time_text: str, status_text: str) -> None:
@@ -819,8 +812,8 @@ class AppointmentDrawer(QDialog):
self._today_blocking = False
self._channel_names: dict[str, str] = {}
self._doctor_buttons: dict[int, QRadioButton] = {}
self._date_buttons: dict[str, _HoverLiftButton] = {}
self._slot_buttons: dict[str, _HoverLiftButton] = {}
self._date_buttons: dict[str, QPushButton] = {}
self._slot_buttons: dict[str, QPushButton] = {}
self._slot_available: dict[str, bool] = {}
self._slot_restore_selection = ""
self._active_loading = ""
@@ -1426,7 +1419,7 @@ class AppointmentDrawer(QDialog):
continue
label = f"{parsed.toString('MM月dd日')} ({week_days[parsed.dayOfWeek() % 7]})"
self.date_combo.addItem(label, value)
button = _HoverLiftButton(label)
button = QPushButton(label)
button.setProperty("appointmentDate", True)
button.setCheckable(True)
button.setFixedSize(130, 40)
@@ -2,143 +2,155 @@
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def appointments_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
return clinical_stylesheet(Template(_QSS).safe_substitute(body=body_family(), heading=heading_family()))
_QSS = """
#AppointmentsPage { background: #F3F7FD; color: #273244; }
#AppointmentsPage { background: $ds_canvas; color: $ds_text; }
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
color: #5D6B80; font-size: 13px; font-weight: 400;
color: $ds_muted; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QFrame#AppointmentFilterPanel,
#AppointmentsPage QFrame#AppointmentMainCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#AppointmentsPage QPushButton {
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
min-height: 30px; padding: 0 12px; border: 1px solid transparent;
border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
#AppointmentsPage QPushButton:hover { color: $ds_accent; background: $ds_surface_alt; border-color: $ds_line_strong; }
#AppointmentsPage QPushButton:pressed { background: $ds_accent_soft; }
#AppointmentsPage QPushButton:focus { border-color: $ds_focus; }
#AppointmentsPage QPushButton[variant="primary"] {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
background: $ds_accent; color: $ds_surface; border-color: $ds_accent;
}
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
#AppointmentsPage QPushButton[variant="primary"]:hover { background: $ds_accent_hover; }
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: $ds_accent_pressed; }
#AppointmentsPage QPushButton[variant="primary"]:focus { border-color: $ds_focus; }
#AppointmentsPage QPushButton[variant="danger"] {
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
color: $ds_danger; background: $ds_danger_soft; border-color: transparent;
}
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
#AppointmentsPage QPushButton[variant="danger"]:hover { background: $ds_danger_soft; }
#AppointmentsPage QPushButton[variant="danger"]:pressed { border-color: $ds_danger; }
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
#AppointmentsPage QPushButton:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
#AppointmentsPage QPushButton[variant="ghost"]:hover { background: $ds_accent_soft; }
#AppointmentsPage QPushButton[variant="ghost"]:focus { border-color: $ds_focus; }
#AppointmentsPage QPushButton:disabled, #AppointmentsPage QPushButton[variant="primary"]:disabled,
#AppointmentsPage QPushButton[variant="danger"]:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
min-height: 32px; padding: 0 10px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; border-radius: $ds_radius_control; selection-background-color: $ds_accent_soft;
}
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: $ds_focus; }
#AppointmentsPage QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
#AppointmentsPage QLineEdit QToolButton:focus { background: $ds_accent_soft; }
#AppointmentsPage QPushButton#AppointmentSearchButton {
min-height: 39px; max-height: 39px; min-width: 52px;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
border-color: transparent; color: #5D6B80; font-size: 13px;
min-height: 34px; max-height: 34px; padding: 0 10px; background: $ds_canvas;
border-color: transparent; color: $ds_muted; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: $ds_accent; background: $ds_accent_soft; }
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
color: $ds_accent; background: $ds_accent_soft; border-color: transparent; font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
color: $ds_warning; background: $ds_warning_soft; border-color: $ds_warning_soft;
}
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
#AppointmentsPage QLabel#FilterRowLabel { color: $ds_muted; font-size: 13px; }
#AppointmentsPage QLabel#FilterDivider { color: $ds_line; padding: 0 10px; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
min-width: 52px; min-height: 32px; padding: 0 12px; color: $ds_muted;
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: $ds_accent; background: $ds_surface_alt; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
color: $ds_accent; background: transparent; border-bottom-color: $ds_accent;
}
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
min-height: 30px; max-height: 30px; padding: 0 12px; color: $ds_muted;
background: transparent; border-color: transparent; font-size: 13px;
}
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: $ds_accent; background: $ds_accent_soft; }
#AppointmentsPage QPushButton[filterChoice="true"]:focus,
#AppointmentsPage QPushButton[appointmentStat="true"]:focus { border-color: $ds_focus; }
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
}
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
color: #1769E8; border-color: #ADC8F0;
color: $ds_text; border-color: transparent;
}
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:hover { color: $ds_accent; background: $ds_accent_soft; }
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:focus { border-color: $ds_focus; }
#AppointmentsPage QTableWidget#AppointmentTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
background: $ds_surface; alternate-background-color: $ds_surface; border: 0; border-radius: 0;
gridline-color: $ds_line; selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid $ds_line; }
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: $ds_accent_soft; color: $ds_text; }
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
min-height: 40px; padding: 0; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-top: 1px solid $ds_line; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
background: transparent; border-left: 3px solid transparent;
}
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: $ds_accent; }
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
width: 16px; height: 16px; background: $ds_surface; border: 1px solid $ds_line_strong; border-radius: 3px;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
#AppointmentsPage QWidget[appointmentInfoHost="true"],
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
background: #EAF2FF; border-radius: 4px; font-size: 13px;
min-height: 20px; max-height: 20px; padding: 0 6px; color: $ds_accent;
background: $ds_accent_soft; border-radius: $ds_radius_small; font-size: 13px;
}
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: $ds_warning; background: $ds_warning_soft; }
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: $ds_muted; background: $ds_disabled_surface; }
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: $ds_muted; font-size: 13px; }
#AppointmentsPage QPushButton[tableCancelAction="true"] {
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
min-height: 20px; max-height: 20px; padding: 0 5px; color: $ds_danger;
background: $ds_danger_soft; border: 0; border-radius: $ds_radius_small; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
color: $ds_accent; background: $ds_accent_soft; border-color: transparent; font-size: 13px;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: $ds_accent_hover; border-color: $ds_line_strong; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus { border-color: $ds_focus; }
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
#AppointmentsPage QWidget#Pager { background: $ds_surface; border: 0; border-top: 1px solid $ds_line; }
#AppointmentsPage QWidget#Pager QLabel { color: $ds_muted; font-size: 13px; border: 0; }
#AppointmentsPage QWidget#Pager QPushButton {
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
padding: 0; border: 1px solid $ds_line; background: $ds_surface; color: $ds_muted; font-size: 13px;
}
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: $ds_accent; background: $ds_accent_soft; border-color: transparent; font-weight: 600; }
#AppointmentsPage QWidget#Pager QPushButton:focus { border-color: $ds_focus; }
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: $ds_disabled; background: $ds_surface_alt; }
"""
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from .design_system import clinical_stylesheet
from .widgets import display_text, first_value, run_async
# 后台 ChatNotifyLogic 写入的三种 type。
@@ -260,7 +261,7 @@ class ChatNotificationCenter(QWidget):
super().__init__(host)
self.repository = repository
self.setObjectName("ChatNotifyLayer")
self.setStyleSheet(CHAT_NOTIFICATION_QSS)
self.setStyleSheet(clinical_stylesheet(CHAT_NOTIFICATION_QSS))
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self._cards: dict[str, _NotificationCard] = {}
self._pending: list[ChatNotification] = []
@@ -2,59 +2,67 @@
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def consultations_stylesheet() -> str:
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
return clinical_stylesheet(Template(_QSS).safe_substitute(body=body_family(), heading=heading_family()))
_QSS = """
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
background: #F3F7FD; color: #273244; border: 0;
background: $ds_canvas; color: $ds_text; border: 0;
}
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
color: #5D6B80; font-size: 13px;
color: $ds_muted; font-size: 13px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
background: transparent; border: 0; border-top: 1px solid $ds_line; border-radius: 0;
}
#DiagnosisIndex QPushButton {
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
border-radius: 6px; background: #FFFFFF;
min-height: 34px; padding: 0 13px; border: 1px solid transparent;
border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
#DiagnosisIndex QPushButton:hover { color: $ds_accent; background: $ds_surface_alt; border-color: $ds_line_strong; }
#DiagnosisIndex QPushButton:pressed { background: $ds_accent_soft; }
#DiagnosisIndex QPushButton:focus { border-color: $ds_focus; }
#DiagnosisIndex QPushButton[variant="primary"] { color: $ds_surface; background: $ds_accent; border-color: $ds_accent; }
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: $ds_accent_hover; }
#DiagnosisIndex QPushButton[variant="primary"]:pressed { background: $ds_accent_pressed; }
#DiagnosisIndex QPushButton[variant="primary"]:focus { border-color: $ds_focus; }
#DiagnosisIndex QPushButton[consultationTool="true"] { color: $ds_text; border-color: transparent; }
#DiagnosisIndex QPushButton[consultationTool="true"]:hover { color: $ds_accent; background: $ds_accent_soft; }
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
#DiagnosisIndex QPushButton[consultationDanger="true"]:hover { border-color: $ds_danger; }
#DiagnosisIndex QPushButton[consultationTool="true"]:focus,
#DiagnosisIndex QPushButton[consultationDanger="true"]:focus { border-color: $ds_focus; }
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled,
#DiagnosisIndex QPushButton[variant="primary"]:disabled, #DiagnosisIndex QPushButton[consultationTool="true"]:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
min-height: 32px; padding: 0 10px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; border-radius: $ds_radius_control; selection-background-color: $ds_accent_soft;
font-size: 13px;
}
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: $ds_focus; }
#DiagnosisIndex QLineEdit QToolButton {
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
}
@@ -62,70 +70,77 @@ _QSS = """
width: 22px; border: 0; background: transparent;
}
#DiagnosisIndex QComboBox QAbstractItemView {
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
color: $ds_text; background: $ds_surface; border: 1px solid $ds_line;
selection-background-color: $ds_accent_soft; selection-color: $ds_accent; outline: 0;
}
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
border-radius: $ds_radius_control; color: $ds_muted; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: $ds_surface_alt; color: $ds_accent; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: $ds_accent_soft; color: $ds_accent; font-weight: 600; }
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; background: $ds_surface_alt; border-color: transparent; }
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { background: $ds_accent_soft; border-color: transparent; }
#DiagnosisIndex QToolButton[diagnosisChip="true"]:focus { border-color: $ds_focus; }
#DiagnosisIndex QToolButton[statusTab="true"] {
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
color: $ds_accent; background: transparent; border-bottom-color: $ds_accent;
}
#DiagnosisIndex QToolButton[statusTab="true"]:focus { background: $ds_accent_soft; }
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
min-height: 32px; padding: 0 6px; color: $ds_muted; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: $ds_accent; background: $ds_surface_alt; }
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: $ds_line; border: 0; }
#DiagnosisIndex QFrame#DiagnosisDateRange {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
}
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
#DiagnosisIndex QTableView {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
background: $ds_surface; alternate-background-color: $ds_surface; border: 0; border-radius: 0;
gridline-color: $ds_line; selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#DiagnosisIndex QTableView QHeaderView::section {
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
min-height: 41px; padding: 0; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
color: $ds_accent; min-height: 26px; padding: 0 4px; border: 1px solid transparent;
border-radius: $ds_radius_small; background: transparent; font-size: 13px;
}
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: $ds_danger; }
#DiagnosisIndex QToolButton[appointmentCancel="true"]:hover { background: $ds_danger_soft; }
#DiagnosisIndex QToolButton[rowLink]:hover { color: $ds_accent; background: $ds_accent_soft; border-radius: $ds_radius_small; }
#DiagnosisIndex QToolButton[rowLink]:focus,
#DiagnosisIndex QToolButton[appointmentCancel="true"]:focus { border-color: $ds_focus; }
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
color: #5D6B80; font-size: 13px;
color: $ds_muted; font-size: 13px;
}
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: $ds_muted; background: $ds_surface; }
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: $ds_danger; }
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid $ds_line; }
#DiagnosisIndex QWidget#DiagnosisPager { background: $ds_surface; border-top: 1px solid $ds_line; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
border: 1px solid $ds_line; border-radius: $ds_radius_control; color: $ds_muted; background: $ds_surface;
}
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: $ds_accent; background: $ds_accent_soft; border-color: transparent; font-weight: 600; }
#DiagnosisIndex QToolButton[pagerButton="true"]:focus { border-color: $ds_focus; }
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#DiagnosisIndex QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#DiagnosisIndex QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#DiagnosisIndex QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,76 @@
"""Clinical desktop design tokens, shared by native pages and dialog chrome.
Keep data-series colours and video pixels outside this palette. Existing scoped
QSS can adopt the system without changing selectors, layout or permissions.
New rules should use the named ``$ds_*`` tokens rather than legacy colours.
"""
from __future__ import annotations
import re
from string import Template
PALETTE = {
"canvas": "#F5F7FA",
"surface": "#FFFFFF",
"surface_alt": "#F7F9FC",
"sidebar": "#F0F3F8",
"line": "#E6EAF0",
"line_strong": "#D4DCE8",
"heading": "#223047",
"text": "#344258",
"muted": "#647287",
"disabled": "#8B96A6",
"disabled_surface": "#F0F2F5",
"accent": "#4267B2",
"accent_hover": "#355995",
"accent_pressed": "#2C4C82",
"accent_soft": "#EAF0FB",
"focus": "#6D8CCB",
"success": "#287B65",
"success_soft": "#EDF7F2",
"warning": "#96601D",
"warning_soft": "#FBF4E8",
"danger": "#B54A59",
"danger_soft": "#FCF0F2",
}
RADII = {"small": 6, "control": 10, "panel": 16, "window": 20}
SPACING = {"small": 8, "control": 12, "group": 16, "section": 24}
TOKENS = {f"ds_{name}": value for name, value in PALETTE.items()}
TOKENS.update({f"ds_radius_{name}": f"{value}px" for name, value in RADII.items()})
TOKENS.update({f"ds_space_{name}": f"{value}px" for name, value in SPACING.items()})
# Explicit migration of the two existing native palettes. This adapter only
# touches six-digit colour literals; selectors and metrics stay page-owned.
_LEGACY_ROLES = {
"accent": ("1769E8", "4F63D9", "5761F4", "5267DF"),
"accent_hover": ("155BCC", "1555B6", "4156C4", "4356C4", "4055CA"),
"accent_pressed": ("124EA9", "354BB4"),
"accent_soft": ("EAF2FF", "EEF1FA", "EEF0FF", "F0F2FF", "DCEAFF", "EDF0FC"),
"focus": ("75A5F0", "8B9AD9"),
"canvas": ("F3F7FD", "F4F6FA", "F6F6F6"),
"surface_alt": ("F7F7F7", "F7FAFE", "F5F8FD", "F7F9FE", "F2F7FF"),
"sidebar": ("EDF4FF",),
"line": ("DBE5F2", "EDEDEE", "E5ECF6", "E6EDF6", "E7EDF7", "EDF0F5", "E7EBF2", "E6EAF5"),
"line_strong": ("E4E4E5", "C8D5E6", "C9DCF7", "ADC8F0", "B6CDEE"),
"heading": ("1A1C1F", "202C3F", "111F46"),
"text": ("273244", "263551", "24334A", "233149", "3F4E75"),
"muted": ("606163", "6A6B6D", "5D6B80", "7886AA", "708097"),
"disabled": ("8E8F90", "8A97A9", "9AA6B7"),
"disabled_surface": ("F2F2F2", "F0F0F0"),
"success_soft": ("EEF7F3", "EAF9F3"),
"warning": ("A9691D", "9C681F"),
"warning_soft": ("FCF5E9", "FFF5E6", "FFF6E7"),
"danger": ("BE4B58", "B84652"),
"danger_soft": ("FFF0F2", "FFF1F3"),
}
_LEGACY = {colour.lower(): PALETTE[role] for role, colours in _LEGACY_ROLES.items() for colour in colours}
_HEX = re.compile(r"#([0-9a-fA-F]{6})(?![0-9a-fA-F])")
def clinical_stylesheet(source: str) -> str:
"""Resolve clinical tokens and migrate known legacy light-theme colours."""
resolved = Template(source).safe_substitute(TOKENS)
return _HEX.sub(lambda match: _LEGACY.get(match[1].lower(), match[0]), resolved)
@@ -64,6 +64,7 @@ from PySide6.QtWidgets import (
)
from .chat_items import ChatItem, parse_chat_item
from .design_system import PALETTE, clinical_stylesheet
from .diagnosis_editors import FlowLayout
from .diagnosis_media import shared_image_manager
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
@@ -80,10 +81,7 @@ QFrame#DiagnosisDrawer {
border-left: 1px solid #EDEDEE;
}
QFrame#DiagnosisDrawerHeader {
background-color: qlineargradient(
x1:0, y1:0, x2:0, y2:1,
stop:0 #FFFFFF, stop:1 #F7F7F7
);
background-color: #FFFFFF;
border-bottom: 1px solid #EDEDEE;
}
QFrame#DiagnosisDrawerFooter {
@@ -105,7 +103,7 @@ QLabel#DiagnosisDrawerModeBadge,
QLabel#DiagnosisReadonlyStatus,
QLabel[diagnosisTag="true"] {
padding: 4px 9px;
border-radius: 9px;
border-radius: $ds_radius_small;
font-size: 12px;
font-weight: 500;
}
@@ -159,8 +157,8 @@ QDialog QPushButton:pressed {
border-color: #354BB4;
}
QDialog QPushButton:disabled {
color: #E4E4E5;
background-color: #FFFFFF;
color: $ds_disabled;
background-color: $ds_disabled_surface;
border-color: #EDEDEE;
}
QDialog QPushButton[variant="primary"] {
@@ -176,9 +174,9 @@ QDialog QPushButton[variant="primary"]:focus {
border-color: #4156C4;
}
QDialog QPushButton[variant="primary"]:disabled {
color: #FFFFFF;
background-color: #F0F0F0;
border-color: #F0F0F0;
color: $ds_disabled;
background-color: $ds_disabled_surface;
border-color: $ds_line;
}
QDialog QPushButton[variant="secondary"] {
color: #4F63D9;
@@ -267,9 +265,9 @@ QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:focus {
border-color: #4156C4;
}
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisSaveButton:disabled {
color: #FFFFFF;
background-color: #F0F0F0;
border-color: #F0F0F0;
color: $ds_disabled;
background-color: $ds_disabled_surface;
border-color: $ds_line;
}
QDialog#DiagnosisDialogRoot QPushButton#DiagnosisFooterCloseButton {
color: #606163;
@@ -383,10 +381,10 @@ QFrame#DiagnosisFormHost {
QFrame[diagnosisSection="true"] {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 12px;
border-radius: 16px;
}
QLabel[diagnosisFieldLabel="true"] {
color: #1A1C1F;
color: $ds_muted;
font-size: 12px;
font-weight: 500;
}
@@ -426,7 +424,7 @@ QDoubleSpinBox[diagnosisField="true"]:focus,
QSpinBox[diagnosisField="true"]:focus,
QTimeEdit[diagnosisField="true"]:focus {
border: 1px solid #8B9AD9;
background-color: #F7F7F7;
background-color: $ds_surface;
}
QLineEdit[diagnosisField="true"][invalid="true"],
QComboBox[diagnosisField="true"][invalid="true"],
@@ -452,8 +450,8 @@ QLabel#DiagnosisSectionTitle {
background-color: transparent;
border: 0;
padding: 0;
font-size: 13px;
font-weight: 500;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
}
QFrame#DiagnosisSectionAccent {
@@ -470,9 +468,9 @@ QFrame#DiagnosisReadonlyPage {
background-color: #F6F6F6;
}
QFrame#DiagnosisReadonlyHero {
background-color: #F7F7F7;
border: 1px solid #F0F0F0;
border-radius: 12px;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: $ds_radius_panel;
}
QLabel#DiagnosisReadonlyTitle {
color: #1A1C1F;
@@ -542,7 +540,7 @@ QFrame#DiagnosisReadonlyCaseCard,
QFrame[diagnosisReadonlyCard="true"] {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 14px;
border-radius: 16px;
}
QLabel#DiagnosisReadonlyCardTitle {
color: #1A1C1F;
@@ -771,7 +769,7 @@ QWidget[diagnosisChoiceHost="true"] QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 999px;
border-radius: 10px;
font-size: 12px;
}
QWidget[diagnosisChoiceHost="true"] QPushButton:hover,
@@ -1068,6 +1066,7 @@ _DIAGNOSIS_BLUE_REPLACEMENTS = (
)
for _source_color, _theme_color in _DIAGNOSIS_BLUE_REPLACEMENTS:
DIAGNOSIS_QSS = DIAGNOSIS_QSS.replace(_source_color, _theme_color)
DIAGNOSIS_QSS = clinical_stylesheet(DIAGNOSIS_QSS)
def _text(value: Any, default: str = "—") -> str:
@@ -1147,12 +1146,19 @@ class DiagnosisSwitch(QAbstractButton):
track = QRectF(1, 3, self.width() - 2, self.height() - 6)
checked = self.isChecked()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#4F63D9" if checked else "#E4E4E5"))
painter.setBrush(QColor(PALETTE["accent"] if checked else PALETTE["line_strong"]))
if not self.isEnabled():
painter.setOpacity(0.6)
painter.drawRoundedRect(track, track.height() / 2, track.height() / 2)
diameter = track.height() - 4
x = track.right() - diameter - 2 if checked else track.left() + 2
painter.setBrush(QColor("#FFFFFF"))
painter.drawEllipse(QRectF(x, track.top() + 2, diameter, diameter))
if self.hasFocus():
painter.setOpacity(1)
painter.setPen(QPen(QColor(PALETTE["focus"]), 1))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5), 11, 11)
painter.end()
@@ -1190,13 +1196,19 @@ class SaveStateButton(QPushButton):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
enabled = self.isEnabled()
idle_color = PALETTE["accent_pressed"] if self.isDown() else PALETTE["accent"]
color = {
"success": "#287B65",
"error": "#BE4B58",
}.get(self._state, "#4F63D9" if enabled else "#8E8F90")
"success": PALETTE["success"],
"error": PALETTE["danger"],
"loading": PALETTE["accent"],
}.get(self._state, idle_color if enabled else PALETTE["disabled_surface"])
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 10, 10)
if self.hasFocus():
painter.setPen(QPen(QColor(PALETTE["focus"]), 1))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5), 10, 10)
label = {
"loading": "正在保存",
"success": "保存成功",
@@ -1220,7 +1232,9 @@ class SaveStateButton(QPushButton):
else:
painter.drawLine(x + 3, center_y - 5, x + 12, center_y + 5)
painter.drawLine(x + 12, center_y - 5, x + 3, center_y + 5)
painter.setPen(QColor("#FFFFFF"))
painter.setPen(QColor(
PALETTE["disabled"] if not enabled and self._state == "idle" else PALETTE["surface"]
))
painter.drawText(
QRect(x + icon_width, 0, metrics.horizontalAdvance(label), self.height()),
Qt.AlignmentFlag.AlignCenter,
@@ -1815,10 +1829,10 @@ class MessageStrip(QFrame):
self.glyph = QLabel("i")
self.glyph.setFixedWidth(18)
self.glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.glyph.setStyleSheet("font-weight:700; color:#4F63D9;")
self.glyph.setStyleSheet(clinical_stylesheet("font-weight:700; color:$ds_accent;"))
self.label = QLabel()
self.label.setWordWrap(True)
self.label.setStyleSheet("color:#1A1C1F; font-size:13px;")
self.label.setStyleSheet(clinical_stylesheet("color:$ds_text; font-size:13px;"))
layout.addWidget(self.glyph)
layout.addWidget(self.label, 1)
self.action_button = QPushButton()
@@ -1894,7 +1908,7 @@ class LoadingOverlay(QFrame):
layout.setSpacing(8)
self.glyph = _LoadingSpinner(self)
label = QLabel(text)
label.setStyleSheet("color:#606163; font-size:13px;")
label.setStyleSheet(clinical_stylesheet("color:$ds_muted; font-size:13px;"))
layout.addWidget(self.glyph)
layout.addWidget(label)
self.hide()
@@ -1985,15 +1999,15 @@ class RecordTable(QTableWidget):
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
if column_index in danger_columns:
item.setForeground(QColor("#BE4B58"))
item.setForeground(QColor(PALETTE["danger"]))
kind = semantic.get(column_index)
if kind:
palette = {
"success": ("#287B65", "#EAF9F3"),
"warning": ("#A9691D", "#FFF5E6"),
"danger": ("#BE4B58", "#FFF1F3"),
"info": ("#4F63D9", "#EEF1FA"),
"neutral": ("#6A6B6D", "#F7F7F7"),
"success": (PALETTE["success"], PALETTE["success_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"]),
"info": (PALETTE["accent"], PALETTE["accent_soft"]),
"neutral": (PALETTE["muted"], PALETTE["surface_alt"]),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -2033,20 +2047,20 @@ class BloodTrendChart(QWidget):
plot = self.rect().adjusted(48, 24, -22, -38)
values = [value for value in (*self._fasting, *self._postprandial) if value is not None]
if not values or plot.width() <= 0 or plot.height() <= 0:
painter.setPen(QColor("#606163"))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "当前时间范围暂无血糖数据")
painter.end()
return
upper = max(12.0, max(values) * 1.15)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
for step in range(5):
y = plot.bottom() - round(plot.height() * step / 4)
painter.drawLine(plot.left(), y, plot.right(), y)
painter.setPen(QColor("#606163"))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(
4, y - 8, 40, 16, Qt.AlignmentFlag.AlignRight, f"{upper * step / 4:.0f}"
)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
count = max(1, len(self._dates) - 1)
def point(index: int, value: float) -> QPointF:
@@ -2076,7 +2090,7 @@ class BloodTrendChart(QWidget):
painter.setPen(QColor("#A9691D"))
painter.drawText(plot.left() + 100, 4, 110, 18, Qt.AlignmentFlag.AlignLeft, "● 餐后血糖")
if self._dates:
painter.setPen(QColor("#606163"))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(
plot.left(),
plot.bottom() + 8,
@@ -2279,7 +2293,7 @@ class DailyRecordPanel(QFrame):
self.todo_table.setMinimumHeight(180)
todo_layout.addWidget(self.todo_table)
self.todo_summary = QLabel("共 0 条")
self.todo_summary.setStyleSheet("color:#606163; font-size:13px;")
self.todo_summary.setStyleSheet(clinical_stylesheet("color:$ds_muted; font-size:13px;"))
todo_layout.addWidget(self.todo_summary, 0, Qt.AlignmentFlag.AlignRight)
root.addWidget(todo_card)
self.clear()
@@ -2437,8 +2451,8 @@ class DailyRecordPanel(QFrame):
for row_index, (metric, label) in enumerate(self.METRICS):
label_item = QTableWidgetItem(label)
label_item.setFont(label_font)
label_item.setForeground(QColor("#606163"))
label_item.setBackground(QColor("#F7F7F7"))
label_item.setForeground(QColor(PALETTE["muted"]))
label_item.setBackground(QColor(PALETTE["surface_alt"]))
self.matrix.setItem(row_index, 0, label_item)
for date_index, date in enumerate(dates, 1):
b = blood.get(date)
@@ -2491,11 +2505,11 @@ class DailyRecordPanel(QFrame):
item = QTableWidgetItem(value)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if high:
item.setForeground(QColor("#BE4B58"))
item.setBackground(QColor("#FFF1F3"))
item.setForeground(QColor(PALETTE["danger"]))
item.setBackground(QColor(PALETTE["danger_soft"]))
elif value.endswith("· 自录"):
item.setForeground(QColor("#1A1C1F"))
item.setBackground(QColor("#F7F7F7"))
item.setForeground(QColor(PALETTE["text"]))
item.setBackground(QColor(PALETTE["surface_alt"]))
edit_kind = ""
edit_source: Any = None
if metric in {"fasting", "postprandial", "other", "bp", "western", "insulin"}:
@@ -3022,7 +3036,7 @@ class ChatPanel(QWidget):
toolbar_layout.setContentsMargins(0, 0, 0, 0)
toolbar_layout.setSpacing(8)
archive = QLabel("仅展示服务端已归档消息(only_archived=1)")
archive.setStyleSheet("color:#606163;font-size:12px;")
archive.setStyleSheet(clinical_stylesheet("color:$ds_muted;font-size:12px;"))
toolbar_layout.addWidget(archive)
toolbar_layout.addStretch(1)
self.sync_button = QPushButton("同步最新(后台异步)")
@@ -3298,7 +3312,7 @@ class CaseGrid(QFrame):
if group_index:
divider = QFrame()
divider.setFrameShape(QFrame.Shape.HLine)
divider.setStyleSheet("border:0; border-top:1px dashed #E4E4E5;")
divider.setStyleSheet(clinical_stylesheet("border:0; border-top:1px solid $ds_line;"))
self.root.addWidget(divider)
group_title = QLabel(f"● {group_name}")
group_title.setProperty("diagnosisCaseGroup", True)
@@ -3456,11 +3470,11 @@ def readonly_card(title: str, object_name: str, body: QWidget) -> QFrame:
def set_tag_item(item: QTableWidgetItem, kind: str) -> None:
palette = {
"success": ("#287B65", "#EAF9F3"),
"warning": ("#A9691D", "#FFF5E6"),
"danger": ("#BE4B58", "#FFF1F3"),
"info": ("#4F63D9", "#EEF1FA"),
"neutral": ("#6A6B6D", "#F7F7F7"),
"success": (PALETTE["success"], PALETTE["success_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"]),
"info": (PALETTE["accent"], PALETTE["accent_soft"]),
"neutral": (PALETTE["muted"], PALETTE["surface_alt"]),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -67,21 +67,22 @@ from ..core.appointment_modes import (
can_appointment_video,
)
from . import icons
from .design_system import PALETTE, clinical_stylesheet
from .reception_style import TECH_BLUE, body_family, heading_family
from .widgets import display_text, first_value, gender_text, get_value
PRIMARY = QColor("#4F63D9")
TEXT = QColor("#1A1C1F")
SECONDARY = QColor("#606163")
PLACEHOLDER = QColor("#8E8F90")
PRIMARY = QColor(PALETTE["accent"])
TEXT = QColor(PALETTE["text"])
SECONDARY = QColor(PALETTE["muted"])
PLACEHOLDER = QColor(PALETTE["disabled"])
_INVALID_INDEX = QModelIndex()
# The last two columns form the compact frozen pane. Video status and ending
# time occupy separate lines; actions keep two inline links plus 更多, with
# the remaining actions in its menu so clinical columns retain more space.
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
_BLUE_WARNING = QColor("#A9691D")
_BLUE_DANGER = QColor("#BE4B58")
_BLUE_WARNING = QColor(PALETTE["warning"])
_BLUE_DANGER = QColor(PALETTE["danger"])
def _blue_font(pixels: int = 13, *, medium: bool = False) -> QFont:
@@ -248,14 +249,14 @@ class _DiagnosisActionMenu(QMenu):
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.fillRect(rect, QColor("#FFFFFF"))
painter.fillRect(rect, QColor(PALETTE["surface"]))
item_rect = rect
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#F7F7F7") if self.activeAction() is action else QColor("#FFFFFF"))
painter.setBrush(QColor(PALETTE["surface_alt"]) if self.activeAction() is action else QColor(PALETTE["surface"]))
painter.drawRoundedRect(item_rect, 3, 3)
icon_rect = QRect(item_rect.left() + 8, item_rect.center().y() - 9, 18, 18)
action.icon().paint(painter, icon_rect)
painter.setPen(QColor("#BE4B58" if action.isEnabled() else "#8E8F90"))
painter.setPen(QColor(PALETTE["danger"] if action.isEnabled() else PALETTE["disabled"]))
painter.drawText(
item_rect.adjusted(34, 0, -10, 0),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
@@ -823,6 +824,8 @@ class DiagnosisTableModel(QAbstractTableModel):
default="",
)
return f"最近渠道:{channel}" if channel else ""
if column == 6:
return DiagnosisTableModel._display(row, column)
if column == 7:
return display_text(first_value(row, "assistant_name"), "")
if column == 9:
@@ -849,7 +852,7 @@ class DiagnosisHeader(QHeaderView):
def paintSection(self, painter: QPainter, rect: QRect, logical_index: int) -> None: # noqa: N802
if self.tech_blue:
painter.save()
painter.fillRect(rect, QColor("#F5F8FD"))
painter.fillRect(rect, QColor(PALETTE["surface_alt"]))
painter.setPen(QPen(QColor(TECH_BLUE["line"]), 1))
painter.drawLine(rect.bottomLeft(), rect.bottomRight())
painter.setFont(_blue_font())
@@ -870,7 +873,7 @@ class DiagnosisHeader(QHeaderView):
painter.save()
painter.setPen(Qt.PenStyle.NoPen)
primary = QColor(TECH_BLUE["accent"]) if self.tech_blue else PRIMARY
secondary = QColor(TECH_BLUE["muted"]) if self.tech_blue else QColor("#606163")
secondary = QColor(TECH_BLUE["muted"]) if self.tech_blue else QColor(PALETTE["muted"])
painter.setBrush(primary if direction == "asc" else secondary)
painter.drawPolygon(
QPolygon(
@@ -953,7 +956,7 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
option.rect,
"已确认" if _confirmed(record) else "未确认",
QColor("#287B65") if _confirmed(record) else QColor("#A9691D"),
QColor(PALETTE["success"]) if _confirmed(record) else QColor(PALETTE["warning"]),
)
elif index.column() == 6:
self._paint_followup(painter, option.rect, record)
@@ -1023,16 +1026,16 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
) -> None:
hovered = model.hover_row == row
base = (
QColor("#EEF1FA")
QColor(PALETTE["accent_soft"])
if selected
else QColor("#F7F7F7" if hovered else "#F7F7F7" if row % 2 else "#FFFFFF")
else QColor(PALETTE["surface_alt"] if hovered or row % 2 else PALETTE["surface"])
)
special = not _confirmed(record) or not _has_appointment(record)
painter.fillRect(rect, base)
if special and column == 0:
stripe = QColor("#A9691D") if not _confirmed(record) else QColor("#4F63D9")
stripe = QColor(PALETTE["warning"]) if not _confirmed(record) else QColor(PALETTE["accent"])
painter.fillRect(QRect(rect.left(), rect.top(), 3, rect.height()), stripe)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
painter.drawLine(QLine(rect.bottomLeft(), rect.bottomRight()))
@staticmethod
@@ -1070,8 +1073,8 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(left, content.center().y() - 10, 31, 20),
"NEW",
QColor("#BE4B58"),
QColor("#FFFFFF"),
QColor(PALETTE["danger"]),
QColor(PALETTE["surface"]),
10,
)
@@ -1081,17 +1084,17 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
self._draw_text(painter, content, "未挂号", PLACEHOLDER, 13)
return
rows = _appointments(record)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.setBrush(QColor("#FFFFFF"))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
painter.setBrush(QColor(PALETTE["surface"]))
painter.drawRoundedRect(content, 6, 6)
y = content.top() + 4
for position, appointment in enumerate(rows or [{}]):
status = _as_int(first_value(appointment, "status", "appointment_status"), -1)
label, fg, bg = {
1: ("已预约", QColor("#4F63D9"), QColor("#EEF1FA")),
3: ("已完成", QColor("#287B65"), QColor("#E8F6F1")),
4: ("已过号", QColor("#A9691D"), QColor("#FFF4D8")),
}.get(status, ("未知", SECONDARY, QColor("#F7F7F7")))
1: ("已预约", QColor(PALETTE["accent"]), QColor(PALETTE["accent_soft"])),
3: ("已完成", QColor(PALETTE["success"]), QColor(PALETTE["success_soft"])),
4: ("已过号", QColor(PALETTE["warning"]), QColor(PALETTE["warning_soft"])),
}.get(status, ("未知", SECONDARY, QColor(PALETTE["surface_alt"])))
self._draw_tag(painter, QRect(content.left() + 6, y, 46, 20), label, bg, fg, 12)
doctor = display_text(first_value(appointment, "doctor_name"), "-")
time_text = display_text(first_value(appointment, "time_text", "appointment_time"), "-")
@@ -1106,12 +1109,12 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(content.left() + 6, y + 19, max(20, content.width() - 12), 17),
time_text,
QColor("#A9691D") if status == 4 else SECONDARY,
QColor(PALETTE["warning"]) if status == 4 else SECONDARY,
13,
)
y += 42
if position < len(rows) - 1:
painter.setPen(QPen(QColor("#EDEDEE"), 1, Qt.PenStyle.DashLine))
painter.setPen(QPen(QColor(PALETTE["line"]), 1, Qt.PenStyle.DashLine))
painter.drawLine(content.left() + 6, y - 2, content.right() - 6, y - 2)
channel = first_value(
record,
@@ -1153,7 +1156,7 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(content.left(), content.top() + 38, 72, 18),
"处方已作废",
QColor("#F7F7F7"),
QColor(PALETTE["surface_alt"]),
SECONDARY,
11,
outlined=True,
@@ -1166,11 +1169,11 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
else:
days = _as_int(value)
color = (
QColor("#287B65")
QColor(PALETTE["success"])
if days <= 2
else QColor("#A9691D")
else QColor(PALETTE["warning"])
if days <= 6
else QColor("#BE4B58")
else QColor(PALETTE["danger"])
)
text, bold = str(days), days > 6
self._draw_text(
@@ -1320,6 +1323,8 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
painter, content, "已确认" if _confirmed(record) else "未确认", pixels=13,
color="text" if _confirmed(record) else _BLUE_WARNING.name(),
)
elif column == 6:
self._followup(painter, content, display_text(index.data(), "—"))
elif column == 8:
prescribed = _as_bool(first_value(record, "has_prescription"))
self._text(painter, content, "已开方" if prescribed else "未开方",
@@ -1348,6 +1353,28 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
medium=column == 2)
painter.restore()
@staticmethod
def _followup(painter: QPainter, rect: QRect, text: str) -> None:
"""Break at the date/time boundary, never in the middle of a number.
Keep the established row metrics. Long time ranges or doctor names
elide within their line; the model tooltip retains the complete text.
"""
painter.setFont(_blue_font(13))
painter.setPen(QColor(TECH_BLUE["muted"]))
metrics = painter.fontMetrics()
lines = text.splitlines()
if lines and metrics.horizontalAdvance(lines[0]) > rect.width():
lines[:1] = lines[0].split(maxsplit=1)
line_height = metrics.height()
top = rect.top() + max(0, (rect.height() - line_height * len(lines)) // 2)
for offset, line in enumerate(lines):
painter.drawText(
QRect(rect.left(), top + offset * line_height, rect.width(), line_height),
int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextSingleLine),
metrics.elidedText(line, Qt.TextElideMode.ElideRight, rect.width()),
)
def _appointments(self, painter: QPainter, rect: QRect, record: Any) -> None:
content = rect.adjusted(8, 0, -8, 0)
if not _has_appointment(record):
@@ -1688,7 +1715,9 @@ class DiagnosisTableHost(QFrame):
self.main.horizontalHeader().sectionResized.connect(self._columns_resized)
self._fixed_render_signature: tuple[Any, ...] | None = None
if tech_blue:
self.setStyleSheet(_TECH_BLUE_TABLE_QSS.replace("BODY_FAMILY", body_family()))
self.setStyleSheet(clinical_stylesheet(
_TECH_BLUE_TABLE_QSS.replace("BODY_FAMILY", body_family())
))
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
@@ -1904,6 +1933,7 @@ class DiagnosisTableHost(QFrame):
.replace("#8E8F90", TECH_BLUE["muted"])
+ f'QMenu {{ font-family: "{body_family()}"; font-weight: 400; }}'
)
menu.setStyleSheet(clinical_stylesheet(menu.styleSheet()))
for label, action, _semantic in demoted_actions:
_add_menu_action(
menu,
@@ -2630,19 +2660,19 @@ DIAGNOSIS_INDEX_QSS += """
#DiagnosisIndex QFrame#DiagnosisFilterCard,
#DiagnosisIndex QFrame#DiagnosisListCard {
border: 1px solid #EDEDEE;
border-radius: 13px;
border-radius: $ds_radius_panel;
}
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox,
#DiagnosisIndex QPushButton {
border-radius: 8px;
border-radius: $ds_radius_control;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"] { border-radius: 8px; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] { border-radius: $ds_radius_small; }
#DiagnosisIndex QFrame#DiagnosisListToolbar {
border-top-left-radius: 13px;
border-top-right-radius: 13px;
border-top-left-radius: $ds_radius_panel;
border-top-right-radius: $ds_radius_panel;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"],
@@ -2650,7 +2680,27 @@ DIAGNOSIS_INDEX_QSS += """
#DiagnosisIndex QPushButton[variant="success"] {
color: #FFFFFF;
}
#DiagnosisIndex QLineEdit:focus,
#DiagnosisIndex QComboBox:focus,
#DiagnosisIndex QDateEdit:focus,
#DiagnosisIndex QSpinBox:focus,
#DiagnosisIndex QPushButton:focus {
border-color: $ds_focus;
}
#DiagnosisIndex QPushButton[variant="primary"]:pressed {
color: $ds_surface;
background-color: $ds_accent_pressed;
border-color: $ds_accent_pressed;
}
#DiagnosisIndex QPushButton:disabled,
#DiagnosisIndex QPushButton[variant="primary"]:disabled,
#DiagnosisIndex QPushButton[variant="success"]:disabled {
color: $ds_disabled;
background-color: $ds_disabled_surface;
border-color: $ds_line;
}
"""
DIAGNOSIS_INDEX_QSS = clinical_stylesheet(DIAGNOSIS_INDEX_QSS)
__all__ = [
@@ -32,6 +32,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from .design_system import clinical_stylesheet
try:
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtMultimediaWidgets import QVideoWidget
@@ -223,7 +225,7 @@ class InlineRecordingPlayer(QWidget):
self.setMinimumHeight(158)
self.setMaximumHeight(180)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setStyleSheet(_INLINE_PLAYER_QSS)
self.setStyleSheet(clinical_stylesheet(_INLINE_PLAYER_QSS))
root = QVBoxLayout(self)
root.setContentsMargins(1, 1, 1, 5)
@@ -466,11 +468,11 @@ class RecordingPlaybackCell(QWidget):
separator = QFrame()
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("color:#EDEDEE;")
separator.setStyleSheet(clinical_stylesheet("color:$ds_line;"))
layout.addWidget(separator)
label = QLabel("备用地址")
label.setObjectName("DiagnosisRecordingAlternateLabel")
label.setStyleSheet("color:#606163; font-size:12px;")
label.setStyleSheet(clinical_stylesheet("color:$ds_muted; font-size:12px;"))
layout.addWidget(label)
links = QHBoxLayout()
links.setContentsMargins(0, 0, 0, 0)
@@ -490,7 +492,7 @@ class RecordingPlaybackCell(QWidget):
layout.addLayout(links)
self.link_status = QLabel("")
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
self.link_status.setStyleSheet("color:#BE4B58; font-size:11px;")
self.link_status.setStyleSheet(clinical_stylesheet("color:$ds_danger; font-size:11px;"))
self.link_status.setWordWrap(True)
self.link_status.hide()
layout.addWidget(self.link_status)
@@ -807,7 +809,7 @@ class ImagePreviewDialog(QDialog):
self.setObjectName("DiagnosisImagePreview")
self.setWindowTitle(title)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setStyleSheet(_IMAGE_PREVIEW_QSS)
self.setStyleSheet(clinical_stylesheet(_IMAGE_PREVIEW_QSS))
self.resize(880, 660)
root = QVBoxLayout(self)
@@ -62,6 +62,7 @@ from shiboken6 import isValid
from .. import icons
from ..chat_items import parse_chat_item as _parse_chat_item
from ..design_system import clinical_stylesheet
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
from ..diagnosis_index_widgets import FlowWidget
from ..diagnosis_media import (
@@ -94,6 +95,9 @@ from ..widgets import (
show_toast,
)
from .ai_consult_charts import GlucoseChartData
from .ai_consult_drug_names import TCM as DRUG_TCM
from .ai_consult_drug_names import WESTERN as DRUG_WESTERN
from .ai_consult_drug_names import summarize_drugs
from .ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
@@ -101,7 +105,6 @@ from .ai_consult_metric_state import (
combined_metric_state,
risk_color,
)
from .ai_consult_drug_names import TCM as DRUG_TCM, WESTERN as DRUG_WESTERN, summarize_drugs
from .ai_consult_metric_text import MetricTextState, metric_text_context, metric_text_html
from .ai_consult_report_visuals import (
ReportVisual,
@@ -114,10 +117,10 @@ from .prescription_ai import (
diagnosis_ai_task,
)
AI_CONSULT_QSS = """
AI_CONSULT_QSS = clinical_stylesheet("""
QDialog#AiConsultDialog {
color: #1A1C1F;
background-color: #F7F7F7; font-size: 14px;
color: $ds_text;
background-color: $ds_canvas; font-size: 14px;
}
QDialog#AiConsultDialog QScrollArea,
QDialog#AiConsultDialog QScrollArea > QWidget,
@@ -149,7 +152,7 @@ QDialog#AiConsultDialog QScrollBar::sub-page:vertical {
background-color: transparent;
}
QDialog#AiConsultDialog QFrame#AiConsultShell {
background-color: #F7F7F7;
background-color: $ds_canvas;
border: 0;
}
QDialog#AiConsultDialog QLabel#AiConsultCrumb {
@@ -171,7 +174,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultBack {
color: #1A1C1F;
background-color: #F0F0F0;
border: 0;
border-radius: 8px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#AiConsultDialog QPushButton#AiConsultBack:hover { background-color: #EEF1FA; }
@@ -183,7 +186,7 @@ QDialog#AiConsultDialog QFrame#AiConsultHero {
QDialog#AiConsultDialog QLabel#AiConsultAvatar {
color: #FFFFFF;
background-color: #4F63D9;
border-radius: 22px;
border-radius: $ds_radius_control;
font-size: 16px;
font-weight: 600;
}
@@ -201,7 +204,7 @@ QDialog#AiConsultDialog QLabel#AiConsultMeta {
QDialog#AiConsultDialog QLabel#AiConsultChip {
color: #6A6B6D;
background-color: #F7F7F7;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 4px 8px;
font-size: 13px;
}
@@ -240,7 +243,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultChatFilter {
color: #4F63D9;
background-color: #FFFFFF;
border: 1px solid #E1E5F4;
border-radius: 8px;
border-radius: $ds_radius_control;
font-size: 13px;
}
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter:hover {
@@ -275,7 +278,7 @@ QDialog#AiConsultDialog QLabel#AiConsultRole {
max-width: 30px;
min-height: 30px;
max-height: 30px;
border-radius: 15px;
border-radius: $ds_radius_small;
color: #FFFFFF;
font-size: 13px;
font-weight: 500;
@@ -331,7 +334,7 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalMeta {
QDialog#AiConsultDialog QLabel#AiConsultClinicalBadge {
color: #4F63D9;
background-color: #EEF1FA;
border-radius: 7px;
border-radius: $ds_radius_small;
padding: 4px 8px;
font-size: 13px;
font-weight: 500;
@@ -346,10 +349,10 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalSectionTitle {
color: #1A1C1F;
background-color: transparent;
font-size: 14px;
font-weight: 500;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultClinicalSummaryBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
font-weight: 400;
@@ -411,7 +414,7 @@ QDialog#AiConsultDialog QLabel#AiConsultHypothesisTag {
QDialog#AiConsultDialog QLabel#AiConsultClinicalBody,
QDialog#AiConsultDialog QLabel#AiConsultWorkflowBody,
QDialog#AiConsultDialog QLabel#AiConsultRiskBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
font-weight: 400;
@@ -456,23 +459,23 @@ QDialog#AiConsultDialog QLabel#AiConsultRiskTitle {
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultRiskBody {
color: #1A1C1F; font-size: 14px;
color: $ds_text; font-size: 14px;
}
QDialog#AiConsultDialog QLabel#AiConsultRiskTitle {
color: #BE4B58;
}
QDialog#AiConsultDialog QLabel#AiConsultRiskBody {
color: #1A1C1F; font-size: 14px;
color: $ds_text; font-size: 14px;
}
/* 结构化报告:正文按阅读节奏排版,不再每句话套一个方框。 */
QDialog#AiConsultDialog QFrame#AiConsultReportIntro {
background-color: #F7F7F7;
background-color: $ds_accent_soft;
border: 1px solid #EDEDEE;
border-left: 3px solid #4F63D9;
border-radius: 9px;
border-radius: $ds_radius_control;
}
QDialog#AiConsultDialog QLabel#AiConsultReportIntroBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
font-weight: 400;
@@ -497,7 +500,7 @@ QDialog#AiConsultDialog QLabel#AiConsultReportSectionTitle {
color: #1A1C1F;
background-color: transparent;
font-size: 15px;
font-weight: 500;
font-weight: 600;
}
QDialog#AiConsultDialog QFrame#AiConsultReportRule {
background-color: #EDEDEE;
@@ -518,7 +521,7 @@ QDialog#AiConsultDialog QLabel#AiConsultReportBullet {
font-weight: 500;
}
QDialog#AiConsultDialog QLabel#AiConsultReportBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
font-weight: 400;
@@ -541,18 +544,18 @@ QDialog#AiConsultDialog QLabel#AiConsultClinicalDisclaimer {
font-weight: 400;
}
QDialog#AiConsultDialog QFrame#AiConsultReportProvenance {
background-color: #F7F8FC;
border: 1px solid #E4E8F2;
border-radius: 9px;
background-color: $ds_surface_alt;
border: 1px solid $ds_line;
border-radius: $ds_radius_control;
}
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceTitle {
color: #27336B;
color: $ds_heading;
background-color: transparent;
font-size: 12px;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultReportProvenanceLine {
color: #555B68;
color: $ds_muted;
background-color: transparent;
font-size: 12px;
}
@@ -647,7 +650,7 @@ QDialog#AiConsultDialog QLabel#AiConsultSection {
font-weight: 500;
}
QDialog#AiConsultDialog QLabel#AiConsultBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
line-height: 1.6;
@@ -681,7 +684,7 @@ QDialog#AiConsultDialog QLabel#AiConsultInsightPlaceholder {
QDialog#AiConsultDialog QLabel#AiConsultTag[level="high"] {
color: #BE4B58;
background-color: #FFF1F3;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
font-weight: 600;
@@ -689,7 +692,7 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="high"] {
QDialog#AiConsultDialog QLabel#AiConsultTag[level="medium"] {
color: #A9691D;
background-color: #FFF5E6;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
font-weight: 500;
@@ -697,7 +700,7 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="medium"] {
QDialog#AiConsultDialog QLabel#AiConsultTag[level="low"] {
color: #287B65;
background-color: #EAF9F3;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
font-weight: 500;
@@ -705,7 +708,7 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="low"] {
QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
color: #BE4B58;
background-color: #FFF1F3;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
font-weight: 600;
@@ -713,7 +716,7 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
QDialog#AiConsultDialog QLabel#AiConsultTag[level="unknown"] {
color: #6A6B6D;
background-color: #F0F0F0;
border-radius: 8px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
}
@@ -739,7 +742,7 @@ QDialog#AiConsultDialog QLineEdit#AiConsultInput {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 12px;
border-radius: $ds_radius_control;
font-size: 14px;
}
QDialog#AiConsultDialog QLineEdit#AiConsultInput:focus {
@@ -763,7 +766,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultIconButton {
max-height: 36px;
padding: 0;
border: 0;
border-radius: 18px;
border-radius: $ds_radius_control;
background-color: #F7F7F7;
}
QDialog#AiConsultDialog QPushButton#AiConsultIconButton:hover { background-color: #EEF1FA; }
@@ -774,7 +777,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultSend {
max-height: 40px;
padding: 0;
border: 0;
border-radius: 20px;
border-radius: $ds_radius_control;
background-color: #4F63D9;
}
QDialog#AiConsultDialog QPushButton#AiConsultSend:hover { background-color: #4156C4; }
@@ -783,7 +786,7 @@ QDialog#AiConsultDialog QLabel#AiConsultSideTitle {
color: #1A1C1F;
background-color: transparent;
font-size: 16px;
font-weight: 500;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultSideSubtitle {
color: #6A6B6D;
@@ -871,7 +874,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultSideClose {
max-height: 28px;
padding: 0;
border: 0;
border-radius: 8px;
border-radius: $ds_radius_control;
color: #606163;
background-color: transparent;
font-size: 16px;
@@ -917,10 +920,10 @@ QDialog#AiConsultDialog QLabel#AiConsultRecordTitle {
color: #1A1C1F;
background-color: transparent;
font-size: 13px;
font-weight: 500;
font-weight: 600;
}
QDialog#AiConsultDialog QLabel#AiConsultRecordBody {
color: #1A1C1F;
color: $ds_text;
background-color: transparent;
font-size: 14px;
}
@@ -951,7 +954,7 @@ QDialog#AiConsultDialog QPushButton#AiConsultMediaOpen {
color: #4F63D9;
background-color: #EEF1FA;
border: 0;
border-radius: 8px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#AiConsultDialog QPushButton#AiConsultRecordRetry:hover,
@@ -1000,10 +1003,11 @@ QDialog#AiConsultDialog QLabel#AiConsultDataLabel {
font-size: 13px;
}
QDialog#AiConsultDialog QLabel#AiConsultDataValue {
color: #1A1C1F;
color: $ds_text;
font-size: 13px;
}
"""
QDialog#AiConsultDialog QPushButton:focus { border-color: $ds_focus; }
""")
QUICK_PROMPTS: tuple[tuple[str, str], ...] = (
("总结当前病情", "请根据当前病历总结患者病情、证候与风险。"),
@@ -1053,8 +1057,8 @@ _DOCTOR_DOCUMENT_CSS = (
# 模型回复几乎都是“分节标题 + 两层要点”的长文。早期样式里标题只比正文大 1px、
# 段落与列表项的间距也几乎相同,整段读起来就是一堵字墙。这里把层级、行距和分节
# 留白拉开:标题明显更大更重,顶层小节之间留出整行空白,嵌套要点单独收紧缩进。
_AI_DOCUMENT_CSS = (
"body { color:#1A1C1F; font-size:14px; line-height:200%; } "
_AI_DOCUMENT_CSS = clinical_stylesheet(
"body { color:$ds_text; font-size:14px; line-height:200%; } "
"h1,h2 { color:#1A1C1F; font-size:17px; margin:22px 0 10px; line-height:160%; } "
"h3,h4 { color:#1A1C1F; font-size:15px; margin:18px 0 8px; line-height:160%; } "
"p { margin:10px 0; line-height:200%; } "
@@ -2219,15 +2223,12 @@ def _escape_html(value: str) -> str:
)
class _FlowRow(QWidget):
class _FlowRow(FlowWidget):
"""Keep patient metadata readable when the details pane is narrow."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(8)
self._layout.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
super().__init__(parent, horizontal_spacing=8, vertical_spacing=6)
self._layout = self.flow
def add(self, widget: QWidget) -> None:
self._layout.addWidget(widget)
@@ -2810,7 +2811,7 @@ class _DrugSummaryStrip(QFrame):
layout.addLayout(row)
@staticmethod
def for_text(text: str, parent: QWidget | None = None) -> "_DrugSummaryStrip | None":
def for_text(text: str, parent: QWidget | None = None) -> _DrugSummaryStrip | None:
summary = summarize_drugs(text)
return _DrugSummaryStrip(summary, parent) if any(summary.values()) else None
@@ -3454,11 +3455,11 @@ class _ChatBubble(QWidget):
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
_style_surface(avatar)
if role == "doctor":
avatar.setStyleSheet("background-color:#4F63D9; color:#FFFFFF; border-radius:15px;")
avatar.setStyleSheet(clinical_stylesheet("background-color:#4F63D9; color:#FFFFFF; border-radius:6px;"))
elif role == "ai":
avatar.setStyleSheet("background-color:#EEF1FA; color:#4F63D9; border-radius:15px;")
avatar.setStyleSheet(clinical_stylesheet("background-color:#EEF1FA; color:#4F63D9; border-radius:6px;"))
else:
avatar.setStyleSheet("background-color:#6A6B6D; color:#FFFFFF; border-radius:15px;")
avatar.setStyleSheet(clinical_stylesheet("background-color:#6A6B6D; color:#FFFFFF; border-radius:6px;"))
bubble = QFrame()
_style_surface(bubble)
if role == "doctor":
@@ -3629,10 +3630,10 @@ class _ChatBubble(QWidget):
toggle.setCursor(Qt.CursorShape.PointingHandCursor)
toggle.setAccessibleName("展开资料提交回执")
toggle.setStyleSheet(
"QPushButton {color:#65718A;font-size:12px;text-align:left;"
clinical_stylesheet("QPushButton {color:$ds_muted;font-size:12px;text-align:left;"
"background:transparent;border:0;padding:4px 0;}"
"QPushButton:hover {color:#4F63D9;}"
"QPushButton:focus {border-bottom:1px solid #4F63D9;}"
"QPushButton:focus {border-bottom:1px solid #4F63D9;}")
)
layout.addWidget(toggle)
label = QLabel(receipt)
@@ -3643,7 +3644,7 @@ class _ChatBubble(QWidget):
Qt.TextInteractionFlag.TextSelectableByMouse
| Qt.TextInteractionFlag.TextSelectableByKeyboard
)
label.setStyleSheet("color:#65718A;font-size:12px;")
label.setStyleSheet(clinical_stylesheet("color:$ds_muted;font-size:12px;"))
label.hide()
layout.addWidget(label)
self._inner.insertWidget(0, receipt)
@@ -3688,7 +3689,7 @@ class _ChatBubble(QWidget):
label.setTextFormat(Qt.TextFormat.PlainText)
label.setWordWrap(True)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
label.setStyleSheet("color:#BE4B58;background:#FEF3F2;border-radius:6px;padding:8px;")
label.setStyleSheet(clinical_stylesheet("color:#BE4B58;background:#FEF3F2;border-radius:6px;padding:8px;"))
self._inner.insertWidget(self._inner.indexOf(self.body), label)
self._stream_failure_label = label
label.setText(f"本次回复未完成\n{text}")
@@ -4949,8 +4950,9 @@ class AiConsultDialog(QDialog):
button.setObjectName("AiConsultChipButton")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
task_key = QUICK_PROMPT_TASKS.get(label, "")
button.clicked.connect(
lambda _checked=False, text=prompt, task=QUICK_PROMPT_TASKS.get(label, ""):
lambda _checked=False, text=prompt, task=task_key:
self._ask(text, replace_active=True, task=task)
)
chips.addWidget(button, 1)
@@ -43,6 +43,7 @@ from doctor_workstation.services.app_update import (
validate_installer_download_policy,
validate_windows_installer,
)
from doctor_workstation.ui.design_system import clinical_stylesheet
from doctor_workstation.ui.widgets import friendly_error, show_toast
LOGGER = logging.getLogger(__name__)
@@ -129,11 +130,11 @@ class AppUpdateDialog(QDialog):
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
self.badge.setObjectName("UpdateBadge")
self.badge.setStyleSheet(
self.badge.setStyleSheet(clinical_stylesheet(
"color:#A9691D;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
if offer.force
else "color:#4F63D9;background:#EEF1FA;border-radius:8px;padding:4px 10px;font-weight:600;"
)
))
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
self.headline = QLabel(title or "医生工作站有新版本")
@@ -146,7 +147,7 @@ class AppUpdateDialog(QDialog):
latest = offer.latest_version or "新版本"
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
self.version_label.setObjectName("UpdateVersionLabel")
self.version_label.setStyleSheet("color:#606163;")
self.version_label.setStyleSheet(clinical_stylesheet("color:$ds_muted;"))
root.addWidget(self.version_label)
self.notes = QTextEdit()
@@ -160,7 +161,7 @@ class AppUpdateDialog(QDialog):
self.status_label = QLabel("")
self.status_label.setObjectName("UpdateStatus")
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet("color:#1A1C1F;")
self.status_label.setStyleSheet(clinical_stylesheet("color:$ds_heading;"))
self.status_label.hide()
root.addWidget(self.status_label)
@@ -175,7 +176,7 @@ class AppUpdateDialog(QDialog):
self.progress_text = QLabel("")
self.progress_text.setObjectName("UpdateProgressText")
self.progress_text.setStyleSheet("color:#606163;font-size:12px;")
self.progress_text.setStyleSheet(clinical_stylesheet("color:$ds_muted;font-size:12px;"))
self.progress_text.hide()
root.addWidget(self.progress_text)
@@ -225,7 +226,7 @@ class AppUpdateDialog(QDialog):
def show_download_progress(self, received: int, total: int) -> None:
self.progress.show()
self.progress_text.show()
self.status_label.setStyleSheet("color:#1A1C1F;")
self.status_label.setStyleSheet(clinical_stylesheet("color:$ds_heading;"))
self.status_label.setText("正在下载安装包…")
self.status_label.show()
if total > 0:
@@ -237,7 +238,7 @@ class AppUpdateDialog(QDialog):
self.progress_text.setText(_format_bytes(received))
def show_status(self, message: str, *, determinate: bool = False) -> None:
self.status_label.setStyleSheet("color:#1A1C1F;")
self.status_label.setStyleSheet(clinical_stylesheet("color:$ds_heading;"))
self.status_label.setText(message)
self.status_label.show()
self.progress.show()
@@ -251,7 +252,7 @@ class AppUpdateDialog(QDialog):
def show_error(self, message: str) -> None:
self._busy = False
self.status_label.setText(message)
self.status_label.setStyleSheet("color:#BE4B58;")
self.status_label.setStyleSheet(clinical_stylesheet("color:$ds_danger;"))
self.status_label.show()
self.progress.hide()
self.progress_text.hide()
@@ -48,6 +48,8 @@ from PySide6.QtWidgets import (
)
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from ...core.transcript_text import simplified_transcript
from ..design_system import clinical_stylesheet
from ..diagnosis_drawer import (
DIAGNOSIS_QSS,
CaseGrid,
@@ -97,6 +99,7 @@ from .issued_prescription_ai import can_open_issued_ai, present_issued_prescript
from .local_audio_queue import LocalAudioQueueDialog
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
class _UploadProgressRelay(QObject):
"""把后台线程中的分片上传进度转回界面线程(信号跨线程自动排队)。"""
@@ -1541,16 +1544,16 @@ class DiagnosisDialog(QDialog):
"""上传本地回放时的进度条:按分片实时更新,医生能看到上传在进行、进行到哪。"""
frame = QFrame()
frame.setObjectName("DiagnosisVideoUploadProgress")
frame.setStyleSheet(
frame.setStyleSheet(clinical_stylesheet(
"QFrame#DiagnosisVideoUploadProgress { background-color: #EEF1FA;"
" border: 1px solid #C9D1F3; border-radius: 8px; }"
"QFrame#DiagnosisVideoUploadProgress QLabel { color: #3347B8; background: transparent;"
" border: 1px solid $ds_line_strong; border-radius: $ds_radius_control; }"
"QFrame#DiagnosisVideoUploadProgress QLabel { color: $ds_accent_hover; background: transparent;"
" font-size: 13px; font-weight: 500; }"
"QFrame#DiagnosisVideoUploadProgress QProgressBar { background-color: #FFFFFF;"
" border: 0; border-radius: 4px; min-height: 8px; max-height: 8px; }"
"QFrame#DiagnosisVideoUploadProgress QProgressBar::chunk { background-color: #4F63D9;"
" border-radius: 4px; }"
)
))
row = QVBoxLayout(frame)
row.setContentsMargins(12, 8, 12, 10)
row.setSpacing(6)
@@ -4031,7 +4034,8 @@ class DiagnosisDialog(QDialog):
content = QPlainTextEdit(dialog)
content.setObjectName("DiagnosisCallTranscriptText")
content.setReadOnly(True)
content.setPlainText(transcript)
# Historical records keep their stored source; only the view is converted.
content.setPlainText(simplified_transcript(transcript))
layout.addWidget(content, 1)
close_button = QPushButton("关闭")
close_button.setProperty("variant", "primary")
@@ -21,6 +21,8 @@ from typing import Any
from PySide6.QtGui import QFont
from ..design_system import PALETTE
DARK: dict[str, str] = {
# ground and cards
"canvas": "#080D18",
@@ -62,23 +64,23 @@ DARK: dict[str, str] = {
}
LIGHT: dict[str, str] = {
"canvas": "#F1F5FC",
"canvas_soft": "#E7EEFA",
"surface": "#FFFFFF",
"surface_2": "#F5F8FE",
"raised": "#E8EFFA",
"line": "#D5E1F2",
"line_soft": "#E3EBF8",
"heading": "#0A182E",
"text": "#2A3C56",
"muted": "#54697F",
"faint": "#7A8DA3",
"selected_text": "#0A182E",
"accent": "#1A5FD0",
"accent_text": "#124DAE",
"accent_pressed": "#0E3F90",
"selection": "#E8EFFA",
"selection_line": "#AEC7EE",
"canvas": PALETTE["canvas"],
"canvas_soft": PALETTE["surface_alt"],
"surface": PALETTE["surface"],
"surface_2": PALETTE["surface_alt"],
"raised": PALETTE["sidebar"],
"line": PALETTE["line_strong"],
"line_soft": PALETTE["line"],
"heading": PALETTE["heading"],
"text": PALETTE["text"],
"muted": PALETTE["muted"],
"faint": PALETTE["disabled"],
"selected_text": PALETTE["heading"],
"accent": PALETTE["accent"],
"accent_text": PALETTE["accent_hover"],
"accent_pressed": PALETTE["accent_pressed"],
"selection": PALETTE["accent_soft"],
"selection_line": PALETTE["focus"],
"qwen": "#0C89AC",
"qwen_text": "#086C88",
"qwen_dim": "#E2F5FA",
@@ -29,6 +29,7 @@ from ...services.local_audio_queue import (
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..design_system import clinical_stylesheet
from ..widgets import run_async
_STATUS_LABELS = {
@@ -174,7 +175,7 @@ class LocalAudioQueueDialog(QDialog):
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self.setStyleSheet(clinical_stylesheet(_LOCAL_AUDIO_QSS))
self._build_ui()
self.poll_timer = QTimer(self)
@@ -28,8 +28,8 @@ from PySide6.QtCore import (
QDate,
QEvent,
QIODevice,
QModelIndex,
QMarginsF,
QModelIndex,
QObject,
QPoint,
QRectF,
@@ -98,6 +98,7 @@ from PySide6.QtWidgets import (
)
from .. import icons
from ..design_system import clinical_stylesheet
from ..diagnosis_terms import shared_terms
from ..infinite_list import InfiniteList
from ..theme import main_window_target_rect
@@ -116,9 +117,9 @@ from ..widgets import (
)
from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog
PRESCRIPTION_DRAWER_QSS = r"""
PRESCRIPTION_DRAWER_QSS = clinical_stylesheet(r"""
QFrame#PrescriptionDrawerSurface {
color: #1A1C1F;
color: $ds_text;
background-color: #FFFFFF;
border-left: 1px solid #EDEDEE;
font-size: 14px;
@@ -145,7 +146,7 @@ QLabel#PrescriptionLockTag {
color: #BE4B58;
background-color: #FEF0F0;
border: 1px solid #FDE2E2;
border-radius: 4px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
}
@@ -175,7 +176,7 @@ QLabel#PrescriptionDrawerMode {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #EDEDEE;
border-radius: 5px;
border-radius: $ds_radius_small;
padding: 4px 9px;
font-size: 13px;
font-weight: 500;
@@ -189,7 +190,7 @@ QPushButton#PrescriptionDrawerClose {
color: #606163;
background-color: transparent;
border: 0;
border-radius: 7px;
border-radius: $ds_radius_control;
font-size: 22px;
font-weight: 400;
}
@@ -208,12 +209,12 @@ QWidget#PrescriptionDrawerContent {
QFrame#PrescriptionFormSection {
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QLabel#PrescriptionSectionTitle {
color: #1A1C1F;
font-size: 15px;
font-weight: 500;
font-weight: 600;
}
QLabel#PrescriptionSectionHint {
color: #6A6B6D;
@@ -242,7 +243,7 @@ QLabel#PrescriptionUsageAux {
QFrame#PrescriptionRpToolbar {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QFrame#PrescriptionRpMarker {
background-color: #4F63D9;
@@ -262,12 +263,12 @@ QLabel#PrescriptionRpLock {
color: #BE4B58;
background-color: #FEF3F2;
border: 1px solid #FECDCA;
border-radius: 5px;
border-radius: $ds_radius_small;
padding: 5px 8px;
font-size: 13px;
}
QLabel#PrescriptionFormulaTag {
border-radius: 4px;
border-radius: $ds_radius_small;
padding: 3px 8px;
font-size: 13px;
font-weight: 500;
@@ -328,7 +329,7 @@ QFrame#PrescriptionDrawerSurface QDoubleSpinBox {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
padding: 0 11px;
selection-background-color: #C9D1F3;
selection-color: #1A1C1F; font-size: 14px;
@@ -338,7 +339,7 @@ QFrame#PrescriptionDrawerSurface QComboBox {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
padding-left: 11px;
padding-right: 28px;
selection-background-color: #C9D1F3;
@@ -379,7 +380,7 @@ QFrame#PrescriptionDatePicker {
min-height: 32px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
}
QFrame#PrescriptionDatePicker:hover {
border-color: #8B9AD9;
@@ -446,7 +447,7 @@ QFrame#PrescriptionDrawerSurface QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QFrame#PrescriptionDrawerSurface QPushButton:hover {
@@ -468,7 +469,7 @@ QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose {
color: #606163;
background-color: transparent;
border: 0;
border-radius: 7px;
border-radius: $ds_radius_control;
font-size: 22px;
font-weight: 400;
}
@@ -496,23 +497,23 @@ QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover {
border-color: #4156C4;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="success"] {
color: #FFFFFF;
background-color: #287B65;
border-color: #287B65;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_line;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="success"]:hover {
background-color: #287B65;
border-color: #287B65;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"] {
color: #A9691D;
color: $ds_accent;
background-color: #FFFFFF;
border-color: #A9691D;
border-color: $ds_accent;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"]:hover {
color: #FFFFFF;
background-color: #A9691D;
border-color: #A9691D;
color: $ds_accent_hover;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"] {
color: #4F63D9;
@@ -615,7 +616,8 @@ QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] {
QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] QLabel {
color: #BE4B58;
}
"""
QFrame#PrescriptionDrawerSurface QPushButton:focus { border: 1px solid $ds_focus; }
""")
_CHEVRON_PATH: Path | None = None
@@ -647,7 +649,7 @@ def _prescription_drawer_qss() -> str:
def _prescription_calendar_qss() -> str:
return """
return clinical_stylesheet("""
QCalendarWidget {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
@@ -672,13 +674,13 @@ QCalendarWidget QAbstractItemView:enabled {
selection-color: #1A1C1F;
outline: 0;
}
"""
""")
PRESCRIPTION_DIALOG_QSS = """
PRESCRIPTION_DIALOG_QSS = clinical_stylesheet("""
QDialog#PrescriptionDialogSurface {
color: #1A1C1F;
background-color: #F7F7F7;
background-color: $ds_canvas;
font-size: 14px;
}
QDialog#PrescriptionDialogSurface QLabel {
@@ -740,7 +742,7 @@ QDialog#PrescriptionDialogSurface QTextBrowser {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
selection-background-color: #EEF1FA;
selection-color: #1A1C1F; font-size: 14px;
}
@@ -768,7 +770,7 @@ QDialog#PrescriptionDialogSurface QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#PrescriptionDialogSurface QPushButton:hover {
@@ -798,7 +800,7 @@ QDialog#PrescriptionDialogSurface QPushButton:disabled {
QDialog#PrescriptionDialogSurface QTabWidget::pane {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 9px;
border-radius: $ds_radius_panel;
top: -1px;
}
QDialog#PrescriptionDialogSurface QTabBar::tab {
@@ -852,7 +854,8 @@ QDialog#PrescriptionDialogSurface QScrollBar::add-line:vertical,
QDialog#PrescriptionDialogSurface QScrollBar::sub-line:vertical {
height: 0;
}
"""
QDialog#PrescriptionDialogSurface QPushButton:focus { border: 1px solid $ds_focus; }
""")
def _apply_prescription_dialog_style(dialog: QDialog) -> None:
@@ -1531,9 +1534,9 @@ class RemoteMedicineComboBox(QComboBox):
popup = self._completer.popup()
popup.setItemDelegate(_MedicineSuggestionDelegate(popup))
popup.setStyleSheet(
"QListView { background-color: #FFFFFF; border: 1px solid #E4E7ED; border-radius: 6px;"
clinical_stylesheet("QListView { background-color: #FFFFFF; border: 1px solid #E4E7ED; border-radius: 6px;"
" padding: 4px 0; font-size: 14px; outline: 0; }"
"QListView::item:selected, QListView::item:hover { background-color: #F5F7FA; }"
"QListView::item:selected, QListView::item:hover { background-color: #F5F7FA; }")
)
self.lineEdit().setCompleter(self._completer)
self.lineEdit().installEventFilter(self)
@@ -5292,8 +5295,8 @@ class PrescriptionDetailDialog(QDialog):
self.preview.setObjectName("PrescriptionPaperPreview")
self.preview.setOpenExternalLinks(False)
self.preview.setStyleSheet(
"QTextBrowser#PrescriptionPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#PrescriptionPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.preview.setDocument(self.document)
@@ -5301,8 +5304,8 @@ class PrescriptionDetailDialog(QDialog):
self.user_preview.setObjectName("PrescriptionUserPaperPreview")
self.user_preview.setOpenExternalLinks(False)
self.user_preview.setStyleSheet(
"QTextBrowser#PrescriptionUserPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#PrescriptionUserPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.user_preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.user_preview.setDocument(self.user_document)
@@ -5320,8 +5323,8 @@ class PrescriptionDetailDialog(QDialog):
self.case_preview.setObjectName("CaseRecordPaperPreview")
self.case_preview.setOpenExternalLinks(False)
self.case_preview.setStyleSheet(
"QTextBrowser#CaseRecordPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#CaseRecordPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.case_preview.setDocument(self.case_document)
case_index = self.tabs.addTab(self.case_preview, "详细病历")
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..design_system import clinical_stylesheet
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm
from ..widgets import (
@@ -169,10 +170,10 @@ def diagnosis_ai_task(prompt: str) -> str:
return "custom"
PRESCRIPTION_AI_QSS = """
PRESCRIPTION_AI_QSS = clinical_stylesheet("""
QDialog#PrescriptionAiDialog {
color: #1A1C1F;
background-color: #F7F7F7;
color: $ds_text;
background-color: $ds_canvas;
font-size: 13px;
}
@@ -182,7 +183,7 @@ QDialog#PrescriptionAiDialog QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#PrescriptionAiDialog QPushButton:hover {
@@ -224,7 +225,7 @@ QLabel#PrescriptionAiSubtitle {
QFrame#PrescriptionAiSnapshot {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QLabel#PrescriptionAiSnapshotLabel {
color: #1A1C1F;
@@ -232,20 +233,20 @@ QLabel#PrescriptionAiSnapshotLabel {
font-weight: 600;
}
QLabel#PrescriptionAiSnapshotBody {
color: #1A1C1F;
font-size: 13px;
color: $ds_text;
font-size: 14px;
line-height: 1.65;
}
QFrame#PrescriptionAiSummary {
background-color: #F0F0F0;
background-color: $ds_accent_soft;
border: 0;
border-left: 3px solid #4F63D9;
border-radius: 0 9px 9px 0;
border-radius: $ds_radius_control;
}
QFrame#PrescriptionAiCaution {
background-color: #FFF9EE;
border: 1px solid #F3DFB5;
border-radius: 9px;
border-radius: $ds_radius_control;
}
QLabel#PrescriptionAiSectionTitle {
color: #1A1C1F;
@@ -253,17 +254,17 @@ QLabel#PrescriptionAiSectionTitle {
font-weight: 600;
}
QLabel#PrescriptionAiBody {
color: #1A1C1F;
font-size: 13px;
color: $ds_text;
font-size: 14px;
line-height: 1.75;
}
QTextBrowser#PrescriptionAiAnswer {
color: #1A1C1F;
color: $ds_text;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
padding: 12px 14px;
selection-color: #1A1C1F;
border-radius: $ds_radius_panel;
padding: 16px 18px;
selection-color: $ds_text;
selection-background-color: #EEF1FA;
}
QLabel#PrescriptionAiMuted {
@@ -279,7 +280,7 @@ QFrame#PrescriptionAiMeta {
QDialog#PrescriptionAiDialog QTabWidget::pane {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
top: -1px;
}
QDialog#PrescriptionAiDialog QTabBar::tab {
@@ -309,7 +310,7 @@ QDialog#PrescriptionAiDialog QTextEdit {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 8px;
border-radius: $ds_radius_control;
padding: 10px;
selection-background-color: #EEF1FA;
}
@@ -330,10 +331,12 @@ QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
height: 0;
}
"""
QDialog#PrescriptionAiDialog QPushButton:focus { border: 1px solid $ds_focus; }
QDialog#PrescriptionAiDialog QPushButton:disabled { color: $ds_disabled; background-color: $ds_disabled_surface; border-color: $ds_line; }
""")
_AI_ANSWER_DOCUMENT_CSS = (
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
_AI_ANSWER_DOCUMENT_CSS = clinical_stylesheet(
"body { color:$ds_text; font-size:14px; line-height:1.72; } "
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
@@ -801,7 +804,7 @@ class PrescriptionAiReportDialog(QDialog):
self.report_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.host = QWidget()
self.host_layout = QVBoxLayout(self.host)
self.host_layout.setContentsMargins(4, 4, 12, 8)
self.host_layout.setContentsMargins(18, 14, 20, 16)
self.host_layout.setSpacing(12)
self.report_scroll.setWidget(self.host)
self.tabs.widget(0).layout().addWidget(self.report_scroll)
@@ -1455,7 +1458,7 @@ class PrescriptionAiReportDialog(QDialog):
label.setTextFormat(Qt.TextFormat.PlainText)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
label.setWordWrap(True)
label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
label.setStyleSheet(clinical_stylesheet("color: $ds_muted; font-size: 12px; padding: 10px 14px; background: $ds_surface_alt; border: 1px solid $ds_line; border-radius: $ds_radius_control;"))
self.host_layout.addWidget(label)
def reject(self) -> None:
@@ -8,6 +8,7 @@ from PySide6.QtCore import QObject, QSize, Qt, Signal
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from . import icons
from .design_system import PALETTE, clinical_stylesheet
class FilterDisclosure(QObject):
@@ -35,17 +36,17 @@ class FilterDisclosure(QObject):
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
self.button.setFixedHeight(32)
self.button.setIconSize(QSize(14, 14))
self.button.setStyleSheet("""
self.button.setStyleSheet(clinical_stylesheet("""
QPushButton#FilterDisclosureButton {
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
border-radius: $ds_radius_control; padding: 0 11px; min-height: 30px; max-height: 30px;
min-width: 92px; font-size: 13px; font-weight: 400;
}
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
""")
QPushButton#FilterDisclosureButton:hover { background: $ds_canvas; border-color: $ds_line_strong; }
QPushButton#FilterDisclosureButton:checked { background: $ds_accent_soft; border-color: $ds_line_strong; }
QPushButton#FilterDisclosureButton:focus { border-color: $ds_focus; }
QPushButton#FilterDisclosureButton:disabled { color: $ds_disabled; border-color: $ds_line; }
"""))
self.button.toggled.connect(self.set_expanded)
self._apply()
@@ -78,4 +79,4 @@ class FilterDisclosure(QObject):
self.button.setAccessibleName(label)
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", PALETTE["accent"], 14))
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from .design_system import clinical_stylesheet
from .widgets import first_value, get_value, page_items, page_total, run_async
@@ -54,16 +55,16 @@ class InfiniteList(QWidget):
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 0, 12, 0)
self.summary_label = QLabel("", self)
self.summary_label.setStyleSheet(
self.summary_label.setStyleSheet(clinical_stylesheet(
"color: #5D6B80; font-size: 12px; background: transparent;"
)
))
layout.addWidget(self.summary_label)
layout.addStretch(1)
self.retry_button = QPushButton("加载失败,点击重试", self)
self.retry_button.setFlat(True)
self.retry_button.setStyleSheet(
self.retry_button.setStyleSheet(clinical_stylesheet(
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
)
))
self.retry_button.hide()
self.retry_button.clicked.connect(self.retry)
layout.addWidget(self.retry_button)
+79 -243
View File
@@ -5,26 +5,21 @@ from __future__ import annotations
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
from PySide6.QtGui import (
QBrush,
QColor,
QFont,
QIcon,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPixmap,
QPolygonF,
QRadialGradient,
QResizeEvent,
)
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFrame,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QLayout,
@@ -45,6 +40,7 @@ from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path
from . import icons
from .design_system import PALETTE, clinical_stylesheet
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -127,7 +123,7 @@ def _round_pen(color: QColor | str, width: float) -> QPen:
class _BrandPanel(QWidget):
"""The quiet medical illustration and copy from the supplied login artwork."""
"""A flat, readable introduction to the clinician's daily workflow."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -137,228 +133,76 @@ class _BrandPanel(QWidget):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._brand_lockup = QPixmap(str(brand_lockup_path()))
@staticmethod
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
x, y = center.x(), center.y()
top = QPolygonF(
[
QPointF(x, y - size * 0.58),
QPointF(x + size * 0.48, y - size * 0.34),
QPointF(x, y - size * 0.10),
QPointF(x - size * 0.48, y - size * 0.34),
]
)
left = QPolygonF(
[
QPointF(x - size * 0.48, y - size * 0.34),
QPointF(x, y - size * 0.10),
QPointF(x, y + size * 0.48),
QPointF(x - size * 0.48, y + size * 0.24),
]
)
right = QPolygonF(
[
QPointF(x, y - size * 0.10),
QPointF(x + size * 0.48, y - size * 0.34),
QPointF(x + size * 0.48, y + size * 0.24),
QPointF(x, y + size * 0.48),
]
)
painter.setPen(QPen(QColor(185, 196, 247, 205), 1.2))
painter.setBrush(QColor(235, 239, 255, 218))
painter.drawPolygon(top)
painter.setBrush(QColor(210, 218, 255, 205))
painter.drawPolygon(left)
painter.setBrush(QColor(224, 230, 255, 220))
painter.drawPolygon(right)
painter.setPen(_round_pen(QColor(137, 154, 248, 135), 2.2))
if kind == "plus":
painter.drawLine(
QPointF(x - size * 0.12, y + size * 0.05),
QPointF(x + size * 0.15, y - size * 0.02),
)
painter.drawLine(
QPointF(x + size * 0.02, y - size * 0.13),
QPointF(x + size * 0.02, y + size * 0.17),
)
else:
shield = QPainterPath(QPointF(x, y - size * 0.16))
shield.lineTo(x + size * 0.15, y - size * 0.08)
shield.lineTo(x + size * 0.10, y + size * 0.17)
shield.lineTo(x, y + size * 0.26)
shield.lineTo(x - size * 0.10, y + size * 0.17)
shield.lineTo(x - size * 0.15, y - size * 0.08)
shield.closeSubpath()
painter.drawPath(shield)
def _draw_illustration(
self, painter: QPainter, width: float, height: float
) -> None:
ox = max(250.0, width - 500.0)
oy = max(365.0, height - 500.0)
glow = QRadialGradient(QPointF(width - 170, oy + 230), 280)
glow.setColorAt(0.0, QColor(102, 120, 255, 62))
glow.setColorAt(1.0, QColor(102, 120, 255, 0))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(glow)
painter.drawEllipse(QPointF(width - 170, oy + 230), 280, 280)
painter.setPen(QPen(QColor(166, 180, 241, 78), 1))
for offset in (0, 72, 144):
painter.drawLine(
QPointF(ox - 80, oy + 280 + offset),
QPointF(width + 20, oy + 170 + offset),
)
painter.drawLine(QPointF(ox + 10, oy + 400), QPointF(ox + 10, oy + 80))
painter.drawLine(QPointF(ox + 170, oy + 430), QPointF(ox + 170, oy + 35))
base = QRectF(ox + 80, oy + 365, 430, 88)
base_gradient = QLinearGradient(base.topLeft(), base.bottomRight())
base_gradient.setColorAt(0.0, QColor(246, 248, 255, 228))
base_gradient.setColorAt(1.0, QColor(186, 199, 255, 184))
painter.setBrush(base_gradient)
painter.setPen(QPen(QColor(166, 181, 248, 158), 1))
painter.drawEllipse(base)
painter.setBrush(QColor(249, 250, 255, 225))
painter.drawEllipse(QRectF(base.x() + 42, base.y() - 12, base.width() - 84, 70))
painter.save()
painter.translate(35, 40)
screen = QPolygonF(
[
QPointF(ox + 130, oy + 60),
QPointF(ox + 435, oy + 100),
QPointF(ox + 435, oy + 365),
QPointF(ox + 130, oy + 310),
]
)
painter.setBrush(QColor(218, 225, 255, 205))
painter.setPen(QPen(QColor(145, 163, 242, 170), 1.3))
painter.drawPolygon(screen)
inner = QPolygonF(
[
QPointF(ox + 141, oy + 80),
QPointF(ox + 425, oy + 117),
QPointF(ox + 425, oy + 345),
QPointF(ox + 141, oy + 295),
]
)
painter.setBrush(QColor(255, 255, 255, 225))
painter.drawPolygon(inner)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(174, 189, 252, 180))
painter.drawRoundedRect(QRectF(ox + 148, oy + 89, 270, 15), 7, 7)
for dot_x in (156, 173, 190):
painter.setBrush(QColor(255, 255, 255, 200))
painter.drawEllipse(QPointF(ox + dot_x, oy + 96.5), 3.5, 3.5)
cards = (
QRectF(ox + 150, oy + 135, 82, 80),
QRectF(ox + 241, oy + 142, 72, 73),
QRectF(ox + 322, oy + 151, 95, 72),
QRectF(ox + 151, oy + 232, 264, 50),
QRectF(ox + 151, oy + 291, 264, 42),
)
painter.setPen(QPen(QColor(188, 199, 246, 160), 1))
painter.setBrush(QColor(249, 250, 255, 228))
for card in cards:
painter.drawRoundedRect(card, 7, 7)
painter.setPen(_round_pen(QColor(117, 137, 236, 165), 4))
for idx, bar_height in enumerate((20, 36, 51)):
x = ox + 164 + idx * 17
painter.drawLine(QPointF(x, oy + 200), QPointF(x, oy + 200 - bar_height))
painter.setBrush(QColor(112, 132, 235, 160))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(ox + 277, oy + 167), 10, 10)
painter.drawRoundedRect(QRectF(ox + 259, oy + 180, 36, 20), 10, 10)
painter.setPen(_round_pen(QColor(137, 153, 235, 150), 5))
for y, length in ((170, 54), (186, 42), (249, 89), (307, 72)):
painter.drawLine(
QPointF(ox + 340, oy + y), QPointF(ox + 340 + length, oy + y + 5)
)
self._draw_cube(painter, QPointF(ox + 352, oy - 25), 76, "plus")
self._draw_cube(painter, QPointF(ox - 8, oy + 210), 76, "shield")
painter.restore()
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
background.setColorAt(0.0, QColor("#FFFFFF"))
background.setColorAt(0.7, QColor("#FFFFFF"))
background.setColorAt(1.0, QColor("#F7F7F7"))
painter.setBrush(background)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawRoundedRect(bounds, 24, 24)
width, height = float(self.width()), float(self.height())
self._draw_illustration(painter, width, height)
left = 64.0 if width >= 620 else 38.0
if not self._brand_lockup.isNull():
logo_width = 180.0
logo_height = logo_width * (
self._brand_lockup.height() / self._brand_lockup.width()
)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(
QRectF(left, 24, logo_width, logo_height),
self._brand_lockup,
QRectF(self._brand_lockup.rect()),
)
tag_rect = QRectF(left, 238, 119, 40)
bounds = QRectF(self.rect()).adjusted(1, 1, -1, -1)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF1FA"))
painter.drawRoundedRect(tag_rect, 11, 11)
painter.setFont(_font(17, QFont.Weight.Medium))
painter.setPen(QColor("#4F63D9"))
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
painter.setBrush(QColor(PALETTE["sidebar"]))
painter.drawRoundedRect(bounds, 20, 20)
left = 36.0
width = max(240.0, self.width() - 72.0)
if not self._brand_lockup.isNull():
brand = self._brand_lockup.scaled(
226, 58, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
painter.drawPixmap(QPointF(left, 36), brand)
else:
painter.setFont(_font(22, QFont.Weight.DemiBold))
painter.setPen(QColor(PALETTE["heading"]))
painter.drawText(QPointF(left, 70), "甄养堂 · 医生工作站")
copy_left = left + 4
painter.setFont(_font(48, QFont.Weight.Medium))
painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
painter.drawText(QPointF(copy_left, 424), "留在一个")
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
highlight = QLinearGradient(
copy_left + prefix_width,
0,
copy_left + prefix_width + 264,
0,
)
highlight.setColorAt(0.0, QColor("#4F63D9"))
highlight.setColorAt(1.0, QColor("#4F63D9"))
painter.setPen(QPen(QBrush(highlight), 1))
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
suffix_x = (
copy_left
+ prefix_width
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
)
painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(suffix_x, 424), "。")
heading_y = max(158.0, min(218.0, self.height() * .27))
painter.setPen(QColor(PALETTE["muted"]))
painter.setFont(_font(13, QFont.Weight.Medium))
painter.drawText(QPointF(left, heading_y - 36), "医 生 工 作 台")
painter.setPen(QColor(PALETTE["heading"]))
painter.setFont(_font(30, QFont.Weight.DemiBold))
painter.drawText(QPointF(left, heading_y), "让诊疗有序,")
painter.drawText(QPointF(left, heading_y + 44), "让沟通更专注。")
painter.setFont(_font(14))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left, heading_y + 82), "从接诊、病历到处方,在一个工作台完成。")
body_left = left + 6
painter.setFont(_font(20))
painter.setPen(QColor("#606163"))
painter.drawText(
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
)
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
row_y = heading_y + 122
for index, (title, detail) in enumerate((
("今日接诊", "快速找到待接诊患者与预约安排"),
("诊疗记录", "病历、随访与面诊资料集中查看"),
("处方协作", "开方与审核流程清晰衔接"),
), start=1):
row = QRectF(left, row_y, width, 66)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(PALETTE["surface"]))
painter.drawRoundedRect(row, 12, 12)
painter.setFont(_font(13, QFont.Weight.DemiBold))
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(QRectF(left + 16, row_y, 28, 66), Qt.AlignmentFlag.AlignCenter, f"0{index}")
painter.setFont(_font(15, QFont.Weight.Medium))
painter.setPen(QColor(PALETTE["heading"]))
painter.drawText(QPointF(left + 58, row_y + 27), title)
painter.setFont(_font(12))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left + 58, row_y + 47), detail)
row_y += 78
if self.height() > row_y + 50:
painter.setFont(_font(12))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left, self.height() - 28), "仅供获授权的医疗人员使用")
painter.end()
painter.setFont(_font(16))
painter.setPen(QColor("#6A6B6D"))
painter.drawText(
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
)
painter.drawText(QPointF(body_left, height - 59), "请勿在公共设备保存账号信息")
class _PasswordField(QFrame):
"""Keep the grouped password control visibly focused for keyboard users."""
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
self.setProperty("focused", event.type() == QEvent.Type.FocusIn)
self.style().unpolish(self)
self.style().polish(self)
self.update()
return super().eventFilter(watched, event)
class _RevealButton(QToolButton):
@@ -392,8 +236,8 @@ class _ServerButton(QPushButton):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
painter.setBrush(QColor(PALETTE["accent_soft"] if self.underMouse() else PALETTE["surface"]))
painter.setPen(QPen(QColor(PALETTE["focus"] if self.hasFocus() else PALETTE["line_strong"]), 1))
painter.drawRoundedRect(rect, 12, 12)
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
painter.setPen(_round_pen("#6A6B6D", 1.8))
@@ -536,7 +380,7 @@ class LoginWindow(QMainWindow):
border: 1px solid #E4E4E5;
border-radius: 12px;
}
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
QFrame#PasswordField[focused="true"] { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox {
color: #606163;
spacing: 13px;
@@ -606,6 +450,9 @@ class LoginWindow(QMainWindow):
self.login_root.addWidget(self.brand_panel, 60)
self.login_root.addWidget(self._build_login_area(), 40)
self._restore_settings()
for widget in (canvas, *canvas.findChildren(QWidget)):
if widget.styleSheet():
widget.setStyleSheet(clinical_stylesheet(widget.styleSheet()))
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
"""Preserve the reference composition while remaining usable on small screens."""
@@ -625,18 +472,15 @@ class LoginWindow(QMainWindow):
# Keep the form fully visible when the 60/40 desktop split would
# otherwise crop its fixed-width card. Small windows focus on login.
self.brand_panel.setVisible(width >= 1120)
compact_height = event.size().height() < 720
self.card.setMinimumHeight(580 if compact_height else 620)
self.login_area_layout.setContentsMargins(
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
0, 12 if compact_height else min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
)
super().resizeEvent(event)
def _build_brand_panel(self) -> QWidget:
panel = _BrandPanel()
shadow = QGraphicsDropShadowEffect(panel)
shadow.setBlurRadius(32)
shadow.setOffset(0, 10)
shadow.setColor(QColor(67, 82, 154, 18))
panel.setGraphicsEffect(shadow)
return panel
def _build_login_area(self) -> QWidget:
@@ -660,11 +504,6 @@ class LoginWindow(QMainWindow):
self.card.setFixedWidth(480)
self.card.setMinimumHeight(620)
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
card_shadow = QGraphicsDropShadowEffect(self.card)
card_shadow.setBlurRadius(38)
card_shadow.setOffset(0, 16)
card_shadow.setColor(QColor(59, 77, 139, 24))
self.card.setGraphicsEffect(card_shadow)
card_layout = QVBoxLayout(self.card)
card_layout.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint)
card_layout.setContentsMargins(47, 42, 47, 26)
@@ -673,13 +512,13 @@ class LoginWindow(QMainWindow):
title = QLabel("欢迎回来")
title.setObjectName("LoginTitle")
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
title.setStyleSheet("color:#1A1C1F; font-size:26px; font-weight:600;")
title.setContentsMargins(1, -3, 0, 3)
title.setFixedHeight(46)
card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted")
subtitle.setStyleSheet("color:#606163; font-size:18px;")
subtitle.setStyleSheet("color:#606163; font-size:15px;")
subtitle.setContentsMargins(1, 8, 0, 0)
subtitle.setFixedHeight(27)
card_layout.addWidget(subtitle)
@@ -710,7 +549,7 @@ class LoginWindow(QMainWindow):
password_label.setFixedHeight(24)
card_layout.addWidget(password_label)
card_layout.addSpacing(11)
password_field = QFrame()
password_field = _PasswordField()
password_field.setObjectName("PasswordField")
password_field.setFixedHeight(54)
password_row = QHBoxLayout(password_field)
@@ -721,6 +560,7 @@ class LoginWindow(QMainWindow):
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setAccessibleName("登录密码")
self.password_edit.installEventFilter(password_field)
self.password_edit.setTextMargins(0, -2, 0, 2)
self.password_edit.returnPressed.connect(self.submit)
password_row.addWidget(self.password_edit, 1)
@@ -730,6 +570,7 @@ class LoginWindow(QMainWindow):
self.reveal_button.setCheckable(True)
self.reveal_button.setFixedSize(87, 52)
self.reveal_button.setToolTip("显示或隐藏密码")
self.reveal_button.installEventFilter(password_field)
self.reveal_button.toggled.connect(self._toggle_password)
password_row.addWidget(self.reveal_button)
card_layout.addWidget(password_field)
@@ -758,11 +599,6 @@ class LoginWindow(QMainWindow):
self.login_button = QPushButton("登录工作站")
self.login_button.setProperty("variant", "primary")
self.login_button.setFixedHeight(58)
login_shadow = QGraphicsDropShadowEffect(self.login_button)
login_shadow.setBlurRadius(20)
login_shadow.setOffset(0, 7)
login_shadow.setColor(QColor(82, 101, 246, 45))
self.login_button.setGraphicsEffect(login_shadow)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
self.debug_settings_section = QWidget()
+160 -24
View File
@@ -37,8 +37,10 @@ from PySide6.QtCore import (
QTimer,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QAbstractScrollArea,
QGraphicsOpacityEffect,
QPlainTextEdit,
QStackedWidget,
QWidget,
)
@@ -75,15 +77,35 @@ def reduced_motion() -> bool:
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
def _stop(target: QObject, key: str) -> None:
"""Retire a replaced animation without firing its completion callback."""
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
previous = running.get(key)
running = getattr(target, "_doctor_motion", {})
previous = running.pop(key, None)
if previous is not None:
previous.stop()
previous.setParent(None)
previous.deleteLater()
def _own(target: QObject, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
_stop(target, key)
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", {})
running[key] = animation
target._doctor_motion = running
def release() -> None:
if running.get(key) is animation:
running.pop(key)
# A callback can hold the last Python reference to the owner. Detach
# first so releasing that callback during deferred deletion cannot
# recursively delete this animation through its former parent.
animation.setParent(None)
animation.deleteLater()
animation.finished.connect(release)
return animation
@@ -107,7 +129,10 @@ def animate(
from inside its own ``finished`` emission.
"""
animation_owner = owner if owner is not None else target
animation_key = key or prop.decode()
if reduced_motion():
_stop(animation_owner, animation_key)
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
if on_finished is not None:
on_finished()
@@ -117,18 +142,24 @@ def animate(
animation.setEasingCurve(easing)
animation.setStartValue(start)
animation.setEndValue(end)
_own(animation_owner, animation_key, animation)
if on_finished is not None:
animation.finished.connect(on_finished)
_own(owner if owner is not None else target, key or prop.decode(), animation)
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
return animation
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect | None:
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
# Do not replace a caller's shadow or opacity effect: Qt deletes it when
# another effect is attached. Only effects created here belong to a fade.
if effect is not None and effect is not getattr(widget, "_doctor_opacity_effect", None):
return None
if effect is None:
effect = QGraphicsOpacityEffect(widget)
effect.setOpacity(1.0)
widget.setGraphicsEffect(effect)
widget._doctor_opacity_effect = effect
effect.setEnabled(True)
return effect
@@ -146,9 +177,17 @@ def _drop_effect(widget: QWidget) -> None:
signal that is still being delivered.
"""
effect = getattr(widget, "_doctor_opacity_effect", None)
revision = getattr(widget, "_doctor_fade_revision", 0)
def detach() -> None:
try:
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
if (
effect is not None
and widget.graphicsEffect() is effect
and getattr(widget, "_doctor_fade_revision", 0) == revision
):
widget._doctor_opacity_effect = None
widget.setGraphicsEffect(None)
except RuntimeError: # the widget went away while the fade was running
pass
@@ -156,6 +195,13 @@ def _drop_effect(widget: QWidget) -> None:
QTimer.singleShot(0, detach)
def _restart_fade(widget: QWidget) -> None:
_stop(widget, "fade")
# A completed fade may still have deferred cleanup waiting in the queue.
# It must not remove an effect now being used by a subsequent fade.
widget._doctor_fade_revision = getattr(widget, "_doctor_fade_revision", 0) + 1
def fade_in(
widget: QWidget,
*,
@@ -165,10 +211,20 @@ def fade_in(
) -> None:
"""Fade a widget up to full opacity, showing it first if needed."""
existing = widget.graphicsEffect()
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
start = float(existing.opacity())
_restart_fade(widget)
if reduced_motion():
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
existing.setOpacity(1.0)
_drop_effect(widget)
widget.show()
return
effect = _opacity_effect(widget)
if effect is None:
widget.show()
return
effect.setOpacity(start)
widget.show()
animate(
@@ -193,9 +249,11 @@ def fade_out(
) -> None:
"""Fade a widget down, optionally hiding it when the fade completes."""
_restart_fade(widget)
if reduced_motion():
if hide:
widget.hide()
_drop_effect(widget)
if on_finished is not None:
on_finished()
return
@@ -208,6 +266,9 @@ def fade_out(
if on_finished is not None:
on_finished()
if effect is None:
done()
return
animate(
effect,
b"opacity",
@@ -229,13 +290,17 @@ def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
the user has to wait out.
"""
previous = getattr(widget, "_doctor_motion", {}).get("enter")
origin = previous.endValue() if previous is not None else widget.pos()
if reduced_motion():
widget.show()
_stop(widget, "enter")
widget.move(origin)
fade_in(widget, duration=duration)
return
fade_in(widget, duration=duration)
if rise:
origin = widget.pos()
widget.move(origin + QPoint(0, rise))
if previous is None:
widget.move(origin + QPoint(0, rise))
animate(
widget,
b"pos",
@@ -260,7 +325,7 @@ def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None
return
stack.setCurrentIndex(index)
page = stack.currentWidget()
if page is None or reduced_motion():
if page is None:
return
enter(page, rise=rise)
@@ -274,50 +339,121 @@ SCROLL_STEP = 120
SCROLL_MS = 190
class _ScrollAnimation(QPropertyAnimation):
"""Identify animation writes so direct scrollbar changes can interrupt us."""
writing = False
def updateCurrentValue(self, value: Any) -> None: # noqa: N802 - Qt API
self.writing = True
try:
super().updateCurrentValue(value)
finally:
self.writing = False
class _SmoothScroller(QObject):
"""Animate a scroll area's wheel movement instead of jumping to it."""
_INTERRUPT_EVENTS = frozenset(
{
QEvent.Type.KeyPress,
QEvent.Type.MouseButtonPress,
QEvent.Type.TouchBegin,
QEvent.Type.Hide,
}
)
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
super().__init__(area)
self._area = area
self._orientation = orientation
self._bar = (
area.verticalScrollBar()
if orientation is Qt.Orientation.Vertical
else area.horizontalScrollBar()
)
self._target = self._bar.value()
self._animation = QPropertyAnimation(self._bar, b"value", self)
self._animation = _ScrollAnimation(self._bar, b"value", self)
self._animation.setEasingCurve(EASE_ENTER)
self._animation.setDuration(SCROLL_MS)
# Keyboard, programmatic and drag movements must not be fought over: when
# nothing is animating, the wheel target follows wherever the bar went.
self._bar.valueChanged.connect(self._sync_target)
self._bar.sliderPressed.connect(self._cancel)
self._bar.actionTriggered.connect(self._cancel)
self._bar.rangeChanged.connect(self._cancel)
self._bar.installEventFilter(self)
area.installEventFilter(self)
area.viewport().installEventFilter(self)
def _cancel(self, *_: Any) -> None:
self._animation.stop()
self._target = self._bar.value()
def _sync_target(self, value: int) -> None:
if self._animation.state() != QAbstractAnimation.State.Running:
if not self._animation.writing:
self._animation.stop()
self._target = value
def _uses_pixels(self) -> bool:
if isinstance(self._area, QAbstractItemView):
mode = (
self._area.verticalScrollMode()
if self._orientation == Qt.Orientation.Vertical
else self._area.horizontalScrollMode()
)
return mode == QAbstractItemView.ScrollMode.ScrollPerPixel
# QPlainTextEdit's vertical scrollbar counts text blocks, not pixels.
return not (
isinstance(self._area, QPlainTextEdit) and self._orientation == Qt.Orientation.Vertical
)
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
del watched
if event.type() is not QEvent.Type.Wheel or reduced_motion():
event_type = event.type()
if event_type in self._INTERRUPT_EVENTS:
self._cancel()
return False
delta = event.angleDelta().y() or event.angleDelta().x()
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
if event_type != QEvent.Type.Wheel:
return False
horizontal = self._orientation == Qt.Orientation.Horizontal
shifted = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
delta = event.angleDelta().x() if horizontal else event.angleDelta().y()
if horizontal and shifted and not delta:
delta = event.angleDelta().y()
if (
watched is not self._area.viewport()
or reduced_motion()
or not self._uses_pixels()
or not event.pixelDelta().isNull()
or event.phase() != Qt.ScrollPhase.NoScrollPhase
or event.modifiers()
& (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier)
or (shifted and not horizontal)
or not delta
):
# Trackpads already provide continuous motion and native momentum.
# Mixing another easing curve into it adds lag and fights gestures.
self._cancel()
return False
lower, upper = self._bar.minimum(), self._bar.maximum()
# Re-clamp first: the range can shrink underneath a running animation
# when the content behind it reloads, which would otherwise leave the
# pending target past the end of the new content.
self._target = max(lower, min(upper, self._target))
target = self._target - round(delta / 120.0 * SCROLL_STEP)
current = self._bar.value()
step = -round(delta / 120.0 * SCROLL_STEP)
# Reverse immediately from the visible position, not the end of a
# backlog of wheel notches still travelling in the opposite direction.
origin = current if (self._target - current) * step < 0 else self._target
target = origin + step
target = max(lower, min(upper, target))
# At either end, hand the wheel back so an enclosing scroll area still
# gets it - swallowing it there is what makes nested panes feel stuck.
if target == self._target:
return False
return target != current
self._target = target
self._animation.stop()
self._animation.setStartValue(self._bar.value())
self._animation.setStartValue(current)
self._animation.setEndValue(target)
self._animation.start()
return True
@@ -328,7 +464,7 @@ def install_smooth_scroll(
*,
orientation: Qt.Orientation = Qt.Orientation.Vertical,
) -> None:
"""Give a scroll area eased wheel scrolling."""
"""Ease discrete wheels on pixel-based areas; preserve native gestures."""
if getattr(area, "_doctor_smooth_scroll", None) is not None:
return
+120 -119
View File
@@ -50,6 +50,7 @@ from ...core.appointment_modes import (
)
from .. import icons
from ..appointment_drawer import AppointmentDrawer
from ..design_system import PALETTE, clinical_stylesheet
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
@@ -110,97 +111,97 @@ FULFILLMENT_TEXT = {
}
_SEMANTIC_COLORS = {
"primary": "#4F63D9",
"success": "#287B65",
"warning": "#A9691D",
"danger": "#BE4B58",
"info": "#4F63D9",
"muted": "#606163",
"primary": PALETTE["accent"],
"success": PALETTE["success"],
"warning": PALETTE["warning"],
"danger": PALETTE["danger"],
"info": PALETTE["accent"],
"muted": PALETTE["muted"],
}
PATIENTS_LIGHT_QSS = """
PATIENTS_LIGHT_QSS = clinical_stylesheet("""
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #1A1C1F;
color: $ds_heading;
font-size: 20px;
font-weight: 600;
}
#PatientsPage QWidget#PageHeader QLabel[role="muted"] {
color: #606163;
color: $ds_muted;
font-size: 13px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 32px;
max-height: 32px;
color: #FFFFFF;
background-color: #4F63D9;
border: 1px solid #4F63D9;
color: $ds_surface;
background-color: $ds_accent;
border: 1px solid $ds_accent;
border-radius: 7px;
font-weight: 500;
}
#PatientsPage QPushButton[patientSearchAction="true"]:hover {
background-color: #4156C4;
border-color: #4156C4;
background-color: $ds_accent_hover;
border-color: $ds_accent_hover;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 88px;
max-height: 88px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 12px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 32px;
max-height: 32px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 7px; font-size: 14px;
}
#PatientsPage QFrame[patientListFilter="true"] QPushButton {
min-height: 32px; max-height: 32px;
}
#PatientsPage QFrame[patientListFilter="true"] QDateEdit:disabled {
color: #8E8F90; background-color: #F7F7F7;
color: $ds_disabled; background-color: $ds_surface_alt;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 10px;
color: #606163;
background-color: #F7F7F7;
color: $ds_muted;
background-color: $ds_surface_alt;
border: 1px solid transparent;
border-radius: 5px;
}
#PatientsPage QPushButton[patientStatusChip="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QPushButton[patientStatusChip="true"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border: 1px solid $ds_focus;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 9px;
color: #1A1C1F;
color: $ds_heading;
background-color: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
#PatientsPage QPushButton[patientQuickDate="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QPushButton[patientQuickDate="true"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border-color: #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
border: 0;
border-top: 1px solid #EDEDEE;
border-top: 1px solid $ds_line;
background-color: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
@@ -208,124 +209,124 @@ PATIENTS_LIGHT_QSS = """
min-height: 34px;
padding: 0 6px;
margin-right: 8px;
color: #606163;
color: $ds_muted;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-weight: 400;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #4F63D9;
border-bottom-color: #4F63D9;
color: $ds_accent;
border-bottom-color: $ds_accent;
font-weight: 500;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 42px;
max-height: 42px;
padding: 0 11px;
color: #1A1C1F;
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
color: $ds_heading;
background-color: $ds_surface_alt;
border: 1px solid $ds_line;
border-radius: 9px;
text-align: left;
font-weight: 500;
}
#PatientsPage QPushButton[summaryCard="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
border-color: #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QPushButton[rowAction="true"] {
min-height: 26px;
max-height: 26px;
padding: 0 7px;
color: #4F63D9;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
color: $ds_accent;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 6px;
font-size: 13px;
font-weight: 400;
}
#PatientsPage QPushButton[rowAction="true"]:hover {
background-color: #EEF1FA;
border-color: #8B9AD9;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QPushButton[rowAction="true"][variant="danger"] {
color: #BE4B58;
background-color: #FFF7F8;
border-color: #F4C4CC;
color: $ds_danger;
background-color: $ds_danger_soft;
border-color: $ds_danger_soft;
}
#PatientsPage QTableWidget#PatientTable QHeaderView::section {
min-height: 34px;
background-color: #F7F7F7; font-size: 13px;
background-color: $ds_surface_alt; font-size: 13px;
}
#PatientsPage QTableWidget#PatientTable::item { padding: 5px 7px; }
#PatientsPage QTableWidget#PatientTable {
gridline-color: #EDEDEE;
selection-background-color: #EEF1FA;
selection-color: #1A1C1F; font-size: 14px;
gridline-color: $ds_line;
selection-background-color: $ds_accent_soft;
selection-color: $ds_heading; font-size: 14px;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator {
width: 13px;
height: 13px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 2px;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator:checked {
background-color: #4F63D9;
border-color: #4F63D9;
background-color: $ds_accent;
border-color: $ds_accent;
}
#PatientsPage QPushButton[variant="ghost"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #4F63D9;
color: $ds_accent;
background-color: $ds_accent_soft;
border: 1px solid $ds_accent;
}
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #EEF1FA; }
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: $ds_accent_soft; }
#PatientsPage QPushButton[statusKind="info"]:checked {
color: #4F63D9;
border-color: #4F63D9;
color: $ds_accent;
border-color: $ds_accent;
}
#PatientsPage QPushButton[statusKind="warning"]:checked {
color: #A9691D;
border-color: #A9691D;
color: $ds_warning;
border-color: $ds_warning;
}
#PatientsPage QPushButton[statusKind="success"]:checked {
color: #287B65;
border-color: #287B65;
color: $ds_success;
border-color: $ds_success;
}
#PatientsPage QPushButton[statusKind="danger"]:checked {
color: #BE4B58;
border-color: #BE4B58;
color: $ds_danger;
border-color: $ds_danger;
}
#PatientsPage QLabel[metricKind="primary"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="info"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="success"] { color: #287B65; }
#PatientsPage QLabel[metricKind="warning"] { color: #A9691D; }
#PatientsPage QLabel[metricKind="danger"] { color: #BE4B58; }
#PatientsPage QLabel[metricKind="primary"] { color: $ds_accent; }
#PatientsPage QLabel[metricKind="info"] { color: $ds_accent; }
#PatientsPage QLabel[metricKind="success"] { color: $ds_success; }
#PatientsPage QLabel[metricKind="warning"] { color: $ds_warning; }
#PatientsPage QLabel[metricKind="danger"] { color: $ds_danger; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
background-color: transparent;
border: 0;
top: -1px;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
color: #606163;
color: $ds_muted;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-weight: 400;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #4F63D9;
color: $ds_accent;
background-color: transparent;
border-bottom-color: #4F63D9;
border-bottom-color: $ds_accent;
font-weight: 500;
}
"""
""")
def _style_table_cell(table: SortableTable, row: int, column: int, kind: str) -> None:
@@ -1282,7 +1283,7 @@ class _PatientDateEdit(QDateEdit):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 15)
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 15)
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 15) / 2, 15, 15), glyph, QRectF(glyph.rect()))
painter.end()
@@ -1300,18 +1301,18 @@ class _PatientSummaryButton(QPushButton):
label, _, count = self.text().partition("\n")
left = max(18, (self.width() - 204) // 2)
icon_rect = QRectF(left, (self.height() - 36) / 2, 36, 36)
glyph = icons.pixmap("calendar", "#1769E8", 36)
glyph = icons.pixmap("calendar", PALETTE["accent"], 36)
painter.drawPixmap(icon_rect, glyph, QRectF(glyph.rect()))
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#5D6B80"))
painter.setPen(QColor(PALETTE["muted"]))
text_rect = self.rect().adjusted(left + 46, 10, -12, -32)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
painter.fontMetrics().elidedText(label, Qt.TextElideMode.ElideRight, text_rect.width()))
font.setPixelSize(16)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
painter.drawText(self.rect().adjusted(left + 46, 31, -12, -9),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, count)
painter.end()
@@ -1324,11 +1325,11 @@ class _PatientInfoDelegate(QStyledItemDelegate):
painter.save()
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#EAF2FF" if selected else "#F6F9FE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
if index.column() in (0, 9):
painter.restore()
return
@@ -1337,9 +1338,9 @@ class _PatientInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
def draw(text: str, target: Any, color: str = "#273244", centered: bool = False) -> None:
def draw(text: str, target: Any, color: str = PALETTE["text"], centered: bool = False) -> None:
painter.setPen(QColor(color))
text = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, target.width())
alignment = Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignHCenter if centered else Qt.AlignmentFlag.AlignLeft)
@@ -1359,17 +1360,17 @@ class _PatientInfoDelegate(QStyledItemDelegate):
doctor = display_text(first_value(row, "appointment_doctor_name"), "")
status, _kind = _patient_status(row)
if not doctor:
draw(status, rect, "#5D6B80")
draw(status, rect, PALETTE["muted"])
else:
draw(doctor, top)
font.setPixelSize(13)
painter.setFont(font)
color, fill = ("#A9691D", "#FFF7EA") if status == "已过号" else ("#1769E8", "#F0F6FF")
color, fill = (PALETTE["warning"], PALETTE["warning_soft"]) if status == "已过号" else (PALETTE["accent"], PALETTE["accent_soft"])
if status == "已取消":
color, fill = "#5D6B80", "#F5F7FA"
color, fill = PALETTE["muted"], PALETTE["canvas"]
badge = QRectF(bottom.x(), bottom.y() + 1, min(bottom.width(), painter.fontMetrics().horizontalAdvance(status) + 10), 20)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QColor("#E7CDA8" if status == "已过号" else "#C7DAF7"))
painter.setPen(QColor(PALETTE["warning_soft"] if status == "已过号" else PALETTE["line_strong"]))
painter.setBrush(QColor(fill))
painter.drawRoundedRect(badge, 3, 3)
draw(status, badge.toRect(), color, True)
@@ -1385,7 +1386,7 @@ class _PatientInfoDelegate(QStyledItemDelegate):
draw(value, rect)
else:
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
draw(text, rect, "#A9691D" if index.column() == 6 and text == "待确认" else "#273244", index.column() == 5)
draw(text, rect, PALETTE["warning"] if index.column() == 6 and text == "待确认" else PALETTE["text"], index.column() == 5)
painter.restore()
@@ -1493,7 +1494,7 @@ class PatientListWorkspace(QWidget):
self.keyword_edit.setMaximumWidth(360)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.returnPressed.connect(self.search)
search_row.addWidget(self.keyword_edit, 1)
self.status_label = QLabel("患者状态:")
@@ -1846,7 +1847,7 @@ class PatientListWorkspace(QWidget):
)
host = RowActions(row_actions, self.table)
if host.more_button is not None:
host.more_button.setIcon(icons.icon("chevron_down", "#5D6B80", 12))
host.more_button.setIcon(icons.icon("chevron_down", PALETTE["muted"], 12))
host.more_button.setIconSize(QSize(12, 12))
host.more_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
host.more_button.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
@@ -2079,7 +2080,7 @@ class _OrderComboBox(QComboBox):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2088,7 +2089,7 @@ class _OrderActionButton(QToolButton):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80" if self.isEnabled() else "#97A4B6", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
painter.drawPixmap(QRectF(self.width() - 23, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2107,18 +2108,18 @@ class _OrderInfoDelegate(QStyledItemDelegate):
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F5F8FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
rect = option.rect.adjusted(16, 0, -12, -1)
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
def draw(value: str, target: Any, color: str = "#273244", right: bool = False) -> None:
def draw(value: str, target: Any, color: str = PALETTE["text"], right: bool = False) -> None:
painter.setPen(QColor(color))
value = painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, target.width())
painter.drawText(target, Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignRight if right else Qt.AlignmentFlag.AlignLeft), value)
@@ -2138,7 +2139,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
font.setWeight(QFont.Weight.Normal)
font.setPixelSize(13)
painter.setFont(font)
draw(secondary, bottom, "#5D6B80")
draw(secondary, bottom, PALETTE["muted"])
else:
draw(text, rect)
elif column in (9, 11):
@@ -2147,7 +2148,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
draw(primary, top)
font.setPixelSize(13)
painter.setFont(font)
draw(("(" if column == 9 else "") + secondary, bottom, "#5D6B80")
draw(("(" if column == 9 else "") + secondary, bottom, PALETTE["muted"])
else:
draw(text, rect)
elif column in (4, 5, 6):
@@ -2158,10 +2159,10 @@ class _OrderInfoDelegate(QStyledItemDelegate):
else:
kind = "blue" if raw in {3, 5, 6, 7, 8, 11} else "danger" if raw in {4, 9, 10} else "warning" if raw in {1, 2, 12} else "neutral"
foreground, background, border = {
"blue": ("#1769E8", "#F2F7FF", "#CEE0FA"),
"warning": ("#AD640C", "#FFFAF2", "#F2DABD"),
"danger": ("#BE4657", "#FFF5F6", "#F0D0D7"),
"neutral": ("#5D6B80", "#F6F8FC", "#E2E9F2"),
"blue": (PALETTE["accent"], PALETTE["accent_soft"], PALETTE["line_strong"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"], PALETTE["danger_soft"]),
"neutral": (PALETTE["muted"], PALETTE["disabled_surface"], PALETTE["line"]),
}[kind]
font.setPixelSize(13)
painter.setFont(font)
@@ -2275,7 +2276,7 @@ class PatientOrdersWorkspace(QWidget):
self.keyword_edit.setMinimumWidth(240)
self.keyword_edit.setPlaceholderText("订单号 / 患者 / 手机号 / 处方ID / 诊单ID")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.returnPressed.connect(self.search)
self.filter_top_row.addWidget(self.keyword_edit, 1)
self.audit_host = QWidget()
@@ -2747,11 +2748,11 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
rect = option.rect.adjusted(16, 0, -16, -1)
font = QFont(body_family())
font.setPixelSize(14)
@@ -2762,20 +2763,20 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
painter.setFont(font)
raw_text = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if raw_text is None else str(raw_text)
color = "#273244"
color = PALETTE["text"]
if not self.schedule and index.column() == 5 and text and text != "—":
row = index.data(Qt.ItemDataRole.UserRole)
status = str(first_value(row, "queue_status", default="") or "").lower()
color = {
"consulting": "#1769E8", "next": "#1555B6",
"waiting": "#AD640C", "missed": "#BE4657",
}.get(status, "#5D6B80")
"consulting": PALETTE["accent"], "next": PALETTE["accent_hover"],
"waiting": PALETTE["warning"], "missed": PALETTE["danger"],
}.get(status, PALETTE["muted"])
painter.setPen(QColor(color))
alignment = Qt.AlignmentFlag.AlignVCenter
alignment |= Qt.AlignmentFlag.AlignHCenter if self.schedule and 1 <= index.column() <= 5 else Qt.AlignmentFlag.AlignLeft
painter.drawText(rect, alignment, painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width()))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
painter.restore()
@@ -2875,7 +2876,7 @@ class PatientProgressWorkspace(QWidget):
row = QHBoxLayout(frame)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(0)
divider = QFrame()
divider = QFrame(frame)
divider.setObjectName("ProgressMetricDivider")
divider.setFixedSize(1, 60)
divider.setVisible(key != "total")
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
)
from .. import motion
from ..design_system import PALETTE
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..filter_disclosure import FilterDisclosure
@@ -119,7 +120,7 @@ def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> Met
icon.setProperty("metricIcon", True)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(32, 32)
color = "#1769E8" if kind == "accent" else "#60789F"
color = PALETTE["accent"] if kind == "accent" else PALETTE["muted"]
icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
card.layout().addWidget(icon)
return card
@@ -543,7 +544,7 @@ class PrescriptionLibraryPage(QWidget):
name_item = self.table.item(row_index, 1)
if name_item is not None:
name_item.setForeground(QColor("#1A1C1F"))
name_item.setForeground(QColor(PALETTE["heading"]))
font = name_item.font()
font.setWeight(QFont.Weight.Medium)
name_item.setFont(font)
@@ -50,6 +50,7 @@ from PySide6.QtWidgets import (
)
from .. import icons, motion
from ..design_system import PALETTE, clinical_stylesheet
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_safe_batch,
@@ -229,11 +230,11 @@ _ROLE_AVATAR = Qt.ItemDataRole.UserRole + 11
_ROLE_LEAD_ICON = Qt.ItemDataRole.UserRole + 12
_TAG_PALETTE = {
"accent": ("#4F63D9", "#EEF1FA"),
"success": ("#287B65", "#E7F8F1"),
"warning": ("#A9691D", "#FFF5DF"),
"danger": ("#BE4B58", "#FFF0F2"),
"neutral": ("#606163", "#F7F7F7"),
"accent": (PALETTE["accent"], PALETTE["accent_soft"]),
"success": (PALETTE["success"], PALETTE["success_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"]),
"neutral": (PALETTE["muted"], PALETTE["surface_alt"]),
}
@@ -293,7 +294,7 @@ class _RowDecorationDelegate(QStyledItemDelegate):
glyph,
QRectF(glyph.rect()),
)
painter.setPen(QColor("#1A1C1F"))
painter.setPen(QColor(PALETTE["heading"]))
painter.setFont(style_option.font)
text_rect = rect.adjusted(28, 0, -8, 0)
painter.drawText(
@@ -306,17 +307,17 @@ class _RowDecorationDelegate(QStyledItemDelegate):
else:
initial = str(avatar)[:1] or "医"
circle = QRectF(rect.left() + 8, rect.center().y() - 10, 20, 20)
painter.setPen(QPen(QColor("#EDEDEE"), 1.0))
painter.setBrush(QColor("#F7F7F7"))
painter.setPen(QPen(QColor(PALETTE["line"]), 1.0))
painter.setBrush(QColor(PALETTE["surface_alt"]))
painter.drawEllipse(circle.adjusted(0.5, 0.5, -0.5, -0.5))
initial_font = QFont(style_option.font)
initial_font.setPixelSize(10)
initial_font.setWeight(QFont.Weight.Medium)
painter.setFont(initial_font)
painter.setPen(QColor("#6A6B6D"))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(circle, Qt.AlignmentFlag.AlignCenter, initial)
painter.setFont(style_option.font)
painter.setPen(QColor("#1A1C1F"))
painter.setPen(QColor(PALETTE["heading"]))
name_rect = rect.adjusted(35, 0, -8, 0)
painter.drawText(
name_rect,
@@ -509,11 +510,11 @@ class _PrescriptionNumberDelegate(QStyledItemDelegate):
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
document.setDefaultTextOption(text_option)
paragraphs = [
f'<p style="margin:0;color:#1A1C1F;font-size:14px;font-weight:500">{escape(number)}</p>',
f'<p style="margin:2px 0 0;color:#606163;font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
f'<p style="margin:0;color:{PALETTE["heading"]};font-size:14px;font-weight:500">{escape(number)}</p>',
f'<p style="margin:2px 0 0;color:{PALETTE["muted"]};font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
]
paragraphs.extend(
'<p style="margin:2px 0 0;color:#BE4B58;font-size:13px;font-weight:400">'
f'<p style="margin:2px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">'
+ escape(warning)
+ "</p>"
for warning in _order_warnings(row)
@@ -603,14 +604,14 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
rect = option.rect.adjusted(2, 1, -2, -1)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
painter.setBrush(QColor(PALETTE["accent_soft"] if checked else PALETTE["canvas"] if hovered else PALETTE["surface"]))
painter.drawRoundedRect(rect, 5, 5)
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
painter.setPen(QPen(QColor(PALETTE["accent"] if checked else PALETTE["line_strong"]), 1))
painter.setBrush(QColor(PALETTE["accent"] if checked else PALETTE["surface"]))
painter.drawRoundedRect(box, 3, 3)
if checked:
pen = QPen(QColor("#FFFFFF"), 2)
pen = QPen(QColor(PALETTE["surface"]), 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
x, y = int(box.x()), int(box.y())
@@ -619,16 +620,16 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
text_rect = rect.adjusted(38, 0, -54, 0)
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
if checked:
painter.setPen(QColor("#1769E8"))
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#ADC8F2"), 1))
painter.setPen(QPen(QColor(PALETTE["line_strong"]), 1))
painter.drawRoundedRect(rect, 5, 5)
painter.restore()
@@ -674,18 +675,18 @@ class _DoctorSelectionDialog(QDialog):
self.setObjectName("DoctorSelectionDialog")
self.setWindowTitle("选择医生")
self.resize(420, 500)
self.setStyleSheet("""
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
""")
self.setStyleSheet(clinical_stylesheet("""
QDialog#DoctorSelectionDialog { background: $ds_surface; color: $ds_text; }
QListWidget#DoctorOptionsList { background: $ds_surface; border: 1px solid $ds_line; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: $ds_accent; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid $ds_line; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: $ds_accent; }
QDialog#DoctorSelectionDialog QPushButton { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: $ds_canvas; border-color: $ds_line_strong; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: $ds_disabled; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: $ds_accent; color: $ds_surface; border-color: $ds_accent; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
"""))
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
@@ -793,9 +794,9 @@ class DoctorMultiSelect(QWidget):
self.button.setText("、".join(names[:2]) + (f" 等{len(names)}人" if len(names) > 2 else ""))
def _blue_prescription_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
def _blue_prescription_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
result = QIcon(icons.icon(kind, color, size))
result.addPixmap(icons.pixmap(kind, "#A4ADBA", size), QIcon.Mode.Disabled)
result.addPixmap(icons.pixmap(kind, PALETTE["disabled"], size), QIcon.Mode.Disabled)
return result
@@ -803,7 +804,7 @@ class _PrescriptionCombo(QComboBox):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -812,7 +813,7 @@ class _PrescriptionDateTime(QDateTimeEdit):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 14)
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -825,7 +826,7 @@ class _PrescriptionDoctorButton(QPushButton):
option.text = self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, max(1, self.width() - 48))
painter = QPainter(self)
self.style().drawControl(QStyle.ControlElement.CE_PushButton, option, painter, self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -837,13 +838,13 @@ class _PrescriptionDoctorButton(QPushButton):
def _prescription_cell_background(painter: QPainter, option: Any, index: QModelIndex) -> None:
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
@@ -875,7 +876,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
column = index.column()
if column == 0:
@@ -888,9 +889,9 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
elif column in (3, 6):
kind = index.data(_ROLE_TAG_KIND)
foreground, background = {
"accent": ("#1769E8", "#EDF4FF"), "success": ("#1769E8", "#EDF4FF"),
"warning": ("#AD640C", "#FFF5E5"), "danger": ("#BE4657", "#FFF0F2"),
}.get(kind, ("#5D6B80", "#F5F8FD"))
"accent": (PALETTE["accent"], PALETTE["accent_soft"]), "success": (PALETTE["accent"], PALETTE["accent_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]), "danger": (PALETTE["danger"], PALETTE["danger_soft"]),
}.get(kind, (PALETTE["muted"], PALETTE["surface_alt"]))
font.setPixelSize(13)
painter.setFont(font)
label = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max(1, rect.width() - 12))
@@ -918,7 +919,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#5D6B80"))
painter.setPen(QColor(PALETTE["muted"]))
second_rect = first_rect.translated(0, 26)
painter.drawText(second_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(secondary, Qt.TextElideMode.ElideRight, rect.width()))
else:
@@ -942,11 +943,11 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
document.setDefaultTextOption(text_option)
paragraphs = [
f'<p style="margin:0;color:#273244;font-size:14px;font-weight:400">{escape(number)}</p>',
f'<p style="margin:6px 0 0;color:#5D6B80;font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
f'<p style="margin:0;color:{PALETTE["text"]};font-size:14px;font-weight:400">{escape(number)}</p>',
f'<p style="margin:6px 0 0;color:{PALETTE["muted"]};font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
]
paragraphs.extend(
'<p style="margin:6px 0 0;color:#BE4657;font-size:13px;font-weight:400">' + escape(warning) + "</p>"
f'<p style="margin:6px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">' + escape(warning) + "</p>"
for warning in _order_warnings(row)
)
document.setHtml("".join(paragraphs))
@@ -976,7 +977,7 @@ class _PrescriptionBlueTable(SortableTable):
for button in widget.findChildren(QPushButton):
kind = {"查看处方": "eye", "编辑处方": "pencil", "删除处方": "trash"}.get(button.accessibleName())
if kind:
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
class PrescriptionsPage(QWidget):
@@ -1060,7 +1061,7 @@ class PrescriptionsPage(QWidget):
self.add_button.setObjectName("PrescriptionAddButton")
self.add_button.setMinimumWidth(122)
self.add_button.setProperty("variant", "primary")
self.add_button.setIcon(_blue_prescription_icon("plus", "#FFFFFF"))
self.add_button.setIcon(_blue_prescription_icon("plus", PALETTE["surface"]))
self.add_button.setIconSize(QSize(15, 15))
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
@@ -1079,7 +1080,7 @@ class PrescriptionsPage(QWidget):
self.table_card = table_card
root.addWidget(table_card, 1)
for button, kind in ((self.view_button, "eye"), (self.edit_button, "pencil"), (self.delete_button, "trash")):
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
self._load_doctor_options()
def _build_filters(self) -> QWidget:
@@ -1226,7 +1227,7 @@ class PrescriptionsPage(QWidget):
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
refresh.setIcon(_blue_prescription_icon("refresh", PALETTE["muted"]))
refresh.setIconSize(QSize(15, 15))
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
@@ -1287,7 +1288,7 @@ class PrescriptionsPage(QWidget):
self.table.setItemDelegate(_PrescriptionInfoDelegate(self.table))
self.table.setItemDelegateForColumn(1, _PrescriptionNumberBlueDelegate(self.table))
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", PALETTE["disabled"], 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
@@ -1537,7 +1538,7 @@ class PrescriptionsPage(QWidget):
sn_item = self.table.item(row_index, 1)
if sn_item is not None:
sn_item.setForeground(QColor("#1A1C1F"))
sn_item.setForeground(QColor(PALETTE["heading"]))
font = sn_item.font()
font.setWeight(QFont.Weight.Normal)
sn_item.setFont(font)
@@ -75,6 +75,7 @@ from PySide6.QtWidgets import (
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from .. import icons
from ..design_system import PALETTE, clinical_stylesheet
from ..diagnosis_drawer import DailyRecordPanel
from ..diagnosis_editors import FlowLayout
from ..dialogs import DiagnosisDialog
@@ -2020,7 +2021,7 @@ def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
outline (bubble / chart page / capsule / clipboard / calendar-clock).
"""
return icons.icon(kind, "#5D6B80", size)
return icons.icon(kind, PALETTE["muted"], size)
def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon:
@@ -2882,7 +2883,7 @@ class _ReceptionSparkIcon(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
glyph = icons.pixmap("ai", "#1769E8", 18)
glyph = icons.pixmap("ai", PALETTE["accent"], 18)
painter.drawPixmap(QRectF(0, 0, 18, 18), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2896,9 +2897,9 @@ class _ReceptionAiSectionIcon(QWidget):
#: Section tone -> (plate, glyph tint, shared glyph name).
_TONES = {
"diagnosis": ("#EAF2FF", "#1769E8", "report"),
"risk": ("#EAF2FF", "#1769E8", "alert"),
"treatment": ("#EAF2FF", "#1769E8", "meds"),
"diagnosis": (PALETTE["accent_soft"], PALETTE["accent"], "report"),
"risk": (PALETTE["accent_soft"], PALETTE["accent"], "alert"),
"treatment": (PALETTE["accent_soft"], PALETTE["accent"], "meds"),
}
def __init__(self, tone: str, parent: QWidget | None = None) -> None:
@@ -2949,9 +2950,9 @@ class _ReceptionAssistantIcon(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#1769E8"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(QRectF(1, 1, 22, 22), 4, 4)
painter.setPen(QColor("#FFFFFF"))
painter.setPen(QColor(PALETTE["surface"]))
font = painter.font()
font.setPixelSize(11)
font.setBold(True)
@@ -2975,7 +2976,7 @@ class _ReceptionSendButton(QPushButton):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(
QPen(
QColor("#FFFFFF"),
QColor(PALETTE["surface"]),
1.8,
Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap,
@@ -3053,7 +3054,7 @@ class _ReceptionDateButton(QPushButton):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#5D6B80")
color = QColor(PALETTE["muted"])
painter.setPen(
QPen(
color,
@@ -3087,7 +3088,7 @@ class _ReceptionAiExpandButton(QPushButton):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#1769E8" if self.isEnabled() else "#8E8F90")
color = QColor(PALETTE["accent"] if self.isEnabled() else PALETTE["disabled"])
painter.setPen(
QPen(
color,
@@ -3120,7 +3121,7 @@ class _ReceptionAiModelCombo(QComboBox):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#273244" if self.isEnabled() else "#8E8F90")
color = QColor(PALETTE["text"] if self.isEnabled() else PALETTE["disabled"])
painter.setPen(
QPen(
color,
@@ -3162,7 +3163,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
self.resize(920, 760)
self.setMinimumSize(720, 560)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setStyleSheet(RECEPTION_AI_DIALOG_QSS)
self.setStyleSheet(clinical_stylesheet(RECEPTION_AI_DIALOG_QSS))
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
@@ -3365,7 +3366,11 @@ class _ReceptionAiAnalysisDialog(QDialog):
source_label.setTextFormat(Qt.TextFormat.PlainText)
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
source_label.setWordWrap(True)
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
source_label.setStyleSheet(clinical_stylesheet(
"color: $ds_muted; font-size: 12px; padding: 10px 14px; "
"background: $ds_surface_alt; border: 1px solid $ds_line; "
"border-radius: $ds_radius_control;"
))
body_layout.addWidget(source_label)
self.source_label = source_label
body_layout.addStretch(1)
@@ -3614,9 +3619,9 @@ class _VitalMetricIcon(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.drawRoundedRect(QRectF(0, 0, 22, 22), 6, 6)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), "#1769E8", 15)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), PALETTE["accent"], 15)
painter.drawPixmap(QRectF(3.5, 3.5, 15, 15), glyph, QRectF(glyph.rect()))
painter.end()
@@ -3636,7 +3641,7 @@ class _LifestyleIcon(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), "#5D6B80", 16)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), PALETTE["muted"], 16)
painter.drawPixmap(QRectF(0, 0, 16, 16), glyph, QRectF(glyph.rect()))
painter.end()
@@ -3672,7 +3677,7 @@ class _GenderMark(QWidget):
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor("#5D6B80"), 1.35)
pen = QPen(QColor(PALETTE["muted"]), 1.35)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
@@ -3737,7 +3742,7 @@ class _ReceptionQueueSpinner(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor("#1769E8"), 2.2)
pen = QPen(QColor(PALETTE["accent"]), 2.2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawArc(QRectF(2, 2, 14, 14), self._angle * 16, 250 * 16)
@@ -3873,15 +3878,15 @@ class QueueRow(QWidget):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
if self._selected:
painter.setBrush(QColor("#EAF2FF"))
painter.setPen(QPen(QColor("#E5ECF6"), 1))
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
else:
painter.setBrush(QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#E5ECF6"), 1))
painter.drawRoundedRect(rect, 6, 6)
painter.setBrush(QColor(PALETTE["surface"]))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
painter.drawRoundedRect(rect, 10, 10)
if self._selected:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#1769E8"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(QRectF(1.2, 1, 3.2, self.height() - 2), 1.6, 1.6)
@@ -4074,7 +4079,7 @@ class ReceptionDailyRecordsPanel(DailyRecordPanel):
font = item.font()
font.setWeight(QFont.Weight.DemiBold if high else QFont.Weight.Normal)
item.setFont(font)
item.setForeground(QBrush(QColor("#BE4B58" if high else "#273244")))
item.setForeground(QBrush(QColor(PALETTE["danger"] if high else PALETTE["text"])))
item.setBackground(
QBrush(
QColor(
@@ -4103,9 +4108,11 @@ class ReceptionPage(QWidget):
super().__init__(parent)
self.setObjectName("ReceptionPage")
self.setStyleSheet(
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
.replace("$heading_family", heading_family())
.replace("$number_family", number_family())
clinical_stylesheet(
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
.replace("$heading_family", heading_family())
.replace("$number_family", number_family())
)
)
self.repository = repository
self.permissions = permissions
@@ -4262,7 +4269,7 @@ class ReceptionPage(QWidget):
header_layout.addWidget(title)
header_layout.addStretch(1)
self.refresh_button = QPushButton("", header)
self.refresh_button.setIcon(icons.icon("refresh", "#5D6B80", 18))
self.refresh_button.setIcon(icons.icon("refresh", PALETTE["muted"], 18))
self.refresh_button.setObjectName("ReceptionRefreshButton")
self.refresh_button.setFixedSize(34, 32)
self.refresh_button.setAccessibleName("刷新接诊台")
@@ -4454,7 +4461,7 @@ class ReceptionPage(QWidget):
self.video_button.clicked.connect(self._request_video)
actions.addWidget(self.video_button)
self.more_button = QPushButton(hero)
self.more_button.setIcon(icons.icon("more", "#5D6B80", 16))
self.more_button.setIcon(icons.icon("more", PALETTE["muted"], 16))
self.more_button.setIconSize(QSize(16, 16))
self.more_button.setToolTip("更多操作")
self.more_button.setAccessibleName("更多操作")
@@ -5286,7 +5293,9 @@ class ReceptionPage(QWidget):
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.ai_source_label.setWordWrap(True)
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
self.ai_source_label.setStyleSheet(clinical_stylesheet(
"color: $ds_muted; font-size: 12px;"
))
analysis_layout.addWidget(self.ai_source_label)
self.ai_detail_actions_host = QWidget(analysis_card)
@@ -1,119 +1,126 @@
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return Template(_ORDERS).substitute(body=body_family())
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: #273244; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return clinical_stylesheet(Template(_ORDERS).safe_substitute(body=body_family()))
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: $ds_text; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientOrdersWorkspace QFrame#OrderSummaryStrip { background: transparent; border: 0; }
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid transparent;
border-radius: $ds_radius_control; background: $ds_surface_alt; color: $ds_text;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: $ds_accent_soft; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton:pressed,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:pressed { background: $ds_accent_pressed; border-color: $ds_accent_pressed; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton:focus,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled,
#PatientOrdersWorkspace QPushButton#OrderSearchButton:disabled,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: $ds_text;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: $ds_accent_soft; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: $ds_surface; alternate-background-color: $ds_surface;
color: $ds_text; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid $ds_line_strong; border-radius: 3px; background: $ds_surface;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: $ds_focus; }
#PatientOrdersWorkspace QCheckBox:focus { color: $ds_accent; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: $ds_line; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: $ds_muted; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: $ds_text; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: $ds_accent; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: $ds_surface; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: $ds_accent; background: $ds_accent_soft; }
#PatientOrdersWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -1,54 +1,55 @@
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return Template(_PROGRESS).substitute(body=body_family())
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: #273244; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return clinical_stylesheet(Template(_PROGRESS).safe_substitute(body=body_family()))
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: $ds_text; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: $ds_line; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: $ds_text; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: $ds_accent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: $ds_accent_soft; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientProgressWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
+154 -141
View File
@@ -1,141 +1,154 @@
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
from string import Template
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
def patient_list_stylesheet() -> str:
return Template(_LIST).substitute(body=body_family())
_CHROME = """
#PatientsPage { background: #F3F7FD; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: #273244; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: #5D6B80; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientSummaryStrip,
#PatientListWorkspace QFrame#PatientListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
}
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
}
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF;
}
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientListWorkspace QDateEdit:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
#PatientListWorkspace QTableWidget#PatientTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Quiet clinical surfaces for the patient-list workspace."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return clinical_stylesheet(Template(_CHROME).safe_substitute(body=body_family(), heading=heading_family()))
def patient_list_stylesheet() -> str:
return clinical_stylesheet(Template(_LIST).safe_substitute(body=body_family()))
_CHROME = """
#PatientsPage { background: $ds_canvas; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: $ds_muted;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; }
#PatientsPage QPushButton#PatientRefreshButton:pressed { background: $ds_accent_soft; }
#PatientsPage QPushButton#PatientRefreshButton:focus { border-color: $ds_focus; }
#PatientsPage QPushButton#PatientRefreshButton:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: $ds_accent; background: $ds_accent_soft; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: $ds_text; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: $ds_muted; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientListCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientListWorkspace QFrame#PatientSummaryStrip { background: transparent; border: 0; }
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: $ds_accent; background: $ds_surface_alt; border-color: $ds_line_strong;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: $ds_focus; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: $ds_accent_hover; }
#PatientSearchToolbar QPushButton#PatientSearchButton:pressed { background: $ds_accent_pressed; }
#PatientSearchToolbar QPushButton#PatientSearchButton:focus { border-color: $ds_focus; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: $ds_surface;
border: 1px solid $ds_line; border-radius: $ds_radius_control; selection-background-color: $ds_accent_soft;
}
#PatientSearchToolbar QLineEdit:focus { border-color: $ds_focus; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: $ds_text; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: $ds_accent; background: $ds_surface_alt; }
#PatientListWorkspace QPushButton[patientStatusChip="true"]:focus { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: $ds_accent; background: $ds_accent_soft; border: 1px solid transparent; font-weight: 600;
}
#PatientListWorkspace QPushButton[patientQuickDate="true"]:focus,
#PatientListWorkspace QPushButton#PatientCustomDateButton:focus { border-color: $ds_focus; }
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
selection-background-color: $ds_accent_soft;
}
#PatientListWorkspace QDateEdit:focus { border-color: $ds_focus; }
#PatientListWorkspace QDateEdit:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent;
border: 1px solid transparent; border-radius: $ds_radius_control;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: $ds_surface_alt; }
#PatientListWorkspace QPushButton[summaryCard="true"]:focus { border-color: $ds_focus; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: $ds_line; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: $ds_text; }
#PatientListWorkspace QTableWidget#PatientTable {
background: $ds_surface; alternate-background-color: $ds_surface; border: 0; border-radius: 0;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-top: 1px solid $ds_line; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 1px solid transparent;
border-radius: $ds_radius_small; background: transparent; color: $ds_accent; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: $ds_text; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton[rowAction="true"]:focus,
#PatientListWorkspace QToolButton#RowActionsMore:focus { border-color: $ds_focus; }
#PatientListWorkspace QMenu { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: $ds_accent_soft; color: $ds_accent; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: $ds_surface; border: 1px solid $ds_line_strong; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
#PatientListWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientListWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -1,410 +1,419 @@
"""Local technology-blue rendering for the approved prescription library."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, TableColumn, first_value
def library_stylesheet() -> str:
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, "#A4ADBA"),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Fit readable columns while preserving actions and selected template IDs."""
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(columns, parent)
self.horizontalHeader().setStretchLastSection(False)
self._minimum_widths = tuple(column.width for column in self.columns)
self._manual_widths: dict[int, int] = {}
self._fitting_columns = False
self._fit_timer = QTimer(self)
self._fit_timer.setSingleShot(True)
self._fit_timer.timeout.connect(self._fit_columns)
self.horizontalHeader().sectionResized.connect(self._column_resized)
self.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
self._schedule_column_fit()
return super().eventFilter(watched, event)
def _schedule_column_fit(self) -> None:
if not self._fit_timer.isActive():
self._fit_timer.start(0)
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
if self._fitting_columns:
return
self._manual_widths[column] = max(self._minimum_widths[column], size)
self._schedule_column_fit()
def _fit_columns(self) -> None:
widths = [self._manual_widths.get(column, minimum)
for column, minimum in enumerate(self._minimum_widths)]
extra = max(0, self.viewport().width() - sum(widths))
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
if column not in self._manual_widths]
# If every text column was manually sized, keep those preferences as
# minimums and let the herb preview absorb any remaining wide-screen space.
flexible = flexible or [(4, 1)]
total_weight = sum(weight for _column, weight in flexible)
for column, weight in flexible:
widths[column] += extra * weight // total_weight
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.setColumnWidth(column, width)
finally:
self._fitting_columns = False
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = "#BE4657" if button.property("danger") else "#1769E8"
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("、")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "、" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor("#5D6B80"))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor("#E6EDF6"))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor("#1769E8"))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor("#273244"))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.drawRoundedRect(pill, 4, 4)
painter.setPen(QColor("#1769E8"))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
#PrescriptionLibraryPage QFrame#MetricCard,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: #273244; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: #1555B6; background: #F2F7FF;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
"""Shared clinical tokens for the prescription library and its native delegates."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .design_system import PALETTE, RADII, clinical_stylesheet
from .reception_style import body_family, heading_family
from .widgets import SortableTable, TableColumn, first_value
def library_stylesheet() -> str:
return clinical_stylesheet(Template(_LIBRARY).safe_substitute(body=body_family(), heading=heading_family()))
def _library_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, PALETTE["disabled"]),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", PALETTE["muted"], 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Fit readable columns while preserving actions and selected template IDs."""
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(columns, parent)
self.horizontalHeader().setStretchLastSection(False)
self._minimum_widths = tuple(column.width for column in self.columns)
self._manual_widths: dict[int, int] = {}
self._fitting_columns = False
self._fit_timer = QTimer(self)
self._fit_timer.setSingleShot(True)
self._fit_timer.timeout.connect(self._fit_columns)
self.horizontalHeader().sectionResized.connect(self._column_resized)
self.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
self._schedule_column_fit()
return super().eventFilter(watched, event)
def _schedule_column_fit(self) -> None:
if not self._fit_timer.isActive():
self._fit_timer.start(0)
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
if self._fitting_columns:
return
self._manual_widths[column] = max(self._minimum_widths[column], size)
self._schedule_column_fit()
def _fit_columns(self) -> None:
widths = [self._manual_widths.get(column, minimum)
for column, minimum in enumerate(self._minimum_widths)]
extra = max(0, self.viewport().width() - sum(widths))
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
if column not in self._manual_widths]
# If every text column was manually sized, keep those preferences as
# minimums and let the herb preview absorb any remaining wide-screen space.
flexible = flexible or [(4, 1)]
total_weight = sum(weight for _column, weight in flexible)
for column, weight in flexible:
widths[column] += extra * weight // total_weight
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.setColumnWidth(column, width)
finally:
self._fitting_columns = False
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = PALETTE["danger"] if button.property("danger") else PALETTE["accent"]
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("、")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "、" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor(PALETTE["muted"]))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor(PALETTE["accent"]))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor(PALETTE["text"]))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.drawRoundedRect(pill, RADII["small"], RADII["small"])
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, PALETTE["muted"], 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: $ds_canvas; color: $ds_text; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: $ds_muted; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionLibraryPage QFrame#MetricCard { background: $ds_surface; border: 0; border-radius: $ds_radius_panel; }
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: $ds_muted; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: $ds_text; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
}
#PrescriptionLibraryPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
#PrescriptionLibraryPage QPushButton:pressed { background: $ds_accent_soft; }
#PrescriptionLibraryPage QPushButton:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: $ds_accent; border-color: $ds_accent; color: $ds_surface;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: $ds_accent_hover; border-color: $ds_accent_hover;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:focus,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
#PrescriptionLibraryPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
#PrescriptionLibraryPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:disabled,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: $ds_text; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: $ds_accent; border-bottom-color: $ds_accent;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: $ds_accent; background: $ds_surface_alt;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:focus { background: $ds_accent_soft; }
#PrescriptionLibraryPage QTableWidget {
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: $ds_disabled; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
@@ -1,107 +1,115 @@
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
_PRESCRIPTIONS = """
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: #A4ADBA;
}
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return clinical_stylesheet(Template(_PRESCRIPTIONS).safe_substitute(body=body_family(), heading=heading_family()))
_PRESCRIPTIONS = """
#PrescriptionsPage { background: $ds_canvas; color: $ds_text; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: $ds_muted; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
}
#PrescriptionsPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
#PrescriptionsPage QPushButton:pressed { background: $ds_accent_soft; }
#PrescriptionsPage QPushButton:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: $ds_accent; color: $ds_surface; border-color: $ds_accent;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: $ds_accent_hover; border-color: $ds_accent_hover;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:focus,
#PrescriptionsPage QPushButton#PrescriptionQueryButton:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
#PrescriptionsPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
#PrescriptionsPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled,
#PrescriptionsPage QPushButton#PrescriptionAddButton:disabled,
#PrescriptionsPage QPushButton#PrescriptionQueryButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: $ds_focus; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: $ds_text; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: $ds_text; background: $ds_surface; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: $ds_accent; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: $ds_disabled;
}
#PrescriptionsPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: $ds_accent; background: $ds_surface_alt; border: 1px solid $ds_line; border-radius: $ds_radius_panel; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
@@ -4,23 +4,24 @@ from __future__ import annotations
from PySide6.QtWidgets import QApplication
from .design_system import PALETTE
from .theme import _register_preferred_cjk_fonts
TECH_BLUE = {
"accent": "#1769E8",
"accent_hover": "#155BCC",
"accent_pressed": "#124EA9",
"selection": "#EAF2FF",
"selected_text": "#1555B6",
"canvas": "#F3F7FD",
"sidebar": "#EDF4FF",
"surface": "#FFFFFF",
"raised": "#F7FAFE",
"line": "#DBE5F2",
"text": "#273244",
"heading": "#202C3F",
"muted": "#5D6B80",
"focus": "#75A5F0",
"accent": PALETTE["accent"],
"accent_hover": PALETTE["accent_hover"],
"accent_pressed": PALETTE["accent_pressed"],
"selection": PALETTE["accent_soft"],
"selected_text": PALETTE["accent_hover"],
"canvas": PALETTE["canvas"],
"sidebar": PALETTE["sidebar"],
"surface": PALETTE["surface"],
"raised": PALETTE["surface_alt"],
"line": PALETTE["line"],
"text": PALETTE["text"],
"heading": PALETTE["heading"],
"muted": PALETTE["muted"],
"focus": PALETTE["focus"],
}
@@ -170,8 +170,8 @@ QWidget#ReceptionPage QStackedWidget#ReceptionNarrowWorkspace {
}
QWidget#ReceptionPage QFrame#ReceptionQueuePanel {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionQueueTitle {
font-size: 18px;
@@ -200,8 +200,8 @@ QWidget#ReceptionPage QFrame#ReceptionHero,
QWidget#ReceptionPage QFrame#ReceptionClinicalCard {
min-height: 0;
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientName {
font-size: 20px;
@@ -223,8 +223,8 @@ QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5ECF6;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab {
@@ -295,8 +295,8 @@ QWidget#ReceptionPage QFrame#ReceptionPatientSummaryCard,
QWidget#ReceptionPage QFrame#ReceptionVitalsCard,
QWidget#ReceptionPage QFrame#ReceptionLabsCard {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue {
color: #24334A;
@@ -367,8 +367,8 @@ QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"][warning=
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard,
QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard {
background-color: #FFFFFF;
border: 1px solid #DCE5F0;
border-radius: 8px;
border: 1px solid $ds_line;
border-radius: $ds_radius_panel;
}
QWidget#ReceptionPage QLabel#ReceptionAiTitle,
QWidget#ReceptionPage QPushButton#ReceptionAiTitle {
@@ -405,4 +405,37 @@ QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::add-line:v
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::sub-line:vertical {
height: 0;
}
QWidget#ReceptionPage QLineEdit#ReceptionQueueSearch,
QWidget#ReceptionPage QPushButton#ReceptionDateButton,
QWidget#ReceptionPage QPushButton#ReceptionQueueFilterToggle,
QWidget#ReceptionPage QPushButton#ReceptionVideoButton,
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
QWidget#ReceptionPage QPushButton#ReceptionImButton,
QWidget#ReceptionPage QPushButton#ReceptionMoreButton,
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton,
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] {
border-radius: $ds_radius_control;
}
QWidget#ReceptionPage QPushButton:focus,
QWidget#ReceptionPage QLineEdit:focus,
QWidget#ReceptionPage QComboBox:focus,
QWidget#ReceptionPage QTextEdit:focus {
border-color: $ds_focus;
}
QWidget#ReceptionPage QPushButton#ReceptionVideoButton:pressed {
color: $ds_surface;
background-color: $ds_accent_pressed;
border-color: $ds_accent_pressed;
}
QWidget#ReceptionPage QLabel[receptionMetricState="true"] {
border-radius: $ds_radius_small;
}
QWidget#ReceptionPage QFrame#ReceptionLabCell,
QWidget#ReceptionPage QWidget#ReceptionCaseField {
border-bottom-color: $ds_line;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue[missing="true"] {
color: $ds_muted;
}
"""
+11 -10
View File
@@ -59,6 +59,7 @@ from .chat_notifications import (
ChatNotification,
ChatNotificationCenter,
)
from .design_system import PALETTE, clinical_stylesheet
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.diagnosis import DiagnosisDialog
@@ -72,7 +73,7 @@ from .pages import (
ReceptionPage,
)
from .reception_style import TECH_BLUE, body_family, heading_family, number_family
from .theme import COLORS, TYPE, crisp_pixmap
from .theme import COLORS, METRICS, TYPE, crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
@@ -93,8 +94,8 @@ _SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
_RECEPTION_SIDEBAR_WIDTH = 208
_RECEPTION_TOPBAR_HEIGHT = 76
_RECEPTION_SIDEBAR_COLOR = "#F3F7FD"
_RECEPTION_CHROME_LINE = "#E7EDF7"
_RECEPTION_SIDEBAR_COLOR = PALETTE["sidebar"]
_RECEPTION_CHROME_LINE = PALETTE["line"]
# Approved pages share one chrome state, so navigation among them keeps it intact.
_TECH_BLUE_PAGES = frozenset({"appointments", "consultations", "patients", "prescriptions", "prescription_library", "reception"})
@@ -1005,7 +1006,7 @@ class ShellWindow(QMainWindow):
chrome_widgets.extend(self.topbar.findChildren(_PaintedIconButton))
chrome_widgets.append(self.tabs_menu_button)
self._legacy_chrome_styles = {
widget: widget.styleSheet() for widget in chrome_widgets
widget: clinical_stylesheet(widget.styleSheet()) for widget in chrome_widgets
}
self._register_pages()
self.search_shortcut = QShortcut(QKeySequence("Ctrl+K"), self)
@@ -1268,7 +1269,7 @@ class ShellWindow(QMainWindow):
color: #606163;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 999px;
border-radius: 10px;
padding: 4px 10px;
font-size: 13px;
font-weight: 500;
@@ -1693,7 +1694,7 @@ class ShellWindow(QMainWindow):
font-family: "{body_family()}"; font-weight: 400;
min-height: 44px; max-height: 44px;
border: 0; border-left: 5px solid transparent;
border-radius: 6px;
border-radius: {METRICS["r_md"]};
padding: 0 16px; font-size: {TYPE['fs_body']};
color: {TECH_BLUE['text']};
}}
@@ -1711,7 +1712,7 @@ class ShellWindow(QMainWindow):
}}
QPushButton#ShellNavButton[collapsed="true"] {{ padding: 0; }}
QFrame#ShellAssistantCard {{
background-color: {TECH_BLUE['surface']}; border-radius: 8px;
background-color: {TECH_BLUE['surface']}; border-radius: {METRICS["r_lg"]};
border: 1px solid {_RECEPTION_CHROME_LINE};
}}
QLabel#ShellAssistantName {{
@@ -1723,7 +1724,7 @@ class ShellWindow(QMainWindow):
QPushButton#ShellAssistantButton, QPushButton#ShellSettingsButton {{
font-family: "{body_family()}"; font-weight: 400; font-size: {TYPE['fs_body']};
}}
QPushButton#ShellAssistantButton {{ border-radius: 6px; }}
QPushButton#ShellAssistantButton {{ border-radius: {METRICS["r_md"]}; }}
QPushButton#ShellSettingsButton {{
min-height: 50px; max-height: 50px;
text-align: left; padding: 0 32px;
@@ -1738,7 +1739,7 @@ class ShellWindow(QMainWindow):
border-radius: 0;
border-bottom: 1px solid {_RECEPTION_CHROME_LINE};
}}
QFrame#ShellGlobalSearch {{ border: 1px solid {_RECEPTION_CHROME_LINE}; border-radius: 5px; }}
QFrame#ShellGlobalSearch {{ background-color: {PALETTE["surface_alt"]}; border: 1px solid transparent; border-radius: {METRICS["r_md"]}; }}
QFrame#ShellUserSeparator {{ background: {_RECEPTION_CHROME_LINE}; border: 0; }}
QLabel#ShellShortcutHint {{
font-family: "{number_family()}"; font-size: {TYPE['fs_small']};
@@ -1767,7 +1768,7 @@ class ShellWindow(QMainWindow):
if isinstance(widget, _PaintedIconButton):
side = 50 if widget in (self.refresh_button, self.notification_button, self.settings_button) else 34
stylesheet += f"QToolButton#ShellPaintedIconButton {{ min-width: {side - 2}px; max-width: {side - 2}px; min-height: {side - 2}px; max-height: {side - 2}px; }}"
widget.setStyleSheet(stylesheet)
widget.setStyleSheet(clinical_stylesheet(stylesheet))
self._set_reception_geometry(active)
for widget in (
+67 -70
View File
@@ -41,46 +41,46 @@ from doctor_workstation.resources import resource_path
# ``motion`` deliberately imports nothing from this module, so this stays a
# one-way dependency: tokens here, animation there.
from . import motion
from .design_system import PALETTE, RADII, SPACING, clinical_stylesheet
# Neutral reading colors follow the installed Codex light chrome defaults.
# Shared reading colors keep native pages and dialog chrome consistent.
# Brand accents are independent of reading ink; use opaque text for stable contrast.
# The legacy teal/ink aliases remain for existing callers.
COLORS = {
"canvas": "#F4F6FA",
"canvas_mid": "#FFFFFF",
"canvas_glow": "#EEF1FA",
"surface": "#FFFFFF",
"surface_alt": "#F7F7F7",
"raised": "#F7F7F7",
"glass": "#FFFFFF",
"glass_alt": "#F7F7F7",
"line": "#EDEDEE",
"line_soft": "#E4E4E5",
"text": "#1A1C1F",
"text_soft": "#606163",
"muted": "#6A6B6D",
"disabled_surface": "#F2F2F2",
"disabled_text": "#8E8F90",
"indigo": "#4F63D9",
"indigo_hover": "#4156C4",
"indigo_pressed": "#354BB4",
"indigo_pale": "#EEF1FA",
"focus": "#8B9AD9",
"selection": "#EEF1FA",
"success": "#287B65",
"success_pale": "#EEF7F3",
"warning": "#A9691D",
"warning_pale": "#FCF5E9",
"danger": "#BE4B58",
"danger_pale": "#FCF0F2",
"info": "#4F63D9",
"info_pale": "#EEF1FA",
# Backward-compatible names used by older UI code and integrations.
"ink": "#1A1C1F",
"ink_soft": "#606163",
"teal": "#4F63D9",
"teal_dark": "#354BB4",
"teal_pale": "#EEF1FA",
"canvas": PALETTE["canvas"],
"canvas_mid": PALETTE["surface"],
"canvas_glow": PALETTE["accent_soft"],
"surface": PALETTE["surface"],
"surface_alt": PALETTE["surface_alt"],
"raised": PALETTE["surface_alt"],
"glass": PALETTE["surface"],
"glass_alt": PALETTE["surface_alt"],
"line": PALETTE["line"],
"line_soft": PALETTE["line_strong"],
"text": PALETTE["heading"],
"text_soft": PALETTE["text"],
"muted": PALETTE["muted"],
"disabled_surface": PALETTE["disabled_surface"],
"disabled_text": PALETTE["disabled"],
"indigo": PALETTE["accent"],
"indigo_hover": PALETTE["accent_hover"],
"indigo_pressed": PALETTE["accent_pressed"],
"indigo_pale": PALETTE["accent_soft"],
"focus": PALETTE["focus"],
"selection": PALETTE["accent_soft"],
"success": PALETTE["success"],
"success_pale": PALETTE["success_soft"],
"warning": PALETTE["warning"],
"warning_pale": PALETTE["warning_soft"],
"danger": PALETTE["danger"],
"danger_pale": PALETTE["danger_soft"],
"info": PALETTE["accent"],
"info_pale": PALETTE["accent_soft"],
"ink": PALETTE["heading"],
"ink_soft": PALETTE["text"],
"teal": PALETTE["accent"],
"teal_dark": PALETTE["accent_pressed"],
"teal_pale": PALETTE["accent_soft"],
}
@@ -122,11 +122,11 @@ METRICS = {
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
# element is far enough from the corner that its own radius is free.
"r_xs": "4px", # chips, badges, tiny status pills
"r_sm": "6px", # inline row actions, tags
"r_md": "8px", # buttons, inputs, combo boxes
"r_lg": "12px", # cards, filter bars, panels
"r_xl": "16px", # the shell surfaces the cards sit on
"pad_control": "12px", # horizontal padding inside default controls
"r_sm": f"{RADII['small']}px", # inline row actions, tags
"r_md": f"{RADII['control']}px", # buttons, inputs, combo boxes
"r_lg": f"{RADII['panel']}px", # cards, filter bars, panels
"r_xl": f"{RADII['window']}px", # the shell surfaces the cards sit on
"pad_control": f"{SPACING['control']}px", # horizontal padding inside default controls
"pad_compact": "9px",
}
@@ -176,7 +176,7 @@ QDialog[businessDialog="true"] QFrame#DialogSurface,
QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
background-color: $surface;
border: 1px solid $line;
border-radius: 14px;
border-radius: $r_lg;
}
QDialog[businessDialog="true"] QFrame#DialogHeader,
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
@@ -275,18 +275,18 @@ QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
QFrame[glass="true"] {
background-color: $glass;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QFrame#FilterBar { background-color: $surface; }
QFrame#SubtleCard {
background-color: $glass_alt;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QFrame#MetricCard {
background-color: $surface;
border: 1px solid $line;
border-radius: 11px;
border-radius: $r_md;
}
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
@@ -297,7 +297,7 @@ QFrame#ReceptionAiCard {
min-height: 132px;
background-color: #FFFFFF;
border: 1px solid $line_soft;
border-radius: 13px;
border-radius: $r_lg;
}
QLabel#ReceptionAiTitle {
color: $indigo_pressed;
@@ -313,7 +313,7 @@ QLabel#MetricGlyph {
color: $indigo;
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 11px;
border-radius: $r_md;
font-size: $fs_section;
font-weight: $fw_heading;
}
@@ -329,7 +329,7 @@ QGroupBox {
margin-top: 12px;
padding-top: 12px;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
background-color: $glass;
font-weight: $fw_control;
}
@@ -351,7 +351,7 @@ QPushButton {
}
QPushButton:hover {
background-color: $raised;
border-color: $indigo_hover;
border-color: $line_soft;
}
QPushButton:pressed {
background-color: $indigo_pale;
@@ -401,7 +401,7 @@ QPushButton[variant="primary"]:disabled {
QPushButton[variant="secondary"] {
color: $indigo_hover;
background-color: $indigo_pale;
border-color: $line_soft;
border-color: $indigo_pale;
}
QPushButton[variant="secondary"]:hover {
color: $indigo_pressed;
@@ -462,7 +462,7 @@ QToolButton#RowActionsMore::menu-indicator { width: 0; height: 0; image: none; }
QFrame#NoteAttachmentTile {
background-color: $surface;
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
}
QPushButton#NoteAttachmentPreview {
min-height: 132px;
@@ -470,7 +470,7 @@ QPushButton#NoteAttachmentPreview {
color: $muted;
background-color: $surface_alt;
border: 1px solid $line;
border-radius: 8px;
border-radius: $r_md;
font-weight: $fw_control;
}
QPushButton#NoteAttachmentPreview:hover {
@@ -558,7 +558,7 @@ QPushButton[variant="chip"] {
color: $text_soft;
background-color: $surface_alt;
border-color: $line;
border-radius: 8px;
border-radius: $r_md;
}
QPushButton[variant="chip"]:hover {
color: $text;
@@ -653,7 +653,7 @@ QComboBox QAbstractItemView {
background-color: $raised;
alternate-background-color: $surface_alt;
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
padding: 5px;
outline: 0;
selection-background-color: $indigo_pressed;
@@ -692,7 +692,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
background-color: $surface;
alternate-background-color: $surface_alt;
border: 0;
border-radius: 12px;
border-radius: $r_lg;
gridline-color: $line;
selection-background-color: $selection;
selection-color: $text;
@@ -729,17 +729,17 @@ QListWidget::item:selected, QListView::item:selected,
QTreeWidget::item:selected, QTreeView::item:selected {
background-color: $indigo_pale;
color: $indigo_pressed;
border-radius: 8px;
border-radius: $r_md;
}
QListWidget::item:hover, QListView::item:hover,
QTreeWidget::item:hover, QTreeView::item:hover {
background-color: $surface_alt;
border-radius: 8px;
border-radius: $r_md;
}
QTabWidget::pane {
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
background-color: $glass;
}
QTabBar::tab {
@@ -749,7 +749,7 @@ QTabBar::tab {
color: $muted;
background-color: transparent;
border: 1px solid transparent;
border-radius: 9px;
border-radius: $r_md;
font-weight: $fw_control;
}
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
@@ -792,7 +792,7 @@ QMenu {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 10px;
border-radius: $r_md;
padding: 6px;
}
QMenu::item {
@@ -815,7 +815,7 @@ QCalendarWidget {
color: $text;
background-color: $surface;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QCalendarWidget QWidget#qt_calendar_navigationbar {
background-color: $raised;
@@ -914,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
color: #FFFFFF;
background-color: $indigo;
border: 1px solid $indigo;
border-radius: 8px;
border-radius: $r_md;
font-size: $fs_caption;
font-weight: $fw_control;
}
@@ -937,7 +937,7 @@ QLabel#EmptyStateGlyph {
QFrame#MessageBanner {
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
color: $text_soft;
}
QFrame#MessageBanner QLabel { background-color: transparent; }
@@ -955,7 +955,7 @@ QLabel#Toast {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 11px;
border-radius: $r_md;
padding: 11px 16px;
font-weight: $fw_control;
}
@@ -995,13 +995,9 @@ QLabel#UserAvatar {
font-weight: $fw_heading;
}
QWidget#LoginBrandPanel {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $surface,
stop: 1 $canvas_glow
);
background-color: $surface_alt;
border: 1px solid $line_soft;
border-radius: 22px;
border-radius: $r_lg;
}
QFrame#LoginCard {
background-color: $glass;
@@ -1043,6 +1039,7 @@ QToolTip {
}
"""
).substitute(_QSS_TOKENS)
GLOBAL_QSS = clinical_stylesheet(GLOBAL_QSS)
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
+2 -1
View File
@@ -44,6 +44,7 @@ from doctor_workstation.core.errors import (
)
from . import icons, motion
from .design_system import PALETTE
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -798,7 +799,7 @@ class _BusyTrack(QWidget):
chunk_width = max(36, int(rect.width() * 0.32))
span = rect.width() + chunk_width
x = int(self._phase * span) - chunk_width
painter.setBrush(QColor("#4F63D9"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(x, 0, chunk_width, rect.height(), 3, 3)
+387
View File
@@ -0,0 +1,387 @@
"""Bounded, Qt-independent AI requests for one authenticated IM window.
Only server-archived clinical context is submitted. This module never sends IM
messages and never shares the ordered call/recording lifecycle worker.
"""
from __future__ import annotations
import re
from collections import deque
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from threading import BoundedSemaphore, Event, RLock, Thread
from time import monotonic
from typing import Any
from uuid import UUID, uuid4
MAX_RESULT_CHARS = 65_536
MAX_SYNC_STEPS = 24
SYNC_TIMEOUT_SECONDS = 45.0
_WORKER_SLOTS = BoundedSemaphore(4)
_CANCEL_SLOTS = BoundedSemaphore(4)
_ACTIONS = frozenset({"summary", "followup", "reply"})
def _integer(value: Any, *, minimum: int) -> int | None:
if isinstance(value, bool) or not isinstance(value, (int, str)):
return None
if isinstance(value, str) and re.fullmatch(r"0|[1-9][0-9]{0,18}", value) is None:
return None
number = int(value)
return number if minimum <= number < 2**63 else None
def _request_uuid(value: Any) -> bool:
if not isinstance(value, str) or len(value) != 36:
return False
try:
return str(UUID(value)) == value.lower()
except (ValueError, AttributeError):
return False
def task_prompt(action: str, doctor_account: str) -> tuple[str, str]:
"""Keep instructions separate from clinical source text and under API limits."""
if action not in _ACTIONS or not re.fullmatch(r"doctor_[1-9][0-9]{0,18}", doctor_account):
raise ValueError("AI 操作或当前医生账号无效")
shared = (
f"这是 IM 问诊辅助,本次医生 IM 账号为 {doctor_account}。"
"优先依据该医生与本患者最近已归档对话,结合服务端授权病历;"
"其他医生及历史记录仅作背景,不能当成本次已确认事实。"
"若未找到本次对话请明确说明,不猜测最新诉求。"
"区分已知、明确否认与未知,保留日期及不确定性;"
"不补问脉象,不编造事实、不自动确诊、不新增或更改药物剂量。"
)
instructions = {
"summary": "请用简短条目总结本次病情、病程、现用治疗与待核实事项,供医生交接复核。",
"followup": (
"请建议最多5条优先追问,直接给出可向患者询问的自然短句,"
"每条可附简短目的;避免重复已回答的问题,不做通用问卷。"
),
"reply": (
"请起草一段医生可审阅后发给患者的简短自然回复,围绕该对话最近的患者问题。"
"不冒称已检查、已预约或已开处方,不承诺疗效,不让患者自行停换药。"
"只给回复草稿,不写分析过程;任何缺失资料通过必要的问题核实。"
),
}
prompt = shared + instructions[action] + "此内容须由医生复核,不能自动发送。"
if len(prompt) > 500:
raise ValueError("AI 提示词超过接口长度限制")
return ("summary" if action == "summary" else "custom"), prompt
def _warnings(value: Any) -> list[str]:
if not isinstance(value, (list, tuple)):
return []
warnings = []
for row in value[:20]:
if isinstance(row, str):
text = row
elif isinstance(row, Mapping):
text = row.get("message") or row.get("label")
if not text and row.get("code"):
text = (
"请复核回答中的脉象判断;本流程不采集脉象。"
if row["code"] == "PULSE_EXCLUDED_FROM_ASSESSMENT"
else "请对照原始记录复核,回答中的事实、否认或未知状态可能不一致。"
)
else:
continue
if isinstance(text, str) and text.strip():
rendered = text.strip()[:400]
if rendered not in warnings:
warnings.append(rendered)
return warnings
class _RequestError(Exception):
"""A deliberately safe, user-facing failure message."""
@dataclass
class _Job:
identity: dict[str, Any]
action: str
cancelled: Event = field(default_factory=Event)
completed: bool = False
succeeded: bool = False
stream_started: bool = False
cancel_sent: bool = False
class ImAiController:
"""One live request, a coalesced mailbox, and bounded daemon workers.
Qt polls ``take_result`` every 60 ms. Worker threads only mutate plain data;
they never touch a QObject. A cancelled worker must retire before a retry
starts, so rapid clicks cannot create an unbounded collection of requests.
"""
def __init__(
self,
repository: Any,
*,
diagnosis_id: Any,
appointment_id: Any,
doctor_account: str,
enabled: bool = False,
clock: Callable[[], float] = monotonic,
max_sync_steps: int = MAX_SYNC_STEPS,
sync_timeout: float = SYNC_TIMEOUT_SECONDS,
) -> None:
self.repository = repository
self.diagnosis_id = _integer(diagnosis_id, minimum=1)
self.appointment_id = _integer(appointment_id, minimum=0)
self.doctor_account = str(doctor_account)
self.enabled = (
enabled is True
and repository is not None
and self.diagnosis_id is not None
and self.appointment_id is not None
and re.fullmatch(r"doctor_[1-9][0-9]{0,18}", self.doctor_account) is not None
)
self.session_id = str(uuid4())
self._clock = clock
self._max_sync_steps = max(1, min(MAX_SYNC_STEPS, max_sync_steps))
self._sync_timeout = max(0.01, min(SYNC_TIMEOUT_SECONDS, sync_timeout))
self._lock = RLock()
self._closed = False
self._current: _Job | None = None
self._running: _Job | None = None
self._worker: Thread | None = None
self._mailbox: dict[str, Any] | None = None
self._seen: deque[str] = deque(maxlen=256)
@property
def config(self) -> dict[str, Any]:
return {
"version": 1,
"enabled": self.enabled and not self._closed,
"sessionId": self.session_id,
**({} if self.enabled else {"reason": "当前账号或问诊暂不能使用 AI 辅助。"}),
}
def _valid_identity(self, message: Mapping[str, Any]) -> bool:
return (
self.enabled
and not self._closed
and message.get("source") == "doctor-call"
and message.get("sessionId") == self.session_id
and _request_uuid(message.get("requestId"))
and isinstance(message.get("contextKey"), str)
and 0 < len(message["contextKey"]) <= 128
and _integer(message.get("diagnosisId"), minimum=1) == self.diagnosis_id
and _integer(message.get("appointmentId"), minimum=0) == self.appointment_id
and isinstance(message.get("action"), str)
and message["action"] in _ACTIONS
)
def handle(self, message: Mapping[str, Any]) -> bool:
with self._lock:
if not self._valid_identity(message):
return False
event = message.get("event")
identity = {key: message[key] for key in (
"sessionId", "requestId", "contextKey", "diagnosisId", "appointmentId", "action"
)}
if event == "im-ai-cancel":
if self._current is None or self._current.identity != identity:
return False
self._cancel(self._current)
return True
if event != "im-ai-request" or message["requestId"] in self._seen:
return False
self._seen.append(message["requestId"])
job = _Job(identity, message["action"])
if self._running is not None:
self._cancel(self._running)
self._current = job
self._publish(job, "error", message="上次请求正在结束,请稍后重试。", terminal=True)
return True
self._current = job
if not _WORKER_SLOTS.acquire(blocking=False):
self._publish(job, "error", message="AI 正在处理其他请求,请稍后重试。", terminal=True)
return True
self._running = job
self._publish(job, "loading", message="正在同步当前聊天记录…")
self._worker = Thread(target=self._run, args=(job,), name="im-ai", daemon=True)
try:
self._worker.start()
except RuntimeError:
self._running = None
_WORKER_SLOTS.release()
self._publish(job, "error", message="AI 请求暂时无法启动,请稍后重试。", terminal=True)
return True
def _publish(self, job: _Job, status: str, *, terminal: bool = False, **fields: Any) -> None:
with self._lock:
if self._closed or self._current is not job or job.cancelled.is_set() or job.completed:
return
if terminal:
job.completed = True
job.succeeded = status == "success"
self._mailbox = {**job.identity, "status": status, **fields}
def take_result(self) -> dict[str, Any] | None:
with self._lock:
result, self._mailbox = self._mailbox, None
return None if self._closed else result
def _sync(self, job: _Job) -> bool:
deadline = self._clock() + self._sync_timeout
token = ""
for _ in range(self._max_sync_steps):
if job.cancelled.is_set():
return False
if self._clock() >= deadline:
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
result = self.repository.sync_im_chat_messages(
self.diagnosis_id, scope="current", sync_token=token
)
if job.cancelled.is_set():
return False
if self._clock() >= deadline:
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
if not isinstance(result, Mapping) or result.get("errors"):
raise _RequestError("当前聊天记录未能完整同步,尚未生成,请稍后重试。")
if result.get("completed") is True:
return True
token = result.get("sync_token", "")
if not isinstance(token, str) or re.fullmatch(r"[a-f0-9]{48}", token) is None:
raise _RequestError("聊天同步进度无效,请更新客户端或稍后重试。")
raise _RequestError("聊天记录仍在同步,尚未生成,请稍后重试。")
def _run(self, job: _Job) -> None:
try:
if not self._sync(job):
return
task, prompt = task_prompt(job.action, self.doctor_account)
with self._lock:
if job.cancelled.is_set() or self._closed:
return
job.stream_started = True
self._publish(job, "loading", message="聊天已同步,正在整理病历资料…")
if job.cancelled.is_set():
return
text = ""
events = self.repository.stream_diagnosis_ai(
self.diagnosis_id, prompt, task=task,
request_id=job.identity["requestId"], cancelled=job.cancelled.is_set,
)
try:
for event in events:
if job.cancelled.is_set():
return
if not isinstance(event, Mapping):
continue
kind = event.get("event")
if kind == "delta":
delta = event.get("text", "")
if not isinstance(delta, str) or len(text) + len(delta) > MAX_RESULT_CHARS:
raise _RequestError("AI 内容超过显示上限,已停止接收,请缩小问题后重试。")
text += delta
self._publish(job, "streaming", text=text, message="正在生成,内容尚未完成。")
elif kind in {"start", "progress"}:
message = str(event.get("message") or "正在整理资料并生成…")[:240]
self._publish(job, "streaming" if text else "loading", text=text, message=message)
elif kind == "done":
result_id = event.get("diagnosis_id")
if result_id is not None and _integer(result_id, minimum=1) != self.diagnosis_id:
raise _RequestError("AI 返回的问诊上下文不匹配,结果未采用。")
answer = event.get("answer", event.get("content", text))
if not isinstance(answer, str) or not answer.strip():
raise _RequestError("AI 未返回完整内容,请重试。")
if len(answer) > MAX_RESULT_CHARS:
raise _RequestError("AI 内容超过显示上限,结果未采用,请缩小问题后重试。")
self._publish(
job, "success", terminal=True, text=answer,
message="已生成,请由医生复核后使用。",
modelLabel=str(event.get("model_label") or "")[:100],
warnings=_warnings(event.get("clinical_review_warnings")),
)
return
elif kind == "error":
raise _RequestError("AI 请求未完成,请检查权限或稍后重试。")
if not job.cancelled.is_set():
raise _RequestError("AI 连接提前结束,内容未完成,请重试。")
finally:
close = getattr(events, "close", None)
if callable(close):
close()
except Exception as error:
message = str(error) if isinstance(error, _RequestError) else (
"AI 请求失败,请检查权限或稍后重试。" if job.stream_started
else "当前聊天同步失败,尚未生成,请检查权限或稍后重试。"
)
self._publish(job, "error", message=message, terminal=True)
if job.stream_started and not job.cancelled.is_set() and not job.succeeded:
self._request_cancel(job)
finally:
with self._lock:
if self._running is job:
self._running = None
_WORKER_SLOTS.release()
def _cancel(self, job: _Job) -> None:
if job.completed or job.cancelled.is_set():
return
job.cancelled.set()
if self._current is job and not self._closed:
self._mailbox = {
**job.identity, "status": "cancelled",
"message": ("已停止本次接收,上游是否结束尚未确认。" if job.stream_started
else "已取消,尚未向 AI 提交请求。"),
}
if job.stream_started:
self._request_cancel(job)
def _request_cancel(self, job: _Job) -> None:
with self._lock:
if job.cancel_sent:
return
job.cancel_sent = True
if not _CANCEL_SLOTS.acquire(blocking=False):
return
def send() -> None:
try:
result = self.repository.cancel_diagnosis_ai(self.diagnosis_id, job.identity["requestId"])
if not isinstance(result, Mapping):
return
message = "已发送取消请求,上游是否结束尚未确认。"
terminal = result.get("upstream_terminal_status")
if result.get("upstream_terminal_confirmed") is True and terminal in {"stopped", "succeeded", "failed"}:
message = "本次接收已停止,上游工作流已结束。"
elif result.get("upstream_stop_confirmed") is True:
message = "上游已接受停止请求,是否结束尚未确认。"
with self._lock:
if not self._closed and self._current is job and job.cancelled.is_set():
self._mailbox = {**job.identity, "status": "cancelled", "message": message}
except Exception:
pass # Keep the visible unconfirmed cancellation; never expose transport details.
finally:
_CANCEL_SLOTS.release()
try:
Thread(target=send, name="im-ai-cancel", daemon=True).start()
except RuntimeError:
_CANCEL_SLOTS.release()
def close(self) -> None:
with self._lock:
if self._closed:
return
self._closed = True
self._mailbox = None
if self._running is not None:
self._cancel(self._running)
def wait(self, timeout: float = 1.0) -> bool:
"""Bounded diagnostic/test helper; never called by the Qt GUI."""
worker = self._worker
if worker is not None:
worker.join(max(0.0, min(timeout, 5.0)))
return worker is None or not worker.is_alive()
@@ -413,6 +413,7 @@ class VideoCallLauncher:
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> Any:
request = self.prepare(
ticket,
@@ -432,6 +433,7 @@ class VideoCallLauncher:
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant is True,
)
@@ -450,6 +452,7 @@ def launch_video_call(
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -468,4 +471,5 @@ def launch_video_call(
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant is True,
)
+25 -8
View File
@@ -18,6 +18,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from ..core.transcript_text import simplified_transcript
from .launcher import VideoCallRequest, normalize_backend_ticket
ResultT = TypeVar("ResultT")
@@ -134,7 +135,7 @@ def _extract_cloud_recording_outcome(result: Any) -> tuple[bool, str] | None:
return None
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
def normalize_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
speaker_user_id = str(
@@ -145,13 +146,14 @@ def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> di
raise ValueError("transcript segment_id must contain 1 to 160 characters")
if not text or len(text) > 4_000:
raise ValueError("transcript text must contain 1 to 4000 characters")
text = simplified_transcript(text)
if len(speaker_user_id) > 160:
raise ValueError("transcript speaker_user_id is too long")
if speaker_role not in {"doctor", "patient", "unknown"}:
speaker_role = "unknown"
try:
timestamp = int(segment.get("timestamp") or 0)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
timestamp = 0
return {
"segment_id": segment_id,
@@ -543,10 +545,25 @@ class OrderedCallLifecycle:
with self._lock:
if self._end_future is not None:
raise RuntimeError("video call has already ended")
if self._transcription_start_future is not None:
if self._transcription_session_id != clean_session:
raise RuntimeError("another transcription session already exists")
return self._transcription_start_future
if self._transcription_finish_future is not None:
raise RuntimeError("call transcription has already stopped")
if (
self._transcription_session_id is not None
and self._transcription_session_id != clean_session
):
raise RuntimeError("another transcription session already exists")
previous = self._transcription_start_future
if previous is not None:
if not previous.done() or (
not previous.cancelled()
and previous.exception() is None
and previous.result() is True
):
return previous
# A completed failure must not poison this call's retry button.
# Keep the exact session identity: the server may have accepted
# an earlier request whose response never reached this client.
self._transcription_active = False
if self._start_future is None:
self.start()
method = getattr(self.repository, "start_call_transcription", None)
@@ -593,7 +610,7 @@ class OrderedCallLifecycle:
raise RuntimeError("call transcription has not started")
if self._transcription_finish_future is not None:
raise RuntimeError("call transcription has already stopped")
cleaned = _clean_transcript_segment(segment, session_id)
cleaned = normalize_transcript_segment(segment, session_id)
segment_id = cleaned["segment_id"]
existing = self._segment_futures.get(segment_id)
if existing is not None:
@@ -738,4 +755,4 @@ class OrderedCallLifecycle:
return self._worker.wait(timeout)
__all__ = ["OrderedCallLifecycle"]
__all__ = ["OrderedCallLifecycle", "normalize_transcript_segment"]
+414
View File
@@ -0,0 +1,414 @@
"""Incremental video assistance; never runs on the call persistence FIFO or Qt.
Only persisted segment notifications trigger work. One in-flight request and
one coalesced latest revision are retained, with explicit patient/call/session
correlation. Clinical content is never written or sent to the patient here.
"""
from __future__ import annotations
import copy
import logging
import math
import re
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass, field
from threading import BoundedSemaphore, Event, RLock, Thread
from time import monotonic
from typing import Any
from uuid import uuid4
from ..core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
_LOGGER = logging.getLogger(__name__)
_SLOTS = BoundedSemaphore(2)
_CANCEL_SLOTS = BoundedSemaphore(4)
def _failure_details(error: Exception) -> tuple[str, str]:
"""Translate stable error codes, never echo an upstream exception/response."""
if isinstance(error, ApiTimeoutError):
code = "UPSTREAM_TIMEOUT"
elif isinstance(error, AuthenticationExpiredError):
code = "AUTH_REQUIRED"
elif isinstance(error, ApiHttpError):
code = ({401: "AUTH_REQUIRED", 403: "PERMISSION_DENIED", 404: "SERVICE_UPDATE_REQUIRED",
405: "SERVICE_UPDATE_REQUIRED", 429: "RATE_LIMITED"}.get(error.status_code)
or "UPSTREAM_UNAVAILABLE")
elif isinstance(error, ApiBusinessError) and isinstance(error.data, Mapping):
code = error.data.get("error_code") or error.data.get("code")
elif isinstance(error, ApiProtocolError):
code = "STREAM_INCOMPLETE"
elif isinstance(error, ApiTransportError):
code = "NETWORK_ERROR"
elif isinstance(error, ValueError):
code = "LIVE_RESULT_INVALID"
else:
code = "UPSTREAM_UNAVAILABLE"
messages = {
"UPSTREAM_TIMEOUT": "分析服务响应超时,可点击重新生成。",
"AUTH_REQUIRED": "登录状态已失效,请重新登录后使用 AI 辅助。",
"PERMISSION_DENIED": "当前账号没有 AI 辅助权限,请联系管理员开通。",
"SERVICE_UPDATE_REQUIRED": "服务端尚不支持实时 AI,请更新服务端后重试。",
"RATE_LIMITED": "分析请求较多,请稍后重新生成。",
"LIVE_RESULT_INVALID": "本次建议未通过格式或引用校验,可点击重新生成。",
"LIVE_CONTEXT_UNAVAILABLE": "本次通话的分析资料尚未就绪,请等待转写保存后重试。",
"STREAM_INCOMPLETE": "分析连接提前结束,可点击重新生成。",
"NETWORK_ERROR": "分析服务连接中断,请检查网络后重试。",
"UPSTREAM_UNAVAILABLE": "分析服务暂时不可用,请稍后重新生成。",
}
if not isinstance(code, str) or code not in messages:
code = "UPSTREAM_UNAVAILABLE"
if code == "LIVE_RESULT_INVALID" and isinstance(getattr(error, "data", None), Mapping):
reason = error.data.get("live_validation_reason")
details = {
"JSON_INVALID": "分析服务返回的格式不完整,可点击重新生成。",
"SCHEMA_INVALID": "分析服务返回的格式不符合要求,可点击重新生成。",
"CITATION_INVALID": "部分建议的原文引用无法核对,可点击重新生成。",
"SOURCE_QUOTE_MISMATCH": "部分建议与引用原文不一致,可点击重新生成。",
"SAFETY_REJECTED": "本次建议包含超出辅助范围的内容,未通过校验,可重新生成。",
}
if isinstance(reason, str) and reason in details:
return code, details[reason]
return code, messages[code]
def empty_video_ai_state(available: bool) -> dict[str, Any]:
return {
"enabled": available, "available": available, "busy": False,
"phase": "waiting", "message": ("接通并产生转写文字后,自动整理本次对话。" if available
else "当前账号或服务暂不能使用 AI 辅助;语音转写可独立运行。"),
"error": "", "revision": "", "throughTimestampMs": 0, "segmentCount": 0,
"transcriptionStatus": "idle", "summary": [], "questions": [],
"checks": [], "review": [], "sources": [],
"elapsedMs": 0, "lastDurationMs": 0, "firstResultMs": None,
"pendingSegments": 0, "errorCode": "", "analysisScope": "",
}
@dataclass
class _Job:
generation: int
call_id: int
session_id: str
dirty: int
task: str
started_at: float = 0.0
first_result_ms: int | None = None
revision: str = ""
analysis_scope: str | None = None
request_id: str = field(default_factory=lambda: str(uuid4()))
cancelled: Event = field(default_factory=Event)
cancel_sent: bool = False
class LiveAiController:
def __init__(self, repository: Any, *, diagnosis_id: int, enabled: bool,
clock: Callable[[], float] = monotonic, interval: float = 2.0) -> None:
self.repository = repository
self.diagnosis_id = diagnosis_id
self.available = enabled is True and callable(getattr(repository, "stream_video_ai", None))
self._clock = clock
self._interval = max(0, interval)
self._lock = RLock()
self._state = empty_video_ai_state(self.available)
self._mailbox: dict[str, Any] | None = copy.deepcopy(self._state)
self._closed = False
self._generation = 0
self._call_id = 0
self._session_id = ""
self._dirty = 0
self._attempted = 0
self._displayed_dirty = 0
self._due = 0.0
self._forced_task = ""
self._running: _Job | None = None
self._worker: Thread | None = None
def _publish(self, **changes: Any) -> None:
self._state.update(changes)
self._state["pendingSegments"] = max(0, self._dirty - self._displayed_dirty)
self._mailbox = copy.deepcopy(self._state)
def take_state(self) -> dict[str, Any] | None:
with self._lock:
result, self._mailbox = self._mailbox, None
return None if self._closed else result
def bind(self, call_id: int, session_id: str) -> None:
with self._lock:
if self._closed or (self._call_id == call_id and self._session_id == session_id):
return
self._cancel()
enabled = self._state["enabled"]
self._generation += 1
self._call_id, self._session_id = call_id, session_id
self._dirty = self._attempted = 0
self._displayed_dirty = 0
self._forced_task = ""
self._state = empty_video_ai_state(self.available)
self._publish(enabled=enabled, transcriptionStatus="running" if session_id else "idle")
def reset(self) -> None:
"""Clear the previous call before a new call's model/storage is ready."""
self.bind(0, "")
def changed(self, session_id: str) -> None:
with self._lock:
if self._closed or session_id != self._session_id:
return
was_clean = self._dirty == self._attempted
self._dirty += 1
if was_clean:
# Coalesce a short speech burst without repeatedly moving the deadline.
self._due = self._clock() + (0.8 if not self._state["revision"] else self._interval)
self._publish()
def transcription_error(self, message: str, *, status: str = "partial") -> None:
with self._lock:
if not self._closed:
self._publish(transcriptionStatus=status, error=message[:240])
def transcription_starting(self) -> None:
with self._lock:
if not self._closed:
self._publish(transcriptionStatus="starting", error="")
def transcription_running(self, session_id: str) -> None:
"""A retried cloud recognizer is ready without resetting saved evidence."""
with self._lock:
if not self._closed and session_id == self._session_id:
self._publish(transcriptionStatus="running", error="")
def finish(self, session_id: str, status: str) -> None:
with self._lock:
if not self._closed and session_id == self._session_id:
self._publish(transcriptionStatus=status)
def handle(self, action: str, enabled: Any = None) -> None:
with self._lock:
if self._closed or not self.available:
return
if action == "toggle" and isinstance(enabled, bool):
if not enabled:
self._cancel()
self._publish(enabled=enabled, busy=False if not enabled else self._state["busy"],
message="已暂停自动分析。" if not enabled else "有新增对话后自动更新。")
return
if action not in {"refresh", "review"} or not self._dirty or self._running is not None:
return
if action == "review" and self._state["transcriptionStatus"] not in {"completed", "partial"}:
return
self._forced_task = "live_review" if action == "review" else "live_consultation"
self._due = self._clock()
def tick(self) -> None:
with self._lock:
if self._running is not None and self._current(self._running):
elapsed = self._elapsed(self._running)
if elapsed // 1000 != self._state["elapsedMs"] // 1000:
self._publish(elapsedMs=elapsed)
if (self._closed or not self.available or not self._call_id or not self._session_id
or self._running is not None or not self._dirty
or (not self._forced_task and (not self._state["enabled"] or self._dirty <= self._attempted))
or self._clock() < self._due):
return
if not _SLOTS.acquire(blocking=False):
return
job = _Job(self._generation, self._call_id, self._session_id, self._dirty,
self._forced_task or "live_consultation", started_at=self._clock())
self._forced_task = ""
self._attempted = self._dirty
self._running = job
self._publish(busy=True, phase="analyzing", error="", errorCode="", elapsedMs=0, firstResultMs=None,
message="正在分析最新对话,优先整理下一步追问…")
self._worker = Thread(target=self._run, args=(job,), name="video-ai", daemon=True)
try:
self._worker.start()
except RuntimeError:
self._running = None
_SLOTS.release()
self._publish(busy=False, phase="error", error="分析任务无法启动,请重试。")
def _elapsed(self, job: _Job) -> int:
return max(0, int((self._clock() - job.started_at) * 1000))
def _current(self, job: _Job) -> bool:
return not self._closed and job.generation == self._generation and not job.cancelled.is_set()
def _validated_result(self, job: _Job, event: Mapping[str, Any]) -> dict[str, Any]:
context = event.get("live_context")
result = event.get("live_assistance")
# Only label the scope of validated displayed content. Older servers do
# not report it; never infer dialogue-only behavior from the task name.
scope = event.get("analysis_scope", "")
if not isinstance(scope, str) or scope not in {"", "current_call_only", "authorized_case"}:
raise ValueError("invalid analysis scope")
if (not isinstance(context, Mapping) or not isinstance(result, Mapping)
or event.get("diagnosis_id") != self.diagnosis_id
or context.get("call_record_id") != job.call_id
or context.get("transcription_session_id") != job.session_id
or not isinstance(context.get("revision"), str)
or not re.fullmatch(r"[a-f0-9]{64}", context["revision"])):
raise ValueError("context mismatch")
sources = context.get("sources")
if not isinstance(sources, list) or len(sources) > 1000:
raise ValueError("invalid evidence")
known: set[str] = set()
clean_sources = []
for source in sources:
if (not isinstance(source, Mapping) or not isinstance(source.get("id"), str)
or not source["id"] or source["id"] in known
or not isinstance(source.get("text"), str) or not source["text"].strip()
or len(source["text"]) > 4000
or source.get("role") not in {"doctor", "patient", "unknown"}
or isinstance(source.get("timestamp_ms"), bool)
or not isinstance(source.get("timestamp_ms"), int) or source["timestamp_ms"] < 0):
raise ValueError("invalid source")
known.add(source["id"])
clean_sources.append({key: source.get(key) for key in ("id", "text", "role", "timestamp_ms")})
cleaned: dict[str, Any] = {}
for key in ("summary", "questions", "checks", "review"):
items = result.get(key, [])
if not isinstance(items, list) or len(items) > 6:
raise ValueError("invalid items")
cleaned[key] = []
for item in items:
if (not isinstance(item, Mapping) or not isinstance(item.get("text"), str)
or not item["text"].strip() or len(item["text"]) > (4020 if key == "summary" else 600)
or not isinstance(item.get("evidence_ids"), list)
or not 1 <= len(item["evidence_ids"]) <= 8
or any(not isinstance(s, str) or s not in known for s in item["evidence_ids"])
or len(set(item["evidence_ids"])) != len(item["evidence_ids"])):
raise ValueError("invalid cited item")
reason = item.get("reason", "")
if not isinstance(reason, str) or len(reason) > 600:
raise ValueError("invalid reason")
cleaned[key].append({"text": item["text"], "reason": reason,
"evidence_ids": list(item["evidence_ids"])})
count = context.get("segment_count")
through = context.get("through_timestamp_ms")
if (isinstance(count, bool) or not isinstance(count, int) or count < 1
or isinstance(through, bool) or not isinstance(through, (int, float))
or not math.isfinite(through) or through < 0
or context.get("transcription_status") not in {"running", "completed", "partial"}):
raise ValueError("invalid revision metadata")
return {**cleaned, "sources": clean_sources, "revision": context["revision"], "analysisScope": scope,
"segmentCount": count, "throughTimestampMs": through,
"transcriptionStatus": context.get("transcription_status", "partial")}
def _run(self, job: _Job) -> None:
events = None
success = False
try:
if job.cancelled.is_set():
return
events = self.repository.stream_video_ai(
self.diagnosis_id, call_record_id=job.call_id, transcription_session_id=job.session_id,
task=job.task, request_id=job.request_id, cancelled=job.cancelled.is_set,
)
for event in events:
with self._lock:
if not self._current(job):
return
if event.get("event") in {"start", "progress"}:
self._publish(message=str(event.get("message") or "正在分析本次对话…")[:200])
elif event.get("event") in {"live_update", "done"}:
result = self._validated_result(job, event)
if job.revision and job.revision != result["revision"]:
raise ValueError("revision changed inside a single snapshot")
if job.analysis_scope is not None and job.analysis_scope != result["analysisScope"]:
raise ValueError("analysis scope changed inside a single snapshot")
job.revision = result["revision"]
job.analysis_scope = result["analysisScope"]
# Preserve a newer native completion status while an older
# in-flight snapshot still reports running.
if self._state["transcriptionStatus"] in {"completed", "partial", "failed"}:
result["transcriptionStatus"] = self._state["transcriptionStatus"]
complete = event.get("event") == "done"
duration = self._elapsed(job)
if job.first_result_ms is None and any(
result[key] for key in ("summary", "questions", "checks", "review")
):
job.first_result_ms = duration
self._displayed_dirty = job.dirty
self._publish(**result, busy=not complete,
phase="ready" if complete else "analyzing", error="", errorCode="",
elapsedMs=duration,
firstResultMs=job.first_result_ms,
lastDurationMs=duration if complete else self._state["lastDurationMs"],
message="已更新,请对照原话复核。" if complete
else "已显示可核对的建议,其余内容仍在整理。")
if complete:
success = True
return
if not job.cancelled.is_set():
raise ApiProtocolError("incomplete stream")
except Exception as error:
with self._lock:
if self._current(job):
code, message = _failure_details(error)
self._publish(busy=False, phase="error", error=message, errorCode=code,
elapsedMs=self._elapsed(job), lastDurationMs=self._elapsed(job),
message=("已显示的建议仍可核对,本次分析尚未全部完成。"
if job.first_result_ms is not None else
"保留上次结果;新内容尚未分析完成。" if any(
self._state[key] for key in ("summary", "questions", "checks", "review")
) else
"本次暂未生成可用建议,可点击重新生成。"))
# Log categories/timing only, never response text or patient details.
_LOGGER.warning("video_ai_failed code=%s type=%s duration_ms=%d",
code, type(error).__name__, self._elapsed(job))
finally:
if events is not None and callable(getattr(events, "close", None)):
with suppress(Exception):
events.close()
with self._lock:
if not success:
self._send_cancel(job)
if self._running is job:
self._running = None
_SLOTS.release()
if success:
_LOGGER.info("video_ai_completed duration_ms=%d first_result_ms=%s",
self._elapsed(job), job.first_result_ms)
def _cancel(self) -> None:
if self._running is not None:
self._running.cancelled.set()
self._send_cancel(self._running)
def _send_cancel(self, job: _Job) -> None:
if job.cancel_sent or not _CANCEL_SLOTS.acquire(blocking=False):
return
job.cancel_sent = True
def cancel() -> None:
try:
self.repository.cancel_diagnosis_ai(self.diagnosis_id, job.request_id)
except Exception:
pass
finally:
_CANCEL_SLOTS.release()
try:
Thread(target=cancel, name="video-ai-cancel", daemon=True).start()
except RuntimeError:
_CANCEL_SLOTS.release()
def close(self) -> None:
with self._lock:
self._closed = True
self._cancel()
self._mailbox = None
def wait(self, timeout: float = 1.0) -> bool:
if self._worker is not None:
self._worker.join(max(0, min(timeout, 5)))
return self._worker is None or not self._worker.is_alive()
@@ -0,0 +1,446 @@
"""Bounded, in-memory speech recognition using an explicitly installed local model.
This module has no dependency on Qt, the recording queue, or the call lifecycle.
Callbacks run on one daemon worker and must only enqueue work (for example, emit
a Qt signal). Runtime code never downloads a model or sends audio to a service.
"""
from __future__ import annotations
import base64
import binascii
import importlib.util
import os
import re
import sys
import threading
from collections import deque
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol
from platformdirs import user_data_path
from ..core.transcript_text import simplified_transcript
SAMPLE_RATE = 16_000
MAX_CHUNK_SECONDS = 12
MAX_PCM_BYTES = SAMPLE_RATE * 2 * MAX_CHUNK_SECONDS
MAX_BASE64_CHARS = ((MAX_PCM_BYTES + 2) // 3) * 4
MAX_SEQUENCE = 2_147_483_647
MAX_TIMESTAMP_MS = 253_402_300_799_999
MAX_TRANSCRIPT_CHARS = 4_000
_SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z")
_REQUIRED_FILES = ("model.bin", "config.json", "tokenizer.json")
_DEPENDENCIES = ("faster_whisper", "numpy", "ctranslate2", "onnxruntime", "opencc")
_MESSAGES = {
"model_missing": "本地语音模型未安装或文件不完整,请先运行本地转写安装工具。",
"dependency_missing": "本地转写运行库未安装,请先安装本地转写依赖。",
"dependency_unavailable": "本地转写运行库无法加载,请检查安装包及系统运行库。",
"model_load_failed": "本地语音模型加载失败,请检查模型文件和本机运行环境。",
"conversion_unavailable": "简体转换字典无法加载,请修复本地转写安装。",
"invalid_audio": "本地转写音频格式无效,需要最长 12 秒的单声道 16 kHz PCM16 音频。",
"invalid_metadata": "本地转写音频的角色、序号或时间戳无效。",
"sequence_out_of_order": "本地转写音频序号重复或倒序,该片段未被接受。",
"sequence_gap": "本地转写检测到音频片段缺失,转写结果可能不完整。",
"overloaded": "本地转写处理速度不足,部分音频片段未转写;通话和录音继续。",
"transcription_failed": "本地音频片段转写失败,结果可能不完整;通话和录音继续。",
}
def default_model_path() -> Path:
"""Return the model directory without creating it or accessing the network."""
override = os.getenv("DOCTOR_ASR_MODEL_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
bundle = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[3]))
bundled_model = bundle / "resources" / "local-asr" / "model"
if bundled_model.is_dir():
return bundled_model
return (
user_data_path("ZhenyangDoctorWorkstation", appauthor="ZhenYangTang")
/ "local-asr"
/ "model"
)
def _missing_model_files(path: Path) -> list[str]:
def exists(name: str) -> bool:
try:
file = path / name
return file.is_file() and file.stat().st_size > 0
except OSError:
return False
missing = [name for name in _REQUIRED_FILES if not exists(name)]
if not any(exists(name) for name in ("vocabulary.txt", "vocabulary.json")):
missing.append("vocabulary.txt or vocabulary.json")
return missing
def local_transcription_readiness(model_path: Path | str | None = None) -> dict[str, Any]:
"""Cheap installation preflight; ``available`` does not claim a model has loaded."""
path = Path(model_path).expanduser().resolve() if model_path else default_model_path()
missing_dependencies = []
for name in _DEPENDENCIES:
try:
available = importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
available = False
if not available:
missing_dependencies.append(name)
missing_files = _missing_model_files(path)
code = (
"model_missing" if missing_files else "dependency_missing" if missing_dependencies else ""
)
return {
"available": not (missing_files or missing_dependencies),
"modelPath": str(path),
"missingDependencies": missing_dependencies,
"missingFiles": missing_files,
"code": code,
"message": _MESSAGES.get(code, "本地转写文件检查通过,启动后将加载模型。"),
}
class LocalTranscriptionError(RuntimeError):
"""A fixed, patient-data-free error suitable for displaying in the UI."""
def __init__(self, code: str) -> None:
self.code = code if code in _MESSAGES else "transcription_failed"
super().__init__(_MESSAGES[self.code])
class SpeechEngine(Protocol):
def transcribe(self, pcm: bytes) -> str:
"""Return verbatim recognized text for mono, little-endian PCM16 at 16 kHz."""
class FasterWhisperEngine:
"""CPU/int8 recognition. Construct only on a background worker or setup CLI."""
def __init__(self, model_path: Path | str | None = None) -> None:
path = Path(model_path).expanduser().resolve() if model_path else default_model_path()
# In particular, require tokenizer.json: upstream may otherwise fetch a
# tokenizer even when WhisperModel(local_files_only=True) is requested.
if _missing_model_files(path):
raise LocalTranscriptionError("model_missing")
try:
import numpy as np
from faster_whisper import WhisperModel
from faster_whisper.vad import get_vad_model
except ModuleNotFoundError:
raise LocalTranscriptionError("dependency_missing") from None
except (ImportError, OSError):
raise LocalTranscriptionError("dependency_unavailable") from None
self._np = np
try:
# Load local dictionaries before ready, so a broken/frozen install
# cannot claim successful ASR and then fail on its first sentence.
simplified_transcript("")
except ImportError:
raise LocalTranscriptionError("dependency_missing") from None
except Exception:
raise LocalTranscriptionError("conversion_unavailable") from None
try:
self._model = WhisperModel(
str(path),
device="cpu",
compute_type="int8",
cpu_threads=min(4, max(1, (os.cpu_count() or 2) // 2)),
num_workers=1,
local_files_only=True,
)
# Silero's ONNX asset ships inside faster-whisper; preflight it so
# a packaged application cannot report ready with a missing VAD.
get_vad_model()
except Exception:
raise LocalTranscriptionError("model_load_failed") from None
def transcribe(self, pcm: bytes) -> str:
if not pcm or len(pcm) % 2 or len(pcm) > MAX_PCM_BYTES:
raise LocalTranscriptionError("invalid_audio")
if not any(pcm):
return ""
audio = self._np.frombuffer(pcm, dtype="<i2").astype(self._np.float32) / 32768.0
segments, _ = self._model.transcribe(
audio,
language="zh",
task="transcribe",
beam_size=5,
temperature=0.0,
condition_on_previous_text=False,
vad_filter=True,
vad_parameters={
"min_speech_duration_ms": 0,
"min_silence_duration_ms": 500,
"speech_pad_ms": 400,
},
)
# Iterating is essential: faster-whisper returns a lazy generator. Do
# not rewrite numbers, units, negations, repetitions, or short answers.
return "".join(segment.text for segment in segments).strip()
@dataclass(frozen=True, slots=True)
class _Chunk:
role: str
sequence: int
timestamp_ms: int
pcm: bytes
@dataclass(slots=True)
class _Session:
id: str
initialized: bool = False
stopping: bool = False
stopped: bool = False
partial: bool = False
last_sequence: dict[str, int] = field(default_factory=dict)
chunks: deque[_Chunk] = field(default_factory=deque)
errors: deque[str] = field(default_factory=deque)
reported_errors: set[str] = field(default_factory=set)
class LocalTranscriptionController:
"""One bounded daemon worker, isolated by session object identity.
``start`` accepts a new session, replacing queued old work. ``submit`` only
acknowledges enqueueing, while ``ready`` confirms successful model loading.
``stop`` rejects further submissions and drains accepted chunks in order.
``close`` cancels immediately without waiting for native inference. No
callbacks from an old generation occur after start/close returns.
The callback must be quick, thread-safe, and must not join/wait for another
thread that is calling this controller. It is invoked under a reentrant
lock, which makes close/session replacement atomic with callback delivery.
"""
def __init__(
self,
on_event: Callable[[dict[str, Any]], None],
engine_factory: Callable[[], SpeechEngine] | None = None,
*,
max_pending_chunks: int = 12,
) -> None:
if type(max_pending_chunks) is not int or not 1 <= max_pending_chunks <= 64:
raise ValueError("max_pending_chunks must be an integer between 1 and 64")
self._on_event = on_event
self._engine_factory = engine_factory or FasterWhisperEngine
self._max_pending_chunks = max_pending_chunks
self._condition = threading.Condition(threading.RLock())
self._session: _Session | None = None
self._engine: SpeechEngine | None = None
self._closed = False
self._worker: threading.Thread | None = None
def start(self, session_id: str) -> bool:
if not isinstance(session_id, str) or not _SESSION_ID.fullmatch(session_id):
return False
with self._condition:
if self._closed:
return False
if self._session and self._session.id == session_id:
return not (self._session.stopping or self._session.stopped)
if self._session:
self._session.chunks.clear()
self._session.errors.clear()
self._session = _Session(session_id)
if self._worker is None:
self._worker = threading.Thread(
target=self._run, name="doctor-local-asr", daemon=True
)
self._worker.start()
self._condition.notify_all()
return True
def submit(
self,
session_id: str,
role: str,
sequence: int,
timestamp_ms: int,
pcm_base64: str,
) -> bool:
with self._condition:
session = self._session
if (
self._closed
or session is None
or session.id != session_id
or session.stopping
or session.stopped
):
return False
if (
role not in ("doctor", "patient")
or type(sequence) is not int
or not 0 <= sequence <= MAX_SEQUENCE
or type(timestamp_ms) is not int
or not 0 < timestamp_ms <= MAX_TIMESTAMP_MS
):
self._error_locked(session, "invalid_metadata")
return False
if (
not isinstance(pcm_base64, str)
or not pcm_base64
or len(pcm_base64) > MAX_BASE64_CHARS
):
self._error_locked(session, "invalid_audio")
return False
try:
pcm = base64.b64decode(pcm_base64, validate=True)
except (ValueError, binascii.Error):
self._error_locked(session, "invalid_audio")
return False
if not pcm or len(pcm) % 2 or len(pcm) > MAX_PCM_BYTES:
self._error_locked(session, "invalid_audio")
return False
previous = session.last_sequence.get(role)
if previous is not None and sequence <= previous:
self._error_locked(session, "sequence_out_of_order")
return False
if previous is not None and sequence != previous + 1:
self._error_locked(session, "sequence_gap")
session.last_sequence[role] = sequence
if len(session.chunks) >= self._max_pending_chunks:
self._error_locked(session, "overloaded")
return False
session.chunks.append(_Chunk(role, sequence, timestamp_ms, pcm))
self._condition.notify_all()
return True
def stop(self, session_id: str) -> bool:
with self._condition:
session = self._session
if self._closed or session is None or session.id != session_id:
return False
session.stopping = True
self._condition.notify_all()
return True
def close(self) -> None:
with self._condition:
self._closed = True
if self._session:
self._session.chunks.clear()
self._session.errors.clear()
self._session = None
self._condition.notify_all()
def _error_locked(self, session: _Session, code: str) -> None:
session.partial = True
# At most one pending/report per fixed error code, even when the browser
# continues submitting into an overloaded queue. No unbounded UI queue.
if code not in session.reported_errors:
session.reported_errors.add(code)
session.errors.append(code)
self._condition.notify_all()
def _emit(self, session: _Session, event: dict[str, Any]) -> None:
with self._condition:
if self._closed or self._session is not session:
return
# A detached UI must not kill the worker or expose transcript
# contents through exception logging.
with suppress(Exception):
self._on_event({"sessionId": session.id, **event})
def _initialize(self, session: _Session) -> None:
try:
if self._engine is None:
self._engine = self._engine_factory()
except Exception as error:
code = error.code if isinstance(error, LocalTranscriptionError) else "model_load_failed"
with self._condition:
if self._closed or self._session is not session:
return
session.initialized = True
session.stopping = True
session.chunks.clear()
self._error_locked(session, code)
return
with self._condition:
if self._closed or self._session is not session:
return
session.initialized = True
if not session.stopping:
self._emit(session, {"type": "ready"})
def _transcribe(self, session: _Session, chunk: _Chunk) -> None:
try:
# Reject only exact digital silence here. VAD in the real engine
# handles room noise without an amplitude cutoff that loses quiet
# numerals or one-word negations.
if not any(chunk.pcm):
return
assert self._engine is not None
text = self._engine.transcribe(chunk.pcm)
if not isinstance(text, str) or len(text) > MAX_TRANSCRIPT_CHARS:
raise LocalTranscriptionError("transcription_failed")
# Normalize before persistence and captions, so AI evidence and the
# doctor's visible transcript refer to the exact same text.
text = simplified_transcript(text.strip())
if text:
self._emit(
session,
{
"type": "segment",
"segmentId": f"{session.id}:{chunk.role}:{chunk.sequence}",
"role": chunk.role,
"text": text,
"timestamp": chunk.timestamp_ms,
"isCompleted": True,
},
)
except Exception:
with self._condition:
if not self._closed and self._session is session:
self._error_locked(session, "transcription_failed")
def _run(self) -> None:
try:
while True:
with self._condition:
while True:
if self._closed:
return
session = self._session
if session is not None and not session.stopped:
if session.errors:
action, payload = "error", session.errors.popleft()
break
if not session.initialized:
action, payload = "initialize", None
break
if session.chunks:
action, payload = "chunk", session.chunks.popleft()
break
if session.stopping:
session.stopped = True
action, payload = "stopped", None
break
self._condition.wait()
if action == "initialize":
self._initialize(session)
elif action == "chunk":
self._transcribe(session, payload)
payload = None # Release raw PCM before waiting for work.
elif action == "error":
self._emit(
session,
{"type": "error", "code": payload, "message": _MESSAGES[payload]},
)
else:
self._emit(
session,
{
"type": "stopped",
"status": "partial" if session.partial else "completed",
},
)
finally:
self._engine = None
@@ -0,0 +1,124 @@
"""Capture the rendered call region without accessing the rest of the desktop."""
from __future__ import annotations
import base64
import math
from collections.abc import Mapping
from typing import Any
class ScreenshotPreviewSize:
"""Temporarily give a compact call the same space as a normal preview."""
def __init__(self) -> None:
self._state: tuple[Any, Any, Any] | None = None
def open(self, window: Any, target: Any = None) -> None:
from PySide6.QtCore import QRect
if self._state is not None or window.isMaximized() or window.isFullScreen():
return
screen = window.screen()
if screen is None:
return
available = screen.availableGeometry().adjusted(24, 24, -24, -24)
if available.isEmpty():
return
original = window.geometry()
width = min(available.width(), max(original.width(), target.width() if target else 1120))
height = min(available.height(), max(original.height(), target.height() if target else 760))
if original.width() >= width and original.height() >= height:
return
expanded = QRect(
available.x() + (available.width() - width) // 2,
available.y() + (available.height() - height) // 2,
width,
height,
)
window.setGeometry(expanded)
self._state = (original, window.geometry(), window.property("doctorSkipOpenGeometry"))
def restore(self, window: Any) -> None:
state, self._state = self._state, None
if state is None:
return
original, expanded, compact_flag = state
# A diagnosis closing or a manual resize takes priority over this temporary size.
if (
window.isMaximized()
or window.isFullScreen()
or window.geometry() != expanded
or window.property("doctorSkipOpenGeometry") != compact_flag
):
return
window.setGeometry(original)
def capture_view_region(web_view: Any, region: Mapping[str, Any]) -> str:
"""Map CSS viewport coordinates to Qt logical pixels; retain composited video."""
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QRect, Qt
values: dict[str, float] = {}
for key in ("x", "y", "width", "height", "viewportWidth", "viewportHeight"):
value = region.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("截屏区域无效,请重试。")
try:
value = float(value)
except OverflowError as error:
raise ValueError("截屏区域无效,请重试。") from error
if not math.isfinite(value):
raise ValueError("截屏区域无效,请重试。")
values[key] = value
viewport_width = values["viewportWidth"]
viewport_height = values["viewportHeight"]
if min(viewport_width, viewport_height, values["width"], values["height"]) <= 0:
raise ValueError("视频区域尚未显示,请稍后重试。")
left = max(0.0, values["x"])
top = max(0.0, values["y"])
right = min(viewport_width, values["x"] + values["width"])
bottom = min(viewport_height, values["y"] + values["height"])
if right <= left or bottom <= top:
raise ValueError("视频区域尚未显示,请稍后重试。")
zoom = web_view.zoomFactor()
tolerance = max(2.0, 2 * zoom)
if (
abs(viewport_width * zoom - web_view.width()) > tolerance
or abs(viewport_height * zoom - web_view.height()) > tolerance
):
raise ValueError("视频窗口尺寸已变化,请重新截屏。")
# QWebEngine's zoom and Windows display scaling are independent. grab() takes
# widget logical coordinates and returns an image at the widget's pixel ratio.
scale_x = web_view.width() / viewport_width
scale_y = web_view.height() / viewport_height
x = math.ceil(left * scale_x)
y = math.ceil(top * scale_y)
width = math.floor(right * scale_x) - x
height = math.floor(bottom * scale_y) - y
if width <= 0 or height <= 0:
raise ValueError("视频区域尚未显示,请稍后重试。")
rect = QRect(x, y, width, height).intersected(web_view.rect())
if rect.isEmpty():
raise ValueError("视频区域尚未显示,请稍后重试。")
image = web_view.grab(rect).toImage()
if image.isNull():
raise ValueError("截屏失败,请稍后重试。")
if max(image.width(), image.height()) > 1920:
image = image.scaled(
1920, 1920,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
data = QByteArray()
buffer = QBuffer(data)
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
if not image.save(buffer, "JPEG", 90):
raise ValueError("截屏图片生成失败,请重试。")
buffer.close()
return "data:image/jpeg;base64," + base64.b64encode(bytes(data)).decode("ascii")
+369 -13
View File
@@ -11,6 +11,7 @@ import base64
import binascii
import json
import logging
import math
import os
import re
import sqlite3
@@ -28,12 +29,15 @@ from ..services.local_audio_queue import (
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .im_ai import ImAiController
from .launcher import (
VideoCallRequest,
VideoTicketError,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
from .lifecycle import OrderedCallLifecycle, normalize_transcript_segment
from .live_ai import LiveAiController
from .screenshot import ScreenshotPreviewSize, capture_view_region
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
@@ -188,9 +192,29 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return None
class _QtVideoBridge(QObject): # type: ignore[misc, valid-type]
def __init__(self, callback: Callable[[Mapping[str, Any]], None]) -> None:
def __init__(
self,
callback: Callable[[Mapping[str, Any]], None],
capture_screenshot: Callable[[Mapping[str, Any]], str],
) -> None:
super().__init__()
self._callback = callback
self._capture_screenshot = capture_screenshot
@Slot(str, result=str) # type: ignore[misc]
def captureScreenshot(self, payload: str) -> str: # noqa: N802 - Qt bridge API
"""Return a local preview only; persistence still needs saveScreenshot."""
try:
if not isinstance(payload, str) or len(payload) > 2048:
raise ValueError("截屏区域无效,请重试。")
region = json.loads(payload)
if not isinstance(region, Mapping):
raise ValueError("截屏区域无效,请重试。")
result = {"dataUrl": self._capture_screenshot(region)}
except (ValueError, TypeError, RuntimeError):
result = {"error": "无法截取当前视频画面,请稍后重试。"}
return json.dumps(result, ensure_ascii=False)
@Slot(str) # type: ignore[misc]
def notify(self, payload: str) -> None:
@@ -274,6 +298,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}
)
@Slot(str, str, int, float, str) # type: ignore[misc]
def appendLocalTranscriptionChunk( # noqa: N802
self, session_id: str, role: str, sequence: int, timestamp: float, encoded: str
) -> None:
if len(encoded) > 512_000:
return
self._callback({"source": "doctor-call", "event": "local-transcription-chunk",
"sessionId": session_id, "role": role, "sequence": sequence,
"timestamp": timestamp, "data": encoded})
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
@@ -282,7 +316,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
_policy_completed = Signal(str, object) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str, str) # type: ignore[misc]
_local_transcription_event = Signal(object) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
@@ -299,6 +334,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
repository: Any = None,
) -> None:
super().__init__()
self.request = request
@@ -311,6 +348,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self._on_open_diagnosis = on_open_diagnosis
self._on_compact_close: Callable[[], None] | None = None
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -327,6 +365,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._im_ai = ImAiController(
repository,
diagnosis_id=request.diagnosis_id,
appointment_id=request.appointment_id,
doctor_account=request.user_id,
enabled=can_ai_assistant is True and self.open_im,
)
self._im_ai_timer = QTimer(self)
self._im_ai_timer.setInterval(60)
self._im_ai_timer.timeout.connect(self._flush_im_ai)
if self._im_ai.enabled:
self._im_ai_timer.start()
self._live_ai = LiveAiController(
repository, diagnosis_id=request.diagnosis_id, enabled=can_ai_assistant is True,
)
# The default recognizer is Tencent's existing call transcriber.
# Keep local audio recording independent; no local model is loaded.
self._local_transcription = None
self._asr_session_id = ""
self._asr_ready = False
self._asr_partial = False
self._transcription_final_status = "idle"
self._video_ai_timer = QTimer(self)
self._video_ai_timer.setInterval(100)
self._video_ai_timer.timeout.connect(self._flush_video_ai)
self._video_ai_timer.start()
self._screenshot_preview_size = ScreenshotPreviewSize()
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
@@ -363,7 +428,9 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.setCentralWidget(self.web_view)
self._configure_settings(self._page.settings())
self._bridge = _QtVideoBridge(self._handle_bridge_message)
self._bridge = _QtVideoBridge(
self._handle_bridge_message, self._capture_screenshot_preview
)
self._channel = QWebChannel(self._page)
self._channel.registerObject("qtVideoBridge", self._bridge)
self._page.setWebChannel(self._channel)
@@ -374,6 +441,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_transcription_event.connect(self._on_local_transcription)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
@@ -449,8 +517,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
"embedded video companion failed to load",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "page-load-failed"
self.close()
self._request_companion_shutdown("page-load-failed")
return
self._media_active = True
@@ -459,6 +526,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
"patientName": self.patient_name,
"patientCase": self.patient_case,
"mode": "chat" if self.open_im else "video",
"imAi": self._im_ai.config,
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
script = f"""
@@ -499,13 +567,37 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
if not self._injected:
self._close_reason = "bridge-api-missing"
self.close()
self._request_companion_shutdown("bridge-api-missing")
return
if not self._im_ai_context_is_trusted():
return
# open() may finish IM login before runJavaScript returns. Only
# acknowledge readiness after the native request gate is open.
session_id = json.dumps(self._im_ai.session_id, ensure_ascii=True)
self._page.runJavaScript(
f"window.doctorConsultation?.imAiReady?.({session_id});"
)
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
if self._closing:
return
event = str(message.get("event", ""))
if event.startswith("local-transcription-"):
# An old companion must not start a second recognizer.
return
if event == "video-ai-action":
self._handle_video_assistance_message(message)
return
if event.startswith("transcription-") and not self._local_transcription_context_is_trusted():
return
if event in {"im-ai-request", "im-ai-cancel"}:
self._handle_im_ai_message(message)
return
if event == "screenshot-preview":
visible = message.get("visible")
if isinstance(visible, bool):
self._set_screenshot_preview_visible(visible)
return
if event == "open-diagnosis-request":
if self._on_open_diagnosis is not None:
QTimer.singleShot(0, self._open_diagnosis_safely)
@@ -548,6 +640,9 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
if (self._shutdown_requested or not self._start_requested
or self._call_cycle_closed or not self.lifecycle.call_record_id):
return
self._start_transcription(
str(message.get("sessionId") or ""),
str(message.get("language") or "zh-CN"),
@@ -556,6 +651,22 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if event == "transcription-segment":
self._save_transcript_segment(message)
return
if event == "transcription-ready":
session_id = str(message.get("sessionId") or "")
if (not self._shutdown_requested and session_id
and session_id == str(self.lifecycle.transcription_session_id or "")):
self._live_ai.transcription_running(session_id)
return
if event == "transcription-error":
if (str(message.get("sessionId") or "")
== str(self.lifecycle.transcription_session_id or "")
and self.lifecycle.transcription_session_id):
self._asr_partial = True
self._live_ai.transcription_error(
str(message.get("message") or "腾讯云实时转写未完成,请重试。")[:240],
status="failed",
)
return
if event == "transcription-stop":
self._finish_transcription(
str(message.get("sessionId") or ""),
@@ -594,6 +705,139 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _im_ai_context_is_trusted(self) -> bool:
return (
not self._closing
and not self._released
and not self._shutdown_requested
and self._injected
and self._policy.allows_main_document(self._page.url().toString())
)
def _handle_im_ai_message(self, message: Mapping[str, Any]) -> None:
if self._im_ai_context_is_trusted():
self._im_ai.handle(message)
def _flush_im_ai(self) -> None:
if not self._im_ai_context_is_trusted():
return
result = self._im_ai.take_result()
if result is not None:
payload = json.dumps(result, ensure_ascii=True, separators=(",", ":"))
self._page.runJavaScript(f"window.doctorConsultation?.imAiResult?.({payload});")
def _close_im_ai(self) -> None:
controller = getattr(self, "_im_ai", None)
if controller is not None:
controller.close()
timer = getattr(self, "_im_ai_timer", None)
if timer is not None:
timer.stop()
def _local_transcription_context_is_trusted(self) -> bool:
return (not self._closing and not self._released and self._injected
and self._policy.allows_main_document(self._page.url().toString()))
def _handle_video_assistance_message(self, message: Mapping[str, Any]) -> None:
if not self._local_transcription_context_is_trusted():
return
event = message.get("event")
if event == "video-ai-action":
if not self._shutdown_requested:
self._live_ai.handle(str(message.get("action") or ""), message.get("enabled"))
return
session_id = str(message.get("sessionId") or "")
if event == "local-transcription-start":
if (self._shutdown_requested or not self._start_requested or self._call_cycle_closed
or not self.lifecycle.call_record_id
or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id) is None):
return
existing = str(self.lifecycle.transcription_session_id or "")
if existing and existing != session_id:
return
was_ready = self._asr_session_id == session_id and self._asr_ready
if self._asr_session_id != session_id:
self._asr_partial = False
self._asr_ready = False
self._asr_session_id = session_id
self._live_ai.transcription_starting()
accepted = self._local_transcription.start(session_id)
if accepted and was_ready:
self._on_local_transcription({"type": "ready", "sessionId": session_id})
elif not accepted:
self._on_local_transcription({"type": "error", "sessionId": session_id,
"message": "本次转写已结束,请重新发起通话。"})
elif session_id and session_id == self._asr_session_id:
if event == "local-transcription-chunk":
if session_id != str(self.lifecycle.transcription_session_id or ""):
return
timestamp = message.get("timestamp")
if (isinstance(timestamp, bool) or not isinstance(timestamp, (int, float))
or not math.isfinite(timestamp) or not 0 < timestamp < 2**53):
return
self._local_transcription.submit(
session_id, str(message.get("role") or ""), message.get("sequence"),
int(timestamp), str(message.get("data") or ""),
)
elif event == "local-transcription-stop":
self._local_transcription.stop(session_id)
def _emit_local_transcription(self, event: dict[str, Any]) -> None:
with suppress(RuntimeError):
self._local_transcription_event.emit(event)
def _on_local_transcription(self, event: Mapping[str, Any]) -> None:
if (not self._local_transcription_context_is_trusted()
or event.get("sessionId") != self._asr_session_id):
return
kind = event.get("type")
if kind == "ready":
self._asr_ready = True
elif kind == "stopped":
self._asr_ready = False
elif kind == "segment":
role = event.get("role")
if role not in {"doctor", "patient"}:
return
self._save_transcript_segment({
"sessionId": self._asr_session_id,
"segment": {
"segment_id": event.get("segmentId"),
"speaker_role": role,
"speaker_user_id": (self.request.user_id if role == "doctor"
else self.request.target_user_id),
"timestamp": event.get("timestamp"), "text": event.get("text"),
},
})
elif kind == "error":
self._asr_partial = True
self._live_ai.transcription_error(
str(event.get("message") or "本地转写未完成"),
status="partial" if self._asr_ready else "failed",
)
payload = json.dumps(dict(event), ensure_ascii=True, separators=(",", ":"))
self._page.runJavaScript(f"window.doctorConsultation?.localTranscriptionEvent?.({payload});")
def _flush_video_ai(self) -> None:
if not self._local_transcription_context_is_trusted() or self._shutdown_requested:
return
self._live_ai.tick()
state = self._live_ai.take_state()
if state is not None:
payload = json.dumps(state, ensure_ascii=True, separators=(",", ":"))
self._page.runJavaScript(f"window.doctorConsultation?.videoAiState?.({payload});")
def _close_video_assistance(self, *, close_asr: bool = True) -> None:
controller = getattr(self, "_live_ai", None)
if controller is not None:
controller.close()
timer = getattr(self, "_video_ai_timer", None)
if timer is not None:
timer.stop()
controller = getattr(self, "_local_transcription", None)
if close_asr and controller is not None:
controller.close()
def _open_diagnosis_safely(self) -> None:
if self._closing or self._on_open_diagnosis is None:
return
@@ -978,6 +1222,12 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
if self._closing or self._shutdown_requested or self._start_requested:
return
self._prepare_call_cycle()
live_ai = getattr(self, "_live_ai", None)
if live_ai is not None:
live_ai.reset()
self._asr_session_id = ""
self._asr_ready = False
self._asr_partial = False
self._start_requested = True
try:
future = self.lifecycle.start(call_type=call_type)
@@ -987,6 +1237,30 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
return
future.add_done_callback(self._notify_start_completed)
def _set_screenshot_preview_visible(self, visible: bool) -> None:
if self._closing or self._released:
return
if not self._policy.allows_main_document(self._page.url().toString()):
return
if not visible:
self.restore_screenshot_preview()
return
if self._shutdown_requested or not self._media_active:
return
from ..ui.theme import main_window_target_rect
self._screenshot_preview_size.open(self, main_window_target_rect(self))
def restore_screenshot_preview(self) -> None:
self._screenshot_preview_size.restore(self)
def _capture_screenshot_preview(self, region: Mapping[str, Any]) -> str:
if self._closing or self._released or self._shutdown_requested or not self._media_active:
raise ValueError("视频窗口已关闭。")
if not self._policy.allows_main_document(self._page.url().toString()):
raise ValueError("视频页面不可用。")
return capture_view_region(self.web_view, region)
def _save_screenshot(self, data_url: str) -> None:
prefix = "data:image/jpeg;base64,"
if not data_url.startswith(prefix):
@@ -1032,6 +1306,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
session_id: str,
segment_id: str,
future: Future[bool],
normalized_segment: Mapping[str, Any] | None = None,
) -> None:
try:
succeeded = bool(future.result())
@@ -1046,7 +1321,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
}.get(operation, "")
with suppress(RuntimeError):
self._transcription_completed.emit(
operation, session_id, segment_id, succeeded, message
operation, session_id, segment_id, succeeded, message,
json.dumps(normalized_segment if succeeded else None, ensure_ascii=True),
)
def _on_transcription_completed(
@@ -1056,16 +1332,47 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
segment_id: str,
succeeded: bool,
message: str,
normalized_segment: str | Mapping[str, Any] | None = None,
) -> None:
if not self._local_transcription_context_is_trusted():
return
live_ai = getattr(self, "_live_ai", None)
if (live_ai is not None
and session_id == str(self.lifecycle.transcription_session_id or "")):
if operation == "start" and succeeded:
live_ai.bind(int(self.lifecycle.call_record_id), session_id)
elif operation == "start":
live_ai.transcription_error(message or "转写文字存储未就绪,请重试。", status="failed")
elif operation == "segment":
if succeeded:
live_ai.changed(session_id)
else:
self._asr_partial = True
live_ai.transcription_error("部分对话文字保存失败,分析资料可能不完整。")
elif operation == "stop":
live_ai.finish(session_id, self._transcription_final_status if succeeded else "partial")
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
if isinstance(normalized_segment, str):
try:
normalized_segment = json.loads(normalized_segment)
except (ValueError, TypeError):
normalized_segment = None
caption = json.dumps(
dict(normalized_segment)
if succeeded and operation == "segment" and isinstance(normalized_segment, Mapping)
else None,
ensure_ascii=True,
)
self._page.runJavaScript(
"window.doctorConsultation?.transcriptionResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
f"{json.dumps(segment_id)}, {state}, {payload});"
f"{json.dumps(segment_id)}, {state}, {payload}, {caption});"
)
def _start_transcription(self, session_id: str, language: str) -> None:
if not self._local_transcription_context_is_trusted() or self._shutdown_requested:
return
try:
future = self.lifecycle.start_transcription(session_id, language=language)
except Exception as error:
@@ -1073,6 +1380,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
"start", session_id, "", False, str(error)[:200]
)
return
self._live_ai.transcription_starting()
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"start", session_id, "", completed
@@ -1080,6 +1388,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
)
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
if not self._local_transcription_context_is_trusted():
return
session_id = str(message.get("sessionId") or "").strip()
segment = message.get("segment")
segment_id = (
@@ -1098,25 +1408,44 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
)
return
try:
future = self.lifecycle.save_transcript_segment(segment)
normalized = normalize_transcript_segment(segment, session_id)
speaker = normalized["speaker_user_id"]
normalized["speaker_role"] = (
"doctor" if speaker == self.request.user_id
else "patient" if speaker == self.request.target_user_id
else "unknown"
)
future = self.lifecycle.save_transcript_segment(normalized)
except Exception as error:
self._on_transcription_completed(
"segment", session_id, segment_id, False, str(error)[:200]
)
return
caption = {
"segmentId": normalized["segment_id"],
"speakerUserId": normalized["speaker_user_id"],
"sourceText": normalized["text"],
"timestamp": normalized["timestamp"],
"isCompleted": True,
}
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"segment", session_id, segment_id, completed
"segment", session_id, segment_id, completed, caption
)
)
def _finish_transcription(self, session_id: str, status: str) -> None:
if not self._local_transcription_context_is_trusted():
return
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"stop", session_id, "", False, "录音会话标识不匹配。"
)
return
try:
if getattr(self, "_asr_partial", False):
status = "partial"
self._transcription_final_status = status
future = self.lifecycle.finish_transcription(status=status)
except Exception as error:
self._on_transcription_completed(
@@ -1137,9 +1466,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
def hangup(self) -> None:
self._request_companion_shutdown("desktop-hangup")
def set_compact_close_handler(self, callback: Callable[[], None] | None) -> None:
"""Let the compact title-bar close return to the same full call window."""
self._on_compact_close = callback
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
_EmbeddedVideoWindow._close_im_ai(self)
_EmbeddedVideoWindow._close_video_assistance(self, close_asr=False)
self._close_reason = reason
if self._closing or self._released:
return
@@ -1150,6 +1486,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
and not self._companion_ended
)
if not should_wait_for_companion:
self._shutdown_requested = True
self.close()
return
if self._shutdown_requested:
@@ -1183,6 +1520,9 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
def _begin_shutdown(self) -> None:
if self._closing:
return
_EmbeddedVideoWindow._close_im_ai(self)
_EmbeddedVideoWindow._close_video_assistance(self)
self._on_compact_close = None
self._closing = True
self._media_active = False
self._shutdown_timer.stop()
@@ -1240,6 +1580,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._on_compact_close is not None
and not self._shutdown_requested
and not self._companion_ended
and not self._closing
and not self._released
):
event.ignore()
self._on_compact_close()
return
if (
self._injected
and self._start_requested
@@ -1273,6 +1623,7 @@ class VideoCallWindow:
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -1291,6 +1642,7 @@ class VideoCallWindow:
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self.can_ai_assistant = can_ai_assistant is True
self.on_open_diagnosis = on_open_diagnosis
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
@@ -1322,6 +1674,8 @@ class VideoCallWindow:
patient_name=self.patient_name,
patient_case=self.patient_case,
on_open_diagnosis=self.on_open_diagnosis,
can_ai_assistant=self.can_ai_assistant,
repository=self.repository,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -1341,7 +1695,7 @@ class VideoCallWindow:
def close(self) -> None:
if self._session is not None:
self._session.close()
self._session._request_companion_shutdown("window-closed")
else:
self.lifecycle.end("unopened-session")
@@ -1367,6 +1721,7 @@ def open_video_call(
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -1383,6 +1738,7 @@ def open_video_call(
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant,
).open()
+3 -2
View File
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
@@ -2306,14 +2307,14 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
assert "#BE4B58" in marker
assert PALETTE["danger"] in marker
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
assert "color: #1a1c1f" in body.lower()
assert f'color: {PALETTE["text"]}' in body
assert "font-size: 14px" in body
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
assert "font-size: 14px" in block
+33 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtCore import QDate, QSize, Qt
from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer
from doctor_workstation.ui.design_system import PALETTE
@pytest.fixture(scope="module")
@@ -565,8 +566,39 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
assert f"border-color: {PALETTE['focus']};" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
application.processEvents()
@pytest.mark.parametrize("button_kind", ["date", "slot"])
def test_appointment_hover_keeps_hit_target_stable_after_relayout(
application: QApplication, button_kind: str,
) -> None:
"""Hovering must not move a time choice or restore a stale layout position."""
from PySide6.QtCore import QEvent, QPoint, QPointF
from PySide6.QtGui import QEnterEvent
host, drawer = _show_drawer(
application, (1440, 900), repository=_VisualRepository(), autoload=True,
)
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
button = drawer.date_buttons[tomorrow] if button_kind == "date" else drawer.slot_buttons["09:30-10:00"]
original = button.geometry()
try:
for _ in range(3):
application.sendEvent(button, QEnterEvent(QPointF(10, 10), QPointF(30, 34), QPointF(30, 34)))
assert button.geometry() == original
assert button.graphicsEffect() is None
relocated = original.topLeft() + QPoint(12, 16)
button.move(relocated) # A resize/reflow while the pointer stays inside.
application.sendEvent(button, QEvent(QEvent.Type.Leave))
assert button.pos() == relocated
button.setGeometry(original)
button.click()
assert button.isChecked()
finally:
drawer.close()
host.close()
@@ -0,0 +1,362 @@
"""Cloud ASR reaches UI and live AI only after trusted, normalized persistence."""
from __future__ import annotations
import json
from concurrent.futures import Future
from types import MethodType, SimpleNamespace
from unittest.mock import Mock
import pytest
from doctor_workstation.video import window
from doctor_workstation.video.security import TrustedDocumentPolicy
pytestmark = pytest.mark.skipif(not window.WEBENGINE_AVAILABLE, reason="optional QtWebEngine")
TRUSTED = "https://video.example.test/call/index.html"
SESSION = "tr-cloud-test"
SOURCE = "沒有過敏,沒有吃藥。0.5 mg,不是 5 mg。血壓 120/80 mmHg。\n唔係,冇食藥。"
SIMPLIFIED = "没有过敏,没有吃药。0.5 mg,不是 5 mg。血压 120/80 mmHg。\n唔系,冇食药。"
TIMESTAMP = 1793332800000
def host(**changes):
item = SimpleNamespace(
_closing=False, _released=False, _shutdown_requested=False, _injected=True,
_start_requested=True, _call_cycle_closed=False,
_policy=TrustedDocumentPolicy.from_url(TRUSTED, is_local=False),
_page=Mock(), _local_transcription=Mock(), _live_ai=Mock(),
_asr_session_id=SESSION, _asr_ready=False, _asr_partial=False,
_transcription_final_status="completed", _transcription_completed=Mock(),
request=SimpleNamespace(user_id="doctor_test", target_user_id="patient_test"),
lifecycle=SimpleNamespace(
call_record_id=71, transcription_session_id=SESSION,
start_transcription=Mock(return_value=Future()),
save_transcript_segment=Mock(return_value=Future()),
finish_transcription=Mock(return_value=Future()),
),
_start_local_audio_recording=Mock(), _append_local_audio_chunk=Mock(),
_finish_local_audio_recording=Mock(), _abort_local_audio_recording=Mock(),
)
item._page.url.return_value.toString.return_value = TRUSTED
for name in (
"_handle_bridge_message", "_handle_video_assistance_message",
"_local_transcription_context_is_trusted", "_start_transcription",
"_save_transcript_segment", "_finish_transcription",
"_notify_transcription_completed", "_on_transcription_completed",
):
setattr(item, name, MethodType(getattr(window._EmbeddedVideoWindow, name), item))
item._transcription_completed.emit.side_effect = item._on_transcription_completed
for name, value in changes.items():
setattr(item, name, value)
return item
def segment_message(**changes):
return {
"source": "doctor-call", "event": "transcription-segment", "sessionId": SESSION,
"segment": {
"segment_id": "cloud-segment-1", "speaker_user_id": "patient_test",
"speaker_role": "doctor", "timestamp": TIMESTAMP, "text": SOURCE,
},
**changes,
}
def transcription_result(item):
script = item._page.runJavaScript.call_args.args[0]
assert script.startswith("window.doctorConsultation?.transcriptionResult?.(")
assert script.endswith(");")
return json.loads("[" + script.split("?.(", 1)[1][:-2] + "]")
@pytest.mark.parametrize(
("speaker_id", "expected_role"),
[("doctor_test", "doctor"), ("patient_test", "patient"), ("other_test", "unknown")],
)
def test_trusted_cloud_segment_is_normalized_and_role_is_host_derived(speaker_id, expected_role):
item = host()
message = segment_message()
message["segment"]["speaker_user_id"] = speaker_id
item._handle_bridge_message(message)
item.lifecycle.save_transcript_segment.assert_called_once()
saved = item.lifecycle.save_transcript_segment.call_args.args[0]
assert saved["text"] == SIMPLIFIED
assert saved["speaker_role"] == expected_role
assert saved["speaker_user_id"] == speaker_id
assert saved["segment_id"] == "cloud-segment-1"
assert saved["timestamp"] == TIMESTAMP
assert message["segment"]["text"] == SOURCE
item._live_ai.changed.assert_not_called()
item._page.runJavaScript.assert_not_called()
item.lifecycle.save_transcript_segment.return_value.set_result(True)
result = transcription_result(item)
assert result == [
"segment", SESSION, "cloud-segment-1", True, "",
{"segmentId": "cloud-segment-1", "speakerUserId": speaker_id,
"sourceText": SIMPLIFIED, "timestamp": TIMESTAMP, "isCompleted": True},
]
assert len(item._transcription_completed.emit.call_args.args) == 6
item._live_ai.changed.assert_called_once_with(SESSION)
item._local_transcription.start.assert_not_called()
item._local_transcription.submit.assert_not_called()
@pytest.mark.parametrize("failure", [False, RuntimeError("storage unavailable")])
def test_failed_storage_never_returns_normalized_text_or_dirties_ai(failure):
item = host()
item._handle_bridge_message(segment_message())
future = item.lifecycle.save_transcript_segment.return_value
if isinstance(failure, Exception):
future.set_exception(failure)
else:
future.set_result(failure)
result = transcription_result(item)
assert result[:4] == ["segment", SESSION, "cloud-segment-1", False]
assert len(result) == 6 and result[5] is None
assert item._asr_partial
item._live_ai.changed.assert_not_called()
item._live_ai.transcription_error.assert_called_once()
def test_synchronous_storage_failure_has_no_normalized_ack():
item = host()
item.lifecycle.save_transcript_segment.side_effect = RuntimeError("storage unavailable")
item._handle_bridge_message(segment_message())
result = transcription_result(item)
assert result[:4] == ["segment", SESSION, "cloud-segment-1", False]
assert result[5] is None
item._live_ai.changed.assert_not_called()
def test_camel_case_cloud_segment_uses_the_same_normalized_contract():
item = host()
item._handle_bridge_message(segment_message(segment={
"segmentId": "cloud-segment-2", "speakerUserId": " doctor_test ",
"speakerRole": "patient", "sourceText": SOURCE, "timestamp": TIMESTAMP,
}))
saved = item.lifecycle.save_transcript_segment.call_args.args[0]
assert saved["speaker_role"] == "doctor"
assert saved["speaker_user_id"] == "doctor_test"
assert saved["text"] == SIMPLIFIED
item.lifecycle.save_transcript_segment.return_value.set_result(True)
assert transcription_result(item)[5] == {
"segmentId": "cloud-segment-2", "speakerUserId": "doctor_test",
"sourceText": SIMPLIFIED, "timestamp": TIMESTAMP, "isCompleted": True,
}
@pytest.mark.parametrize("segment", [
None, {}, {"segment_id": "s1", "text": " "},
{"segment_id": "s1", "text": "字" * 4001},
])
def test_malformed_segment_is_rejected_before_storage(segment):
item = host()
item._handle_bridge_message(segment_message(segment=segment))
item.lifecycle.save_transcript_segment.assert_not_called()
result = transcription_result(item)
assert result[0] == "segment" and result[3] is False and result[5] is None
item._live_ai.changed.assert_not_called()
def test_conversion_failure_does_not_store_or_ack_unnormalized_text(monkeypatch):
def unavailable_dictionary(_text):
raise RuntimeError("conversion dictionary unavailable")
monkeypatch.setattr(
"doctor_workstation.video.lifecycle.simplified_transcript", unavailable_dictionary,
)
item = host()
item._handle_bridge_message(segment_message())
item.lifecycle.save_transcript_segment.assert_not_called()
result = transcription_result(item)
assert result[:4] == ["segment", SESSION, "cloud-segment-1", False]
assert result[5] is None
item._live_ai.changed.assert_not_called()
def test_json_ack_preserves_text_without_inserting_executable_source():
item = host()
message = segment_message()
message["segment"]["text"] = '沒有;0.5 mg,不是 5 mg。</script>"\\\u2028\u2029結尾'
item._handle_bridge_message(message)
item.lifecycle.save_transcript_segment.return_value.set_result(True)
result = transcription_result(item)
assert result[5]["sourceText"] == '没有;0.5 mg,不是 5 mg。</script>"\\\u2028\u2029结尾'
script = item._page.runJavaScript.call_args.args[0]
assert "\u2028" not in script and "\u2029" not in script
@pytest.mark.parametrize("session_id", ["tr-old-call", ""])
def test_segment_for_another_or_empty_session_is_not_saved(session_id):
item = host()
item._handle_bridge_message(segment_message(sessionId=session_id))
item.lifecycle.save_transcript_segment.assert_not_called()
item._live_ai.changed.assert_not_called()
def test_late_persistence_completion_does_not_dirty_new_call():
item = host()
item._handle_bridge_message(segment_message())
original_future = item.lifecycle.save_transcript_segment.return_value
item.lifecycle = SimpleNamespace(call_record_id=72, transcription_session_id="tr-new-call")
item._asr_session_id = "tr-new-call"
original_future.set_result(True)
item._live_ai.changed.assert_not_called()
item._live_ai.bind.assert_not_called()
item._live_ai.finish.assert_not_called()
def test_navigation_before_persistence_ack_blocks_page_delivery_and_ai_change():
item = host()
item._handle_bridge_message(segment_message())
item._page.url.return_value.toString.return_value = "https://another.example.test/"
item.lifecycle.save_transcript_segment.return_value.set_result(True)
item._page.runJavaScript.assert_not_called()
item._live_ai.changed.assert_not_called()
@pytest.mark.parametrize("succeeded", [True, False])
def test_start_marks_starting_and_binds_only_after_successful_storage_ack(succeeded):
item = host()
item._handle_bridge_message({
"event": "transcription-start-request", "sessionId": SESSION, "language": "zh-CN",
})
item._live_ai.transcription_starting.assert_called_once()
item.lifecycle.start_transcription.assert_called_once_with(SESSION, language="zh-CN")
item._live_ai.bind.assert_not_called()
item._local_transcription.start.assert_not_called()
item.lifecycle.start_transcription.return_value.set_result(succeeded)
if succeeded:
item._live_ai.bind.assert_called_once_with(71, SESSION)
else:
item._live_ai.bind.assert_not_called()
assert transcription_result(item)[:4] == ["start", SESSION, "", succeeded]
item._live_ai.changed.assert_not_called()
def test_cloud_error_marks_failed_and_stop_keeps_partial_storage_status():
item = host()
item._handle_bridge_message({
"event": "transcription-error", "sessionId": SESSION, "message": "云转写连接中断",
})
item._live_ai.transcription_error.assert_called_once_with("云转写连接中断", status="failed")
assert item._asr_partial
item._handle_bridge_message({
"event": "transcription-stop", "sessionId": SESSION, "status": "completed",
})
item.lifecycle.finish_transcription.assert_called_once_with(status="partial")
item._live_ai.finish.assert_not_called()
item.lifecycle.finish_transcription.return_value.set_result(True)
item._live_ai.finish.assert_called_once_with(SESSION, "partial")
item._live_ai.changed.assert_not_called()
@pytest.mark.parametrize("session,shutdown,ready", [(SESSION, False, True), ("old", False, False), (SESSION, True, False), ("", False, False)])
def test_cloud_ready_after_retry_is_scoped_to_active_call(session, shutdown, ready):
item = host(_shutdown_requested=shutdown)
item._handle_bridge_message({"event": "transcription-ready", "sessionId": session})
assert item._live_ai.transcription_running.called is ready
if ready:
item._live_ai.transcription_running.assert_called_once_with(SESSION)
def test_error_or_stop_from_old_session_does_not_change_current_call():
item = host()
item._handle_bridge_message({
"event": "transcription-error", "sessionId": "tr-old-call", "message": "old failure",
})
item._handle_bridge_message({
"event": "transcription-stop", "sessionId": "tr-old-call", "status": "partial",
})
assert not item._asr_partial
item.lifecycle.finish_transcription.assert_not_called()
item._live_ai.transcription_error.assert_not_called()
item._live_ai.finish.assert_not_called()
@pytest.mark.parametrize("event", [
"transcription-start-request", "transcription-segment", "transcription-stop", "transcription-error", "transcription-ready",
])
@pytest.mark.parametrize("untrusted", ["closing", "released", "uninjected", "navigated"])
def test_cloud_commands_require_injected_trusted_live_document(event, untrusted):
item = host()
if untrusted == "navigated":
item._page.url.return_value.toString.return_value = "https://another.example.test/"
else:
setattr(item, {"closing": "_closing", "released": "_released", "uninjected": "_injected"}[untrusted],
untrusted != "uninjected")
message = segment_message(event=event, message="cloud failure", language="zh-CN", status="completed")
item._handle_bridge_message(message)
item.lifecycle.start_transcription.assert_not_called()
item.lifecycle.save_transcript_segment.assert_not_called()
item.lifecycle.finish_transcription.assert_not_called()
item._live_ai.transcription_starting.assert_not_called()
item._live_ai.transcription_error.assert_not_called()
item._live_ai.transcription_running.assert_not_called()
item._live_ai.changed.assert_not_called()
@pytest.mark.parametrize("operation", ["start", "segment", "stop"])
def test_direct_transcription_methods_also_require_a_trusted_document(operation):
item = host(_injected=False)
if operation == "start":
item._start_transcription(SESSION, "zh-CN")
elif operation == "segment":
item._save_transcript_segment(segment_message())
else:
item._finish_transcription(SESSION, "completed")
item.lifecycle.start_transcription.assert_not_called()
item.lifecycle.save_transcript_segment.assert_not_called()
item.lifecycle.finish_transcription.assert_not_called()
@pytest.mark.parametrize("event", [
"local-transcription-start", "local-transcription-chunk", "local-transcription-stop",
])
def test_old_local_asr_dispatch_cannot_run_recognition(event):
item = host()
item._handle_bridge_message({
"event": event, "sessionId": SESSION, "role": "patient", "sequence": 0,
"timestamp": TIMESTAMP, "data": "AQACAA==",
})
item._local_transcription.start.assert_not_called()
item._local_transcription.submit.assert_not_called()
item._local_transcription.stop.assert_not_called()
item.lifecycle.start_transcription.assert_not_called()
item.lifecycle.save_transcript_segment.assert_not_called()
@pytest.mark.parametrize(
("event", "handler", "arguments"),
[
("local-audio-start", "_start_local_audio_recording", (SESSION, "audio/webm")),
("local-audio-chunk", "_append_local_audio_chunk", (SESSION, 0, "AQACAA==")),
("local-audio-finish", "_finish_local_audio_recording", (SESSION, 4)),
("local-audio-abort", "_abort_local_audio_recording", (SESSION,)),
],
)
def test_local_audio_recording_dispatch_remains_available(event, handler, arguments):
item = host()
item._handle_bridge_message({
"event": event, "sessionId": SESSION, "mimeType": "audio/webm",
"sequence": 0, "data": "AQACAA==", "totalBytes": 4,
})
getattr(item, handler).assert_called_once_with(*arguments)
def test_pending_shutdown_drains_cloud_segments_and_stop_without_new_start():
item = host(_shutdown_requested=True)
item._handle_bridge_message({"event": "transcription-start-request", "sessionId": "tr-next"})
item._handle_bridge_message(segment_message())
item._handle_bridge_message({"event": "transcription-stop", "sessionId": SESSION})
item.lifecycle.start_transcription.assert_not_called()
item.lifecycle.save_transcript_segment.assert_called_once()
item.lifecycle.finish_transcription.assert_called_once_with(status="completed")
item._local_transcription.start.assert_not_called()
+343
View File
@@ -0,0 +1,343 @@
from __future__ import annotations
import os
from collections.abc import Callable, Iterator
from types import MethodType, SimpleNamespace
from typing import Any
from unittest.mock import Mock
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
from doctor_workstation.app import ApplicationController
from doctor_workstation.video import window as video_window
class _CallWindow(QMainWindow):
"""Exercise production Qt close handling without starting WebEngine or media."""
closeEvent = video_window._EmbeddedVideoWindow.closeEvent
set_compact_close_handler = video_window._EmbeddedVideoWindow.set_compact_close_handler
hangup = video_window._EmbeddedVideoWindow.hangup
_request_companion_shutdown = video_window._EmbeddedVideoWindow._request_companion_shutdown
_close_from_companion = video_window._EmbeddedVideoWindow._close_from_companion
_force_requested_shutdown = video_window._EmbeddedVideoWindow._force_requested_shutdown
_begin_shutdown = video_window._EmbeddedVideoWindow._begin_shutdown
_handle_bridge_message = video_window._EmbeddedVideoWindow._handle_bridge_message
_on_load_finished = video_window._EmbeddedVideoWindow._on_load_finished
_after_injection = video_window._EmbeddedVideoWindow._after_injection
def __init__(self, **changes: Any) -> None:
super().__init__()
self._on_compact_close: Callable[[], None] | None = None
self._closing = False
self._released = False
self._shutdown_requested = False
self._companion_ended = False
self._injected = True
self._start_requested = True
self._call_cycle_closed = False
self._media_active = True
self._close_reason = "window-closed"
self.open_im = False
self.lifecycle = SimpleNamespace(end=Mock())
self._page = SimpleNamespace(runJavaScript=Mock())
self._shutdown_timer = SimpleNamespace(start=Mock(), stop=Mock())
self._abort_local_audio_recording = Mock()
self._append_local_audio_chunk = Mock()
self._release_webengine = Mock(side_effect=self._mark_released)
self.restore_screenshot_preview = Mock()
self.call_ended = SimpleNamespace(emit=Mock())
self.call_error = SimpleNamespace(emit=Mock())
self.logger = Mock()
self.request = SimpleNamespace(safe_log_context=lambda: {})
for key, value in changes.items():
setattr(self, key, value)
self.setProperty("doctorSkipOpenGeometry", True)
self.setMinimumSize(620, 420)
self.setGeometry(40, 50, 680, 480)
def _mark_released(self) -> None:
self._released = True
@pytest.fixture
def qapp() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def make_window(qapp: QApplication) -> Iterator[Callable[..., _CallWindow]]:
windows: list[_CallWindow] = []
def create(**changes: Any) -> _CallWindow:
window = _CallWindow(**changes)
windows.append(window)
return window
yield create
for window in windows:
window.set_compact_close_handler(None)
window._companion_ended = True
window.close()
window.deleteLater()
qapp.processEvents()
def _controller() -> SimpleNamespace:
controller = SimpleNamespace(_video_preview_state=None, _video_preview_generation=0)
controller._show_video_preview = MethodType(
ApplicationController._show_video_preview, controller
)
controller._restore_video_preview = MethodType(
ApplicationController._restore_video_preview, controller
)
return controller
def test_native_compact_close_restores_visible_window_and_next_close_ends_call(
qapp: QApplication, make_window: Callable[..., _CallWindow]
) -> None:
window = make_window()
window.show()
qapp.processEvents()
original_geometry = QRect(window.geometry())
original_minimum = QSize(window.minimumSize())
controller = _controller()
dialog = QDialog()
controller._show_video_preview(window, dialog)
qapp.processEvents()
assert window.isVisible()
assert window.minimumSize() == QSize(440, 300)
assert window.geometry() != original_geometry
assert window.windowFlags() & Qt.WindowType.WindowStaysOnTopHint
assert window._on_compact_close is not None
assert window.close() is False
qapp.processEvents()
assert window.isVisible()
assert window.geometry() == original_geometry
assert window.minimumSize() == original_minimum
assert not window.windowFlags() & Qt.WindowType.WindowStaysOnTopHint
assert window.property("doctorSkipOpenGeometry") is False
assert controller._video_preview_state is None
assert window._on_compact_close is None
assert window._shutdown_requested is False
window._page.runJavaScript.assert_not_called()
window.lifecycle.end.assert_not_called()
window._abort_local_audio_recording.assert_not_called()
window._release_webengine.assert_not_called()
window.restore_screenshot_preview.assert_called_once_with()
assert window.close() is False
assert window._shutdown_requested is True
assert window.isVisible()
assert window._closing is False
window._page.runJavaScript.assert_called_once()
window._release_webengine.assert_not_called()
window._handle_bridge_message({"event": "hangup", "status": "local-hangup"})
qapp.processEvents()
assert not window.isVisible()
assert window._closing is True
window._release_webengine.assert_called_once_with()
@pytest.mark.parametrize("action", ["hangup", "facade-close"])
@pytest.mark.parametrize("wait_for_recording", [False, True], ids=["idle", "recording"])
def test_explicit_shutdown_bypasses_compact_restore_and_drains_recording(
make_window: Callable[..., _CallWindow], action: str, wait_for_recording: bool
) -> None:
window = make_window(_start_requested=wait_for_recording)
restore = Mock()
window.set_compact_close_handler(restore)
window.show()
def end_call() -> None:
if action == "hangup":
window.hangup()
else:
video_window.VideoCallWindow.close(SimpleNamespace(_session=window))
end_call()
restore.assert_not_called()
assert window._shutdown_requested is True
if wait_for_recording:
assert window.isVisible()
assert window._closing is False
window.lifecycle.end.assert_not_called()
window._release_webengine.assert_not_called()
window._abort_local_audio_recording.assert_not_called()
window._shutdown_timer.start.assert_called_once_with(190_000)
window._page.runJavaScript.assert_called_once()
assert "doctorConsultation?.close?.()" in window._page.runJavaScript.call_args.args[0]
end_call()
window.close()
window._page.runJavaScript.assert_called_once()
window._handle_bridge_message(
{"event": "local-audio-chunk", "sessionId": "recording", "sequence": 7, "data": "YQ=="}
)
window._append_local_audio_chunk.assert_called_once_with("recording", 7, "YQ==")
window._release_webengine.assert_not_called()
window._handle_bridge_message({"event": "hangup", "status": "local-hangup"})
else:
window._page.runJavaScript.assert_not_called()
window._shutdown_timer.start.assert_not_called()
reason = "desktop-hangup" if action == "hangup" else "window-closed"
window.lifecycle.end.assert_called_once_with(reason)
restore.assert_not_called()
assert not window.isVisible()
assert window._on_compact_close is None
assert window._closing is True
assert window._released is True
assert window._media_active is False
window._shutdown_timer.stop.assert_called_once_with()
window._abort_local_audio_recording.assert_called_once_with("")
window._release_webengine.assert_called_once_with()
window._begin_shutdown()
window._release_webengine.assert_called_once_with()
@pytest.mark.parametrize(
"trigger",
["remote-hangup", "remote-error", "timeout", "load-failed", "load-failed-active", "bridge-missing"],
)
def test_terminal_paths_close_compact_window_without_restoring(
make_window: Callable[..., _CallWindow], trigger: str
) -> None:
window = make_window()
restore = Mock()
window.set_compact_close_handler(restore)
window.show()
if trigger == "remote-hangup":
window._handle_bridge_message({"event": "hangup", "status": "remote-hangup"})
window.call_ended.emit.assert_called_once_with("remote-hangup")
elif trigger == "remote-error":
window._handle_bridge_message({"event": "error", "message": "connection lost"})
window.call_error.emit.assert_called_once_with("connection lost")
elif trigger == "timeout":
window.hangup()
window._force_requested_shutdown()
window.lifecycle.end.assert_any_call("desktop-hangup-timeout")
elif trigger.startswith("load-failed"):
if trigger == "load-failed":
window._injected = False
window._start_requested = False
window._on_load_finished(False)
assert window._close_reason == "page-load-failed"
if trigger == "load-failed-active":
assert window._closing is False
window._release_webengine.assert_not_called()
restore.assert_not_called()
window._force_requested_shutdown()
window.lifecycle.end.assert_any_call("page-load-failed-timeout")
else:
window._after_injection(False)
assert window._close_reason == "bridge-api-missing"
restore.assert_not_called()
assert not window.isVisible()
assert window._closing is True
assert window._released is True
assert window._on_compact_close is None
window._release_webengine.assert_called_once_with()
window._shutdown_timer.stop.assert_called_once_with()
@pytest.mark.parametrize(
"state", ["_shutdown_requested", "_companion_ended", "_closing", "_released"]
)
def test_close_event_never_restores_a_stopping_or_released_call(
make_window: Callable[..., _CallWindow], state: str
) -> None:
window = make_window(_start_requested=False, **{state: True})
restore = Mock()
window.set_compact_close_handler(restore)
event = QCloseEvent()
window.closeEvent(event)
restore.assert_not_called()
assert event.isAccepted()
def test_repeated_preview_refreshes_handler_and_ignores_old_dialog_generation(
make_window: Callable[..., _CallWindow],
) -> None:
window = make_window()
controller = _controller()
first_dialog, second_dialog, third_dialog = QDialog(), QDialog(), QDialog()
controller._show_video_preview(window, first_dialog)
first_handler = window._on_compact_close
original_state = controller._video_preview_state
controller._show_video_preview(window, second_dialog)
second_handler = window._on_compact_close
assert first_handler is not None
assert second_handler is not None
assert first_handler is not second_handler
assert controller._video_preview_state is original_state
first_dialog.finished.emit(0)
first_handler()
assert controller._video_preview_state is original_state
assert window._on_compact_close is second_handler
second_handler()
assert controller._video_preview_state is None
assert window._on_compact_close is None
controller._show_video_preview(window, third_dialog)
current_handler = window._on_compact_close
second_dialog.finished.emit(0)
second_handler()
assert controller._video_preview_state is not None
assert window._on_compact_close is current_handler
third_dialog.finished.emit(0)
assert controller._video_preview_state is None
assert window._on_compact_close is None
window.lifecycle.end.assert_not_called()
window._page.runJavaScript.assert_not_called()
def test_switching_preview_restores_old_window_and_isolates_old_callbacks(
make_window: Callable[..., _CallWindow],
) -> None:
first, second = make_window(), make_window()
controller = _controller()
first_dialog, second_dialog = QDialog(), QDialog()
first_geometry = QRect(first.geometry())
controller._show_video_preview(first, first_dialog)
old_handler = first._on_compact_close
controller._show_video_preview(second, second_dialog)
current_handler = second._on_compact_close
assert first._on_compact_close is None
assert first.geometry() == first_geometry
assert controller._video_preview_state["window"] is second
assert old_handler is not None
old_handler()
first_dialog.finished.emit(0)
assert controller._video_preview_state["window"] is second
assert second._on_compact_close is current_handler
second_dialog.finished.emit(0)
assert controller._video_preview_state is None
assert second._on_compact_close is None
first.lifecycle.end.assert_not_called()
second.lifecycle.end.assert_not_called()
+4 -3
View File
@@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.diagnosis_drawer import (
DiagnosisChoiceButtons,
DiagnosisComboBox,
@@ -1012,14 +1013,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#eef1fa"
assert enabled_color.name().lower() == PALETTE["accent_soft"].lower()
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#eef1fa"
assert disabled_color.name().lower() == PALETTE["accent_soft"].lower()
dialog.close()
application.processEvents()
@@ -1039,7 +1040,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#eef1fa"
assert color.name().lower() == PALETTE["accent_soft"].lower()
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
+6 -5
View File
@@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.diagnosis_index_widgets import (
DIAGNOSIS_INDEX_QSS,
DiagnosisItemDelegate,
@@ -424,8 +425,8 @@ def test_empty_loading_and_compact_footer_keep_the_table_shell(
@pytest.mark.parametrize(
("record", "stripe", "channel"),
[
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), PALETTE["warning"].lower(), "warning"),
(_row(702, has_appointment=0, appointments=[]), PALETTE["accent"].lower(), "info"),
],
)
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
@@ -452,9 +453,9 @@ def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
assert image.pixelColor(0, 20).name() == stripe
assert image.pixelColor(1, 20).name() == stripe
assert image.pixelColor(2, 20).name() == stripe
assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
assert image.pixelColor(6, 20).name() == "#f7f7f7"
assert image.pixelColor(40, 20).name() == "#f7f7f7"
assert image.pixelColor(3, 20).name() == PALETTE["surface_alt"].lower(), f"{channel} stripe widened"
assert image.pixelColor(6, 20).name() == PALETTE["surface_alt"].lower()
assert image.pixelColor(40, 20).name() == PALETTE["surface_alt"].lower()
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
+83 -3
View File
@@ -9,10 +9,17 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QFont
from PySide6.QtCore import QPoint, QRect, Qt
from PySide6.QtGui import QFont, QImage, QPainter
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QApplication,
QLabel,
QStyleOptionViewItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.ui.diagnosis_index_widgets import (
DiagnosisTableHost,
@@ -115,6 +122,79 @@ def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QA
legacy.close()
@pytest.mark.parametrize(
"size,column_width,row_height",
[((1024, 640), 84, 75), ((1440, 900), 126, 72)],
)
def test_followup_paint_keeps_time_whole_and_preserves_density(
application: QApplication, size: tuple[int, int], column_width: int, row_height: int,
) -> None:
class RecordingPainter(QPainter):
def __init__(self, image: QImage) -> None:
super().__init__(image)
self.lines: list[tuple[QRect, int, str]] = []
def drawText(self, rect: QRect, flags: int, text: str) -> None: # noqa: N802
self.lines.append((QRect(rect), flags, text))
super().drawText(rect, flags, text)
row = {
"id": 501, "has_prescription": 1,
"followup_time_text": "2026-08-17 09:00", "followup_doctor_name": "陈医生",
}
original = copy.deepcopy(row)
host = DiagnosisTableHost(tech_blue=True)
host.resize(*size)
host.set_rows([row])
host.show()
_settle(application)
try:
assert host.main.columnWidth(6) == column_width
assert host.main.rowHeight(0) == row_height
assert host.fixed.rowHeight(0) == row_height
index = host.model.index(0, 6)
full_text = "2026-08-17 09:00\n陈医生"
assert index.data() == full_text
assert index.data(Qt.ItemDataRole.ToolTipRole) == full_text
option = QStyleOptionViewItem()
option.rect = QRect(0, 0, column_width, row_height)
image = QImage(column_width, row_height, QImage.Format.Format_ARGB32)
painter = RecordingPainter(image)
host.main.itemDelegate().paint(painter, option, index)
painter.end()
expected = ["2026-08-17", "09:00", "陈医生"] if size[0] == 1024 else [
"2026-08-17 09:00", "陈医生",
]
assert [line[2] for line in painter.lines] == expected
content = option.rect.adjusted(8, 8, -8, -8)
for rect, flags, _text in painter.lines:
assert content.contains(rect)
assert flags & Qt.TextFlag.TextSingleLine
assert not flags & Qt.TextFlag.TextWrapAnywhere
assert row == original
finally:
host.close()
_settle(application)
def test_followup_long_metadata_has_complete_tooltip(application: QApplication) -> None:
host = DiagnosisTableHost(tech_blue=True)
row = {
"id": 502, "has_prescription": 1,
"followup_time_text": "2026-08-17 09:00-09:30",
"followup_doctor_name": "陈医生(内分泌科专家门诊)", "followup_rx_voided": True,
}
original = copy.deepcopy(row)
host.set_rows([row])
index = host.model.index(0, 6)
assert index.data(Qt.ItemDataRole.ToolTipRole) == (
"2026-08-17 09:00-09:30\n陈医生(内分泌科专家门诊)\n处方已作废"
)
assert index.data() == index.data(Qt.ItemDataRole.ToolTipRole)
assert row == original
host.close()
@pytest.mark.parametrize("tech_blue", [False, True])
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
application: QApplication, tech_blue: bool,
+2 -1
View File
@@ -8,6 +8,7 @@ from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
from doctor_workstation.ui.theme import apply_theme
@@ -74,7 +75,7 @@ def test_checked_row_blue_and_unchecked_white(dialog, application):
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
int(rect.center().y() * scale)).name())
assert colors == ["#eaf2ff", "#ffffff"]
assert colors == [PALETTE["accent_soft"].lower(), PALETTE["surface"].lower()]
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
+42
View File
@@ -0,0 +1,42 @@
"""Current-peer archive paging preserves old callers and request identities."""
import pytest
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
class Client:
def __init__(self):
self.calls = []
def post(self, endpoint, payload, *, timeout=None):
self.calls.append((endpoint, payload, timeout))
return {"completed": False, "sync_token": "a" * 48, "errors": []}
def test_sync_optional_parameters_and_timeout_preserve_exact_legacy_payload():
client = Client()
repository = RemoteDoctorRepository(client)
repository.sync_im_chat_messages(501)
result = repository.sync_im_chat_messages(501, scope="current", sync_token="a" * 48)
assert client.calls == [
("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": 501}, 15.0),
("tcm.diagnosis/triggerImChatSync",
{"diagnosis_id": 501, "scope": "current", "sync_token": "a" * 48}, 15.0),
]
assert result["completed"] is False
@pytest.mark.parametrize("kwargs", [{"scope": "other"}, {"sync_token": "bad"}, {"sync_token": None}])
def test_sync_rejects_invalid_options_without_network(kwargs):
client = Client()
with pytest.raises(ValueError):
RemoteDoctorRepository(client).sync_im_chat_messages(501, **kwargs)
assert not client.calls
def test_mock_sync_supports_paging_contract_and_legacy_receipt():
repository = DemoDoctorRepository()
assert repository.sync_im_chat_messages(501)["queued"] is True
assert repository.sync_im_chat_messages(501, scope="current")["completed"] is True
+270
View File
@@ -0,0 +1,270 @@
"""Offline tests for native IM AI authorization, synchronization and races."""
from __future__ import annotations
from collections import deque
from threading import BoundedSemaphore, Event
from uuid import uuid4
import pytest
from doctor_workstation.video import im_ai
class Repository:
def __init__(self, pages=None, events=None):
self.pages = deque(pages or [{"completed": True, "errors": []}])
self.events = events or [{"event": "done", "diagnosis_id": 501, "answer": "合成回复"}]
self.sync_calls = []
self.stream_calls = []
self.cancel_calls = []
self.cancel_called = Event()
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
self.sync_calls.append((diagnosis_id, kwargs))
return self.pages.popleft()
def stream_diagnosis_ai(self, diagnosis_id, prompt, **kwargs):
self.stream_calls.append((diagnosis_id, prompt, kwargs))
yield from self.events
def cancel_diagnosis_ai(self, diagnosis_id, request_id):
self.cancel_calls.append((diagnosis_id, request_id))
self.cancel_called.set()
return {"cancel_requested": True}
def controller(repository=None, **kwargs):
return im_ai.ImAiController(
repository or Repository(), diagnosis_id=501, appointment_id=101,
doctor_account="doctor_7", enabled=True, **kwargs,
)
def request(service, **changes):
return {
"source": "doctor-call", "event": "im-ai-request", "sessionId": service.session_id,
"requestId": str(uuid4()), "contextKey": "view-1", "diagnosisId": "501",
"appointmentId": 101, "action": "summary", **changes,
}
@pytest.mark.parametrize("action,task", [("summary", "summary"), ("followup", "custom"), ("reply", "custom")])
def test_current_peer_sync_completes_before_ai_and_keeps_identity(action, task):
token = "a" * 48
repository = Repository(pages=[
{"completed": False, "phase": "checking_accounts", "sync_token": token, "errors": []},
{"completed": False, "phase": "syncing", "sync_token": token, "errors": []},
{"completed": True, "errors": []},
], events=[
{"event": "start"}, {"event": "delta", "text": "合成"},
{"event": "done", "diagnosis_id": 501, "answer": "合成完整回复", "model_label": "模型",
"clinical_review_warnings": [{"code": "PULSE_EXCLUDED_FROM_ASSESSMENT", "source_id": "report"}]},
])
service = controller(repository)
message = request(service, action=action)
assert service.handle(message)
assert service.wait()
result = service.take_result()
assert result["status"] == "success"
assert result["text"] == "合成完整回复"
assert result["requestId"] == message["requestId"]
assert result["modelLabel"] == "模型"
assert "不采集脉象" in result["warnings"][0]
assert len(repository.sync_calls) == 3
assert repository.sync_calls[0] == (501, {"scope": "current", "sync_token": ""})
assert repository.sync_calls[1][1]["sync_token"] == token
assert len(repository.stream_calls) == 1
diagnosis_id, prompt, options = repository.stream_calls[0]
assert diagnosis_id == 501 and options["task"] == task
assert options["request_id"] == message["requestId"]
assert "doctor_7" in prompt and len(prompt) <= 500
assert "不补问脉象" in prompt and "不自动确诊" in prompt
assert not repository.cancel_calls
assert service.take_result() is None
assert not service.handle(message) # A replay cannot submit another billable request.
@pytest.mark.parametrize("changes", [
{"sessionId": "other"}, {"diagnosisId": "502"}, {"diagnosisId": True},
{"diagnosisId": "0501"}, {"appointmentId": 102}, {"requestId": "invalid"},
{"contextKey": ""}, {"contextKey": "x" * 129}, {"action": "prescription"},
{"action": {}}, {"source": "another-page"}, {"event": "send-message"},
])
def test_invalid_identity_or_action_cannot_read_patient_context(changes):
repository = Repository()
service = controller(repository)
assert not service.handle(request(service, **changes))
assert not repository.sync_calls and not repository.stream_calls
def test_permission_default_denied_and_each_host_has_unique_session():
repository = Repository()
disabled = im_ai.ImAiController(
repository, diagnosis_id=501, appointment_id=101, doctor_account="doctor_7"
)
assert disabled.config["enabled"] is False
assert not disabled.handle(request(disabled))
assert controller().session_id != controller().session_id
@pytest.mark.parametrize("page", [
{"queued": True}, {"completed": "true"}, {"completed": True, "errors": ["private detail"]},
{"completed": False, "sync_token": "bad"}, None,
])
def test_incomplete_or_failed_sync_never_generates(page):
repository = Repository(pages=[page])
service = controller(repository)
assert service.handle(request(service))
assert service.wait()
result = service.take_result()
assert result["status"] == "error"
assert "private detail" not in str(result)
assert not repository.stream_calls and not repository.cancel_calls
def test_sync_step_and_time_budget_are_bounded():
page = {"completed": False, "sync_token": "b" * 48, "errors": []}
repository = Repository(pages=[page] * 10)
service = controller(repository, max_sync_steps=2)
service.handle(request(service))
assert service.wait()
assert len(repository.sync_calls) == 2
assert service.take_result()["status"] == "error"
assert not repository.stream_calls
times = iter([0, 0, 46])
repository = Repository()
service = controller(repository, clock=lambda: next(times))
service.handle(request(service))
assert service.wait()
assert "超时" in service.take_result()["message"]
assert not repository.stream_calls
def test_cancel_during_sync_stops_before_model_submission():
entered, release = Event(), Event()
class SlowSync(Repository):
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
entered.set()
assert release.wait(2)
return super().sync_im_chat_messages(diagnosis_id, **kwargs)
repository = SlowSync()
service = controller(repository)
message = request(service)
service.handle(message)
try:
assert entered.wait(1)
assert service.handle({**message, "event": "im-ai-cancel"})
assert "尚未向 AI" in service.take_result()["message"]
finally:
release.set()
assert service.wait()
assert not repository.stream_calls and not repository.cancel_calls
assert service.take_result() is None
def test_cancel_is_independent_and_late_response_cannot_overwrite_next_request():
entered, release, cancel_release = Event(), Event(), Event()
class SlowStream(Repository):
def stream_diagnosis_ai(self, *args, **kwargs):
self.stream_calls.append((args, kwargs))
yield {"event": "delta", "text": "尚未完成"}
entered.set()
assert release.wait(2)
yield {"event": "done", "answer": "迟到的旧结果"}
def cancel_diagnosis_ai(self, *args):
result = super().cancel_diagnosis_ai(*args)
assert cancel_release.wait(2)
return result
repository = SlowStream()
service = controller(repository)
first = request(service)
service.handle(first)
try:
assert entered.wait(1)
assert service.take_result()["text"] == "尚未完成"
service.handle({**first, "event": "im-ai-cancel"})
assert repository.cancel_called.wait(1) # Independent HTTP starts while stream is blocked.
assert service.take_result()["status"] == "cancelled"
second = request(service, contextKey="view-2")
assert service.handle(second)
result = service.take_result()
assert result["requestId"] == second["requestId"] and result["status"] == "error"
assert len(repository.stream_calls) == 1
finally:
release.set()
cancel_release.set()
assert service.wait()
assert service.take_result() is None
def test_close_invalidates_mailbox_and_cancels_without_waiting():
entered, release = Event(), Event()
class SlowStream(Repository):
def stream_diagnosis_ai(self, *args, **kwargs):
entered.set()
assert release.wait(2)
yield {"event": "done", "answer": "旧患者回复"}
repository = SlowStream()
service = controller(repository)
message = request(service)
service.handle(message)
try:
assert entered.wait(1)
service.close()
assert repository.cancel_called.wait(1)
assert service.take_result() is None
assert not service.handle(request(service))
finally:
release.set()
assert service.wait()
assert service.take_result() is None
@pytest.mark.parametrize("events", [
[{"event": "delta", "text": "不完整"}],
[{"event": "done", "answer": ""}],
[{"event": "done", "diagnosis_id": 502, "answer": "其他患者"}],
[{"event": "delta", "text": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
[{"event": "done", "answer": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
])
def test_incomplete_mismatched_and_oversized_results_are_not_usable(events):
repository = Repository(events=events)
service = controller(repository)
service.handle(request(service))
assert service.wait()
assert service.take_result()["status"] == "error"
assert repository.cancel_called.wait(1)
assert len(repository.stream_calls) == 1 # No transport/validation retry.
def test_thousands_of_deltas_are_coalesced_into_one_mailbox_result():
events = [{"event": "delta", "text": "字"}] * 4000 + [{"event": "done"}]
service = controller(Repository(events=events))
service.handle(request(service))
assert service.wait()
result = service.take_result()
assert result["status"] == "success" and result["text"] == "字" * 4000
assert service.take_result() is None
def test_capacity_rejection_does_not_spawn_or_sync(monkeypatch):
slots = BoundedSemaphore(1)
slots.acquire()
monkeypatch.setattr(im_ai, "_WORKER_SLOTS", slots)
repository = Repository()
service = controller(repository)
try:
service.handle(request(service))
assert service.take_result()["status"] == "error"
assert not repository.sync_calls
finally:
slots.release()
+157
View File
@@ -0,0 +1,157 @@
"""Exercise bridge trust and JSON delivery without starting WebEngine or HTTP."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from doctor_workstation.video import window
from doctor_workstation.video.security import TrustedDocumentPolicy
pytestmark = pytest.mark.skipif(not window.WEBENGINE_AVAILABLE, reason="optional QtWebEngine")
TRUSTED = "https://video.example.test/im/index.html"
def host(**changes):
item = SimpleNamespace(
_closing=False, _released=False, _shutdown_requested=False, _injected=True,
_policy=TrustedDocumentPolicy.from_url(TRUSTED, is_local=False),
_page=Mock(), _im_ai=Mock(), _im_ai_timer=Mock(),
)
item._page.url.return_value.toString.return_value = TRUSTED
item._im_ai_context_is_trusted = lambda: window._EmbeddedVideoWindow._im_ai_context_is_trusted(item)
for key, value in changes.items():
setattr(item, key, value)
return item
@pytest.mark.parametrize("changes", [
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True}, {"_injected": False},
])
def test_inactive_or_closing_page_cannot_submit_or_receive_ai(changes):
item = host(**changes)
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
window._EmbeddedVideoWindow._flush_im_ai(item)
item._im_ai.handle.assert_not_called()
item._im_ai.take_result.assert_not_called()
item._page.runJavaScript.assert_not_called()
@pytest.mark.parametrize("url", ["about:blank", "https://evil.example.test/im/index.html", TRUSTED + "?other=1"])
def test_document_navigation_cannot_reuse_authenticated_ai_bridge(url):
item = host()
item._page.url.return_value.toString.return_value = url
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
window._EmbeddedVideoWindow._flush_im_ai(item)
item._im_ai.handle.assert_not_called()
item._page.runJavaScript.assert_not_called()
def test_trusted_bridge_routes_only_to_identity_checked_controller():
item = host()
message = {"source": "doctor-call", "event": "im-ai-request", "diagnosisId": 501}
window._EmbeddedVideoWindow._handle_im_ai_message(item, message)
item._im_ai.handle.assert_called_once_with(message)
def test_result_content_is_json_encoded_and_no_javascript_is_executed():
item = host()
payload = {"status": "success", "text": '\");window.bad=true;//\n</script>\u2028', "warnings": ["合成提示"]}
item._im_ai.take_result.return_value = payload
window._EmbeddedVideoWindow._flush_im_ai(item)
script = item._page.runJavaScript.call_args.args[0]
prefix, suffix = "window.doctorConsultation?.imAiResult?.(", ");"
assert script.startswith(prefix) and script.endswith(suffix)
assert json.loads(script[len(prefix):-len(suffix)]) == payload
assert "\\u2028" in script
def test_host_acknowledges_initial_summary_only_after_request_gate_opens():
item = host(_injected=False)
item._im_ai.session_id = 'synthetic-session-\\"\u2028'
def verify_gate(script):
assert item._im_ai_context_is_trusted() is True
prefix, suffix = "window.doctorConsultation?.imAiReady?.(", ");"
assert script.startswith(prefix) and script.endswith(suffix)
assert json.loads(script[len(prefix):-len(suffix)]) == item._im_ai.session_id
item._page.runJavaScript.side_effect = verify_gate
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_called_once()
@pytest.mark.parametrize("changes", [
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True},
])
def test_late_injection_cannot_acknowledge_a_closed_ai_context(changes):
item = host(_injected=False, **changes)
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_not_called()
def test_navigation_cannot_receive_ai_readiness_acknowledgement():
item = host(_injected=False)
item._page.url.return_value.toString.return_value = "https://evil.example.test/im/index.html"
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_not_called()
def test_failed_injection_closes_without_ai_readiness_acknowledgement():
item = host(_injected=False, _request_companion_shutdown=Mock())
window._EmbeddedVideoWindow._after_injection(item, False)
item._request_companion_shutdown.assert_called_once_with("bridge-api-missing")
item._page.runJavaScript.assert_not_called()
def test_shutdown_cancels_ai_before_waiting_for_media_or_destroying_page():
item = host(
_start_requested=True, _call_cycle_closed=False, _companion_ended=False,
_shutdown_timer=Mock(), close=Mock(),
)
window._EmbeddedVideoWindow._request_companion_shutdown(item, "window-closed")
item._im_ai.close.assert_called_once()
item._im_ai_timer.stop.assert_called_once()
assert item._shutdown_requested is True
item.close.assert_not_called() # Recording shutdown still gets its existing grace period.
item._shutdown_timer.start.assert_called_once_with(190_000)
def test_qt_notify_rejects_oversized_or_wrong_source_payloads():
callback = Mock()
bridge = window._QtVideoBridge(callback, Mock())
bridge.notify("x" * 16_385)
bridge.notify(json.dumps({"source": "evil", "event": "im-ai-request"}))
bridge.notify("[]")
callback.assert_not_called()
message = {"source": "doctor-call", "event": "im-ai-cancel"}
bridge.notify(json.dumps(message))
callback.assert_called_once_with(message)
def test_video_module_remains_importable_without_optional_qt():
source = Path(__file__).resolve().parents[1] / "src"
script = """
import builtins
original_import = builtins.__import__
def without_qt(name, *args, **kwargs):
if name == 'PySide6' or name.startswith('PySide6.'):
raise ImportError('synthetic core-only installation')
return original_import(name, *args, **kwargs)
builtins.__import__ = without_qt
from doctor_workstation.video.window import WEBENGINE_AVAILABLE, VideoCallWindow
from doctor_workstation.video.im_ai import ImAiController
assert WEBENGINE_AVAILABLE is False
"""
result = subprocess.run(
[sys.executable, "-c", script], env={**os.environ, "PYTHONPATH": str(source)},
capture_output=True, text=True, timeout=10, check=False,
)
assert result.returncode == 0, result.stderr
+512
View File
@@ -0,0 +1,512 @@
from __future__ import annotations
from copy import deepcopy
from threading import Event
import pytest
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiTimeoutError
from doctor_workstation.video.live_ai import LiveAiController
def result(call=71, session="tr-local", **changes):
value = {
"event": "done", "diagnosis_id": 9,
"live_context": {
"call_record_id": call, "transcription_session_id": session,
"revision": "a" * 64, "segment_count": 151,
"through_timestamp_ms": 1793332800000, "transcription_status": "running",
"sources": [{"id": "patient-1", "role": "patient", "timestamp_ms": 1793332800000,
"text": "这是用于软件测试的合成对话。"}],
},
"live_assistance": {
"summary": [{"text": "合成测试要点", "evidence_ids": ["patient-1"]}],
"questions": [], "checks": [], "review": [],
},
}
value.update(changes)
return value
class Repo:
def __init__(self):
self.calls = []
self.cancels = []
self.response = result()
self.started = Event()
self.release = None
self.fail = False
def stream_video_ai(self, diagnosis_id, **kwargs):
self.calls.append((diagnosis_id, kwargs))
self.started.set()
if self.release is not None:
assert self.release.wait(3)
if self.fail:
raise RuntimeError("private connection details must not leak")
yield deepcopy(self.response)
def cancel_diagnosis_ai(self, diagnosis_id, request_id):
self.cancels.append((diagnosis_id, request_id))
return {"accepted": True}
@pytest.fixture
def configured():
repo = Repo()
clock = [100.0]
controller = LiveAiController(repo, diagnosis_id=9, enabled=True, clock=lambda: clock[0])
controller.bind(71, "tr-local")
yield controller, repo, clock
if repo.release is not None:
repo.release.set()
controller.close()
assert controller.wait(3)
def run(controller, clock):
clock[0] += 10
controller.tick()
assert controller.wait(3)
return controller.take_state()
def test_only_persisted_current_call_changes_trigger(configured):
controller, repo, clock = configured
controller.changed("another-call")
run(controller, clock)
assert not repo.calls
controller.changed("tr-local")
state = run(controller, clock)
assert state["summary"][0]["evidence_ids"] == ["patient-1"]
assert state["segmentCount"] == 151
assert "transcript" not in repo.calls[0][1]
assert repo.calls[0][1]["call_record_id"] == 71
run(controller, clock)
assert len(repo.calls) == 1
def test_cloud_retry_clears_failure_without_losing_saved_evidence(configured):
controller, repo, clock = configured
controller.changed("tr-local")
before = run(controller, clock)
controller.transcription_error("云转写中断", status="failed")
controller.transcription_running("stale-session")
assert controller.take_state()["transcriptionStatus"] == "failed"
controller.transcription_running("tr-local")
after = controller.take_state()
assert after["transcriptionStatus"] == "running"
assert after["error"] == ""
assert after["summary"] == before["summary"]
assert after["revision"] == before["revision"]
run(controller, clock)
assert len(repo.calls) == 1
def test_one_inflight_and_many_changes_coalesce(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 2
controller.tick()
assert repo.started.wait(2)
for _ in range(50):
controller.changed("tr-local")
controller.tick()
assert len(repo.calls) == 1
repo.release.set()
assert controller.wait(3)
run(controller, clock)
assert len(repo.calls) == 2
run(controller, clock)
assert len(repo.calls) == 2
def test_pause_stops_auto_but_explicit_refresh_works(configured):
controller, repo, clock = configured
controller.handle("toggle", False)
controller.changed("tr-local")
run(controller, clock)
assert not repo.calls
controller.handle("refresh")
state = run(controller, clock)
assert len(repo.calls) == 1
assert state["enabled"] is False
def test_no_retry_loop_on_failure(configured):
controller, repo, clock = configured
repo.fail = True
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert "private" not in str(state)
for _ in range(5):
run(controller, clock)
assert len(repo.calls) == 1
repo.fail = False
controller.handle("refresh")
assert run(controller, clock)["phase"] == "ready"
@pytest.mark.parametrize("mutation", [
lambda r: r.update(diagnosis_id=10),
lambda r: r["live_context"].update(call_record_id=72),
lambda r: r["live_context"].update(transcription_session_id="other"),
lambda r: r["live_context"].update(revision="invalid"),
lambda r: r["live_context"].update(segment_count=-1),
lambda r: r["live_context"].update(through_timestamp_ms=float("nan")),
lambda r: r["live_context"]["sources"][0].update(role="guessed"),
lambda r: r["live_context"]["sources"][0].update(timestamp_ms=True),
lambda r: r["live_assistance"]["summary"][0].update(evidence_ids=["made-up"]),
lambda r: r["live_assistance"]["summary"][0].update(evidence_ids=[]),
])
def test_invalid_identity_or_citations_never_published(configured, mutation):
controller, repo, clock = configured
mutation(repo.response)
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert state["summary"] == []
def test_new_call_discards_late_result(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 2
controller.tick()
assert repo.started.wait(2)
controller.bind(72, "tr-next")
repo.release.set()
assert controller.wait(3)
state = controller.take_state()
assert state["summary"] == []
assert state["revision"] == ""
def test_dialing_clears_previous_call_before_new_transcription_exists(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["summary"]
controller.reset()
state = controller.take_state()
assert state["summary"] == [] and state["sources"] == []
assert state["transcriptionStatus"] == "idle"
controller.changed("tr-local")
controller.handle("review")
run(controller, clock)
assert len(repo.calls) == 1
def test_complete_long_source_quote_is_not_truncated(configured):
controller, repo, clock = configured
source = "合成测试原文。" * 400
repo.response["live_context"]["sources"][0]["text"] = source
repo.response["live_assistance"]["summary"][0]["text"] = "患者原话:" + source
controller.changed("tr-local")
state = run(controller, clock)
assert state["summary"][0]["text"] == "患者原话:" + source
def test_previous_good_result_retained_during_update_and_failure(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["summary"]
repo.release = Event()
repo.fail = True
controller.changed("tr-local")
clock[0] += 10
controller.tick()
state = controller.take_state()
assert state["busy"] and state["summary"]
repo.release.set()
assert controller.wait(3)
assert controller.take_state()["summary"]
def test_review_only_after_transcript_finish(configured):
controller, repo, clock = configured
controller.changed("tr-local")
run(controller, clock)
controller.handle("review")
run(controller, clock)
assert len(repo.calls) == 1
controller.finish("tr-local", "partial")
controller.handle("review")
state = run(controller, clock)
assert repo.calls[-1][1]["task"] == "live_review"
assert state["transcriptionStatus"] == "partial"
def test_no_permission_no_work(configured):
_, repo, clock = configured
disabled = LiveAiController(repo, diagnosis_id=9, enabled=False)
disabled.bind(71, "tr-local")
disabled.changed("tr-local")
disabled.handle("refresh")
disabled.tick()
assert not repo.calls
assert disabled.take_state()["available"] is False
disabled.close()
def test_first_and_subsequent_updates_have_short_bounded_coalescing(configured):
controller, repo, clock = configured
controller.changed("tr-local")
clock[0] += 0.79
controller.tick()
assert not repo.calls
# More speech does not indefinitely postpone the first request.
controller.changed("tr-local")
clock[0] += 0.02
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 1
controller.changed("tr-local")
clock[0] += 1.99
controller.tick()
assert len(repo.calls) == 1
clock[0] += 0.02
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 2
def test_validated_suggestions_are_visible_before_stream_finishes(configured):
controller, repo, clock = configured
delivered = Event()
repo.release = Event()
def stream(*args, **kwargs):
yield result(event="live_update")
delivered.set()
assert repo.release.wait(3)
yield result()
repo.stream_video_ai = stream
controller.changed("tr-local")
clock[0] += 1
controller.tick()
assert delivered.wait(2)
state = controller.take_state()
assert state["busy"] is True and state["phase"] == "analyzing"
assert state["firstResultMs"] == 0
assert state["summary"] and state["revision"] == "a" * 64
assert state["pendingSegments"] == 0
controller.changed("tr-local")
clock[0] += 3
controller.tick()
state = controller.take_state()
assert state["elapsedMs"] == 3000 and state["pendingSegments"] == 1
repo.release.set()
assert controller.wait(3)
state = controller.take_state()
assert state["busy"] is False and state["lastDurationMs"] == 3000
assert state["firstResultMs"] == 0
assert state["pendingSegments"] == 1
def test_slow_request_does_not_add_another_wait_for_queued_speech(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 1
controller.tick()
assert repo.started.wait(2)
controller.changed("tr-local")
clock[0] += 20
controller.tick()
assert len(repo.calls) == 1
repo.release.set()
assert controller.wait(3)
# The queued snapshot is already due; no new sleep after the slow request ends.
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 2
assert controller.take_state()["pendingSegments"] == 0
def test_failed_stream_keeps_only_validated_progress(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update")
raise ApiTimeoutError("secret raw upstream content")
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and state["summary"]
assert state["errorCode"] == "UPSTREAM_TIMEOUT"
assert "尚未全部完成" in state["message"]
assert "secret" not in str(state)
def test_progress_cannot_change_snapshot_mid_request(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update")
different = result()
different["live_context"]["revision"] = "b" * 64
yield different
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert state["revision"] == "a" * 64
@pytest.mark.parametrize("scope", ["current_call_only", "authorized_case"])
def test_displayed_scope_comes_from_validated_server_result(configured, scope):
controller, repo, clock = configured
repo.response["analysis_scope"] = scope
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == scope
controller.bind(72, "new-session")
assert controller.take_state()["analysisScope"] == ""
def test_legacy_server_does_not_claim_dialogue_only(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == ""
@pytest.mark.parametrize("scope", [None, [], "invented"])
def test_unrecognized_result_scope_is_rejected(configured, scope):
controller, repo, clock = configured
repo.response["analysis_scope"] = scope
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and not state["summary"]
assert state["analysisScope"] == ""
def test_scope_cannot_change_within_one_stream(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update", analysis_scope="current_call_only")
yield result(analysis_scope="authorized_case")
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and state["summary"]
assert state["analysisScope"] == "current_call_only"
def test_scope_of_previous_results_survives_review_start_or_failure(configured):
controller, repo, clock = configured
repo.response["analysis_scope"] = "current_call_only"
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == "current_call_only"
controller.finish("tr-local", "completed")
observed = []
def stream(*args, **kwargs):
assert kwargs["task"] == "live_review"
yield {"event": "start", "analysis_scope": "authorized_case"}
observed.append(controller.take_state())
raise ApiTimeoutError("synthetic timeout")
repo.stream_video_ai = stream
controller.handle("review")
state = run(controller, clock)
assert observed[0]["busy"] and observed[0]["analysisScope"] == "current_call_only"
assert state["phase"] == "error" and state["analysisScope"] == "current_call_only"
def test_successful_review_and_refresh_replace_displayed_scope(configured):
controller, repo, clock = configured
repo.response["analysis_scope"] = "current_call_only"
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == "current_call_only"
controller.finish("tr-local", "completed")
repo.response["analysis_scope"] = "authorized_case"
controller.handle("review")
assert run(controller, clock)["analysisScope"] == "authorized_case"
assert repo.calls[-1][1]["task"] == "live_review"
repo.response["analysis_scope"] = "current_call_only"
controller.handle("refresh")
assert run(controller, clock)["analysisScope"] == "current_call_only"
assert repo.calls[-1][1]["task"] == "live_consultation"
def test_items_in_the_same_group_are_published_cumulatively(configured):
controller, repo, clock = configured
observed = []
def stream(*args, **kwargs):
response = result(event="live_update")
for index in range(3):
response["live_assistance"]["questions"].append({
"text": f"合成测试追问 {index + 1}?", "evidence_ids": ["patient-1"],
})
yield deepcopy(response)
observed.append(controller.take_state())
yield {**response, "event": "done"}
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert [len(item["questions"]) for item in observed] == [1, 2, 3]
assert all(item["busy"] and item["revision"] == "a" * 64 for item in observed)
assert len(state["questions"]) == 3 and not state["busy"]
def test_invalid_progress_never_publishes_or_accepts_a_later_done(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
bad = result(event="live_update")
bad["live_assistance"]["summary"][0]["evidence_ids"] = ["invented"]
yield bad
yield result()
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and not state["summary"]
@pytest.mark.parametrize("error, expected", [
(ApiTimeoutError("private payload"), "UPSTREAM_TIMEOUT"),
(ApiHttpError("private payload", status_code=403), "PERMISSION_DENIED"),
(ApiHttpError("private payload", status_code=404), "SERVICE_UPDATE_REQUIRED"),
(ApiBusinessError("private payload", data={"error_code": "LIVE_RESULT_INVALID"}), "LIVE_RESULT_INVALID"),
(ApiBusinessError("private payload", data={"error_code": "UPSTREAM_TIMEOUT"}), "UPSTREAM_TIMEOUT"),
(ApiBusinessError("private payload", data={"error_code": {"secret": "payload"}}), "UPSTREAM_UNAVAILABLE"),
])
def test_failure_categories_do_not_leak_payloads(configured, error, expected, caplog):
controller, repo, clock = configured
def stream(*args, **kwargs):
raise error
yield
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["errorCode"] == expected
assert "private payload" not in str(state) + caplog.text
assert expected in caplog.text
def test_validated_error_reason_distinguishes_citations_from_format(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
raise ApiBusinessError("private source text", data={
"error_code": "LIVE_RESULT_INVALID", "live_validation_reason": "CITATION_INVALID",
})
yield
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert "引用无法核对" in state["error"]
assert "private" not in str(state)
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import pytest
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.services.repository import RemoteDoctorRepository
class Client:
def __init__(self, events=None):
self.calls = []
self.events = events if events is not None else [{"event": "done", "data": {"diagnosis_id": 9, "live_context": {"revision": "a" * 64}}}]
def post_event_stream(self, endpoint, body, **kwargs):
self.calls.append((endpoint, body, kwargs))
yield from self.events
def invoke(client, **changes):
args = {"call_record_id": 71, "transcription_session_id": "tr-local", "request_id": "a" * 36}
args.update(changes)
return list(RemoteDoctorRepository(client).stream_video_ai(9, **args))
def test_scope_is_server_bound_and_structured_done_is_preserved():
client = Client()
result = invoke(client)
assert result[0]["live_context"]["revision"] == "a" * 64
endpoint, body, options = client.calls[0]
assert endpoint == "tcm.diagnosis/aiAssistantStream"
assert body["call_record_id"] == 71 and body["transcription_session_id"] == "tr-local"
assert body["task"] == "live_consultation"
assert "audio" not in body and "transcript" not in body
assert options["timeout"] == 105
@pytest.mark.parametrize("changes", [{"call_record_id": True}, {"call_record_id": 0},
{"transcription_session_id": ""}, {"transcription_session_id": "../other"},
{"task": "prescription_generate"}])
def test_bad_identity_or_task_never_sends(changes):
client = Client()
with pytest.raises(ValueError):
invoke(client, **changes)
assert client.calls == []
def test_early_disconnect_does_not_fallback_to_unbound_general_assistant():
client = Client([])
with pytest.raises(ApiProtocolError):
invoke(client)
assert len(client.calls) == 1
def test_cancelled_stream_does_not_publish_late_done():
client = Client()
assert invoke(client, cancelled=lambda: True) == []
def test_validated_progress_is_forwarded_before_done_without_ending_stream():
client = Client([
{"event": "live_update", "data": {"diagnosis_id": 9, "live_assistance": {"questions": []}}},
{"event": "done", "data": {"diagnosis_id": 9, "live_context": {"revision": "a" * 64}}},
])
events = invoke(client)
assert [event["event"] for event in events] == ["live_update", "done"]
assert events[0]["live_assistance"] == {"questions": []}
def test_progress_does_not_substitute_for_final_done():
client = Client([{"event": "live_update", "data": {"diagnosis_id": 9}}])
with pytest.raises(ApiProtocolError):
invoke(client)
def test_server_analysis_scope_is_preserved_in_stream_metadata():
client = Client([
{"event": kind, "data": {"analysis_scope": "current_call_only"}}
for kind in ("start", "live_update", "done")
])
events = invoke(client)
assert [event["analysis_scope"] for event in events] == ["current_call_only"] * 3
assert "analysis_scope" not in client.calls[0][1]
+571
View File
@@ -0,0 +1,571 @@
from __future__ import annotations
import base64
import builtins
import socket
import struct
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from doctor_workstation.video.local_transcription import (
MAX_BASE64_CHARS,
MAX_PCM_BYTES,
MAX_SEQUENCE,
FasterWhisperEngine,
LocalTranscriptionController,
LocalTranscriptionError,
default_model_path,
local_transcription_readiness,
)
TIMESTAMP = 1_795_000_000_000
PCM = struct.pack("<hhhh", 0, 100, -100, 200)
AUDIO = base64.b64encode(PCM).decode("ascii")
class Events:
def __init__(self) -> None:
self.items: list[dict[str, Any]] = []
self.thread_ids: list[int] = []
self.condition = threading.Condition()
def __call__(self, event: dict[str, Any]) -> None:
with self.condition:
self.items.append(event)
self.thread_ids.append(threading.get_ident())
self.condition.notify_all()
def wait(self, kind: str, session: str = "call-1") -> dict[str, Any]:
with self.condition:
def match() -> dict[str, Any] | None:
return next(
(e for e in self.items if e["type"] == kind and e["sessionId"] == session),
None,
)
assert self.condition.wait_for(lambda: match() is not None, timeout=3)
event = match()
assert event is not None
return event
class Engine:
def __init__(self, text: str = "无过敏史,服用 0.5 mg,不是 5 mg。") -> None:
self.text = text
self.inputs: list[bytes] = []
def transcribe(self, pcm: bytes) -> str:
self.inputs.append(pcm)
return self.text
@pytest.fixture
def make_controller():
controllers = []
def make(events: Events, factory: Callable | None = None, **kwargs):
controller = LocalTranscriptionController(events, factory or Engine, **kwargs)
controllers.append(controller)
return controller
yield make
for controller in controllers:
controller.close()
if controller._worker:
controller._worker.join(3)
assert not controller._worker.is_alive()
def test_lazy_daemon_load_async_events_and_exact_numeric_negation_text(make_controller) -> None:
events = Events()
engine = Engine()
factory_threads = []
def factory():
factory_threads.append(threading.get_ident())
return engine
controller = make_controller(events, factory)
assert not factory_threads
assert controller.start("call-1")
assert events.wait("ready") == {"sessionId": "call-1", "type": "ready"}
assert controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
assert controller.stop("call-1")
assert events.wait("stopped")["status"] == "completed"
assert events.wait("segment") == {
"sessionId": "call-1",
"type": "segment",
"segmentId": "call-1:patient:0",
"role": "patient",
"text": engine.text,
"timestamp": TIMESTAMP,
"isCompleted": True,
}
assert engine.inputs == [PCM]
assert controller._worker.daemon
assert factory_threads == [events.thread_ids[0]]
assert all(i != threading.get_ident() for i in events.thread_ids)
def test_controller_converts_before_caption_and_persistence_events(make_controller) -> None:
events = Events()
controller = make_controller(events, lambda: Engine("沒有吃藥,0.5 mg,不是 5 mg。"))
controller.start("call-1")
controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("segment")["text"] == "没有吃药,0.5 mg,不是 5 mg。"
assert events.wait("stopped")["status"] == "completed"
def test_conversion_failure_does_not_emit_unconverted_text(make_controller, monkeypatch) -> None:
def broken(text):
raise OSError("private dictionary path")
monkeypatch.setattr("doctor_workstation.video.local_transcription.simplified_transcript", broken)
events = Events()
controller = make_controller(events, lambda: Engine("沒有吃藥"))
controller.start("call-1")
controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("error")["code"] == "transcription_failed"
assert events.wait("stopped")["status"] == "partial"
assert not any(e["type"] == "segment" for e in events.items)
def test_roles_have_independent_monotonic_sequences_and_drain_is_ordered(make_controller) -> None:
events = Events()
entered, release = threading.Event(), threading.Event()
engine = Engine()
def transcribe(pcm):
entered.set()
assert release.wait(3)
return engine.text
engine.transcribe = transcribe
controller = make_controller(events, lambda: engine)
assert controller.start("call-1")
events.wait("ready")
try:
assert controller.submit("call-1", "doctor", 0, TIMESTAMP, AUDIO)
assert entered.wait(3)
assert controller.submit("call-1", "patient", 0, TIMESTAMP + 100, AUDIO)
assert controller.submit("call-1", "doctor", 1, TIMESTAMP + 200, AUDIO)
assert controller.stop("call-1")
assert not controller.submit("call-1", "doctor", 2, TIMESTAMP + 300, AUDIO)
assert not any(e["type"] == "stopped" for e in events.items)
finally:
release.set()
assert events.wait("stopped")["status"] == "completed"
assert [e["type"] for e in events.items] == [
"ready",
"segment",
"segment",
"segment",
"stopped",
]
assert [e["segmentId"] for e in events.items if e["type"] == "segment"] == [
"call-1:doctor:0",
"call-1:patient:0",
"call-1:doctor:1",
]
assert controller.stop("call-1")
assert not controller.start("call-1")
@pytest.mark.parametrize("sequence", [0, -1, True, 1.5, MAX_SEQUENCE + 1, "2"])
def test_rejects_duplicate_invalid_or_unbounded_sequence(make_controller, sequence) -> None:
events = Events()
controller = make_controller(events)
assert controller.start("call-1")
assert controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
assert not controller.submit("call-1", "patient", sequence, TIMESTAMP, AUDIO)
assert controller.stop("call-1")
assert events.wait("stopped")["status"] == "partial"
assert len([e for e in events.items if e["type"] == "segment"]) == 1
@pytest.mark.parametrize(
("role", "timestamp", "audio"),
[
("unknown", TIMESTAMP, AUDIO),
("patient", 0, AUDIO),
("patient", True, AUDIO),
("patient", float("nan"), AUDIO),
("patient", 253_402_300_800_000, AUDIO),
("patient", TIMESTAMP, "!!!"),
("patient", TIMESTAMP, ""),
("patient", TIMESTAMP, "é"),
("patient", TIMESTAMP, "AA=="),
("patient", TIMESTAMP, "A" * (MAX_BASE64_CHARS + 1)),
],
ids=[
"role",
"zero-time",
"bool-time",
"nan-time",
"large-time",
"base64",
"empty",
"non-ascii",
"odd-pcm",
"oversized",
],
)
def test_input_validation_rejects_bad_metadata_and_audio(
make_controller, role, timestamp, audio
) -> None:
events = Events()
engine = Engine()
controller = make_controller(events, lambda: engine)
controller.start("call-1")
assert not controller.submit("call-1", role, 0, timestamp, audio)
controller.stop("call-1")
assert events.wait("stopped")["status"] == "partial"
assert events.wait("error")["code"] in {"invalid_audio", "invalid_metadata"}
assert not engine.inputs
def test_accepts_twelve_seconds_rejects_extra_sample_and_reports_sequence_gap(
make_controller,
) -> None:
events = Events()
controller = make_controller(events)
controller.start("call-1")
assert controller.submit(
"call-1", "doctor", 0, TIMESTAMP, base64.b64encode(bytes(MAX_PCM_BYTES)).decode()
)
assert not controller.submit(
"call-1", "doctor", 1, TIMESTAMP, base64.b64encode(bytes(MAX_PCM_BYTES + 2)).decode()
)
assert controller.submit("call-1", "doctor", 2, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("stopped")["status"] == "partial"
assert {e["code"] for e in events.items if e["type"] == "error"} == {
"invalid_audio",
"sequence_gap",
}
def test_silence_and_vad_empty_result_produce_no_fabricated_segment(make_controller) -> None:
events = Events()
engine = Engine("")
controller = make_controller(events, lambda: engine)
controller.start("call-1")
assert controller.submit(
"call-1", "patient", 0, TIMESTAMP, base64.b64encode(bytes(3200)).decode()
)
assert controller.submit("call-1", "patient", 1, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("stopped")["status"] == "completed"
assert engine.inputs == [PCM]
assert not any(e["type"] == "segment" for e in events.items)
def test_queue_overload_is_bounded_coalesced_and_stop_still_drains(make_controller) -> None:
events = Events()
entered, release = threading.Event(), threading.Event()
class SlowEngine(Engine):
def transcribe(self, pcm):
entered.set()
assert release.wait(3)
return super().transcribe(pcm)
engine = SlowEngine()
controller = make_controller(events, lambda: engine, max_pending_chunks=2)
controller.start("call-1")
events.wait("ready")
try:
assert controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
assert entered.wait(3)
assert controller.submit("call-1", "patient", 1, TIMESTAMP, AUDIO)
assert controller.submit("call-1", "patient", 2, TIMESTAMP, AUDIO)
for sequence in range(3, 100):
assert not controller.submit("call-1", "patient", sequence, TIMESTAMP, AUDIO)
assert controller.stop("call-1")
finally:
release.set()
assert events.wait("stopped")["status"] == "partial"
assert len(engine.inputs) == 3
assert len([e for e in events.items if e.get("code") == "overloaded"]) == 1
assert events.items[-1]["type"] == "stopped"
@pytest.mark.parametrize("during_load", [False, True])
def test_new_session_cancels_old_inflight_result_and_queued_audio(
make_controller, during_load
) -> None:
events = Events()
entered, release = threading.Event(), threading.Event()
engine = Engine()
loads = []
def block():
entered.set()
assert release.wait(3)
def factory():
loads.append(1)
if during_load:
block()
return engine
if not during_load:
original = engine.transcribe
def transcribe(pcm):
block()
return original(pcm)
engine.transcribe = transcribe
controller = make_controller(events, factory)
controller.start("call-1")
try:
if not during_load:
events.wait("ready")
controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
assert entered.wait(3)
controller.submit("call-1", "patient", 1, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert controller.start("call-2")
cutoff = len(events.items)
assert not controller.submit("call-1", "patient", 2, TIMESTAMP, AUDIO)
assert not controller.stop("call-1")
finally:
release.set()
events.wait("ready", "call-2")
assert controller.submit("call-2", "doctor", 0, TIMESTAMP, AUDIO)
controller.stop("call-2")
events.wait("stopped", "call-2")
assert all(e["sessionId"] == "call-2" for e in events.items[cutoff:])
assert len(loads) == 1
assert len(engine.inputs) == (1 if during_load else 2)
def test_close_suppresses_callbacks_without_waiting_for_native_inference(make_controller) -> None:
events = Events()
entered, release = threading.Event(), threading.Event()
class SlowEngine(Engine):
def transcribe(self, pcm):
entered.set()
assert release.wait(3)
return "不"
controller = make_controller(events, SlowEngine)
controller.start("call-1")
events.wait("ready")
try:
controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
assert entered.wait(3)
controller.close()
count = len(events.items)
assert not controller.start("call-2")
assert not controller.stop("call-1")
finally:
release.set()
controller._worker.join(3)
assert not controller._worker.is_alive()
assert len(events.items) == count
def test_missing_model_failure_is_explicit_and_stops_partial(make_controller) -> None:
events = Events()
def missing_model():
raise LocalTranscriptionError("model_missing")
controller = make_controller(events, missing_model)
controller.start("call-1")
assert events.wait("error")["code"] == "model_missing"
assert events.wait("stopped")["status"] == "partial"
assert not any(e["type"] == "ready" for e in events.items)
assert not controller.submit("call-1", "doctor", 0, TIMESTAMP, AUDIO)
def test_inference_failure_does_not_leak_data_or_abort_later_chunks(make_controller) -> None:
events = Events()
class FailingEngine(Engine):
def transcribe(self, pcm):
if not self.inputs:
self.inputs.append(pcm)
raise RuntimeError("SECRET_PATIENT_DETAILS")
return "不"
controller = make_controller(events, FailingEngine)
controller.start("call-1")
controller.submit("call-1", "patient", 0, TIMESTAMP, AUDIO)
controller.submit("call-1", "patient", 1, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("stopped")["status"] == "partial"
assert events.wait("error")["code"] == "transcription_failed"
assert events.wait("segment")["text"] == "不"
assert "SECRET_PATIENT_DETAILS" not in repr(events.items)
def test_callback_failure_does_not_kill_worker(make_controller) -> None:
events = Events()
def callback(event):
if event["type"] == "ready":
raise RuntimeError("detached UI")
events(event)
controller = make_controller(callback)
controller.start("call-1")
controller.submit("call-1", "doctor", 0, TIMESTAMP, AUDIO)
controller.stop("call-1")
assert events.wait("stopped")["status"] == "completed"
@pytest.mark.parametrize("session", ["", " ", "a" * 129, "x\n", "患者", None, 123])
def test_invalid_session_cannot_create_worker(make_controller, session) -> None:
events = Events()
controller = make_controller(events)
assert not controller.start(session)
assert controller._worker is None
assert not events.items
def write_model_files(path: Path) -> None:
for name in ("model.bin", "config.json", "tokenizer.json", "vocabulary.txt"):
(path / name).write_text("test", encoding="utf-8")
def test_missing_tokenizer_never_imports_model_or_accesses_network(tmp_path, monkeypatch) -> None:
write_model_files(tmp_path)
(tmp_path / "tokenizer.json").unlink()
original_import = builtins.__import__
def guarded_import(name, *args, **kwargs):
assert name != "faster_whisper"
return original_import(name, *args, **kwargs)
def no_network(*args, **kwargs):
pytest.fail("Runtime ASR must not access the network")
monkeypatch.setattr(builtins, "__import__", guarded_import)
monkeypatch.setattr(socket.socket, "connect", no_network)
with pytest.raises(LocalTranscriptionError) as raised:
FasterWhisperEngine(tmp_path)
assert raised.value.code == "model_missing"
def test_missing_dependency_is_explicit_without_exposing_exception(tmp_path, monkeypatch) -> None:
write_model_files(tmp_path)
original_import = builtins.__import__
def guarded_import(name, *args, **kwargs):
if name == "numpy":
raise ModuleNotFoundError("internal path / private details")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", guarded_import)
with pytest.raises(LocalTranscriptionError) as raised:
FasterWhisperEngine(tmp_path)
assert raised.value.code == "dependency_missing"
assert "private" not in str(raised.value)
def test_missing_conversion_dictionary_fails_before_model_ready(tmp_path, monkeypatch) -> None:
write_model_files(tmp_path)
def broken_dictionary(text):
raise FileNotFoundError("private path to missing t2s.json")
def model_must_not_load(*args, **kwargs):
pytest.fail("Missing conversion resources must fail before expensive model loading")
monkeypatch.setitem(sys.modules, "numpy", SimpleNamespace())
monkeypatch.setitem(sys.modules, "faster_whisper", SimpleNamespace(WhisperModel=model_must_not_load))
monkeypatch.setitem(sys.modules, "faster_whisper.vad", SimpleNamespace(get_vad_model=lambda: None))
monkeypatch.setattr(
"doctor_workstation.video.local_transcription.simplified_transcript", broken_dictionary
)
with pytest.raises(LocalTranscriptionError) as raised:
FasterWhisperEngine(tmp_path)
assert raised.value.code == "conversion_unavailable"
assert "private" not in str(raised.value)
def test_engine_uses_local_cpu_int8_vad_and_iterates_without_rewriting(
tmp_path, monkeypatch
) -> None:
write_model_files(tmp_path)
calls = {}
class Array:
def astype(self, dtype):
calls["float_dtype"] = dtype
return self
def __truediv__(self, divisor):
calls["divisor"] = divisor
return self
def frombuffer(pcm, dtype):
calls["pcm"] = pcm
calls["pcm_dtype"] = dtype
return Array()
class Model:
def __init__(self, path, **kwargs):
calls["path"] = path
calls["load"] = kwargs
def transcribe(self, audio, **kwargs):
calls["transcribe"] = kwargs
def segments():
calls["iterated"] = True
yield SimpleNamespace(text=" 没有。​")
yield SimpleNamespace(text=" 0.5 mg,不是 5 mg。 ")
return segments(), None
def vad():
calls["vad_loaded"] = True
def no_network(*args, **kwargs):
pytest.fail("Runtime ASR must not access the network")
monkeypatch.setattr(socket.socket, "connect", no_network)
monkeypatch.setitem(
sys.modules, "numpy", SimpleNamespace(frombuffer=frombuffer, float32="float32")
)
monkeypatch.setitem(sys.modules, "faster_whisper", SimpleNamespace(WhisperModel=Model))
monkeypatch.setitem(sys.modules, "faster_whisper.vad", SimpleNamespace(get_vad_model=vad))
engine = FasterWhisperEngine(tmp_path)
assert engine.transcribe(PCM) == "没有。​ 0.5 mg,不是 5 mg。"
assert calls["path"] == str(tmp_path.resolve())
assert calls["load"]["local_files_only"] is True
assert calls["load"]["device"] == "cpu"
assert calls["load"]["compute_type"] == "int8"
assert calls["load"]["num_workers"] == 1
assert calls["vad_loaded"] and calls["iterated"]
assert calls["pcm"] == PCM and calls["pcm_dtype"] == "<i2"
assert calls["float_dtype"] == "float32" and calls["divisor"] == 32768.0
assert calls["transcribe"]["vad_filter"] is True
assert calls["transcribe"]["vad_parameters"]["min_speech_duration_ms"] == 0
assert calls["transcribe"]["condition_on_previous_text"] is False
assert calls["transcribe"]["task"] == "transcribe"
assert engine.transcribe(bytes(3200)) == ""
def test_preflight_reports_model_path_override_without_downloading(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("DOCTOR_ASR_MODEL_PATH", str(tmp_path))
assert default_model_path() == tmp_path.resolve()
info = local_transcription_readiness()
assert not info["available"]
assert info["modelPath"] == str(tmp_path.resolve())
assert "tokenizer.json" in info["missingFiles"]
assert not list(tmp_path.iterdir())
+135
View File
@@ -0,0 +1,135 @@
"""Trusted native PCM ingestion and exact transcript/AI identity handoff."""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from doctor_workstation.video import window
from doctor_workstation.video.security import TrustedDocumentPolicy
pytestmark = pytest.mark.skipif(not window.WEBENGINE_AVAILABLE, reason="optional QtWebEngine")
TRUSTED = "https://video.example.test/call/index.html"
def host(**changes):
item = SimpleNamespace(
_closing=False, _released=False, _shutdown_requested=False, _injected=True,
_start_requested=True, _call_cycle_closed=False,
_policy=TrustedDocumentPolicy.from_url(TRUSTED, is_local=False),
_page=Mock(), _local_transcription=Mock(), _live_ai=Mock(),
_video_ai_timer=Mock(), _asr_session_id="tr-local", _asr_ready=True,
_asr_partial=False, _transcription_final_status="completed",
lifecycle=SimpleNamespace(call_record_id=71, transcription_session_id="tr-local"),
request=SimpleNamespace(user_id="doctor_1", target_user_id="patient_9"),
_save_transcript_segment=Mock(),
)
item._page.url.return_value.toString.return_value = TRUSTED
for key, value in changes.items():
setattr(item, key, value)
item._local_transcription_context_is_trusted = lambda: window._EmbeddedVideoWindow._local_transcription_context_is_trusted(item)
item._on_local_transcription = lambda event: window._EmbeddedVideoWindow._on_local_transcription(item, event)
return item
def chunk(**changes):
return {"event": "local-transcription-chunk", "sessionId": "tr-local", "role": "patient",
"sequence": 3, "timestamp": 1793332800000.0, "data": "AQACAA==", **changes}
def test_pcm_uses_bound_session_and_double_timestamp_is_converted_safely():
item = host()
window._EmbeddedVideoWindow._handle_video_assistance_message(item, chunk())
item._local_transcription.submit.assert_called_once_with("tr-local", "patient", 3, 1793332800000, "AQACAA==")
@pytest.mark.parametrize("changes", [{"sessionId": "other"}, {"timestamp": float("nan")},
{"timestamp": float("inf")}, {"timestamp": -1}, {"timestamp": True}])
def test_wrong_session_or_unbounded_pcm_metadata_rejected(changes):
item = host()
window._EmbeddedVideoWindow._handle_video_assistance_message(item, chunk(**changes))
item._local_transcription.submit.assert_not_called()
@pytest.mark.parametrize("changes", [{"_closing": True}, {"_released": True}, {"_injected": False}])
def test_untrusted_lifecycle_cannot_ingest_audio(changes):
item = host(**changes)
window._EmbeddedVideoWindow._handle_video_assistance_message(item, chunk())
item._local_transcription.submit.assert_not_called()
def test_navigation_blocks_pcm_and_analysis_commands():
item = host()
item._page.url.return_value.toString.return_value = "https://another.example.test/"
window._EmbeddedVideoWindow._handle_video_assistance_message(item, chunk())
window._EmbeddedVideoWindow._handle_video_assistance_message(item, {"event": "video-ai-action", "action": "refresh"})
item._local_transcription.submit.assert_not_called()
item._live_ai.handle.assert_not_called()
def test_pending_shutdown_drains_pcm_but_cannot_start_new_inference_session():
item = host(_shutdown_requested=True)
window._EmbeddedVideoWindow._handle_video_assistance_message(item, chunk())
window._EmbeddedVideoWindow._handle_video_assistance_message(item, {"event": "local-transcription-start", "sessionId": "tr-next"})
window._EmbeddedVideoWindow._handle_video_assistance_message(item, {"event": "video-ai-action", "action": "refresh"})
item._local_transcription.submit.assert_called_once()
item._local_transcription.start.assert_not_called()
item._live_ai.handle.assert_not_called()
def test_asr_segment_is_persisted_with_host_speaker_before_json_display():
item = host()
event = {"type": "segment", "sessionId": "tr-local", "role": "patient", "segmentId": "tr-local:patient:3",
"text": '没有;0.5 mg,不是 5 mg。</script>\u2028', "timestamp": 1793332800000, "isCompleted": True}
item._on_local_transcription(event)
item._save_transcript_segment.assert_called_once()
saved = item._save_transcript_segment.call_args.args[0]
assert saved["segment"]["text"] == event["text"]
assert saved["segment"]["speaker_user_id"] == "patient_9"
script = item._page.runJavaScript.call_args.args[0]
assert json.loads(script.split("?.(", 1)[1][:-2]) == event
item._live_ai.changed.assert_not_called() # waits for persistence ACK
def test_late_asr_segment_cannot_enter_new_call():
item = host(_asr_session_id="tr-next")
item._on_local_transcription({"type": "segment", "sessionId": "tr-local", "role": "patient"})
item._save_transcript_segment.assert_not_called()
item._page.runJavaScript.assert_not_called()
def test_persistence_ack_is_the_only_ai_dirty_trigger():
item = host()
window._EmbeddedVideoWindow._on_transcription_completed(item, "segment", "other", "s1", True, "")
item._live_ai.changed.assert_not_called()
window._EmbeddedVideoWindow._on_transcription_completed(item, "segment", "tr-local", "s1", True, "")
item._live_ai.changed.assert_called_once_with("tr-local")
window._EmbeddedVideoWindow._on_transcription_completed(item, "segment", "tr-local", "s2", False, "")
assert item._asr_partial
assert item._live_ai.changed.call_count == 1
def test_model_missing_is_failed_and_partial_chunk_is_not_false_ready():
item = host(_asr_ready=False)
item._on_local_transcription({"type": "error", "sessionId": "tr-local", "message": "本地模型未安装"})
item._live_ai.transcription_error.assert_called_once_with("本地模型未安装", status="failed")
def test_shutdown_keeps_asr_for_drain_then_closes_it():
item = host()
window._EmbeddedVideoWindow._close_video_assistance(item, close_asr=False)
item._live_ai.close.assert_called_once()
item._local_transcription.close.assert_not_called()
window._EmbeddedVideoWindow._close_video_assistance(item)
item._local_transcription.close.assert_called_once()
def test_special_pcm_bridge_bounds_data_and_preserves_epoch():
callback = Mock()
bridge = window._QtVideoBridge(callback, Mock())
bridge.appendLocalTranscriptionChunk("tr-local", "doctor", 0, 1793332800000.0, "x" * 512001)
callback.assert_not_called()
bridge.appendLocalTranscriptionChunk("tr-local", "doctor", 0, 1793332800000.0, "AQACAA==")
assert callback.call_args.args[0]["timestamp"] == 1793332800000.0
+295 -12
View File
@@ -12,17 +12,16 @@ from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# The module disables itself under the offscreen platform so widget grabs in the
# other suites capture settled frames; these tests are about the animation, so
# they opt back in.
os.environ["DOCTOR_MOTION"] = "on"
import pytest
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtCore import QAbstractAnimation, QEvent, QPoint, QPointF, QPropertyAnimation, Qt
from PySide6.QtGui import QKeyEvent, QWheelEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QGraphicsDropShadowEffect,
QLabel,
QListWidget,
QPlainTextEdit,
QScrollArea,
QStackedWidget,
QVBoxLayout,
@@ -32,6 +31,13 @@ from PySide6.QtWidgets import (
from doctor_workstation.ui import motion
@pytest.fixture(autouse=True)
def enable_motion(monkeypatch: pytest.MonkeyPatch) -> None:
# Opt in per test so collecting this module does not animate other suites'
# screenshots or leak motion settings across the shared QApplication.
monkeypatch.setenv("DOCTOR_MOTION", "on")
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@@ -122,15 +128,22 @@ def _scroll_area(application: QApplication) -> QScrollArea:
return area
def _wheel(delta: int) -> QWheelEvent:
def _wheel(
delta: int,
*,
horizontal: int = 0,
pixels: int = 0,
modifiers: Qt.KeyboardModifier = Qt.KeyboardModifier.NoModifier,
phase: Qt.ScrollPhase = Qt.ScrollPhase.NoScrollPhase,
) -> QWheelEvent:
return QWheelEvent(
QPointF(50, 50),
QPointF(50, 50),
QPoint(0, 0),
QPoint(0, delta),
QPoint(0, pixels),
QPoint(horizontal, delta),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
modifiers,
phase,
False,
)
@@ -226,3 +239,273 @@ def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
assert getattr(area, "_doctor_smooth_scroll", None) is not None
area.close()
def test_repeated_fade_continues_from_current_opacity(application: QApplication) -> None:
widget = QWidget()
motion.fade_in(widget)
first = widget._doctor_motion["fade"]
first.setCurrentTime(motion.BASE // 2)
opacity = widget.graphicsEffect().opacity()
motion.fade_in(widget)
assert widget.graphicsEffect().opacity() == pytest.approx(opacity)
assert widget._doctor_motion["fade"].startValue() == pytest.approx(opacity)
assert first.state() == QAbstractAnimation.State.Stopped
widget.close()
def test_fade_out_starts_at_full_opacity(application: QApplication) -> None:
widget = QWidget()
widget.show()
motion.fade_out(widget)
assert widget.graphicsEffect().opacity() == pytest.approx(1.0)
widget.close()
def test_reversing_fade_does_not_run_cancelled_hide_callback(application: QApplication) -> None:
widget = QWidget()
widget.show()
completed = []
motion.fade_out(widget, on_finished=lambda: completed.append(True))
outgoing = widget._doctor_motion["fade"]
outgoing.setCurrentTime(motion.FAST // 2)
opacity = widget.graphicsEffect().opacity()
motion.fade_in(widget)
incoming = widget._doctor_motion["fade"]
assert incoming.startValue() == pytest.approx(opacity)
incoming.setCurrentTime(incoming.duration())
application.processEvents()
assert widget.isVisible()
assert not completed
assert widget.graphicsEffect() is None
widget.close()
def test_previous_fade_cleanup_does_not_delete_reentrant_fade(application: QApplication) -> None:
widget = QWidget()
widget.show()
motion.fade_out(widget, on_finished=lambda: motion.fade_in(widget))
outgoing = widget._doctor_motion["fade"]
outgoing.setCurrentTime(outgoing.duration())
incoming = widget._doctor_motion["fade"]
effect = widget.graphicsEffect()
application.processEvents()
assert widget.graphicsEffect() is effect
assert incoming.state() == QAbstractAnimation.State.Running
incoming.setCurrentTime(incoming.duration())
application.processEvents()
assert widget.graphicsEffect() is None
assert widget.isVisible()
widget.close()
def test_replaced_and_completed_animations_are_released(application: QApplication) -> None:
widget = QWidget()
destroyed = []
for _ in range(10):
motion.fade_in(widget)
widget._doctor_motion["fade"].destroyed.connect(lambda: destroyed.append(True))
fade = widget._doctor_motion["fade"]
fade.setCurrentTime(fade.duration())
application.processEvents()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not widget._doctor_motion
assert not widget.findChildren(QPropertyAnimation)
assert len(destroyed) == 10
widget.close()
def test_fades_preserve_existing_graphics_effects(application: QApplication) -> None:
widget = QWidget()
shadow = QGraphicsDropShadowEffect(widget)
widget.setGraphicsEffect(shadow)
motion.fade_in(widget)
assert widget.graphicsEffect() is shadow
motion.fade_out(widget)
application.processEvents()
assert widget.graphicsEffect() is shadow
assert widget.isHidden()
widget.close()
def test_repeated_enter_does_not_accumulate_position_offset(application: QApplication) -> None:
host = QWidget()
widget = QWidget(host)
origin = QPoint(10, 20)
widget.move(origin)
motion.enter(widget)
widget._doctor_motion["enter"].setCurrentTime(motion.BASE // 3)
position = widget.pos()
motion.enter(widget)
assert widget.pos() == position
arrival = widget._doctor_motion["enter"]
assert arrival.endValue() == origin
arrival.setCurrentTime(arrival.duration())
assert widget.pos() == origin
host.close()
def test_disabling_motion_settles_an_active_entry(
application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
host = QWidget()
widget = QWidget(host)
origin = QPoint(10, 20)
widget.move(origin)
motion.enter(widget)
monkeypatch.setenv("DOCTOR_MOTION", "off")
motion.enter(widget)
application.processEvents()
assert widget.pos() == origin
assert widget.graphicsEffect() is None
assert not widget._doctor_motion
host.close()
def test_wheel_reversal_discards_pending_travel(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
bar.setValue(500)
for _ in range(4):
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
position = bar.value()
scroller.eventFilter(area.viewport(), _wheel(120))
assert scroller._animation.startValue() == position
assert scroller._animation.endValue() == position - motion.SCROLL_STEP
scroller._animation.setCurrentTime(60)
assert bar.value() < position
area.close()
@pytest.mark.parametrize("interaction", ["set_value", "keyboard", "slider_press", "range_change"])
def test_direct_scroll_interaction_cancels_pending_wheel(
application: QApplication, interaction: str
) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
if interaction == "set_value":
bar.setValue(500)
elif interaction == "keyboard":
application.sendEvent(
area,
QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Down, Qt.KeyboardModifier.NoModifier),
)
elif interaction == "slider_press":
bar.setSliderDown(True)
else:
bar.setRange(0, 50)
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
assert scroller._target == bar.value()
if interaction == "set_value":
scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller._animation.endValue() == 500 + motion.SCROLL_STEP
area.close()
@pytest.mark.parametrize(
"wheel_options",
[
{"pixels": -8},
{"phase": Qt.ScrollPhase.ScrollUpdate},
{"phase": Qt.ScrollPhase.ScrollMomentum},
{"modifiers": Qt.KeyboardModifier.ControlModifier},
{"modifiers": Qt.KeyboardModifier.ShiftModifier},
{"horizontal": -120},
],
)
def test_native_gestures_cancel_pending_wheel(
application: QApplication, wheel_options: dict
) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
delta = 0 if "horizontal" in wheel_options else -120
assert not scroller.eventFilter(area.viewport(), _wheel(delta, **wheel_options))
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
assert scroller._target == area.verticalScrollBar().value()
area.close()
@pytest.mark.parametrize("kind", ["items", "text_blocks"])
def test_non_pixel_scrollbars_keep_native_wheel_steps(application: QApplication, kind: str) -> None:
if kind == "items":
area = QListWidget()
area.addItems([f"row {index}" for index in range(300)])
area.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
else:
area = QPlainTextEdit("\n".join(f"line {index}" for index in range(300)))
area.resize(300, 200)
area.show()
application.processEvents()
motion.install_smooth_scroll(area)
assert not area._doctor_smooth_scroll.eventFilter(area.viewport(), _wheel(-120))
application.sendEvent(area.viewport(), _wheel(-120))
assert 0 < area.verticalScrollBar().value() < motion.SCROLL_STEP
area.close()
def test_pending_scroll_reaches_edge_before_handing_wheel_back(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
bar.setValue(bar.maximum() - 20)
assert scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(scroller._animation.duration())
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
area.close()
def test_horizontal_scroller_honors_horizontal_and_shift_wheel(application: QApplication) -> None:
area = QScrollArea()
content = QWidget()
content.resize(2000, 1000)
area.setWidget(content)
area.resize(300, 200)
motion.install_smooth_scroll(area, orientation=Qt.Orientation.Horizontal)
area.show()
application.processEvents()
scroller = area._doctor_smooth_scroll
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller.eventFilter(area.viewport(), _wheel(0, horizontal=-120))
scroller._animation.setCurrentTime(scroller._animation.duration())
assert area.horizontalScrollBar().value() == motion.SCROLL_STEP
assert area.verticalScrollBar().value() == 0
assert scroller.eventFilter(
area.viewport(), _wheel(-120, modifiers=Qt.KeyboardModifier.ShiftModifier)
)
assert scroller._animation.endValue() == motion.SCROLL_STEP * 2
area.close()
+10 -3
View File
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import icons
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
@@ -222,7 +223,7 @@ def test_sorted_native_row_view_button_targets_visible_record(
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
kind, color, size = "pencil", "#1769E8", 19
kind, color, size = "pencil", PALETTE["accent"], 19
shared = icons.icon(kind, color, size)
original_key = shared.cacheKey()
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
@@ -230,13 +231,19 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
assert blue is not shared
assert icons.icon(kind, color, size) is shared
assert shared.cacheKey() == original_key
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, PALETTE["disabled"], size).toImage()
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
# Both surfaces share the disabled colour; changing the returned icon still
# must not mutate the cached icon used by another page.
blue.addPixmap(icons.pixmap(kind, PALETTE["danger"], size), QIcon.Mode.Disabled)
assert shared.cacheKey() == original_key
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
+5 -4
View File
@@ -6,6 +6,7 @@ from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import ReceptionPage
from doctor_workstation.ui.theme import apply_theme
@@ -43,12 +44,12 @@ def page(application, monkeypatch):
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
page.video_button.setEnabled(True)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == PALETTE["accent"].lower()
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["surface"].lower()
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == PALETTE["text"].lower()
page.video_button.setEnabled(False)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["disabled"].lower()
def test_typography_preserves_regular_body_and_distinct_values(page):
+322
View File
@@ -0,0 +1,322 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import Mock
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QRect, QSize, Qt, QUrl
from PySide6.QtWidgets import QApplication, QMainWindow
from doctor_workstation.app import ApplicationController
from doctor_workstation.ui import theme
from doctor_workstation.video import window as video_window
from doctor_workstation.video.screenshot import ScreenshotPreviewSize
from doctor_workstation.video.security import TrustedDocumentPolicy
TRUSTED_URL = "https://video.example.test/companion/index.html?mode=call"
class _Window:
"""Supply geometry and state only, so preview sizing cannot alter other settings."""
def __init__(
self,
available: QRect | None = None,
geometry: QRect | None = None,
) -> None:
self.current_geometry = QRect(geometry) if geometry is not None else QRect(1362, 18, 540, 356)
self.available = QRect(available) if available is not None else QRect(0, 0, 1920, 1040)
self.compact_flag: bool | None = True
self.maximized = False
self.full_screen = False
self.geometry_changes: list[QRect] = []
def screen(self) -> SimpleNamespace:
return SimpleNamespace(availableGeometry=lambda: QRect(self.available))
def geometry(self) -> QRect:
return QRect(self.current_geometry)
def setGeometry(self, geometry: QRect) -> None: # noqa: N802 - Qt-compatible double
self.current_geometry = QRect(geometry)
self.geometry_changes.append(QRect(geometry))
def property(self, name: str) -> bool | None:
assert name == "doctorSkipOpenGeometry"
return self.compact_flag
def isMaximized(self) -> bool: # noqa: N802 - Qt-compatible double
return self.maximized
def isFullScreen(self) -> bool: # noqa: N802 - Qt-compatible double
return self.full_screen
@pytest.fixture
def qapp() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize(
("available", "target", "expected_size"),
[
(QRect(0, 0, 1920, 1040), None, QSize(1120, 760)),
(QRect(-1920, -120, 1920, 1040), None, QSize(1120, 760)),
(QRect(-1280, 180, 1280, 720), QRect(0, 0, 1400, 950), QSize(1232, 672)),
(QRect(100, 50, 800, 600), None, QSize(752, 552)),
(QRect(0, 0, 1920, 1040), QRect(40, 60, 1360, 850), QSize(1360, 850)),
],
ids=["default", "negative-monitor", "bounded-target", "small-screen", "normal-target"],
)
def test_preview_expands_within_current_screen_and_restores(
available: QRect, target: QRect | None, expected_size: QSize
) -> None:
window = _Window(available, QRect(available.x() + 30, available.y() + 30, 440, 300))
original = window.geometry()
preview = ScreenshotPreviewSize()
preview.open(window, target)
expanded = window.geometry()
assert expanded.size() == expected_size
assert available.adjusted(24, 24, -24, -24).contains(expanded)
assert abs(expanded.center().x() - available.center().x()) <= 1
assert abs(expanded.center().y() - available.center().y()) <= 1
assert window.compact_flag is True
preview.restore(window)
assert window.geometry() == original
assert window.compact_flag is True
def test_repeated_open_preserves_first_geometry_and_restore_is_idempotent() -> None:
window = _Window()
original = window.geometry()
preview = ScreenshotPreviewSize()
preview.open(window)
expanded = window.geometry()
preview.open(window, QRect(0, 0, 1500, 900))
assert window.geometry() == expanded
assert len(window.geometry_changes) == 1
preview.restore(window)
preview.restore(window)
assert window.geometry() == original
assert window.geometry_changes == [expanded, original]
@pytest.mark.parametrize("state", ["maximized", "full_screen", "large", "no-screen", "tiny-screen"])
def test_preview_does_not_modify_windows_that_do_not_need_expansion(state: str) -> None:
window = _Window()
if state == "large":
window.current_geometry = QRect(25, 30, 1300, 850)
elif state == "no-screen":
window.screen = lambda: None # type: ignore[method-assign, return-value]
elif state == "tiny-screen":
window.available = QRect(0, 0, 40, 40)
else:
setattr(window, state, True)
original = window.geometry()
preview = ScreenshotPreviewSize()
preview.open(window)
preview.restore(window)
assert window.geometry() == original
assert window.geometry_changes == []
@pytest.mark.parametrize("change", ["move", "resize", "compact-flag", "maximize", "fullscreen"])
def test_restore_preserves_external_window_changes_and_forgets_old_snapshot(change: str) -> None:
window = _Window()
preview = ScreenshotPreviewSize()
preview.open(window)
expanded = window.geometry()
if change == "move":
window.current_geometry.translate(40, 15)
elif change == "resize":
window.current_geometry.setWidth(window.current_geometry.width() + 50)
elif change == "compact-flag":
window.compact_flag = False
elif change == "maximize":
window.maximized = True
else:
window.full_screen = True
external_geometry = window.geometry()
writes_before_restore = len(window.geometry_changes)
preview.restore(window)
assert window.geometry() == external_geometry
assert len(window.geometry_changes) == writes_before_restore
# Even when those external changes later disappear, a late close is harmless.
window.current_geometry = expanded
window.compact_flag = True
window.maximized = False
window.full_screen = False
preview.restore(window)
assert window.geometry() == expanded
assert len(window.geometry_changes) == writes_before_restore
def test_real_window_preserves_theme_opt_out_minimum_and_flags(qapp: QApplication) -> None:
theme._install_business_dialog_styling(qapp)
main = QMainWindow()
main.setProperty("doctorMainWindow", True)
main.setGeometry(30, 30, 980, 720)
main.show()
window = QMainWindow()
window.setProperty("doctorSkipOpenGeometry", True)
window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
window.setMinimumSize(440, 300)
window.setGeometry(30, 30, 440, 300)
window.show()
qapp.processEvents()
original = window.geometry()
flags = window.windowFlags()
minimum = window.minimumSize()
preview = ScreenshotPreviewSize()
try:
preview.open(window, theme.main_window_target_rect(window))
qapp.processEvents()
assert window.width() > original.width()
assert window.height() > original.height()
assert window.minimumSize() == minimum
assert window.windowFlags() == flags
assert window.property("doctorSkipOpenGeometry") is True
preview.restore(window)
qapp.processEvents()
assert window.geometry() == original
assert window.minimumSize() == minimum
assert window.windowFlags() == flags
assert window.property("doctorSkipOpenGeometry") is True
finally:
window.close()
main.close()
def test_diagnosis_restore_clears_screenshot_layer_before_restoring_large_window(
qapp: QApplication,
) -> None:
theme._install_business_dialog_styling(qapp)
window = QMainWindow()
window.setProperty("doctorSkipOpenGeometry", True)
window.setMinimumSize(440, 300)
window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
window.setGeometry(30, 30, 440, 300)
window.show()
qapp.processEvents()
compact = window.geometry()
normal = QRect(40, 50, 670, 500)
preview = ScreenshotPreviewSize()
restored_layers: list[QRect] = []
def restore_screenshot_preview() -> None:
preview.restore(window)
restored_layers.append(window.geometry())
window.restore_screenshot_preview = restore_screenshot_preview
controller = SimpleNamespace(
_video_preview_generation=4,
_video_preview_state={
"window": window,
"geometry": normal,
"minimum_size": QSize(500, 360),
"maximized": False,
"full_screen": False,
"stays_on_top": False,
},
)
try:
preview.open(window, QRect(0, 0, 600, 450))
ApplicationController._restore_video_preview(controller, 4, activate=False)
qapp.processEvents()
assert restored_layers == [compact]
assert window.geometry() == normal
assert window.minimumSize() == QSize(500, 360)
assert not window.windowFlags() & Qt.WindowType.WindowStaysOnTopHint
assert window.property("doctorSkipOpenGeometry") is False
assert controller._video_preview_state is None
assert controller._video_preview_generation == 5
restore_screenshot_preview()
assert window.geometry() == normal
finally:
window.close()
def _host(**changes: Any) -> SimpleNamespace:
return SimpleNamespace(
**{
"_closing": False,
"_released": False,
"_shutdown_requested": False,
"_media_active": True,
"_policy": TrustedDocumentPolicy.from_url(TRUSTED_URL, is_local=False),
"_page": SimpleNamespace(url=lambda: QUrl(TRUSTED_URL)),
"_screenshot_preview_size": SimpleNamespace(open=Mock()),
"restore_screenshot_preview": Mock(),
**changes,
}
)
@pytest.mark.parametrize(
"changes",
[
{"_closing": True},
{"_released": True},
{"_shutdown_requested": True},
{"_media_active": False},
{"_page": SimpleNamespace(url=lambda: QUrl("about:blank"))},
{"_page": SimpleNamespace(url=lambda: QUrl("https://untrusted.example.test/"))},
],
ids=["closing", "released", "shutdown", "inactive", "blank-document", "untrusted-document"],
)
def test_host_does_not_open_preview_when_inactive_or_untrusted(changes: dict[str, Any]) -> None:
host = _host(**changes)
video_window._EmbeddedVideoWindow._set_screenshot_preview_visible(host, True)
host._screenshot_preview_size.open.assert_not_called()
host.restore_screenshot_preview.assert_not_called()
def test_host_uses_normal_window_target_for_trusted_preview(monkeypatch: pytest.MonkeyPatch) -> None:
host = _host()
target = QRect(30, 40, 1360, 850)
target_for_window = Mock(return_value=target)
monkeypatch.setattr(theme, "main_window_target_rect", target_for_window)
video_window._EmbeddedVideoWindow._set_screenshot_preview_visible(host, True)
target_for_window.assert_called_once_with(host)
host._screenshot_preview_size.open.assert_called_once_with(host, target)
host.restore_screenshot_preview.assert_not_called()
@pytest.mark.parametrize("changes", [{}, {"_shutdown_requested": True}, {"_media_active": False}])
def test_host_allows_preview_cleanup_after_shutdown_request(changes: dict[str, Any]) -> None:
host = _host(**changes)
video_window._EmbeddedVideoWindow._set_screenshot_preview_visible(host, False)
host.restore_screenshot_preview.assert_called_once_with()
host._screenshot_preview_size.open.assert_not_called()
+53
View File
@@ -164,6 +164,59 @@ def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
@pytest.mark.parametrize("permissions, expected", [
(None, False),
([], False),
(["tcm.diagnosis/read"], False),
(["tcm.diagnosis.aiAssistant"], False),
(["tcm.diagnosis/aiAssistant"], True),
(["tcm.diagnosis/*"], True),
(["*"], True),
])
def test_im_ai_capability_comes_from_current_shell_permissions(chat_controller, permissions, expected):
case = chat_controller
case.controller.shell_window.permissions = permissions
case.controller._request_video({
"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456,
"mode": "im", "can_ai_assistant": True,
})
case.complete(0)
assert case.launched[0][1]["can_ai_assistant"] is expected
def test_im_ai_permission_is_evaluated_after_ticket_load(chat_controller):
case = chat_controller
case.controller.shell_window.permissions = ["tcm.diagnosis/aiAssistant"]
case.request(456)
case.controller.shell_window.permissions = []
case.complete(0)
assert case.launched[0][1]["can_ai_assistant"] is False
@pytest.mark.parametrize("capability, expected", [(True, True), (False, False), (None, False), (1, False), ("true", False)])
def test_video_launcher_forwards_only_explicit_ai_capability(monkeypatch, capability, expected):
from doctor_workstation.video import window as window_module
from doctor_workstation.video.launcher import launch_video_call
launches = []
sentinel = object()
def capture(request, **kwargs):
launches.append((request, kwargs))
return sentinel
monkeypatch.setattr(window_module, "open_video_call", capture)
result = launch_video_call(
ticket(), repository=object(), open_im=True, can_ai_assistant=capability,
)
assert result is sentinel
request, options = launches[0]
assert request.diagnosis_id == 123
assert request.patient_id == 8
assert request.appointment_id == 456
assert options["can_ai_assistant"] is expected
@pytest.mark.parametrize("second_diagnosis", [123, 124])
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import os
import socket
from concurrent.futures import ThreadPoolExecutor
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QPlainTextEdit, QWidget
from doctor_workstation.core.transcript_text import simplified_transcript
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
@pytest.mark.parametrize(
("source", "expected"),
[
("你會說老家話嗎?很會,很會。", "你会说老家话吗?很会,很会。"),
("沒有過敏,沒有吃藥。0.5 mg,不是 5 mg。", "没有过敏,没有吃药。0.5 mg,不是 5 mg。"),
("血壓 120/80 mmHg,血糖 2.00–30.00 mmol/L,HbA1c 6.5%。",
"血压 120/80 mmHg,血糖 2.00–30.00 mmol/L,HbA1c 6.5%。"),
("唔係,冇食藥,咩事?", "唔系,冇食药,咩事?"),
("患者:口乾。\n醫生:不?\n患者:不。", "患者:口干。\n医生:不?\n患者:不。"),
("简体,不! \n0.5 mg\tB12", "简体,不! \n0.5 mg\tB12"),
("", ""),
],
)
def test_script_conversion_is_offline_and_preserves_clinical_tokens(monkeypatch, source, expected):
def no_network(*args, **kwargs):
pytest.fail("Script conversion must use bundled dictionaries only")
monkeypatch.setattr(socket.socket, "connect", no_network)
# A fresh thread loads its own dictionaries, even when a prior test ran.
with ThreadPoolExecutor(max_workers=1) as pool:
assert pool.submit(simplified_transcript, source).result() == expected
assert simplified_transcript(expected) == expected
def test_historical_transcript_dialog_displays_simplified_without_changing_record(monkeypatch):
application = QApplication.instance() or QApplication([])
parent = QWidget()
record = {"transcript_text": "患者:你會說老家話嗎?\n患者:沒有吃藥,0.5 mg。"}
source = DiagnosisDialog._call_transcript_text(record)
displayed = []
def inspect(dialog):
displayed.append(dialog.findChild(QPlainTextEdit, "DiagnosisCallTranscriptText").toPlainText())
return 0
monkeypatch.setattr(QDialog, "exec", inspect)
try:
DiagnosisDialog._view_call_transcript(parent, 12, source)
assert displayed == ["患者:你会说老家话吗?\n患者:没有吃药,0.5 mg。"]
assert record["transcript_text"] == source
finally:
parent.close()
application.processEvents()
+234
View File
@@ -0,0 +1,234 @@
"""Retry a failed storage start without duplicate requests or reopening a call."""
from __future__ import annotations
import logging
import threading
from concurrent.futures import Future, ThreadPoolExecutor
import pytest
from doctor_workstation.video.launcher import VideoCallRequest
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
class Repository:
def __init__(self, *, fail_first: bool = False, block_attempt: int = 0) -> None:
self.fail_first = fail_first
self.block_attempt = block_attempt
self.attempts: list[dict] = []
self.entered = threading.Event()
self.release = threading.Event()
def start_call(self, **_payload):
return {"call_record_id": 71}
def start_call_transcription(self, **payload):
self.attempts.append(payload)
attempt = len(self.attempts)
if attempt == self.block_attempt:
self.entered.set()
assert self.release.wait(3), "test did not release storage request"
if self.fail_first and attempt == 1:
raise RuntimeError("temporary storage failure")
def finish_call_transcription(self, **_payload):
return True
def end_call(self, **_payload):
return True
@pytest.fixture
def make_lifecycle():
lifecycles = []
def make(**options):
repository = Repository(**options)
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor-test",
user_sig="test-ticket",
target_user_id="patient-test",
diagnosis_id=12,
)
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
lifecycles.append((lifecycle, repository))
assert lifecycle.start().result(timeout=2) is True
return lifecycle, repository
yield make
for lifecycle, repository in lifecycles:
repository.release.set()
lifecycle.end("test-cleanup").result(timeout=3)
assert lifecycle.wait(3)
def test_failed_storage_start_retries_same_session_then_reuses_success(make_lifecycle):
lifecycle, repository = make_lifecycle(fail_first=True)
failed = lifecycle.start_transcription("tr-retry", language="zh")
with pytest.raises(RuntimeError, match="temporary storage failure"):
failed.result(timeout=2)
retry = lifecycle.start_transcription("tr-retry", language="zh")
assert retry is not failed
assert retry.result(timeout=2) is True
assert lifecycle.start_transcription("tr-retry") is retry
assert lifecycle.transcription_session_id == "tr-retry"
assert len(repository.attempts) == 2
assert (
repository.attempts[0]
== repository.attempts[1]
== {
"diagnosis_id": 12,
"call_record_id": 71,
"transcription_session_id": "tr-retry",
"language": "zh",
}
)
def test_inflight_start_is_reused_without_blocking_caller(make_lifecycle):
lifecycle, repository = make_lifecycle(block_attempt=1)
first = lifecycle.start_transcription("tr-running")
try:
assert repository.entered.wait(2)
assert not first.done()
assert lifecycle.start_transcription("tr-running") is first
assert len(repository.attempts) == 1
finally:
repository.release.set()
assert first.result(timeout=2) is True
def test_concurrent_retry_callers_share_one_new_request(make_lifecycle):
lifecycle, repository = make_lifecycle(fail_first=True, block_attempt=2)
with pytest.raises(RuntimeError, match="temporary storage failure"):
lifecycle.start_transcription("tr-concurrent").result(timeout=2)
try:
with ThreadPoolExecutor(max_workers=4) as callers:
futures = list(
callers.map(lambda _: lifecycle.start_transcription("tr-concurrent"), range(4))
)
assert repository.entered.wait(2)
assert all(future is futures[0] for future in futures)
assert not futures[0].done()
assert len(repository.attempts) == 2
finally:
repository.release.set()
assert futures[0].result(timeout=2) is True
def test_false_completion_can_retry_without_changing_identity(make_lifecycle):
lifecycle, repository = make_lifecycle()
# Seed the documented False completion (e.g. call-start dependency had not
# succeeded), then exercise retry against a now-available actual lifecycle.
previous: Future[bool] = Future()
previous.set_result(False)
lifecycle._transcription_session_id = "tr-false"
lifecycle._transcription_start_future = previous
retry = lifecycle.start_transcription("tr-false")
assert retry is not previous
assert retry.result(timeout=2) is True
assert lifecycle.start_transcription("tr-false") is retry
assert len(repository.attempts) == 1
assert repository.attempts[0]["transcription_session_id"] == "tr-false"
def test_cancelled_queued_start_can_retry_same_session(make_lifecycle):
lifecycle, repository = make_lifecycle()
entered, release = threading.Event(), threading.Event()
def occupy_worker():
entered.set()
assert release.wait(3)
lifecycle._worker.submit("test-block", occupy_worker)
try:
assert entered.wait(2)
cancelled = lifecycle.start_transcription("tr-cancelled")
assert cancelled.cancel()
retry = lifecycle.start_transcription("tr-cancelled")
assert retry is not cancelled
finally:
release.set()
assert retry.result(timeout=2) is True
assert len(repository.attempts) == 1
@pytest.mark.parametrize("state", ["running", "succeeded", "failed", "false"])
def test_session_identity_cannot_change_even_after_failure(make_lifecycle, state):
lifecycle, repository = make_lifecycle(
fail_first=state == "failed", block_attempt=1 if state == "running" else 0
)
if state == "false":
first: Future[bool] = Future()
first.set_result(False)
lifecycle._transcription_session_id = "tr-original"
lifecycle._transcription_start_future = first
else:
first = lifecycle.start_transcription("tr-original")
if state == "running":
assert repository.entered.wait(2)
elif state == "failed":
with pytest.raises(RuntimeError, match="temporary storage failure"):
first.result(timeout=2)
else:
assert first.result(timeout=2)
try:
with pytest.raises(RuntimeError, match="another transcription session"):
lifecycle.start_transcription("tr-different")
assert lifecycle.transcription_session_id == "tr-original"
finally:
repository.release.set()
@pytest.mark.parametrize("terminal", ["finish", "end"])
@pytest.mark.parametrize("failed", [False, True])
def test_finish_or_end_blocks_restart_before_and_after_completion(make_lifecycle, terminal, failed):
lifecycle, repository = make_lifecycle(fail_first=failed)
first = lifecycle.start_transcription("tr-terminal")
if failed:
with pytest.raises(RuntimeError, match="temporary storage failure"):
first.result(timeout=2)
else:
assert first.result(timeout=2)
ending = (
lifecycle.finish_transcription(status="partial")
if terminal == "finish"
else lifecycle.end("test-terminal")
)
error = "already stopped" if terminal == "finish" else "already ended"
with pytest.raises(RuntimeError, match=error):
lifecycle.start_transcription("tr-terminal")
ending.result(timeout=2)
with pytest.raises(RuntimeError, match=error):
lifecycle.start_transcription("tr-terminal")
assert len(repository.attempts) == 1
@pytest.mark.parametrize("terminal", ["finish", "end"])
def test_terminal_request_blocks_start_even_while_original_start_is_running(
make_lifecycle, terminal
):
lifecycle, repository = make_lifecycle(block_attempt=1)
first = lifecycle.start_transcription("tr-draining")
try:
assert repository.entered.wait(2)
ending = (
lifecycle.finish_transcription()
if terminal == "finish"
else lifecycle.end("test-draining")
)
assert not first.done()
assert not ending.done()
error = "already stopped" if terminal == "finish" else "already ended"
with pytest.raises(RuntimeError, match=error):
lifecycle.start_transcription("tr-draining")
finally:
repository.release.set()
assert first.result(timeout=2) is True
assert ending.result(timeout=2) is True
assert len(repository.attempts) == 1
+42
View File
@@ -295,6 +295,48 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
application.processEvents()
def test_login_password_group_keeps_focus_when_reveal_is_keyboard_focused(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
window = LoginWindow(
object(), settings=QSettings(str(tmp_path / "focus.ini"), QSettings.Format.IniFormat),
)
window.show()
application.processEvents()
group = window.password_edit.parentWidget()
geometry = group.geometry()
try:
for control in (window.password_edit, window.reveal_button):
control.setFocus()
application.processEvents()
assert group.property("focused") is True
assert group.geometry() == geometry
window.login_button.setFocus()
application.processEvents()
assert group.property("focused") is False
assert group.geometry() == geometry
finally:
window.close()
def test_compact_login_keeps_the_whole_form_visible(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
window = LoginWindow(
object(), settings=QSettings(str(tmp_path / "compact.ini"), QSettings.Format.IniFormat),
)
window.resize(860, 640)
window.show()
application.processEvents()
try:
assert not window.brand_panel.isVisible()
assert window.login_scroll.verticalScrollBar().maximum() == 0
assert window.card.height() >= window.card.minimumSizeHint().height()
assert window.login_button.isVisibleTo(window)
finally:
window.close()
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings_path = tmp_path / "remember-password.ini"
+4
View File
@@ -38,6 +38,8 @@ def page(application, monkeypatch):
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
# The responsive workspace exposes insights in its second tab.
page.workbench.mode_tabs.setCurrentIndex(1)
settle(application)
for button in page.ai_prompt_buttons:
assert button.width() >= button.minimumSizeHint().width()
@@ -48,6 +50,8 @@ def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, applicati
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
# Force real overflow: a short case is allowed to fill the viewport.
page.case_labels["present"].setText("Long clinical history\n" * 80)
page.detail_tabs.setCurrentIndex(0)
settle(application)
consultation_height = page.clinical_pages.height()
+24 -15
View File
@@ -62,7 +62,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
assert "operations.push(stopLocalRecording())" in source
assert "operations.push(stopTranscription('completed'))" in source
assert "Promise.allSettled(operations)" in source
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
assert "本机录音已启动" in source
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
@@ -83,7 +83,7 @@ def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
assert "diagnosisId: activeConfig.diagnosisId" in room_source
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
def test_room_binding_is_acknowledged_independently_of_cloud_transcription() -> None:
companion_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
@@ -91,8 +91,11 @@ def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> No
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "observeRoomId(roomId)" in companion_source
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
assert "observeRoomId(readRoomId())" in companion_source
assert "getAITranscriberManager" in companion_source
assert "startRealtimeTranscriber" in companion_source
assert "sourceLanguage: 'zh'" in companion_source
assert "LocalPcmCapture" not in companion_source
assert "function roomBindingResult(" in companion_source
assert "if (boundRoomId) return" in companion_source
assert "roomBindingResult?.(" in window_source
@@ -186,7 +189,7 @@ def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() ->
assert "store.bind_identity(" in source
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
def test_companion_displays_only_native_normalized_cloud_transcript_acks() -> None:
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
@@ -196,13 +199,18 @@ def test_companion_shows_incremental_subtitles_but_only_persists_final_segments(
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
"function subscribeTranscriber", 1
)[0]
acknowledgement = main_source.split("function transcriptionResult", 1)[1].split(
"function videoAiAction", 1
)[0]
assert "showLiveCaption(message)" in handler
assert "if (message.isCompleted !== true) return" in handler
assert handler.index("showLiveCaption(message)") < handler.index(
"if (message.isCompleted !== true) return"
)
assert 'aria-label="实时语音字幕"' in component_source
assert "transcription-segment" in handler
assert "showLiveCaption(" not in handler
assert "message.isCompleted !== true" in handler
assert "sessionId === transcriptionSessionId" in acknowledgement
assert "normalizedSegment?.segmentId === segmentId" in acknowledgement
assert "normalizedSegment.isCompleted === true" in acknowledgement
assert "showLiveCaption(normalizedSegment)" in acknowledgement
assert 'aria-label="腾讯云实时转写的对话原文"' in component_source
assert "liveCaptions.value" in component_source
assert "caption.speaker" in component_source
assert "caption.text" in component_source
@@ -219,7 +227,7 @@ def test_companion_keeps_transcript_and_patient_case_visible_in_a_side_rail() ->
assert "liveCaptions.value = [...previous, caption].slice(-120)" in main_source
assert "liveCaptionClearTimer" not in main_source
assert 'aria-label="患者病例与实时对话"' in component_source
assert 'aria-label="AI辅助、对话原文与患者资料"' in component_source
assert 'id="patient-case-title"' in component_source
assert 'id="live-transcript-title"' in component_source
assert 'aria-label="打开完整诊单"' in component_source
@@ -269,7 +277,7 @@ def test_video_diagnosis_entry_uses_existing_permission_scoped_drawer() -> None:
assert "not modeless and not self._standalone_readonly" in diagnosis_source
compact_styles = styles.split("@media (max-width: 700px)", 1)[1]
assert ".consultation-rail { display: none; }" in compact_styles
assert ".capture-button { display: none; }" in compact_styles
assert ".capture-button { display: none; }" not in compact_styles
assert "on_open_diagnosis=on_open_diagnosis" in launcher_source
assert "event: 'open-diagnosis-request'" in main_source
@@ -559,7 +567,7 @@ def test_built_video_companion_contains_the_patient_case_rail() -> None:
assert ".consultation-rail" in styles
assert ".video-layer--with-rail" in styles
assert "patientCase" in scripts
assert "患者病例与实时对话" in scripts
assert "AI辅助、对话原文与患者资料" in scripts
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
@@ -573,7 +581,8 @@ def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> No
"watch(", 1
)[0]
assert "screenshotPreview.value = canvas.toDataURL" in capture
assert "await captureVideoStage(videoStage.value)" in capture
assert "generation === screenshotGeneration" in capture
assert "onSaveScreenshot" not in capture
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
assert "确认画面后再保存到患者资料" in source
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import base64
import json
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any
from unittest.mock import Mock
import pytest
from PySide6.QtCore import QRect, QUrl
from PySide6.QtGui import QColor, QImage, QPainter
from doctor_workstation.video import window
from doctor_workstation.video.screenshot import capture_view_region
from doctor_workstation.video.security import TrustedDocumentPolicy
TRUSTED_URL = "https://video.example.test/companion/index.html?mode=call"
REGION_KEYS = ("x", "y", "width", "height", "viewportWidth", "viewportHeight")
def _region(**changes: Any) -> dict[str, Any]:
return {
"x": 120,
"y": 80,
"width": 480,
"height": 320,
"viewportWidth": 1000,
"viewportHeight": 800,
**changes,
}
class _View:
"""Keep grab coordinates logical, as QWidget does, and its image physical."""
def __init__(
self,
width: int = 1000,
height: int = 800,
*,
zoom: float = 1,
dpr: float = 1,
source: QImage | None = None,
) -> None:
self._width = width
self._height = height
self._zoom = zoom
self.dpr = dpr
self.source = source
self.grabbed: list[QRect] = []
def width(self) -> int:
return self._width
def height(self) -> int:
return self._height
def zoomFactor(self) -> float: # noqa: N802 - Qt API
return self._zoom
def rect(self) -> QRect:
return QRect(0, 0, self._width, self._height)
def grab(self, rect: QRect) -> SimpleNamespace:
self.grabbed.append(QRect(rect))
physical_rect = QRect(
round(rect.x() * self.dpr),
round(rect.y() * self.dpr),
round(rect.width() * self.dpr),
round(rect.height() * self.dpr),
)
if self.source is not None:
image = self.source.copy(physical_rect)
else:
image = QImage(physical_rect.size(), QImage.Format.Format_RGB32)
image.fill(QColor("#143b67"))
image.setDevicePixelRatio(self.dpr)
return SimpleNamespace(toImage=lambda: image)
def _decode_jpeg(data_url: str) -> QImage:
prefix = "data:image/jpeg;base64,"
assert data_url.startswith(prefix)
data = base64.b64decode(data_url[len(prefix) :], validate=True)
assert data.startswith(b"\xff\xd8")
image = QImage.fromData(data, "JPEG")
assert not image.isNull()
return image
@pytest.mark.parametrize("zoom", [0.8, 1, 1.25, 1.5, 2])
def test_css_coordinates_follow_webengine_zoom(zoom: float) -> None:
view = _View(round(1000 * zoom), round(800 * zoom), zoom=zoom)
image = _decode_jpeg(capture_view_region(view, _region()))
expected = QRect(round(120 * zoom), round(80 * zoom), round(480 * zoom), round(320 * zoom))
assert view.grabbed == [expected]
assert image.size() == expected.size()
def test_fractional_edges_round_inward_and_exclude_adjacent_patient_panel() -> None:
source = QImage(1250, 1000, QImage.Format.Format_RGB32)
source.fill(QColor("red"))
painter = QPainter(source)
# Only complete logical pixels inside the fractional video rectangle are safe.
expected = QRect(17, 26, 374, 249)
painter.fillRect(expected, QColor("#143b67"))
painter.end()
view = _View(1250, 1000, zoom=1.25, source=source)
image = _decode_jpeg(
capture_view_region(view, _region(x=13.1, y=20.2, width=300.4, height=200.4))
)
assert view.grabbed == [expected]
for x, y in ((0, 0), (373, 0), (0, 248), (373, 248), (187, 124)):
color = image.pixelColor(x, y)
assert color.blue() > color.red() + 50
@pytest.mark.parametrize(
("changes", "expected"),
[
({"x": -20, "y": -10, "width": 150, "height": 110}, QRect(0, 0, 130, 100)),
({"x": 950, "y": 760, "width": 100, "height": 80}, QRect(950, 760, 50, 40)),
({"x": -50, "y": -50, "width": 1200, "height": 1000}, QRect(0, 0, 1000, 800)),
],
)
def test_capture_is_clipped_to_the_visible_viewport(
changes: dict[str, int], expected: QRect
) -> None:
view = _View()
image = _decode_jpeg(capture_view_region(view, _region(**changes)))
assert view.grabbed == [expected]
assert image.size() == expected.size()
@pytest.mark.parametrize("key", REGION_KEYS)
@pytest.mark.parametrize(
"value",
[float("nan"), float("inf"), float("-inf"), True, False, None, "12"],
ids=["nan", "infinity", "negative-infinity", "true", "false", "null", "string"],
)
def test_invalid_coordinate_values_are_rejected_before_grab(key: str, value: Any) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**{key: value}))
assert view.grabbed == []
@pytest.mark.parametrize("key", REGION_KEYS)
def test_missing_coordinates_are_rejected_before_grab(key: str) -> None:
view = _View()
region = _region()
del region[key]
with pytest.raises(ValueError):
capture_view_region(view, region)
assert view.grabbed == []
@pytest.mark.parametrize("key", ["width", "height", "viewportWidth", "viewportHeight"])
@pytest.mark.parametrize("value", [0, -1])
def test_empty_or_negative_sizes_are_rejected_before_grab(key: str, value: int) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**{key: value}))
assert view.grabbed == []
@pytest.mark.parametrize(
"changes",
[
{"x": 1000},
{"y": 800},
{"x": -480},
{"y": -320},
{"x": 12.2, "width": 0.2},
{"y": 12.2, "height": 0.2},
],
)
def test_offscreen_or_subpixel_empty_regions_are_rejected(changes: dict[str, Any]) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**changes))
assert view.grabbed == []
@pytest.mark.parametrize("width,height,zoom", [(1200, 800, 1), (1000, 900, 1), (1000, 800, 1.25)])
def test_resize_or_zoom_race_rejects_stale_viewport(width: int, height: int, zoom: float) -> None:
view = _View(width, height, zoom=zoom)
with pytest.raises(ValueError, match="尺寸已变化"):
capture_view_region(view, _region())
assert view.grabbed == []
@pytest.mark.parametrize("dpr", [1, 1.25, 2])
def test_dpr_changes_encoded_pixels_without_scaling_grab_coordinates(dpr: float) -> None:
view = _View(1250, 1000, zoom=1.25, dpr=dpr)
image = _decode_jpeg(capture_view_region(view, _region()))
assert view.grabbed == [QRect(150, 100, 600, 400)]
assert (image.width(), image.height()) == (round(600 * dpr), round(400 * dpr))
@pytest.mark.parametrize(
"width,height,dpr,expected",
[
(3000, 1500, 1, (1920, 960)),
(1500, 3000, 1, (960, 1920)),
(1600, 900, 2, (1920, 1080)),
(320, 240, 1, (320, 240)),
],
)
def test_jpeg_longest_edge_is_limited_to_1920_without_upscaling(
width: int, height: int, dpr: float, expected: tuple[int, int]
) -> None:
view = _View(width, height, dpr=dpr)
region = _region(
x=0, y=0, width=width, height=height, viewportWidth=width, viewportHeight=height
)
image = _decode_jpeg(capture_view_region(view, region))
assert (image.width(), image.height()) == expected
assert view.grabbed == [QRect(0, 0, width, height)]
def test_null_grab_and_jpeg_encoding_failure_are_reported() -> None:
view = _View()
view.grab = Mock(return_value=SimpleNamespace(toImage=lambda: QImage()))
with pytest.raises(ValueError, match="截屏失败"):
capture_view_region(view, _region())
image = SimpleNamespace(
isNull=lambda: False, width=lambda: 480, height=lambda: 320, save=lambda *_: False
)
view.grab = Mock(return_value=SimpleNamespace(toImage=lambda: image))
with pytest.raises(ValueError, match="图片生成失败"):
capture_view_region(view, _region())
def _host(view: _View, **changes: Any) -> SimpleNamespace:
return SimpleNamespace(
**{
"_closing": False,
"_released": False,
"_shutdown_requested": False,
"_media_active": True,
"_policy": TrustedDocumentPolicy.from_url(TRUSTED_URL, is_local=False),
"_page": SimpleNamespace(url=lambda: QUrl(TRUSTED_URL)),
"web_view": view,
"lifecycle": SimpleNamespace(save_screenshot=Mock()),
**changes,
}
)
def test_bridge_returns_preview_without_saving_until_explicit_save() -> None:
view = _View()
host = _host(view)
events: list[Mapping[str, Any]] = []
bridge = window._QtVideoBridge(
events.append,
lambda region: window._EmbeddedVideoWindow._capture_screenshot_preview(host, region),
)
result = json.loads(bridge.captureScreenshot(json.dumps(_region())))
assert set(result) == {"dataUrl"}
assert _decode_jpeg(result["dataUrl"]).size() == QRect(0, 0, 480, 320).size()
assert events == []
host.lifecycle.save_screenshot.assert_not_called()
bridge.saveScreenshot(result["dataUrl"])
assert events == [
{"source": "doctor-call", "event": "screenshot", "dataUrl": result["dataUrl"]}
]
@pytest.mark.parametrize("payload", [None, 12, "", "{", "null", "[]", '"region"', " " * 2049])
def test_bridge_rejects_invalid_payloads_without_calling_capture(payload: Any) -> None:
capture = Mock()
notify = Mock()
bridge = window._QtVideoBridge(notify, capture)
result = json.loads(bridge.captureScreenshot(payload))
assert set(result) == {"error"}
assert result["error"]
capture.assert_not_called()
notify.assert_not_called()
@pytest.mark.parametrize(
"value",
[float("nan"), float("inf"), True, 0, -1, 10**400],
ids=["nan", "infinity", "boolean", "empty", "negative", "oversized-integer"],
)
def test_bridge_returns_error_for_invalid_region_values(value: Any) -> None:
view = _View()
notify = Mock()
bridge = window._QtVideoBridge(notify, lambda region: capture_view_region(view, region))
result = json.loads(bridge.captureScreenshot(json.dumps(_region(width=value))))
assert set(result) == {"error"}
assert result["error"]
assert view.grabbed == []
notify.assert_not_called()
@pytest.mark.parametrize("error_type", [ValueError, TypeError, RuntimeError])
def test_bridge_reports_capture_failure_without_exposing_error_details(
error_type: type[Exception],
) -> None:
notify = Mock()
bridge = window._QtVideoBridge(
notify, Mock(side_effect=error_type("private path or patient information"))
)
result = json.loads(bridge.captureScreenshot(json.dumps(_region())))
assert set(result) == {"error"}
assert "private" not in result["error"]
notify.assert_not_called()
@pytest.mark.parametrize(
"changes",
[
{"_closing": True},
{"_released": True},
{"_shutdown_requested": True},
{"_media_active": False},
],
)
def test_host_rejects_capture_when_call_is_inactive_or_closing(changes: dict[str, bool]) -> None:
view = _View()
host = _host(view, **changes)
with pytest.raises(ValueError, match="已关闭"):
window._EmbeddedVideoWindow._capture_screenshot_preview(host, _region())
assert view.grabbed == []
host.lifecycle.save_screenshot.assert_not_called()
@pytest.mark.parametrize(
"url",
[
"about:blank",
"https://untrusted.example.test/companion/index.html?mode=call",
"https://video.example.test/other",
"https://video.example.test/companion/index.html?mode=other",
],
)
def test_host_rejects_capture_after_navigation_away_from_trusted_document(url: str) -> None:
view = _View()
host = _host(view, _page=SimpleNamespace(url=lambda: QUrl(url)))
with pytest.raises(ValueError, match="页面不可用"):
window._EmbeddedVideoWindow._capture_screenshot_preview(host, _region())
assert view.grabbed == []
host.lifecycle.save_screenshot.assert_not_called()
Generated
+582 -2
View File
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]
[[package]]
name = "altgraph"
@@ -24,6 +28,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "av"
version = "18.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" },
{ url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" },
{ url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" },
{ url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" },
{ url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" },
{ url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" },
{ url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" },
{ url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" },
]
[[package]]
name = "av"
version = "19.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/23/01/76b6bf563fe907597cf58f10d10168e93fe588b6db9f2658db87e87efa7e/av-19.0.2.tar.gz", hash = "sha256:bebbc52598886480f4d861c575dc2b020785d6c04e1308680ddcc545e1b94c66", size = 4277157, upload-time = "2026-10-21T03:45:30.799Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/2f/f51f37820741134516e841053d5ecebf6350fbd451adcbff9ead96aa1244/av-19.0.2-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:f5f7bcf987567cd3f9b01f1c375c59bf59d8a5b98c9c5c154aeda7a11e0c5dce", size = 22627374, upload-time = "2026-10-21T03:44:28.296Z" },
{ url = "https://files.pythonhosted.org/packages/7c/42/067d0bcd2c07103f18cc23b510a8289dd80330e984c49d51ce0bafcee017/av-19.0.2-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:b0dff6dfcc6ff9944fbbff15bc4ee2d90a2c1695d493bdea96e86f48823e9feb", size = 18556422, upload-time = "2026-10-21T03:44:31.267Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/448f89f76cf5e4da35dd6294a1e8155b75daa8839f2f2fa353775855d152/av-19.0.2-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:04c8e412642c652cb2b1997a9cb2114546ee7d894600c631f4be8ceb26a0d0d2", size = 32683287, upload-time = "2026-10-21T03:44:34.588Z" },
{ url = "https://files.pythonhosted.org/packages/e1/7f/9f1237feb894afa472ef834aa1fbfe4d6398f28dc7e86a1b418650204c36/av-19.0.2-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c1da7aad0fa6c13b4a5c1e7985be0d13f8526a96284573acaf3d55ead6d3fe8", size = 34990159, upload-time = "2026-10-21T03:44:37.637Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b5/1bde0e54e525d88ad8f5becae657f8142543fd3be9d10b72141f041ca868/av-19.0.2-cp312-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9d4f9f78c0ce1f20deb72626fdb3c723757f61a8cd6fbb7dd44d9401884a91fd", size = 44422922, upload-time = "2026-10-21T03:44:41.847Z" },
{ url = "https://files.pythonhosted.org/packages/08/21/65997929fa4dd7c20f64e742aa87205a8d65485718a2ab51633756078e83/av-19.0.2-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfe7af5aa8243afd5d4778f85ad1a7c948e37fad272869da1f8f93f8d57fa7ad", size = 33754699, upload-time = "2026-10-21T03:44:45.772Z" },
{ url = "https://files.pythonhosted.org/packages/30/44/4de8c0ff5a5e25ceb0841efc0a4a7530a22d61800bc0cc19a1d03aa04358/av-19.0.2-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:44d3faae747cf04a33f39db8ae983ff3d93adc864ee663bc186b25b686803c3e", size = 36014845, upload-time = "2026-10-21T03:44:49.64Z" },
{ url = "https://files.pythonhosted.org/packages/32/5a/743a83666ded197df8b9957e2800bef9a39d52b23be2ca211bbd14b5fb22/av-19.0.2-cp312-abi3-win_amd64.whl", hash = "sha256:3ce5a1d7b916d13c16ac58f1756f0fee4af2d53f02bc1403e18550b50d375a22", size = 28150949, upload-time = "2026-10-21T03:44:53.434Z" },
{ url = "https://files.pythonhosted.org/packages/42/fe/e79d786628d9c0428f72214dbe4b90ee0c625e04d552a604d5235ff35515/av-19.0.2-cp312-abi3-win_arm64.whl", hash = "sha256:79a277b077e3768a0ea9f881ca0b3bc96d4135a8d3a7a579938adb58718d0dfd", size = 20708023, upload-time = "2026-10-21T03:44:56.435Z" },
{ url = "https://files.pythonhosted.org/packages/08/f5/e7988f64d3debf50748f1c4372a3dac70cfe53720563056cb57bd77771a6/av-19.0.2-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b632e026f4375037438a6b2b5d6a489e55bc9504fad22078991ef96211bec2a9", size = 22911715, upload-time = "2026-10-21T03:44:59.378Z" },
{ url = "https://files.pythonhosted.org/packages/76/86/dbe91321e6b233970c7fdfdb82cb1750a745e5779ab9ee69a6ce2589b1cb/av-19.0.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:32a8d37bde9e24ad0cc855140cbeecde17a363d7dc7928dcfc71a34b5f3ed387", size = 18864540, upload-time = "2026-10-21T03:45:02.227Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c7/afca86772d825f5eb5fd8d1a193b206ed9a4a0d74ffbe5bbe0a19521ed7d/av-19.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:1e7cb7b66a832bce8996af5b94f1cf4014a05ff51e0005feac4cb2566d711020", size = 36479296, upload-time = "2026-10-21T03:45:05.955Z" },
{ url = "https://files.pythonhosted.org/packages/b3/71/6c6fc7e80dd5aa71109cb9324638620c40b7de8f677a6446d2f744295587/av-19.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b559ff381bd800c46fee6a17d7e22a3443708cdd58d0901ae91d27cf5dd0578c", size = 38467551, upload-time = "2026-10-21T03:45:09.493Z" },
{ url = "https://files.pythonhosted.org/packages/3f/93/fa67f5d4180e57e7f740053fd855a6d312f4848f1e5582b8d3ab131184dd/av-19.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:91924f5c836b1cbf89fe1522c642f98e89a733124908561fd49dc7a70e9b6d10", size = 46223361, upload-time = "2026-10-21T03:45:13.396Z" },
{ url = "https://files.pythonhosted.org/packages/25/f9/f98627770ccb4761e8cb87edd8b4bcf91549288bca95130c1a91ea7cdcb8/av-19.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ae4618eab172f8135596972252f39c7399291c921e661c1771ba26b9254b800f", size = 37349208, upload-time = "2026-10-21T03:45:17.213Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3a/183b4f96a604a8544180e256d16f9a6c0534d6b7db22dc22fb0842aa96fc/av-19.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8596fad2017d380708de35ddf9d34f808b8488c912cdc3f361691d866d9dc32d", size = 39475989, upload-time = "2026-10-21T03:45:20.658Z" },
{ url = "https://files.pythonhosted.org/packages/22/6e/84b65c58171a6d75a5d129a9cf856ca730843f8c476f08033e7f9d4a1af6/av-19.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9db27c2248112a8eed3edd3ca83cf4405f52a402cda2eb1956035e646e6b99c6", size = 29064804, upload-time = "2026-10-21T03:45:24.24Z" },
{ url = "https://files.pythonhosted.org/packages/60/4e/d96cc9fff457069c52754f63151a0340cb4886330d58e09061f203696b60/av-19.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:0e425d631510ca21e47c58f3837a9dceae0453c24e926408c8a12b95239cbbd6", size = 21916888, upload-time = "2026-10-21T03:45:27.298Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
@@ -105,6 +167,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -281,6 +352,96 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
]
[[package]]
name = "ctranslate2"
version = "4.8.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pyyaml" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/01/4bdbbb7f564725629dfa638c4492997e12697822d5f5855a61705d116bb3/ctranslate2-4.8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7b6cd9e832e47c00d3da14976b1621b065d8680a7d27a95a009af4a41e169ac", size = 1489306, upload-time = "2026-10-25T19:04:02.114Z" },
{ url = "https://files.pythonhosted.org/packages/da/a7/c9cd5ab778b2f1eed8c4552e94c236e4e26a3742ffd54eb8ec54880fec8f/ctranslate2-4.8.4-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:2c366cb89ffcd99f76e44dfbb199979d450a9c698dae5eefd65e94a7ef25443c", size = 12167392, upload-time = "2026-10-25T19:04:03.775Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/e0250f31eadfbb70e467b2c33867399665ab783ecc3fff18412595b1693f/ctranslate2-4.8.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd6ed9d10b46b9bc2ee801ea9624acf28bd53ffddfd5e92d23b2340dbc4d6474", size = 18793231, upload-time = "2026-10-25T19:04:06.692Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fb/c20dd76f7636bdef710e2f6e8419db3a07687cec65e78228ff3624875afe/ctranslate2-4.8.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1037b3f51d18f1546120686670d71fcdaf85a0500fffccc56d8439ded5d284e5", size = 42428932, upload-time = "2026-10-25T19:04:10.532Z" },
{ url = "https://files.pythonhosted.org/packages/45/a0/7f9744949aac9a143a1245511fb0ce5f62434aed9b1ca026cb72d9ff6e5f/ctranslate2-4.8.4-cp311-cp311-win_amd64.whl", hash = "sha256:01bfca5d6e1736e38602511066a437677470c690e1458fa7c72ff322a3cfc4f9", size = 20369372, upload-time = "2026-10-25T19:04:13.542Z" },
{ url = "https://files.pythonhosted.org/packages/45/18/68692f8cf2684690cc6f2b407fa6d5909895cac37aa9d1df1bc5849017f9/ctranslate2-4.8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:36ebf8d2f5b7af176cef7869cd98a1c722ee01c177168fda5c9a045fc930be67", size = 1490254, upload-time = "2026-10-25T19:04:15.694Z" },
{ url = "https://files.pythonhosted.org/packages/8b/2f/7495643e0d5f530f927bbaf9daecb180606fe484d6afc056fcb773974344/ctranslate2-4.8.4-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:1094d0128657903677b3be3aab9186bf28ac8d89a4676fb590bcabf3c7605971", size = 12172517, upload-time = "2026-10-25T19:04:17.411Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/f7aad2355dbb4a0115d9265f0ab04feddfd506388d19bfe7e0387fd7c5e1/ctranslate2-4.8.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a102683e7dc5d0cd0c31de5771ce4c27fffa837721470e7aae64ebffa64f3206", size = 18976989, upload-time = "2026-10-25T19:04:19.951Z" },
{ url = "https://files.pythonhosted.org/packages/39/42/ab136f76cf379ed97d3f95563ad227fdd5cdbbeb20eaa797b0962be62b1a/ctranslate2-4.8.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d1830d12c84b2240bf51893ee6bfe24a671dccfd232b05a74ef197d27fb164a", size = 42669573, upload-time = "2026-10-25T19:04:23.931Z" },
{ url = "https://files.pythonhosted.org/packages/af/25/cca7f6807d37cbfd9eebed8bbf136f5214c31d7d66de267ad05b676e9deb/ctranslate2-4.8.4-cp312-cp312-win_amd64.whl", hash = "sha256:056db4b4c2a31b7811d453155a3d6ab316ddb1dc104f413beee7bce26bcbf92d", size = 20371102, upload-time = "2026-10-25T19:04:26.986Z" },
{ url = "https://files.pythonhosted.org/packages/56/83/f96841f2123ee983c41a69477ca00f171d75e6434a4c40c0e3decbe457bb/ctranslate2-4.8.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:183a21b4c0da1846f8c3faac0267c497d7bdc8dd43350e23fa4f584b4ba2d4ba", size = 1490195, upload-time = "2026-10-25T19:04:29.168Z" },
{ url = "https://files.pythonhosted.org/packages/40/ef/570715e8b607df28e129e913a496f41e6efa2665b85e3536f54e87b31d1e/ctranslate2-4.8.4-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:a547c5970c17c3259a59e731e5479579463b2d3a6235862a99a46654437faeb9", size = 12172432, upload-time = "2026-10-25T19:04:31.073Z" },
{ url = "https://files.pythonhosted.org/packages/db/5a/29467590dbc2fe339e159da946f9192f231cfd753d36dd30095de88d6578/ctranslate2-4.8.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4ed771f82c4078113d811c5f9ad0a4597bb79f54784adb58fd50d69b04cb7a5", size = 18959426, upload-time = "2026-10-25T19:04:33.746Z" },
{ url = "https://files.pythonhosted.org/packages/17/0b/430c7f43bc06cbdb5f9f66dac1ce13c1d713e03e729b295856a51ac33043/ctranslate2-4.8.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:271e77faf49ff9fa117b490b9008ac0b294744c19dabc93087e202299c1a8e27", size = 42612303, upload-time = "2026-10-25T19:04:37.457Z" },
{ url = "https://files.pythonhosted.org/packages/37/ae/4fbe636b36fd4fd4266e309d050548c1ae0a23c88ef73ef89c6485db99d1/ctranslate2-4.8.4-cp313-cp313-win_amd64.whl", hash = "sha256:d9523961651ac0f51e4e62d03307609ff93d828b32ae92dc8b14a7e55243f6f2", size = 20371182, upload-time = "2026-10-25T19:04:40.548Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/3f133db61ec4483b67ca264b63ae5fef9d60c0c5d6f253fad3f9bbf9e850/ctranslate2-4.8.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87145e4ebdabd19c7bc8eb1f96a30b2df2b6923e6bdea8c97c637c872962f51a", size = 1490930, upload-time = "2026-10-25T19:04:43.285Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/b38afe110dc1af43a45434e4c37564371e10ce9e91a3b200dda4494cc851/ctranslate2-4.8.4-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:40d49ce50fc38432eae920c36a13fb943fc73363934ff699986eadaf1a9e178b", size = 12172898, upload-time = "2026-10-25T19:04:45.409Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e1/2f4b85f47567b934fb3e38da9f76451fd243f5111daf3dcef7b9211dfb7d/ctranslate2-4.8.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec9639dd36511eecf23bbc99a61a8fe77f16e2169e8dd35be6502ae3b2147a20", size = 18934359, upload-time = "2026-10-25T19:04:48.674Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f4/d14d4ad8d1eac0e49aafcc11ff9ec83ac5758ae23acc526f15b73c980c19/ctranslate2-4.8.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e838fb78e8db9daee96187a08c3d0839bf45bd4853afda839f3b4b4979336ed6", size = 42590297, upload-time = "2026-10-25T19:04:52.638Z" },
{ url = "https://files.pythonhosted.org/packages/50/98/6bd005eb63150a8d029aa06076180bc3452243b56da592c770128ac6f47b/ctranslate2-4.8.4-cp314-cp314-win_amd64.whl", hash = "sha256:4b322215f3b8eaab1db45b9e6aebfb8ddfe1336651e15a569c90826bfdb4b136", size = 20638792, upload-time = "2026-10-25T19:04:55.966Z" },
{ url = "https://files.pythonhosted.org/packages/89/7b/51b68b23870e9e09c3dda27e629b78dbbc259750036474bea8e38171f721/ctranslate2-4.8.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fc200ff85351d6e6f7403c43857086df8ebc754bf243a77079fee7c2156ada5", size = 1517691, upload-time = "2026-10-25T19:04:58.538Z" },
{ url = "https://files.pythonhosted.org/packages/45/9d/83f3f40d0e07878f62eb3b633ca186fe163b3c4fbdb960ab7bb66064a625/ctranslate2-4.8.4-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:1cf96ce8f625166ef67358b42c798271b307bb0b4a1d39a9965ff848f17f9d74", size = 12199614, upload-time = "2026-10-25T19:05:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e3/a1a4b5a2b66acda963f08a6e73a4866ad79fa7bd079569be881da8d08f25/ctranslate2-4.8.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:230b5216b71f5502d6ae0d9151c646aa69de7817b2e424ee67d6a87a9aeb19e1", size = 19315986, upload-time = "2026-10-25T19:05:03.08Z" },
{ url = "https://files.pythonhosted.org/packages/78/69/8750a4bd23c85a41d14686de0fadb101d5241a2a79e0c5a36d56d04c69ba/ctranslate2-4.8.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbf71062d93031ad709a20588a6d2cb624f66253c53e8509c8f9410078d53bd9", size = 42942312, upload-time = "2026-10-25T19:05:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/22/3e/94c4fb70d2844b5b396fa29e04139013292b8129feab427c6241953a1a6f/ctranslate2-4.8.4-cp314-cp314t-win_amd64.whl", hash = "sha256:81397be8ef6c7966ebe5b549244114e543f19219976d7e1437fdebb624f296c0", size = 20665797, upload-time = "2026-10-25T19:05:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/e8/de/7dd4ad23e7fa5c0ebb3be622d94283a73885847f886c7803e5e683617548/ctranslate2-4.8.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ed6d6e53e9414f702868f8c9543da8b526b8d1703dd0c1136c99d01c8e6b0614", size = 1491298, upload-time = "2026-10-25T19:05:12.626Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4a/a37f99662f23132282540b33c26080dc498d66a3dd63418026d6bf143639/ctranslate2-4.8.4-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:18286b049aa9afb1df301bf627eb8dcbe4e9069810e7e3e4ef069e55a68b5934", size = 12172901, upload-time = "2026-10-25T19:05:14.422Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7d/56ef3c91f58c1ad99daaf1b4ff798109e6a73ae5784549e0bcc3efe37b6f/ctranslate2-4.8.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac23582ee919235fd6a73ab383eb7cb97b544dc7c70fcc445dc22de7d70dc90", size = 18944558, upload-time = "2026-10-25T19:05:17.362Z" },
{ url = "https://files.pythonhosted.org/packages/58/cc/07a6c500aa65910691eb345fb4fca463906168cd75c6dde8735f02f01746/ctranslate2-4.8.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6139f57b316420469d1400968d40185ff7b99123d45a88ab3112d364809fffb", size = 42613272, upload-time = "2026-10-25T19:05:21.321Z" },
{ url = "https://files.pythonhosted.org/packages/d4/93/c3f255c6a7e7aded6c3fcabbb383d5b9277f05ff62d960dd9a99dae698f1/ctranslate2-4.8.4-cp315-cp315-win_amd64.whl", hash = "sha256:a6c28ac653a3598d3a978e9c9c629c3a4f30072b0d0473be2880d3d82599e11a", size = 20638838, upload-time = "2026-10-25T19:05:24.766Z" },
{ url = "https://files.pythonhosted.org/packages/ba/9f/890c9c520135642c5c338c0687872f41e65e25dd1d58a217d514258c9211/ctranslate2-4.8.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8eb7a167ff37ddc2c0237064fd25101fe00775a7deaf1f094be9c7f88694d54c", size = 1518152, upload-time = "2026-10-25T19:05:27.037Z" },
{ url = "https://files.pythonhosted.org/packages/ec/80/08a06ade4d67b63384189c280edfd00be9a5ec3b9f6c0d263d322b7c12e5/ctranslate2-4.8.4-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:63a715c34b5e3c462e5680ea32ea12bad30ada8db1a6d99fc8e0e541d6117542", size = 12199353, upload-time = "2026-10-25T19:05:29.26Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/b44cd0038a265768ba649bd45359e6029be670fd6f0135a5d07637d38182/ctranslate2-4.8.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82caf13dce0563d96a92e0bcfe541f3ff73875b4d3d9335e8dfc46a66d0e2a51", size = 19368566, upload-time = "2026-10-25T19:05:32.297Z" },
{ url = "https://files.pythonhosted.org/packages/01/76/81a2e86d63183c597ef977c03d19166bab0857c1a92bdcfe394770bbd274/ctranslate2-4.8.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8849e5dfd27f3e5ad781d327f477434badfcb3deead4c4758ceeb621aed930a7", size = 43008255, upload-time = "2026-10-25T19:05:36.566Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f7/d3ca639be986018bc1ee06e8c07652c44fc7c65b70022f161a7adce2d014/ctranslate2-4.8.4-cp315-cp315t-win_amd64.whl", hash = "sha256:b8f5ea44f93342cb460642f966bb87bf79d3fdcded6db049001cca28e82c0be8", size = 20666116, upload-time = "2026-10-25T19:05:39.895Z" },
]
[[package]]
name = "faster-whisper"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "av", version = "18.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "av", version = "19.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "ctranslate2" },
{ name = "huggingface-hub" },
{ name = "onnxruntime" },
{ name = "tokenizers" },
{ name = "tqdm" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" },
]
[[package]]
name = "filelock"
version = "4.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" },
]
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
[[package]]
name = "fsspec"
version = "2026.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -290,6 +451,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hf-xet"
version = "1.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/ed/54c8ef80981b548649edda8f1302e5a42edb4e97672d742ee020c97f4856/hf_xet-1.7.1.tar.gz", hash = "sha256:89d06f4975e807eefc6f6777180f452cfb86f6c79f2a6caa6fb28809c84c6777", size = 993876, upload-time = "2026-10-28T15:09:54.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/94/c8447b413386554c528808eb147ade34669dce5ddf97cd9368345821bc1d/hf_xet-1.7.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4498ae151a4576d18f8714302c9be16aceda4b34ed028b3f5226656377d77163", size = 3816404, upload-time = "2026-10-28T15:09:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/8889221e6f156a0167b5a07e20913ff50dee60832879c3471798156df91a/hf_xet-1.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fc2767e5b6ba9f74aa5b86728e64cef48668bed91c50e7313ba1d78f7c510c75", size = 3563533, upload-time = "2026-10-28T15:09:20.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/42/f9d4df9447fb8d502963f02569d069bf5c3b7d772a8a869b7773eb194c48/hf_xet-1.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cfcb057a0e9dc337839056f3eb11b2d7e3b594686a56f37a0e4458071b6f84ca", size = 4211327, upload-time = "2026-10-28T15:09:22.651Z" },
{ url = "https://files.pythonhosted.org/packages/17/b0/abda85b0b444526c650ed8b8a208aa1fed0b8c872c194c3a8e19f0a11cd2/hf_xet-1.7.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6e6639f77da484eb36671d86262a48f7904946f31f6ce980dcf0690f2dfdc1c4", size = 3988957, upload-time = "2026-10-28T15:09:29.21Z" },
{ url = "https://files.pythonhosted.org/packages/6f/44/7e608548a11bfacf3f8ad0c82c7e95034d36a042b38d8772dc74fc31b75d/hf_xet-1.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b269c6fe9f5c8104561b7320458a07865f64d159ee1306487be6f95ed9960013", size = 4193780, upload-time = "2026-10-28T15:09:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/61/db/2116ce2701399bbb2fd42e438fc24e3b1743ed2c210bebfdca173b6e686c/hf_xet-1.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bfa59cfeea1d3161bb6b86e8ceaaf1ee4bd07b0353222c27cfb643e0521c04d4", size = 4421379, upload-time = "2026-10-28T15:09:33.303Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2c/8ec86426c7e09877d5e143cffaa97c7021857ebdf91f1ff0d59a73e48009/hf_xet-1.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4e649d3e9fd045a8563cf1346a6f67bbe3fa8215f5cce9b84042c876f26fe02c", size = 3825028, upload-time = "2026-10-28T15:09:35.391Z" },
{ url = "https://files.pythonhosted.org/packages/84/ae/49ef831fbba17570663e68663bd69dbdba6abd38481b7884c5baf16dbdc2/hf_xet-1.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:4b5bc399ad059a2a7484f185162666fb7ecd77b573e8bafc336fd38a5741a522", size = 3656777, upload-time = "2026-10-28T15:09:37.335Z" },
{ url = "https://files.pythonhosted.org/packages/2e/cd/cd5c2a9145f406008cb35ae91ad48877ca6439bee3b64b5061e673b963df/hf_xet-1.7.1-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e2f21d368a84f798cd394549292e3abe3be22d362aaf13fdd10b0f85a630590c", size = 3809415, upload-time = "2026-10-28T15:09:39.566Z" },
{ url = "https://files.pythonhosted.org/packages/9c/7b/e3fada39f556579503808a93e51231bb2f926f4633c038bc9c5237f6c39c/hf_xet-1.7.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:46d09bc6200388622f6aeb6db6d7b47060f63c483b6fb637e0e13e87470bfabf", size = 3573230, upload-time = "2026-10-28T15:09:41.356Z" },
{ url = "https://files.pythonhosted.org/packages/91/99/105c23da56161acdc4e5b3022ae6d225ab54269a44eb7303f41e3a7be09e/hf_xet-1.7.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f26bfd4a6e31b4e37e749a07e8642589406e8537be05eccfd633a92b95e2fb51", size = 4219728, upload-time = "2026-10-28T15:09:43.24Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/861af7b14825974ea3c787c224f08758046ed17615d7c79c7571e7f26353/hf_xet-1.7.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b7fe4b5c4cc912a2c7b36538c6696d3312bd1471835ee814c3dd3dfc5509c3b9", size = 4000405, upload-time = "2026-10-28T15:09:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/94/bd/9cf9db82e60962b203489c89f5fb9809f417b7385da50d8a60bb79c299a0/hf_xet-1.7.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1b710504674091061d4f26fb7991773992dad091f8e162d0a8c965c3ec7427fe", size = 4202381, upload-time = "2026-10-28T15:09:47.315Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e7/1521ffbecec428bbba62f495ba91ea294b1cada4c6289a7354be4edc7823/hf_xet-1.7.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aaa2cf2f7a35bd653a365f6444ac0860b7b5ec11b7658090005d4aa6e10ae60b", size = 4430493, upload-time = "2026-10-28T15:09:49.113Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/e0f82a9f31772c2db59ca95e85beeb99e6da1a55f47d3fedac1b23ea1bc9/hf_xet-1.7.1-cp38-abi3-win_amd64.whl", hash = "sha256:d6e2c99e5dc7d6fa301e92e79a4f02cf962620dfc9fef84b687df597845cd72a", size = 3833194, upload-time = "2026-10-28T15:09:51.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ce/faaa9326f8dce6e3e6eef1738a27834e77eebd3c0db00e5757a01dee0f7e/hf_xet-1.7.1-cp38-abi3-win_arm64.whl", hash = "sha256:c1f2c5685e89ee251e885c65ab366d981f555b2c8857642f4f8ee1e7dc4efdf6", size = 3665289, upload-time = "2026-10-28T15:09:53.107Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -303,6 +488,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -318,6 +516,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "huggingface-hub"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'ARM64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "httpx2" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/dd/585639d95f6a987c0bdfb9045f050f4ad67240775644ba7a4fdc5aca3a3b/huggingface_hub-2.3.0.tar.gz", hash = "sha256:0b783b37f200f1d25bef2344a997c7d410f4437fbe9d8fd965f958e75cec7aef", size = 1063410, upload-time = "2026-10-23T08:52:06.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/89/5f6ef1162d0323f167b6baab04d1b4bd6a946161a8af6d86f622d9d79aa1/huggingface_hub-2.3.0-py3-none-any.whl", hash = "sha256:5da369600233568a126eb5578754935b2add9446b42d9e2fc0927d56840ac1a5", size = 855161, upload-time = "2026-10-23T08:52:04.78Z" },
]
[[package]]
name = "idna"
version = "3.18"
@@ -332,7 +576,7 @@ name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
{ name = "zipp", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
@@ -432,6 +676,211 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "numpy"
version = "2.5.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" },
{ url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" },
{ url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" },
{ url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" },
{ url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" },
{ url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" },
{ url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" },
{ url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" },
{ url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" },
{ url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" },
{ url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" },
{ url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" },
{ url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" },
{ url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" },
{ url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" },
{ url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" },
{ url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
{ url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
{ url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
{ url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
{ url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
{ url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
{ url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
{ url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
{ url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
{ url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
{ url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
{ url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
{ url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
{ url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
{ url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
{ url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
{ url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
{ url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
{ url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
{ url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
{ url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
{ url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
{ url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
{ url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
{ url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
{ url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
]
[[package]]
name = "onnxruntime"
version = "1.31.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "packaging" },
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/44/e9e04a60f726d16ba3abe30ab267e1569e061f7f6f0e175ee103661e1780/onnxruntime-1.31.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:60b6b69bfe580eab576cacc5d132a0e9cf2a37fcfd18d46dd191042b1d1ed0d7", size = 20871676, upload-time = "2026-10-26T19:26:00.372Z" },
{ url = "https://files.pythonhosted.org/packages/02/ee/b173a49293b146b54bd057bf8681a015aef764d8f56d999ad8c6e53ab18d/onnxruntime-1.31.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:da05378364c44d4407e463982d2bf8d5e4cfea873283757e48beeb41df97b7f3", size = 21413526, upload-time = "2026-10-26T19:26:03.573Z" },
{ url = "https://files.pythonhosted.org/packages/b4/1d/0f85ee31a3d93f2243a5cc29252becf858f53ac5cfa6c9c15d9ffe107eea/onnxruntime-1.31.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5752bd508126ae7c49894d3b9d2e83f0c3d487319246d04c444b393308d7ad7f", size = 23753652, upload-time = "2026-10-26T19:26:06.944Z" },
{ url = "https://files.pythonhosted.org/packages/69/82/2e2bf967ade9567d77c307533566ef56a19c32e2114fc062a405703def88/onnxruntime-1.31.1-cp311-cp311-win_amd64.whl", hash = "sha256:c3d91b5de31e2fc82d71bd65b2b22bddee8b49057a3a36338e9a868e0ea90c05", size = 14885761, upload-time = "2026-10-26T19:26:09.8Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c6/1995e04cc964c6abac426f59525b41c31f766c9392cabfb1e67c51718823/onnxruntime-1.31.1-cp311-cp311-win_arm64.whl", hash = "sha256:93800007a7023b3ab26ad72e6463d0d57d84ead49a2f6341954d91438e046bc0", size = 14735267, upload-time = "2026-10-26T19:26:12.915Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f7/cecc0efeeea83bff2f7789ae1b80f27fa0f0210d6d9d66d1f0031a6b5e47/onnxruntime-1.31.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:96dd2564b10ea7153ad9c207e7cd8e5917cb3ba521e0e0a62ac03486fdc8d56c", size = 20882125, upload-time = "2026-10-26T19:26:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e4/1ed1a7a5f4702d899091fa2b976d377995250c307326ec8dc9ae6378aa13/onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b9a492176886992e0e875f06bc0367e333f5fff11711a2d642e18b02f3009cc", size = 21420803, upload-time = "2026-10-26T19:26:19.487Z" },
{ url = "https://files.pythonhosted.org/packages/eb/27/c6a823bac55b2977bea2027c3ec9393a2c1002cb9a09eb40354563e02730/onnxruntime-1.31.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1f3dea47f2f0e5117c2404cea7d21fd253ae7836a3bf167a501cdfbb2600d536", size = 23760988, upload-time = "2026-10-26T19:26:23.153Z" },
{ url = "https://files.pythonhosted.org/packages/45/b2/793b4051d8dc6acb379ead9b72050f0ffa283ca49a6904add2e218b83e2e/onnxruntime-1.31.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae8a9a351efda685cc59a66226cdefaa37a126c1d66872ca23a2de9425326130", size = 14888466, upload-time = "2026-10-26T19:26:25.816Z" },
{ url = "https://files.pythonhosted.org/packages/ed/72/09d635ab4b6137d8ac63d4911a37dad8b1ef15a77d20b65d7ed40fdca012/onnxruntime-1.31.1-cp312-cp312-win_arm64.whl", hash = "sha256:fe8b9ac1ae8e894e12c47ee82e4f6dfc1835768133687257866469fda80d0bf1", size = 14740724, upload-time = "2026-10-26T19:26:28.682Z" },
{ url = "https://files.pythonhosted.org/packages/b2/93/d2b855793302ef57d55cc0583b72a00a8dcb01d9bdb3adac380f09b050e8/onnxruntime-1.31.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e4ad067d36e2220218e085d7223283b2ee1bc73d887e811fd14d21cf901a11ee", size = 20881825, upload-time = "2026-10-26T19:26:31.51Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e1/633d4994db0620d31f34d9962119bc4ebd0b2ef4c9a030f91c7843fb9672/onnxruntime-1.31.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:dcaedc50333876f3829ab966b242147f0e548229e29c36fe44622ecf748c25ee", size = 21420628, upload-time = "2026-10-26T19:26:34.487Z" },
{ url = "https://files.pythonhosted.org/packages/fc/0a/c887bcb84a9455b2723825381a77af38611a80f996f804e20fde5f671b63/onnxruntime-1.31.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:74a6a52994616c4d18b33db1e13c226333a70a6ba07480a5cab9c2a50dff1430", size = 23760717, upload-time = "2026-10-26T19:26:37.721Z" },
{ url = "https://files.pythonhosted.org/packages/59/bf/a1d282b4c753ba3e9b31f4c11abd2bc2390513dc5e3792ca3b78e42401c3/onnxruntime-1.31.1-cp313-cp313-win_amd64.whl", hash = "sha256:0558f29d5f6ac09957390c9c5495931485ee8ae7f0ce781ccee86a0ae5c2348b", size = 14888304, upload-time = "2026-10-26T19:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fd/529135287474dbaf19d9362ccda61998128ab18507f78bc82931e1bbaf22/onnxruntime-1.31.1-cp313-cp313-win_arm64.whl", hash = "sha256:075147c2e2430b4b29c436bffa78537b902a6ed58ed66f99ae3c6795e2081cb5", size = 14740772, upload-time = "2026-10-26T19:26:44.042Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d4/56ea7d601695be4018ccc6bb9a47637a1deaccc5c4db6c6ad272e48d88e2/onnxruntime-1.31.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:ae0987aedb9ae589f3395615c58b22aad2eae6657b14508cec70c24b367cfad1", size = 21432641, upload-time = "2026-10-26T19:26:47.065Z" },
{ url = "https://files.pythonhosted.org/packages/05/10/e37d9f06dae61cecaad344aef213a19643b66761a06667bdfe5282c1fb37/onnxruntime-1.31.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6711108bcddf8b401145fb31ac93776b8ca6e7e7e56acdfa9d478575b8df9a2f", size = 23773876, upload-time = "2026-10-26T19:26:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/d7/55/b511099a435972b86b5d04ef374cdca8de920311cc979180029b80145bf5/onnxruntime-1.31.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8536ac43682caa7492c46d6cfd4c3c8db288dcf0619508431a22170b670ee1d0", size = 20883427, upload-time = "2026-10-26T19:26:54.067Z" },
{ url = "https://files.pythonhosted.org/packages/06/ef/98f0060d6cf58f9f663268fef65fe729120b8fbe26ef35eaa2c8d9030d33/onnxruntime-1.31.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:c878686b75cade1ed59131aa018ab004b1e64368b0d54041912450b9642cf04f", size = 21421619, upload-time = "2026-10-26T19:26:57.332Z" },
{ url = "https://files.pythonhosted.org/packages/aa/74/a891a659ccc427469de0d7bdbd5ff770a35f73084ee590ce02456cf61f7f/onnxruntime-1.31.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c20d7f15d84427db070b163f78c2c7146cc93edd6113fa7327671259a24184fe", size = 23762999, upload-time = "2026-10-26T19:27:00.865Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e9/9112e9322aef42bff80e805915b61cdcbb82577589bdd42dedf73a5ff9b6/onnxruntime-1.31.1-cp314-cp314-win_amd64.whl", hash = "sha256:0a3998e18cb667ef54287556bd62291a788ed235723dd90c83b2294b24e71d81", size = 15268752, upload-time = "2026-10-26T19:27:03.629Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ee/b69384d9b94c6bf3beb030bcba25dc573e490d3136117be0f78d79078d35/onnxruntime-1.31.1-cp314-cp314-win_arm64.whl", hash = "sha256:fc01b4e7c0a81778fb741bcf5c16b7871d196275f6240720a8b8bfd9f0c0af14", size = 15153930, upload-time = "2026-10-26T19:27:06.159Z" },
{ url = "https://files.pythonhosted.org/packages/2b/5b/beeb6acb5a9e17311b4ebc36fe91ac4893200ddb290257fb072509a7c26b/onnxruntime-1.31.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:564d7b83fb3e94fb3d18629c6b339e9bce10966dd29a8442c433175aac3d60fd", size = 21432341, upload-time = "2026-10-26T19:27:09.131Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3d/a5b15c4ab9d67a384da5a2c10fb569e643076c28179fe1a265af7d8f1b87/onnxruntime-1.31.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d5f5fcc01f0c334d282a8991eab44e7752ad01c47b336d848ba43eb38084958", size = 23772582, upload-time = "2026-10-26T19:27:12.025Z" },
]
[[package]]
name = "opencc-python-reimplemented"
version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/6d/c6f37eed651dd6b752e50f80a93396cdaa42a6acc6ce05ad7452303ea511/opencc-python-reimplemented-0.1.7.tar.gz", hash = "sha256:4f777ea3461a25257a7b876112cfa90bb6acabc6dfb843bf4d11266e43579dee", size = 482566, upload-time = "2023-02-11T03:58:42.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/6b/055b7806f320cc8f2cdf23c5f70221c0dc1683fca9ffaf76dfc2ad4b91b6/opencc_python_reimplemented-0.1.7-py2.py3-none-any.whl", hash = "sha256:41b3b92943c7bed291f448e9c7fad4b577c8c2eae30fcfe5a74edf8818493aa6", size = 481813, upload-time = "2023-02-11T03:58:39.66Z" },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -468,6 +917,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "protobuf"
version = "7.36.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" },
{ url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" },
{ url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" },
{ url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -622,6 +1086,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.16.2"
@@ -681,6 +1200,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]
[[package]]
name = "tokenizers"
version = "0.23.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/7c/2cabb2174e772636683008f2c5621949b645da7d303c596589e84516a184/tokenizers-0.23.3.tar.gz", hash = "sha256:cded33237c77caeef62944d32aa9a7ef42bdce2b3497e18d137e072a8c4be438", size = 385286, upload-time = "2026-10-09T10:16:55.759Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/2e/4ce5b9716f26e526eff6b0502ebed4ea8d7161f03b3c77617c9f25528e97/tokenizers-0.23.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9d2b5c97daf61688c2ad1803ca851800feaba50fb68d5821779e9ea5880d968c", size = 3148800, upload-time = "2026-10-09T10:00:51.457Z" },
{ url = "https://files.pythonhosted.org/packages/b2/72/01e49f032bb346e5aaf06c10c74fe8aeec847173adbadd66eb7c53054bf2/tokenizers-0.23.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:68649e97d5b43c44c031d8d848874a6eecae8f8fe40ea989aa777a5a83aca716", size = 3101381, upload-time = "2026-10-09T10:00:54.063Z" },
{ url = "https://files.pythonhosted.org/packages/15/fc/ae987741829b1cd547668c4c94be732ae3eefd1d74344e64c3d2ca714acd/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec82e80e65a862275b97c3d90b7a523df8d9519ee48aeb4e9625b2cc909274e0", size = 3519944, upload-time = "2026-10-09T10:00:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/1c/da/cc8f6c030afaf05fbddc608158fbb761dca46913cbeba6b112e59fc82e2a/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c64a0713180ff16829d4e7f39a658b77ea11443af4e1aa46523692943c9b1414", size = 3397695, upload-time = "2026-10-09T10:00:57.444Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f1/256f78d1365fa2cd3ea6db716883d74667c8cbb6a21f15fa5b89a773cdc2/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddedfd4b3b4be6be24ff6ca645c4a37fddfd305f6f3e354c54cf10b715c48215", size = 3753125, upload-time = "2026-10-09T10:01:00.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/93/eee007ac2fcbf4ecfce7fbc354826cf3611f56bdb886f3e91b1f7dd06b8f/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a89614730d7b80940a5d2ed9320e1ec8add5a745c6151d8d05071b7215505b6", size = 4018598, upload-time = "2026-10-09T10:01:02.05Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/0c96c4739461fce9d8d865b416728081bf6230022d7163bd6244f35f4b31/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e88646b8580c5ad7f4361477f1298e9cc01771a1ee9aecfe32c47b8ff614cc38", size = 3602442, upload-time = "2026-10-09T10:01:03.77Z" },
{ url = "https://files.pythonhosted.org/packages/3a/40/6706b82693715581457c6d5423eaa7faae576bb0526c5738a57085eb4449/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:376851d22bcf9d650a5c3090bb83e6cf9e895fbf0595369fa4cd43c1f69b5f87", size = 3396193, upload-time = "2026-10-09T10:01:05.48Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0c/85946de40e25b7364b8f1bcf56def129069acd5bb364b7c86a32919e1a23/tokenizers-0.23.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:bf501c40b72d2d5c8623620210430e9cac1ce47a46e45b34107b70a1557d46b0", size = 3553483, upload-time = "2026-10-09T10:01:07.387Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6b/8d615d92cad1d511ca5ab188d1c7c167f0b3d295cc0d96207f9f82d486d8/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:114e2b55ed177179d59f4ab98200a4471e11e78f9e4b5a922d146740f96fcf52", size = 9972248, upload-time = "2026-10-09T10:01:09.437Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7d/a922e37ddd58d1b463bbc2ad08120c8f59c60b814cd353519a116b24f8ba/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d3407fb7b9c4d75dd68850ffd7180bc0a5d2dbaf0762d888e612f31fec3f9c6b", size = 9802957, upload-time = "2026-10-09T10:01:11.869Z" },
{ url = "https://files.pythonhosted.org/packages/4b/06/5d3f506a86ae0699a0e4ea05c05978f9aee169ef2c1d844e68c971cf8194/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:84513ef0aeb8bf8f4ea11a2e8a7ac163ec5288aa115e649a59b470ac5c3107df", size = 10145487, upload-time = "2026-10-09T10:01:14.268Z" },
{ url = "https://files.pythonhosted.org/packages/26/e5/065625317690ea3548d834dad81f48ea1fd32e4964610e658e195d7fe28e/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e05ab7baf7f47b406a95fea6f3b0a484b2ddcd9e1d14b68844c457eb755085a3", size = 10266026, upload-time = "2026-10-09T10:16:33.054Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/babede85d0d19f5e3deeef0063e01848141329934d3d77c31b5cab5ac2b4/tokenizers-0.23.3-cp310-abi3-win32.whl", hash = "sha256:1ebf28794e7e4954e20a7f70fbea410b2d1f0418f7dbbca97ca384fcfef38c25", size = 2588086, upload-time = "2026-10-09T10:16:35.686Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6c/24f074c9a0efb98e61b20aafe6b2641922d5db24e447d5d6daffd9e17555/tokenizers-0.23.3-cp310-abi3-win_amd64.whl", hash = "sha256:1f0823bb00c5fdc98e487354d54dd55a03848d61a1a0bf29a68c77f24f3b26c3", size = 2872101, upload-time = "2026-10-09T10:16:37.533Z" },
{ url = "https://files.pythonhosted.org/packages/53/77/a476b6f73a661c11d113a342d2326b91506cf2285f0995d1212a6bb2022d/tokenizers-0.23.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e48734d2de9260d86f03ab056d2cfeeff3869f61dbd49aaa15a2793b5f3458b", size = 2742580, upload-time = "2026-10-09T10:16:39.244Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/f66baaedd42414a3f583c47379dc350e3e1f858a690d2574fd85ae70681b/tokenizers-0.23.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:efa3d7318406b4d115dce61ad5061953f1f44b128e79c020ce4615d763e23b6e", size = 3154274, upload-time = "2026-10-09T10:16:40.876Z" },
{ url = "https://files.pythonhosted.org/packages/c6/41/8de8c63b2d935eee5a0f42011fb7b786ffafeab0b8eb6d17acb8af2293b7/tokenizers-0.23.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a4fbb3662f9f59d199d61338e54b4bcc11d07ebbb1aeb3540dacb2be9c521cb7", size = 3077805, upload-time = "2026-10-09T10:16:42.856Z" },
{ url = "https://files.pythonhosted.org/packages/e3/08/b1cbae8dc8fc7c91f992ac2d87a086e9b3f25a28814047ca16a82fe8c87b/tokenizers-0.23.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de536665495cb4b409d25bade41963f801aff4225c19a6b804b048f7d14e34c7", size = 3491678, upload-time = "2026-10-09T10:16:45.093Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0d/aac0cb2f3a1fdbef514145b4c5f2df4d05deeb1ee8f73ae641a1b4a62a85/tokenizers-0.23.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cc24bb457dd4a8af89c8fcb40074d570129ec473df2a866c276ee55db4749d7", size = 3367420, upload-time = "2026-10-09T10:16:47.112Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1d/41a697d0c193a320b243fbd68b2057b6eb2f01ecf80899e1a16e646ff699/tokenizers-0.23.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:acd5c57b4bd3e56e246e2731a3a3a6825a7a7d89b7e3b761ba80bc521710f04b", size = 9945973, upload-time = "2026-10-09T10:16:49.326Z" },
{ url = "https://files.pythonhosted.org/packages/37/e9/b56e619fcd583000a2b1254bb46af8dc6a174d3ba3329f454ad5a95a2be2/tokenizers-0.23.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82eb480f6f1c21cea3349dec32cf1a6384c6c1e775f00f83b0d51197bc013687", size = 10237491, upload-time = "2026-10-09T10:16:51.943Z" },
{ url = "https://files.pythonhosted.org/packages/6f/68/f58b3beb95f3b62816e91e5e768e684cd63e58f9cbece22036dae3b1c971/tokenizers-0.23.3-cp314-cp314t-win_amd64.whl", hash = "sha256:1554a6eed34d9d6a78d23360f4e06df8dffab1ae08c7e8488e0b3e3b36cc266f", size = 2847654, upload-time = "2026-10-09T10:16:54.166Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
@@ -735,6 +1288,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "tqdm"
version = "4.70.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
]
[[package]]
name = "truststore"
version = "0.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -750,6 +1324,7 @@ source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "keyring" },
{ name = "opencc-python-reimplemented" },
{ name = "platformdirs" },
{ name = "pyside6" },
{ name = "python-dotenv" },
@@ -764,11 +1339,16 @@ dev = [
{ name = "pytest-cov" },
{ name = "ruff" },
]
local-asr = [
{ name = "faster-whisper" },
]
[package.metadata]
requires-dist = [
{ name = "faster-whisper", marker = "extra == 'local-asr'", specifier = "==1.2.1" },
{ name = "httpx", specifier = ">=0.27.2,<1" },
{ name = "keyring", specifier = ">=25.5,<26" },
{ name = "opencc-python-reimplemented", specifier = "==0.1.7" },
{ name = "platformdirs", specifier = ">=4.3,<5" },
{ name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.11,<7" },
{ name = "pyside6", specifier = ">=6.8.2,<7" },
@@ -777,7 +1357,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
]
provides-extras = ["build", "dev"]
provides-extras = ["build", "dev", "local-asr"]
[[package]]
name = "zipp"
+8
View File
@@ -16,6 +16,14 @@ npm run build
## 宿主契约
截屏通过 `qtVideoBridge.captureScreenshot(region, callback)` 获取 QtWebEngine 实际渲染的视频区域,保留主画面裁切、本地小窗、镜像和可见控件。`region` 为包含 `x`、`y`、`width`、`height`、`viewportWidth`、`viewportHeight` 的 JSON,使用 CSS 像素;宿主处理窗口缩放与显示 DPI,并返回包含 `dataUrl` 或 `error` 的 JSON。
截取仅生成内存预览,医生点击“确认并上传”后才调用 `saveScreenshot`。该功能需要同步更新桌面宿主与前端资源。
预览显示后发送 `screenshot-preview` 通知(`visible: true/false`)。宿主将小窗临时放大到正常窗口尺寸,取消、提交或结束通话后恢复原位置和大小;先抓图再放大,避免改变截图内容。图片按预览区域等比例放大显示。
打开诊单后的通话小窗,标题栏“×”用于恢复原大窗,通话与录音继续。结束视频、正常大窗关闭、退出登录及程序退出仍走完整的挂断和录音收尾流程。
页面加载后会暴露:
```ts
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-CNqfE3p9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
<script type="module" crossorigin src="./assets/index-CTyzVIZB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-eotZHXBt.css">
</head>
<body>
<div id="app"></div>
+530 -54
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import { TUICallKit } from '@trtc/calls-uikit-vue'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { captureVideoStage } from './video-screenshot'
import ImAiPanel from './ImAiPanel.vue'
import VideoAiPanel from './VideoAiPanel.vue'
import { transcriptionStatus } from './transcription-status'
import { emptyVideoAiState } from './video-ai-types'
import type { VideoAiState } from './video-ai-types'
import { appendAiDraft, shouldClearSubmittedDraft } from './im-ai'
import type { ImAiAction, ImAiState } from './im-ai'
import { aiPanelBounds, clampAiPanelWidth, resizeAiPanelByKey } from './im-ai-layout'
interface ChatMessage {
id: string
@@ -64,9 +73,15 @@ const props = defineProps<{
callDisabledReason: Readonly<Ref<string>>
hasMoreMessages: Readonly<Ref<boolean>>
transcriptionState: Readonly<Ref<string>>
transcriptionError?: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
patientCase: Readonly<Ref<PatientCase>>
contextKey: Readonly<Ref<string>>
imAiState: Readonly<Ref<ImAiState>>
onRequestImAi: (action: ImAiAction) => void
onRegenerateImAi: (action: ImAiAction) => void
onCancelImAi: () => void
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
@@ -75,21 +90,295 @@ const props = defineProps<{
onHangup: () => Promise<void>
onOpenDiagnosis: () => Promise<void>
onSaveScreenshot: (dataUrl: string) => Promise<void>
liveAiState?: Readonly<Ref<VideoAiState>>
onRefreshVideoAi?: () => void | Promise<void>
onToggleVideoAi?: (enabled: boolean) => void | Promise<void>
onReviewVideoAi?: () => void | Promise<void>
onRetryTranscription?: () => void | Promise<void>
}>()
const draft = ref('')
let draftRevision = 0
const composerInput = ref<HTMLTextAreaElement | null>(null)
const aiToggle = ref<HTMLButtonElement | null>(null)
const aiOpen = ref(false)
const aiMedia = window.matchMedia('(max-width: 980px)')
const aiDrawer = ref(aiMedia.matches)
const updateAiDrawer = (event: MediaQueryListEvent) => { aiDrawer.value = event.matches }
aiMedia.addEventListener('change', updateAiDrawer)
onBeforeUnmount(() => aiMedia.removeEventListener('change', updateAiDrawer))
const aiViewportWidth = ref(window.innerWidth)
const aiWideWidth = ref(330)
const aiDrawerWidth = ref(360)
const aiResizeHandle = ref<HTMLElement | null>(null)
const aiResizing = ref(false)
const aiBounds = computed(() => aiPanelBounds(aiViewportWidth.value, aiDrawer.value))
const aiPanelWidth = computed(() => clampAiPanelWidth(
aiDrawer.value ? aiDrawerWidth.value : aiWideWidth.value, aiViewportWidth.value, aiDrawer.value,
))
let aiResizeDrag: { pointerId: number; startX: number; startWidth: number; drawer: boolean } | null = null
function setAiPanelWidth(width: number): void {
const next = clampAiPanelWidth(width, aiViewportWidth.value, aiDrawer.value)
if (aiDrawer.value) aiDrawerWidth.value = next
else aiWideWidth.value = next
}
function endAiResize(restore = false): void {
const drag = aiResizeDrag
aiResizeDrag = null
aiResizing.value = false
if (!drag) return
if (restore) {
if (drag.drawer) aiDrawerWidth.value = drag.startWidth
else aiWideWidth.value = drag.startWidth
}
if (aiResizeHandle.value?.hasPointerCapture(drag.pointerId)) {
aiResizeHandle.value.releasePointerCapture(drag.pointerId)
}
}
function startAiResize(event: PointerEvent): void {
if (event.button !== 0 || !event.isPrimary || aiResizeDrag) return
event.preventDefault()
aiResizeHandle.value?.focus()
try {
aiResizeHandle.value?.setPointerCapture(event.pointerId)
} catch { return }
aiResizeDrag = { pointerId: event.pointerId, startX: event.clientX, startWidth: aiPanelWidth.value, drawer: aiDrawer.value }
aiResizing.value = true
}
function moveAiResize(event: PointerEvent): void {
if (!aiResizeDrag || event.pointerId !== aiResizeDrag.pointerId) return
setAiPanelWidth(aiResizeDrag.startWidth + aiResizeDrag.startX - event.clientX)
}
function onAiResizeKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && aiResizeDrag) {
event.preventDefault()
event.stopPropagation()
endAiResize(true)
return
}
const width = resizeAiPanelByKey(event.key, aiPanelWidth.value, aiViewportWidth.value, aiDrawer.value, event.shiftKey)
if (width === null) return
event.preventDefault()
endAiResize()
setAiPanelWidth(width)
}
const updateAiViewport = () => {
endAiResize()
aiViewportWidth.value = window.innerWidth
}
const releaseAiResize = () => endAiResize()
window.addEventListener('resize', updateAiViewport)
window.addEventListener('blur', releaseAiResize)
watch([aiDrawer, aiOpen], () => endAiResize())
onBeforeUnmount(() => {
endAiResize()
window.removeEventListener('resize', updateAiViewport)
window.removeEventListener('blur', releaseAiResize)
})
const aiDraftNotice = ref('')
watch(draft, () => {
draftRevision += 1
if (!draft.value) aiDraftNotice.value = ''
}, { flush: 'sync' })
const actionBusy = ref(false)
const localError = ref('')
const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const videoStage = ref<HTMLElement | null>(null)
let screenshotGeneration = 0
const stickToMessageBottom = ref(true)
const transcriptList = ref<HTMLElement | null>(null)
type VideoRailTab = 'ai' | 'transcript' | 'patient'
const videoRailTabs: { key: VideoRailTab; label: string }[] = [
{ key: 'ai', label: 'AI辅助' },
{ key: 'transcript', label: '对话原文' },
{ key: 'patient', label: '患者资料' },
]
const videoRailTab = ref<VideoRailTab>('ai')
const videoRailOpen = ref(false)
const videoRailDrawer = computed(() => aiViewportWidth.value <= 700)
const videoRail = ref<HTMLElement | null>(null)
const videoRailToggle = ref<HTMLButtonElement | null>(null)
const videoRailClose = ref<HTMLButtonElement | null>(null)
const videoResizeHandle = ref<HTMLElement | null>(null)
const videoRailWideWidth = ref(380)
const videoRailDrawerWidth = ref(380)
const videoRailResizing = ref(false)
// The video stage needs 280px, while the shared helper reserves 480px for IM.
const videoSizingViewport = computed(() => aiViewportWidth.value + (videoRailDrawer.value ? 0 : 200))
const videoRailBounds = computed(() => aiPanelBounds(videoSizingViewport.value, videoRailDrawer.value))
const videoRailWidth = computed(() => clampAiPanelWidth(
videoRailDrawer.value ? videoRailDrawerWidth.value : videoRailWideWidth.value,
videoSizingViewport.value, videoRailDrawer.value,
))
let videoResizeDrag: { pointerId: number; startX: number; startWidth: number; drawer: boolean } | null = null
const videoAiActionBusy = ref(false)
const videoAiActionError = ref('')
const videoAiFallback = emptyVideoAiState()
const videoAiState = computed(() => {
const state = props.liveAiState?.value || videoAiFallback
return {
...state,
transcriptionStatus: transcriptionStatus(props.transcriptionState.value, state.transcriptionStatus, props.phase.value === 'ended'),
error: props.transcriptionError?.value || videoAiActionError.value || state.error,
}
})
function setVideoRailWidth(width: number): void {
const next = clampAiPanelWidth(width, videoSizingViewport.value, videoRailDrawer.value)
if (videoRailDrawer.value) videoRailDrawerWidth.value = next
else videoRailWideWidth.value = next
}
function endVideoResize(restore = false): void {
const drag = videoResizeDrag
videoResizeDrag = null
videoRailResizing.value = false
if (!drag) return
if (restore) {
if (drag.drawer) videoRailDrawerWidth.value = drag.startWidth
else videoRailWideWidth.value = drag.startWidth
}
if (videoResizeHandle.value?.hasPointerCapture(drag.pointerId)) videoResizeHandle.value.releasePointerCapture(drag.pointerId)
}
function startVideoResize(event: PointerEvent): void {
if (event.button !== 0 || !event.isPrimary || videoResizeDrag) return
event.preventDefault()
videoResizeHandle.value?.focus()
try { videoResizeHandle.value?.setPointerCapture(event.pointerId) } catch { return }
videoResizeDrag = { pointerId: event.pointerId, startX: event.clientX, startWidth: videoRailWidth.value, drawer: videoRailDrawer.value }
videoRailResizing.value = true
}
function moveVideoResize(event: PointerEvent): void {
if (!videoResizeDrag || event.pointerId !== videoResizeDrag.pointerId) return
setVideoRailWidth(videoResizeDrag.startWidth + videoResizeDrag.startX - event.clientX)
}
function onVideoResizeKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && videoResizeDrag) {
event.preventDefault()
event.stopPropagation()
endVideoResize(true)
return
}
const width = resizeAiPanelByKey(event.key, videoRailWidth.value, videoSizingViewport.value, videoRailDrawer.value, event.shiftKey)
if (width === null) return
event.preventDefault()
endVideoResize()
setVideoRailWidth(width)
}
function openVideoRail(): void {
videoRailTab.value = 'ai'
videoRailOpen.value = true
}
async function closeVideoRail(): Promise<void> {
videoRailOpen.value = false
await nextTick()
videoRailToggle.value?.focus()
}
function onVideoRailKeydown(event: KeyboardEvent): void {
if (!videoRailDrawer.value || !videoRailOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
void closeVideoRail()
return
}
if (event.key !== 'Tab') return
const controls = Array.from(videoRail.value?.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), [tabindex="0"]') || [])
.filter((element) => element.getClientRects().length)
const first = controls[0]
const last = controls.at(-1)
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
function onVideoTabKeydown(event: KeyboardEvent): void {
const current = videoRailTabs.findIndex((tab) => tab.key === videoRailTab.value)
const index = event.key === 'ArrowRight' ? (current + 1) % videoRailTabs.length
: event.key === 'ArrowLeft' ? (current + videoRailTabs.length - 1) % videoRailTabs.length
: event.key === 'Home' ? 0 : event.key === 'End' ? videoRailTabs.length - 1 : -1
if (index < 0) return
event.preventDefault()
videoRailTab.value = videoRailTabs[index].key
void nextTick(() => videoRail.value?.querySelector<HTMLButtonElement>(`#video-tab-${videoRailTab.value}`)?.focus())
}
async function runVideoAiAction(action?: () => void | Promise<void>): Promise<void> {
if (!action || videoAiActionBusy.value) return
videoAiActionBusy.value = true
videoAiActionError.value = ''
try { await action() } catch (error) {
videoAiActionError.value = error instanceof Error ? error.message : 'AI 辅助操作失败,请重试。'
} finally { videoAiActionBusy.value = false }
}
watch([videoRailOpen, videoRailDrawer], async () => {
endVideoResize()
if (videoRailOpen.value && videoRailDrawer.value) {
await nextTick()
videoRailClose.value?.focus()
}
})
watch(aiViewportWidth, () => endVideoResize())
watch(videoRailTab, async (tab) => {
if (tab !== 'transcript') return
await nextTick()
if (transcriptList.value) transcriptList.value.scrollTop = transcriptList.value.scrollHeight
})
const releaseVideoResize = () => endVideoResize()
window.addEventListener('blur', releaseVideoResize)
onBeforeUnmount(() => {
endVideoResize()
window.removeEventListener('blur', releaseVideoResize)
})
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
const postCallReview = ref<HTMLDialogElement | null>(null)
const postCallReviewTrigger = ref<HTMLButtonElement | null>(null)
const canOpenReview = computed(() => !isCalling.value && ['idle', 'error'].includes(props.transcriptionState.value)
&& ['completed', 'partial'].includes(videoAiState.value.transcriptionStatus))
watch(() => props.contextKey.value, () => postCallReview.value?.close())
watch(isCalling, (calling) => { if (calling) postCallReview.value?.close() })
function onReviewKeydown(event: KeyboardEvent): void {
if (event.key !== 'Tab') return
const controls = Array.from(postCallReview.value?.querySelectorAll<HTMLElement>('button:not(:disabled), [tabindex="0"]') || [])
.filter((element) => element.getClientRects().length)
const first = controls[0]
const last = controls.at(-1)
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
const videoVisible = computed(() => !isChat.value || isCalling.value)
const canCapture = computed(() => props.phase.value === 'connected')
watch([isCalling, () => props.contextKey.value], () => {
videoRailOpen.value = false
videoRailTab.value = 'ai'
videoAiActionError.value = ''
})
const chatConnectionText = computed(() => {
if (!props.chatReady.value) return props.statusText.value
if (['starting', 'dialing', 'connected', 'ended', 'error'].includes(props.phase.value)) {
@@ -105,19 +394,27 @@ const transcriptionFailed = computed(() => (
props.transcriptionState.value === 'error'
|| props.localRecordingState.value === 'error'
))
const cloudTranscriptStatus = computed(() => videoAiState.value.transcriptionStatus)
const cloudTranscriptActive = computed(() => ['recording', 'running'].includes(cloudTranscriptStatus.value))
const cloudTranscriptFailed = computed(() => ['error', 'failed'].includes(cloudTranscriptStatus.value))
const cloudTranscriptText = computed(() => {
if (cloudTranscriptActive.value) return '腾讯云实时转写中'
if (cloudTranscriptFailed.value) return '腾讯云实时转写失败'
if (cloudTranscriptStatus.value === 'completed') return '腾讯云实时转写已完成'
if (cloudTranscriptStatus.value === 'partial') return '腾讯云实时转写不完整'
if (['starting', 'initializing'].includes(cloudTranscriptStatus.value)) return '腾讯云实时转写启动中'
if (['stopping', 'finalizing'].includes(cloudTranscriptStatus.value)) return '腾讯云实时转写收尾中'
if (cloudTranscriptStatus.value === 'unavailable') return '腾讯云实时转写未就绪'
return '等待腾讯云实时转写'
})
const transcriptionStatusText = computed(() => {
const localState = props.localRecordingState.value
const textState = props.transcriptionState.value
if (localState === 'uploading') return '正在上传本机录音到 COS…'
if (localState === 'stopping' || textState === 'stopping') return '正在保存录音与转写文字…'
if (localState === 'starting' || textState === 'starting') return '自动录音与转写启动中…'
if (localState === 'recording' && textState === 'recording') return '本机录音与实时转写中'
if (localState === 'recording') return '本机录音中,实时转写未就绪'
if (textState === 'recording') return '实时转写中,本机录音未就绪'
if (localState === 'error' && textState === 'error') return '本机录音与实时转写均失败'
if (localState === 'error') return '本机录音失败,实时转写仍在运行'
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
return '录音与转写已结束'
const recording = localState === 'recording' ? '本机录音中'
: localState === 'stopping' ? '本机录音保存中'
: localState === 'starting' ? '本机录音启动中'
: localState === 'error' ? '本机录音失败' : '本机录音未运行'
return `${recording} · ${cloudTranscriptText.value}`
})
const patientMetaText = computed(() => {
const detail = props.patientCase.value
@@ -218,14 +515,57 @@ async function runAction(action: () => Promise<void>): Promise<void> {
}
async function sendText(): Promise<void> {
if (!props.chatReady.value || actionBusy.value) return
const content = draft.value.trim()
if (!content) return
const submitted = { text: draft.value, contextKey: props.contextKey.value, revision: draftRevision }
await runAction(async () => {
await props.onSendText(content)
draft.value = ''
if (shouldClearSubmittedDraft(
{ text: draft.value, contextKey: props.contextKey.value, revision: draftRevision }, submitted,
)) draft.value = ''
})
}
function onComposerKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return
if (event.isComposing || event.keyCode === 229) return
event.preventDefault()
void sendText()
}
async function closeAi(): Promise<void> {
aiOpen.value = false
await nextTick()
aiToggle.value?.focus()
}
async function applyAiDraft(): Promise<void> {
aiDraftNotice.value = ''
try {
draft.value = appendAiDraft(draft.value, props.imAiState.value, props.contextKey.value)
localError.value = ''
aiDraftNotice.value = '已填入草稿,请复核编辑后手动发送。'
aiOpen.value = false
await nextTick()
composerInput.value?.focus()
composerInput.value?.setSelectionRange(draft.value.length, draft.value.length)
} catch (error) {
localError.value = error instanceof Error ? error.message : '无法填入草稿,请重试。'
// Keep the draft intact and expose the composer feedback in compact windows.
aiOpen.value = false
await nextTick()
composerInput.value?.focus()
}
}
watch(() => props.contextKey.value, () => {
draft.value = ''
localError.value = ''
aiDraftNotice.value = ''
aiOpen.value = false
}, { flush: 'sync' })
async function selectAttachment(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -234,29 +574,11 @@ async function selectAttachment(event: Event): Promise<void> {
await runAction(() => props.onSendAttachment(file))
}
function findRemoteVideo(): HTMLVideoElement | null {
const videos = Array.from(document.querySelectorAll('video'))
.filter((video) => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0)
if (!videos.length) return null
return videos.sort((left, right) => {
const leftArea = left.getBoundingClientRect().width * left.getBoundingClientRect().height
const rightArea = right.getBoundingClientRect().width * right.getBoundingClientRect().height
return rightArea - leftArea
})[0]
}
async function captureScreenshot(): Promise<void> {
await runAction(async () => {
const video = findRemoteVideo()
if (!video) throw new Error('尚未检测到患者视频画面')
const scale = Math.min(1, 1920 / video.videoWidth)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(video.videoWidth * scale))
canvas.height = Math.max(1, Math.round(video.videoHeight * scale))
const context = canvas.getContext('2d')
if (!context) throw new Error('无法创建截图画布')
context.drawImage(video, 0, 0, canvas.width, canvas.height)
screenshotPreview.value = canvas.toDataURL('image/jpeg', 0.9)
const generation = screenshotGeneration
const preview = await captureVideoStage(videoStage.value)
if (canCapture.value && generation === screenshotGeneration) screenshotPreview.value = preview
})
}
@@ -265,6 +587,15 @@ function discardScreenshot(): void {
screenshotPreview.value = ''
}
function notifyScreenshotPreview(visible: boolean): void {
window.qtVideoBridge?.notify?.(JSON.stringify({
source: 'doctor-call', event: 'screenshot-preview', visible,
}))
}
watch(() => Boolean(screenshotPreview.value), notifyScreenshotPreview, { flush: 'post' })
onBeforeUnmount(() => notifyScreenshotPreview(false))
async function confirmScreenshot(): Promise<void> {
if (!screenshotPreview.value || actionBusy.value) return
actionBusy.value = true
@@ -280,16 +611,18 @@ async function confirmScreenshot(): Promise<void> {
}
watch(
() => props.phase.value,
(value) => {
if (value !== 'connected') screenshotPreview.value = ''
() => [props.phase.value, props.patientCase.value.diagnosisId, props.patientName.value],
() => {
screenshotGeneration += 1
screenshotPreview.value = ''
},
{ flush: 'sync' },
)
</script>
<template>
<main class="consultation-shell" :class="{ 'consultation-shell--video-only': !isChat }">
<section v-if="isChat" class="chat-panel">
<main class="consultation-shell" :class="{ 'consultation-shell--video-only': !isChat, 'consultation-shell--with-ai': isChat, 'consultation-shell--resizing': aiResizing }" :style="{ '--im-ai-panel-width': `${aiPanelWidth}px` }">
<section v-if="isChat" class="chat-panel" :inert="aiDrawer && aiOpen ? true : undefined">
<header class="chat-header">
<div class="patient-avatar" aria-hidden="true">{{ patientName.value.slice(0, 1) }}</div>
<div class="chat-heading">
@@ -318,6 +651,7 @@ watch(
<span aria-hidden="true">▣</span>
{{ isCalling ? '视频通话中' : '发起视频' }}
</button>
<button v-if="canOpenReview" ref="postCallReviewTrigger" class="secondary-action" type="button" @click="postCallReview?.showModal()">本次通话复盘</button>
</header>
<div ref="messageList" class="message-list" aria-live="polite" @scroll="handleMessageScroll">
@@ -392,21 +726,29 @@ watch(
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
{{ localError || notice.value }}
</div>
<div v-if="aiDraftNotice && !localError" class="inline-notice" role="status">{{ aiDraftNotice }}</div>
<div class="composer-toolbar">
<button type="button" title="发送图片或文件" :disabled="!chatReady.value || actionBusy" @click="fileInput?.click()">
+ 图片/文件
</button>
<div class="composer-tools">
<button type="button" title="发送图片或文件" :disabled="!chatReady.value || actionBusy" @click="fileInput?.click()">
+ 图片/文件
</button>
<button ref="aiToggle" class="im-ai-toggle" type="button" :aria-expanded="aiOpen" aria-controls="im-ai-panel" @click="aiOpen = true">
AI 助手
<span v-if="imAiState.value.pendingAction || imAiState.value.status === 'loading' || imAiState.value.status === 'streaming'">· 生成中</span>
</button>
</div>
<input ref="fileInput" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" hidden @change="selectAttachment">
<span>Enter 发送,Shift + Enter 换行</span>
</div>
<div class="composer-row">
<textarea
ref="composerInput"
v-model="draft"
rows="3"
maxlength="3000"
placeholder="输入问诊消息…"
:disabled="!chatReady.value"
@keydown.enter.exact.prevent="sendText"
@keydown="onComposerKeydown"
/>
<button
class="send-button"
@@ -420,15 +762,73 @@ watch(
</footer>
</section>
<ImAiPanel
v-if="isChat"
:state="imAiState.value"
:open="aiOpen"
:drawer="aiDrawer"
:draft-has-text="Boolean(draft)"
:can-open-diagnosis="Boolean(patientCase.value.diagnosisId) && !actionBusy"
@request="onRequestImAi"
@regenerate="onRegenerateImAi"
@cancel="onCancelImAi"
@apply="applyAiDraft"
@close="closeAi"
@open-diagnosis="runAction(onOpenDiagnosis)"
>
<template #resize>
<div
v-if="isChat && (!aiDrawer || aiOpen)"
ref="aiResizeHandle"
class="im-ai-resizer"
:class="{ 'im-ai-resizer--drawer': aiDrawer }"
role="separator"
aria-label="左右拖动调整 AI 助手宽度"
aria-orientation="vertical"
aria-controls="im-ai-panel"
:aria-valuemin="aiBounds.minimum"
:aria-valuemax="aiBounds.maximum"
:aria-valuenow="aiPanelWidth"
tabindex="0"
title="左右拖动调整宽度;方向键微调,双击恢复"
@pointerdown="startAiResize"
@pointermove="moveAiResize"
@pointerup="endAiResize()"
@pointercancel="endAiResize(true)"
@lostpointercapture="endAiResize()"
@keydown="onAiResizeKeydown"
@dblclick="setAiPanelWidth(aiBounds.initial)"
><span aria-hidden="true" /></div>
</template>
</ImAiPanel>
<dialog v-if="isChat" ref="postCallReview" class="video-review-dialog" aria-labelledby="video-review-title" @keydown="onReviewKeydown" @close="postCallReviewTrigger?.focus()" @click="(event) => { if (event.target === postCallReview) postCallReview?.close() }">
<header>
<div><h2 id="video-review-title">本次通话复盘</h2><p>对照原话查看要点与待核实信息</p></div>
<button type="button" autofocus aria-label="关闭通话复盘" @click="postCallReview?.close()">×</button>
</header>
<VideoAiPanel
:state="videoAiState"
:can-refresh="Boolean(onRefreshVideoAi)"
:can-toggle="Boolean(onToggleVideoAi)"
:can-review="Boolean(onReviewVideoAi)"
@refresh="runVideoAiAction(onRefreshVideoAi)"
@toggle="(enabled) => runVideoAiAction(() => onToggleVideoAi?.(enabled))"
@review="runVideoAiAction(onReviewVideoAi)"
/>
</dialog>
<section
v-if="videoVisible"
class="video-layer"
:class="{
'video-layer--overlay': isChat,
'video-layer--with-rail': isCalling,
'video-layer--resizing': videoRailResizing,
}"
:style="{ '--video-rail-width': `${videoRailWidth}px` }"
>
<div class="video-stage">
<div ref="videoStage" class="video-stage" :inert="videoRailDrawer && videoRailOpen ? true : undefined">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
@@ -449,6 +849,16 @@ watch(
{{ statusText.value }}
</div>
<button
v-if="isCalling"
ref="videoRailToggle"
class="video-ai-toggle"
type="button"
:aria-expanded="videoRailOpen"
aria-controls="video-consultation-rail"
@click="openVideoRail"
>AI辅助<span v-if="videoAiState.busy" aria-label="正在更新"> · 更新中</span></button>
<div v-if="isCalling" class="video-actions">
<div
v-if="canCapture"
@@ -481,7 +891,43 @@ watch(
</div>
</div>
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
<div v-if="isCalling && videoRailDrawer && videoRailOpen" class="video-rail-backdrop" aria-hidden="true" @click="closeVideoRail" />
<aside
v-if="isCalling"
id="video-consultation-rail"
ref="videoRail"
class="consultation-rail consultation-rail--ai"
:class="{ 'consultation-rail--open': videoRailOpen }"
:role="videoRailDrawer ? 'dialog' : 'complementary'"
:aria-modal="videoRailDrawer && videoRailOpen ? true : undefined"
aria-label="AI辅助、对话原文与患者资料"
@keydown="onVideoRailKeydown"
>
<div
v-if="!videoRailDrawer || videoRailOpen"
ref="videoResizeHandle"
class="video-rail-resizer"
role="separator"
aria-label="左右拖动调整视频辅助栏宽度"
aria-orientation="vertical"
aria-controls="video-consultation-rail"
:aria-valuemin="videoRailBounds.minimum"
:aria-valuemax="videoRailBounds.maximum"
:aria-valuenow="videoRailWidth"
tabindex="0"
title="左右拖动调整宽度;方向键微调,双击恢复"
@pointerdown="startVideoResize"
@pointermove="moveVideoResize"
@pointerup="endVideoResize()"
@pointercancel="endVideoResize(true)"
@lostpointercapture="endVideoResize()"
@keydown="onVideoResizeKeydown"
@dblclick="setVideoRailWidth(videoRailBounds.initial)"
><span aria-hidden="true" /></div>
<div v-if="videoRailDrawer" class="video-rail-drawer-heading">
<strong>视频问诊辅助</strong>
<button ref="videoRailClose" type="button" aria-label="关闭 AI 辅助,返回视频" @click="closeVideoRail">×</button>
</div>
<header class="consultation-rail__header">
<div class="consultation-rail__avatar" aria-hidden="true">
{{ (patientCase.value.name || patientName.value).slice(0, 1) }}
@@ -505,11 +951,37 @@ watch(
</div>
</header>
<section class="case-panel" aria-labelledby="patient-case-title">
<nav class="video-rail-tabs" role="tablist" aria-label="视频问诊资料">
<button
v-for="tab in videoRailTabs"
:id="`video-tab-${tab.key}`"
:key="tab.key"
type="button"
role="tab"
:aria-selected="videoRailTab === tab.key"
:aria-controls="`video-tabpanel-${tab.key}`"
:tabindex="videoRailTab === tab.key ? 0 : -1"
@click="videoRailTab = tab.key"
@keydown="onVideoTabKeydown"
>{{ tab.label }}</button>
</nav>
<div v-show="videoRailTab === 'ai'" id="video-tabpanel-ai" class="video-rail-tabpanel" role="tabpanel" aria-labelledby="video-tab-ai">
<VideoAiPanel
:state="videoAiState"
:can-refresh="Boolean(onRefreshVideoAi) && !videoAiActionBusy"
:can-toggle="Boolean(onToggleVideoAi) && !videoAiActionBusy"
:can-review="Boolean(onReviewVideoAi) && !videoAiActionBusy"
@refresh="runVideoAiAction(onRefreshVideoAi)"
@toggle="(enabled) => runVideoAiAction(() => onToggleVideoAi?.(enabled))"
@review="runVideoAiAction(onReviewVideoAi)"
/>
</div>
<section v-show="videoRailTab === 'patient'" id="video-tabpanel-patient" class="case-panel video-rail-tabpanel" role="tabpanel" aria-labelledby="video-tab-patient">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">01</span>
<h2 id="patient-case-title">患者病例</h2>
<h2 id="patient-case-title">患者资料</h2>
</div>
<span>{{ patientVisitText }}</span>
</header>
@@ -532,14 +1004,13 @@ watch(
</div>
</section>
<section class="transcript-panel" aria-labelledby="live-transcript-title">
<section v-show="videoRailTab === 'transcript'" id="video-tabpanel-transcript" class="transcript-panel video-rail-tabpanel" role="tabpanel" aria-labelledby="video-tab-transcript">
<header class="rail-section-heading">
<div>
<span class="rail-section-heading__index">02</span>
<h2 id="live-transcript-title">实时对话</h2>
<h2 id="live-transcript-title">腾讯云实时转写</h2>
</div>
<span class="transcript-state" :class="{ 'transcript-state--active': transcriptionActive }">
{{ transcriptionActive ? '转写中' : '等待语音' }}
<span class="transcript-state" :class="{ 'transcript-state--active': cloudTranscriptActive, 'transcript-state--error': cloudTranscriptFailed }">
{{ cloudTranscriptText }}
</span>
</header>
<div
@@ -547,11 +1018,16 @@ watch(
class="live-captions"
role="log"
aria-live="polite"
aria-label="实时语音字幕"
aria-label="腾讯云实时转写的对话原文"
>
<div v-if="cloudTranscriptFailed || cloudTranscriptStatus === 'unavailable'" class="video-transcript-notice" :class="{ 'video-transcript-notice--error': cloudTranscriptFailed }" role="status">
<strong>{{ cloudTranscriptText }}</strong>
<p>{{ videoAiState.error || videoAiState.message || '当前无法提供新的转写文字,已保存的原文仍可查看。' }}</p>
<button v-if="onRetryTranscription" type="button" :disabled="videoAiActionBusy || !isCalling" @click="runVideoAiAction(onRetryTranscription)">重试腾讯云实时转写</button>
</div>
<div v-if="!liveCaptions.value.length" class="rail-empty">
<strong>对话文字会显示在这里</strong>
<span>接通后自动识别医生与患者语音,并保留本次通话内容。</span>
<span>腾讯云完成转写并保存后,以简体中文显示带说话人和时间的原文。</span>
</div>
<article
v-for="caption in liveCaptions.value"
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import type { ImAiAction, ImAiState } from './im-ai'
import { renderImAiMarkdown } from './im-ai-markdown'
const props = defineProps<{
state: ImAiState
open: boolean
drawer: boolean
draftHasText: boolean
canOpenDiagnosis: boolean
}>()
const emit = defineEmits<{
request: [action: ImAiAction]
regenerate: [action: ImAiAction]
cancel: []
apply: []
close: []
openDiagnosis: []
}>()
const panel = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
const resultBody = ref<HTMLElement | null>(null)
const scrollPositions = new Map<ImAiAction, number>()
const actions: { key: ImAiAction; label: string }[] = [
{ key: 'summary', label: '总结病情' },
{ key: 'followup', label: '建议追问' },
{ key: 'reply', label: '起草回复' },
]
const busy = computed(() => ['loading', 'streaming'].includes(props.state.status))
const pendingAction = computed(() => props.state.pendingAction || (busy.value ? props.state.action : null))
const generating = computed(() => Boolean(pendingAction.value))
const completedActions = computed(() => props.state.completedActions || [])
const resultHtml = computed(() => renderImAiMarkdown(props.state.text))
const title = computed(() => actions.find((action) => action.key === props.state.action)?.label || '问诊辅助')
const canApply = computed(() => props.state.status === 'success' && props.state.action !== 'summary' && Boolean(props.state.text.trim()))
const statusText = computed(() => {
if (props.state.message) return props.state.message
if (props.state.status === 'streaming') return '正在生成,请等待完整内容后复核。'
if (props.state.status === 'success') return '内容已生成,请结合患者实际情况复核。'
return ''
})
watch([() => props.state.contextKey, () => props.state.action] as const, ([context, action], [previousContext, previous]) => {
if (context !== previousContext) scrollPositions.clear()
else if (previous && resultBody.value) scrollPositions.set(previous, resultBody.value.scrollTop)
// Restore after rendering the newly selected result, retaining streaming position.
void nextTick(() => {
if (props.state.contextKey === context && props.state.action === action && resultBody.value) {
resultBody.value.scrollTop = action ? scrollPositions.get(action) || 0 : 0
}
})
}, { flush: 'pre' })
function regenerate(): void {
if (!props.state.action || generating.value) return
scrollPositions.delete(props.state.action)
if (resultBody.value) resultBody.value.scrollTop = 0
emit('regenerate', props.state.action)
}
watch(() => [props.open, props.drawer], async () => {
if (props.open && props.drawer) {
await nextTick()
closeButton.value?.focus()
}
})
function onKeydown(event: KeyboardEvent): void {
if (!props.drawer || !props.open) return
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
}
if (event.key !== 'Tab') return
const focusable = Array.from(panel.value?.querySelectorAll<HTMLElement>('button:not(:disabled), [tabindex="0"]') || [])
.filter((element) => element.getClientRects().length)
const first = focusable[0]
const last = focusable.at(-1)
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
</script>
<template>
<div v-if="drawer && open" class="im-ai-backdrop" aria-hidden="true" @click="emit('close')" />
<aside
id="im-ai-panel"
ref="panel"
class="im-ai-panel"
:class="{ 'im-ai-panel--open': open }"
:role="drawer ? 'dialog' : 'complementary'"
:aria-modal="drawer && open ? true : undefined"
aria-labelledby="im-ai-title"
@keydown="onKeydown"
>
<slot name="resize" />
<header class="im-ai-header">
<span class="im-ai-mark" aria-hidden="true">AI</span>
<div>
<h2 id="im-ai-title">AI 问诊助手</h2>
<p>辅助整理,由医生判断</p>
</div>
<button
class="open-diagnosis-button im-ai-edit-diagnosis"
type="button"
:disabled="!canOpenDiagnosis"
title="打开当前患者的诊单编辑界面"
@click="emit('openDiagnosis')"
>编辑诊单</button>
<button ref="closeButton" class="im-ai-close" type="button" aria-label="关闭 AI 助手,返回聊天" @click="emit('close')">×</button>
</header>
<div class="im-ai-tools" aria-label="选择 AI 辅助任务">
<button
v-for="action in actions"
:key="action.key"
type="button"
:data-ai-action="action.key"
:class="{ 'im-ai-tool--selected': state.action === action.key }"
:aria-pressed="state.action === action.key"
:title="completedActions.includes(action.key) ? '已生成,点击查看' : action.label"
:disabled="!state.available || (generating && pendingAction !== action.key && !completedActions.includes(action.key))"
@click="emit('request', action.key)"
>
{{ action.label }}
<span v-if="completedActions.includes(action.key)" class="im-ai-tool__saved" aria-label="已有内容">·</span>
</button>
</div>
<div ref="resultBody" class="im-ai-body" tabindex="0" aria-label="AI 结果">
<div v-if="state.status === 'unavailable'" class="im-ai-empty">
<strong>AI 助手暂不可用</strong>
<p>{{ state.message }}</p>
</div>
<div v-else-if="state.status === 'idle'" class="im-ai-empty">
<span class="im-ai-empty__line" aria-hidden="true" />
<strong>让沟通中的信息更清楚</strong>
<p>总结已知病情、梳理待追问的问题,或起草一段回复。</p>
<p class="im-ai-source">每次生成前先同步聊天,结合本次诊单资料整理。已有结果可直接切换查看;需要更新时点击“重新生成”。</p>
</div>
<template v-else>
<div class="im-ai-result-heading">
<h3>{{ title }}</h3>
<span v-if="state.modelLabel">{{ state.modelLabel }}</span>
</div>
<p
v-if="statusText"
class="im-ai-status"
:class="{ 'im-ai-status--error': state.status === 'error' }"
:role="state.status === 'error' ? 'alert' : 'status'"
aria-live="polite"
>{{ statusText }}</p>
<div v-if="busy && !state.text" class="im-ai-loading" aria-hidden="true"><span /><span /><span /></div>
<!-- Only the escaped, inert Markdown renderer output is accepted here. -->
<div v-if="state.text" class="im-ai-text" :aria-busy="busy" v-html="resultHtml" />
<ul v-if="state.warnings.length" class="im-ai-warnings" aria-label="临床复核提示">
<li v-for="(warning, index) in state.warnings" :key="index">{{ warning }}</li>
</ul>
</template>
</div>
<footer class="im-ai-footer">
<div class="im-ai-result-actions">
<button v-if="busy" class="im-ai-stop" type="button" @click="emit('cancel')">停止接收</button>
<button
v-else-if="state.action && state.available"
class="im-ai-retry"
type="button"
title="结合最新聊天重新生成当前内容"
:disabled="generating"
@click="regenerate"
>{{ state.status === 'error' ? '重试' : '重新生成' }}</button>
<button v-if="canApply" class="im-ai-apply" type="button" @click="emit('apply')">
{{ draftHasText ? '追加到草稿' : '填入输入框' }}
</button>
</div>
<p>AI 内容需由医生复核。填入后可编辑,点击发送才会发给患者。</p>
</footer>
</aside>
</template>
+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { renderImAiMarkdown } from './im-ai-markdown'
import type { VideoAiState } from './video-ai-types'
const props = defineProps<{
state: VideoAiState
canRefresh: boolean
canToggle: boolean
canReview: boolean
}>()
const emit = defineEmits<{
refresh: []
toggle: [enabled: boolean]
review: []
}>()
const selectedSourceId = ref('')
const panelId = `video-ai-${useId()}`
const selectedSource = computed(() => props.state.sources.find((source) => source.id === selectedSourceId.value))
const sections = computed(() => [
{ key: 'questions', title: '下一步追问', empty: props.state.busy ? '追问建议仍在整理。' : '本轮未列出补充追问。', items: props.state.questions },
{ key: 'summary', title: '患者关键信息', empty: props.state.busy ? '患者信息仍在整理。' : '本轮暂无可引用的患者陈述。', items: props.state.summary },
{ key: 'checks', title: '待核实事项', empty: props.state.busy ? '待核实事项仍在整理。' : '本轮未列出待核实事项。', items: props.state.checks },
...(props.state.review.length ? [{ key: 'review', title: '问诊复盘', empty: '', items: props.state.review }] : []),
])
const hasResult = computed(() => sections.value.some((section) => section.items.length))
const hasError = computed(() => Boolean(props.state.error) || props.state.phase === 'error')
const resultScope = computed(() => {
if (!props.state.revision) return ''
if (props.state.analysisScope === 'current_call_only') return '当前内容仅依据本次通话文字,不含历史病历和图片。'
if (props.state.analysisScope === 'authorized_case') return '当前内容结合本次对话与授权病历。'
return ''
})
const pendingSegments = computed(() => Math.floor(nonnegativeNumber(props.state.pendingSegments)))
const clockNow = ref(Date.now())
const elapsedStartedAt = ref(clockNow.value - nonnegativeNumber(props.state.elapsedMs))
const elapsedMs = computed(() => Math.max(nonnegativeNumber(props.state.elapsedMs), clockNow.value - elapsedStartedAt.value))
let clockTimer: ReturnType<typeof setInterval> | undefined
// The host supplies measured elapsed time; the local clock only advances that
// duration between updates. It never predicts a completion percentage or ETA.
watch(() => [props.state.busy, props.state.elapsedMs] as const, ([busy, elapsed]) => {
clockNow.value = Date.now()
if (busy) elapsedStartedAt.value = clockNow.value - nonnegativeNumber(elapsed)
})
onMounted(() => {
clockTimer = setInterval(() => {
if (props.state.busy) clockNow.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => { if (clockTimer !== undefined) clearInterval(clockTimer) })
const analysisLabel = computed(() => {
if (props.state.busy) return hasResult.value ? '正在更新建议' : '正在整理对话'
if (hasError.value) return hasResult.value ? '本轮更新未完成 · 已保留当前内容' : '本轮未完成'
if (!props.state.available) return '辅助服务未就绪'
if (!props.state.enabled) return '自动辅助已暂停'
if (pendingSegments.value) return '收到新对话,等待更新'
return hasResult.value ? '本次对话已整理' : '自动辅助已开启'
})
const statusDetail = computed(() => {
if (pendingSegments.value) return `${pendingSegments.value} 段新对话${props.state.enabled ? '待处理' : '尚未分析'}`
if (props.state.busy && hasResult.value) return '当前内容可先查看,新建议会陆续显示。'
if (!props.state.busy && hasResult.value && nonnegativeNumber(props.state.lastDurationMs)) {
if (typeof props.state.firstResultMs === 'number' && Number.isFinite(props.state.firstResultMs) && props.state.firstResultMs >= 0) {
return `首条结果 ${durationLabel(props.state.firstResultMs)} · 本轮完成 ${durationLabel(props.state.lastDurationMs)}`
}
return `上次整理用时 ${durationLabel(props.state.lastDurationMs)}`
}
return ''
})
const emptyState = computed(() => {
if (props.state.busy) return { title: '正在生成下一步追问', description: '建议会随生成进度陆续显示。' }
if (hasError.value) return { title: '本轮尚无可用建议', description: props.canRefresh && props.state.available ? '可点击下方“重新生成”重试。' : '服务恢复后,可重新生成建议。' }
if (!props.state.available) return { title: '等待辅助服务连接', description: props.state.message || '连接就绪后,将根据本次完整转写生成建议。' }
if (!props.state.enabled) return { title: '需要时再继续', description: '可恢复自动辅助,或手动重新生成。' }
if (['completed', 'partial'].includes(props.state.transcriptionStatus)) return { title: '本次转写已结束', description: '可重新生成建议,或生成问诊复盘。' }
return { title: '等待完整的对话分段', description: props.state.message || '有完整转写后,将整理下一步追问和患者关键信息。' }
})
const sourceStatus = computed(() => {
const status = props.state.transcriptionStatus
if (status === 'completed') return '腾讯云实时转写已完成'
if (status === 'partial') return '腾讯云实时转写不完整'
if (['error', 'failed'].includes(status)) return '腾讯云实时转写失败'
if (['recording', 'running'].includes(status)) return '腾讯云实时转写进行中'
if (['starting', 'initializing'].includes(status)) return '腾讯云实时转写启动中'
if (['stopping', 'finalizing'].includes(status)) return '腾讯云实时转写收尾中'
if (['unavailable', 'disabled'].includes(status)) return '腾讯云实时转写未就绪'
return '等待腾讯云实时转写'
})
const canReviewNow = computed(() => props.canReview && ['completed', 'partial'].includes(props.state.transcriptionStatus))
function nonnegativeNumber(value: number | undefined): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
}
function durationLabel(value: number | undefined): string {
const seconds = Math.floor(nonnegativeNumber(value) / 1000)
return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`
}
function timeLabel(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '时间未提供'
const date = new Date(timestamp)
return Number.isNaN(date.valueOf()) ? '时间未提供' : date.toLocaleTimeString('zh-CN', { hour12: false })
}
function roleLabel(role: string): string {
return ({ doctor: '医生', patient: '患者', unknown: '说话人未确认' })[role] || role || '说话人未确认'
}
function sourceLabel(id: string): string {
const source = props.state.sources.find((entry) => entry.id === id)
return source ? `${roleLabel(source.role)} · ${timeLabel(source.timestamp_ms)}` : '原文未提供'
}
watch(() => props.state.revision, () => { selectedSourceId.value = '' })
</script>
<template>
<section class="video-ai-panel" aria-label="视频问诊 AI 辅助">
<header class="video-ai-heading">
<div>
<h2>实时问诊辅助</h2>
<p>随对话更新,供医生复核</p>
</div>
<span class="video-ai-mode" :class="{ 'video-ai-mode--active': state.enabled && state.available }">{{ state.enabled && state.available ? '自动' : '待命' }}</span>
</header>
<div class="video-ai-body" tabindex="0" aria-label="AI 辅助结果">
<p v-if="resultScope" class="video-ai-scope">{{ resultScope }}</p>
<div class="video-ai-status" :class="{ 'video-ai-status--busy': state.busy, 'video-ai-status--error': hasError && !state.busy }">
<div class="video-ai-status__line">
<p role="status" aria-live="polite">{{ analysisLabel }}</p>
<span v-if="state.busy" class="video-ai-elapsed" aria-live="off">已用 {{ durationLabel(elapsedMs) }}</span>
</div>
<p v-if="hasError && !state.busy" class="video-ai-error" role="alert">{{ state.error || '本轮未能完成,请重新生成。' }}</p>
<p v-else-if="statusDetail" class="video-ai-message">{{ statusDetail }}</p>
</div>
<div v-if="!hasResult" class="video-ai-empty">
<span aria-hidden="true">AI</span>
<strong>{{ emptyState.title }}</strong>
<p>{{ emptyState.description }}</p>
<div v-if="state.busy" class="video-ai-skeleton" aria-hidden="true"><span /><span /><span /></div>
</div>
<section v-for="section in hasResult ? sections : []" :key="section.key" class="video-ai-section" :aria-labelledby="`${panelId}-${section.key}-title`">
<header>
<h3 :id="`${panelId}-${section.key}-title`">{{ section.title }}</h3>
<span v-if="section.items.length">{{ section.items.length }} 项</span>
</header>
<p v-if="!section.items.length" class="video-ai-section__empty">{{ section.empty }}</p>
<ol v-else class="video-ai-items" :aria-busy="state.busy">
<li v-for="(item, index) in section.items" :key="`${section.key}-${index}`" :class="{ 'video-ai-item--next': section.key === 'questions' && index === 0 }">
<p v-if="section.key === 'questions' && index === 0" class="video-ai-priority">先问这一句</p>
<!-- The shared renderer escapes source HTML and emits no active content. -->
<div class="im-ai-text video-ai-item-text" v-html="renderImAiMarkdown(item.text)" />
<div v-if="item.reason" class="video-ai-reason">
<span>{{ section.key === 'questions' ? '为什么问' : '说明' }}</span>
<div class="im-ai-text" v-html="renderImAiMarkdown(item.reason)" />
</div>
<div v-if="item.evidence_ids.length" class="video-ai-citations" aria-label="查看引用原文">
<button
v-for="(id, sourceIndex) in item.evidence_ids"
:key="id"
type="button"
:aria-expanded="selectedSourceId === id"
:aria-controls="`${panelId}-source`"
:aria-label="`查看依据 ${sourceIndex + 1}:${sourceLabel(id)}`"
@click="selectedSourceId = selectedSourceId === id ? '' : id"
>依据 {{ sourceIndex + 1 }} · {{ sourceLabel(id) }}</button>
</div>
<p v-else class="video-ai-unverified">未提供原文依据,请核实</p>
</li>
</ol>
</section>
</div>
<section v-if="selectedSourceId" :id="`${panelId}-source`" class="video-ai-source" aria-label="引用原文" aria-live="polite">
<header>
<strong>{{ selectedSource ? roleLabel(selectedSource.role) : '引用原文' }}</strong>
<time v-if="selectedSource">{{ timeLabel(selectedSource.timestamp_ms) }}</time>
<button type="button" aria-label="收起引用原文" @click="selectedSourceId = ''">×</button>
</header>
<p>{{ selectedSource?.text || '此条依据的原文尚未提供,当前无法核对。' }}</p>
</section>
<footer class="video-ai-footer">
<p class="video-ai-through">
<span>{{ state.throughTimestampMs > 0 ? `内容依据截至 ${timeLabel(state.throughTimestampMs)}` : '尚无已分析内容' }}</span>
<span v-if="hasResult">{{ state.segmentCount }} 段对话</span>
</p>
<p class="video-ai-source-status">{{ sourceStatus }}</p>
<div class="video-ai-actions">
<button type="button" :disabled="!canToggle || (!state.available && !state.enabled)" :aria-pressed="state.enabled" @click="emit('toggle', !state.enabled)">{{ state.enabled ? '暂停自动辅助' : '恢复自动辅助' }}</button>
<button type="button" :disabled="!canRefresh || !state.available || state.busy" title="结合当前已完成的对话,重新生成辅助内容" @click="emit('refresh')">重新生成</button>
<button v-if="canReviewNow" type="button" :disabled="!state.available || state.busy" title="结合授权病历与本次对话生成复盘" @click="emit('review')">生成问诊复盘</button>
</div>
<p class="video-ai-boundary">仅作辅助;不会自动发送消息或写入诊断、医嘱。</p>
</footer>
</section>
</template>
+10
View File
@@ -23,6 +23,7 @@ interface DoctorCallConfig {
patientName?: string
patientCase?: DoctorPatientCase
mode?: 'chat' | 'video'
imAi?: import('./im-ai').ImAiCapabilities
}
interface DoctorPatientCase {
@@ -54,6 +55,9 @@ interface DoctorCallApi {
}
interface DoctorConsultationApi {
videoAiState(state: import('./video-ai-types').VideoAiState): void
imAiResult(payload: import('./im-ai').ImAiResult): void
imAiReady(sessionId: string): void
open(config: DoctorCallConfig): Promise<void>
close(): Promise<void>
startVideo(): Promise<void>
@@ -69,6 +73,10 @@ interface DoctorConsultationApi {
segmentId: string,
ok: boolean,
message: string,
normalizedSegment?: {
segmentId: string, speakerUserId: string, sourceText: string,
timestamp: number, isCompleted: boolean,
},
): void
localRecordingResult(
operation: 'start' | 'chunk' | 'finish',
@@ -80,7 +88,9 @@ interface DoctorConsultationApi {
}
interface QtVideoBridge {
appendLocalTranscriptionChunk?: (sessionId: string, role: 'doctor' | 'patient', sequence: number, timestamp: number, encoded: string) => void
notify?: (payload: string) => void
captureScreenshot?: (region: string, callback: (result: string) => void) => void
saveScreenshot?: (dataUrl: string) => void
startLocalAudioRecording?: (sessionId: string, mimeType: string) => void
appendLocalAudioChunk?: (sessionId: string, sequence: number, encoded: string) => void

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