Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
@@ -1,5 +1,6 @@
|
||||
.env
|
||||
.local-workstation/
|
||||
resources/local-asr/
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 77 KiB |
@@ -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)],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 代码与新前端资源才能一起生效。已冻结安装包需重新构建,不能只更换网页资源。
|
||||
@@ -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`。
|
||||
|
||||
尚未部署远程服务端或重启正在运行的医生客户端。需要服务端加载最新代码、客户端加载本次构建后才会生效;腾讯云实时转写与简体中文配置保持原样。
|
||||
@@ -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):明确列出的多方言/地域口音范围。
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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%)``.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||