This commit is contained in:
Your Name
2026-10-29 16:14:55 +08:00
parent a5c3235bf7
commit 28ba9b97f2
81 changed files with 6361 additions and 2301 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+72
View File
@@ -0,0 +1,72 @@
# 医生工作站客户端设计规范
本轮覆盖现有 PySide6 客户端与 Vue 视频伴随窗口。按 `redesign-existing-projects` 审查现有页面,结合 `minimalist-ui` 的扁平化原则实施;医疗工作台保留信息密度,不套用营销页面的大留白、装饰动效或布局模板。
## 审查结论
- 主框架与业务页面原先并存科技蓝、紫蓝及多套中性色;登录、AI、诊单的控件也各自定义配色。
- 页面内圆角、边框和层级不统一,部分阴影、悬浮位移和过渡使操作显得跳动。
- 小窗口的表格、诊单与视频操作需要分别验证,不能依靠整体缩小解决。
- 键盘焦点、禁用与选择状态应始终可识别,不通过改变控件几何位置表达状态。
## 视觉系统
设计变量以 `src/doctor_workstation/ui/design_system.py` 为准。旧页面通过 `clinical_stylesheet` 迁移已知的浅色主题颜色,新样式使用命名变量。医学数据曲线、模型标识色和深色主题不进行全局替色。
| 角色 | 值 | 使用场景 |
| --- | --- | --- |
| 主色 | `#4267B2` | 主操作、选中项、重要链接 |
| 主色浅底 | `#EAF0FB` | 选中背景、轻量状态 |
| 画布 | `#F5F7FA` | 主窗口背景 |
| 表面 | `#FFFFFF` | 内容区、对话框 |
| 次级表面 | `#F7F9FC` | 筛选栏、分组、只读区域 |
| 侧栏 | `#F0F3F8` | 导航区域 |
| 标题 / 正文 | `#223047` / `#344258` | 文字层级 |
| 次级文字 | `#647287` | 说明、辅助信息 |
| 边框 / 强边框 | `#E6EAF0` / `#D4DCE8` | 低对比结构线、输入边界 |
| 成功 / 警示 / 危险 | `#287B65` / `#96601D` / `#B54A59` | 保留明确语义,不只依赖颜色 |
- 圆角:标签 6 px、输入与按钮 10 px、内容面板 16 px、独立窗口 20 px。
- 间距:8 / 12 / 16 / 24 px,根据现有表格密度调整,不整体放大行高。
- 字体:继续使用打包的中文字体与现有可读字号;标题层级通过字重、字号和间距体现。
- 表面:纯色为主,轻边框划分区域,减少嵌套卡片与装饰阴影。
- 按钮:每个操作组主次清晰;次要操作使用浅底或边框;终止、删除等危险操作保留语义色。
## 交互规范
- 悬浮、按下和焦点反馈不移动布局、不改变控件尺寸。
- 动画短而克制;滚动可被键盘、拖动、程序定位与反向滚动即时打断;尊重减少动画设置。
- 切换页面保留原有路由、权限和数据加载机制。
- 登录表单保留密码存储、服务器设置与提交保护;组合密码框使用实际 Qt 焦点状态。
- 大小窗口都必须能找到主要操作,优先局部滚动而非把全部内容压小。
- 视频保留原有截图捕获、确认上传、大预览和小窗关闭返回大窗逻辑;视觉样式不改变媒体内容或尺寸计算。
## 覆盖范围
1. 登录与品牌区:扁平品牌面板、简洁功能说明、清晰表单与焦点。
2. 主框架:导航、顶部搜索、工具按钮、标签与 AI 入口。
3. 列表:挂号、接诊记录、患者、处方库、已开处方、业务订单及患者进度。
4. 诊疗:接诊台、预约抽屉、诊单编辑与只读、指标和保存状态。
5. 辅助窗口:AI 问诊、处方编辑、处方解读与报告控制台。
6. 视频:通话控制、聊天、录制提示与截图确认。
## 验收方式
`scripts/render_client_design.py` 使用演示仓库生成 1440 × 900、1024 × 640 的真实页面,以及 1280 × 800、860 × 640 的登录页;输出 `artifacts/client-design/index.html` 与原尺寸 PNG。诊疗和 AI 弹窗使用现有专用渲染脚本验证。
检查重点:小窗操作可达、中文可读、表格信息密度、长文本换行、键盘焦点、布局稳定、登录与页面路由契约、视频截图与窗口状态回归。生产服务与真实患者资料不用于视觉验收。
本轮共享组件还覆盖筛选展开按钮、列表加载状态、聊天通知、更新提示、本机录音队列、媒体预览和上传进度。860 × 640 登录表单完整显示,不需要为纯留白滚动。
## 验证记录与边界
- 主框架、登录、遮罩与动效首轮 100 项通过;新增组合密码框键盘焦点检查通过。
- 小窗登录与焦点补测 2 项、最终登录及主框架复查 68 项通过。
- 筛选、无限列表、通知、更新窗口与本机录音队列 37 项通过。
- 原生视频截图、截图预览尺寸、小窗关闭与视频契约 199 项通过。
- 视频前端 26 项检查通过,生产 dist 已重新构建。
- 当前视频 dist 在 1120 × 760、540 × 356、440 × 300 的合成画面中完成 27 项布局检查,截图与关闭事件正常,未访问外部服务。
- 临床页面、AI、处方及图表分别完成专项测试和真实控件渲染,详细记录位于 `artifacts/client-design/*-report.md`。
- 最终修改文件的 Ruff 与 `git diff --check` 均通过;AI 工作区及快捷操作权限最后复查 24 项通过。
此记录不表示整仓所有历史测试全部通过。扩展测试中存在已用原始 HEAD 复现的 AI 流式/延迟生命周期失败、共享 QApplication 样式引发的图表测试顺序依赖,以及 Windows Qt 网络模块异常;本轮没有改变相关业务行为。未连接真实面诊服务,也未重新打包或安装 Windows 客户端。
+75
View File
@@ -0,0 +1,75 @@
# IM 问诊 AI 辅助
用户确认范围:在现有 IM 问诊窗口内增加「总结病情」「建议追问」「起草回复」。结果供医生审阅,医生主动填入输入框后编辑并手动发送。
## 集成契约
- 复用 `stream_diagnosis_ai`:总结使用 `summary`,追问和回复使用 `custom`。不新增模型配置、不把凭据传到浏览器、不新增服务端路由。
- 每次生成前,通过 `sync_im_chat_messages(scope="current", sync_token=...)` 完成当前账号对话归档;响应 `completed=true` 且没有错误后才能调用 AI。准备失败明确报错,不静默降级为旧聊天。服务器按授权诊单聚合临床资料与归档 IM,提示词限定本次医生账号的近期对话。
- 三项提示词保持 500 字以内;不在提示词中挤入、截断完整聊天或未发送草稿。
- AI 独立后台 worker,不占用通话、录音和消息发送的队列。
原生配置 `DoctorCallConfig.imAi`:`{version:1, enabled:boolean, sessionId:string, reason?:string}`。宿主生成 sessionId,每个新窗口独立。上层将 AI 权限布尔值贯穿 launcher;默认拒绝。
浏览器到原生:
```json
{"source":"doctor-call","event":"im-ai-request","sessionId":"host-uuid","requestId":"uuid","contextKey":"frontend-context","diagnosisId":"501","appointmentId":101,"action":"summary"}
```
取消使用同一组身份字段及 `event="im-ai-cancel"`。只允许 `summary | followup | reply`。宿主核验可信文档、窗口未关闭、AI 权限、会话、诊单、挂号和请求格式;实际 repository 调用始终使用宿主绑定诊单。
原生通过 `window.doctorConsultation.imAiResult(payload)` 返回相同身份字段与:
```text
status: loading | streaming | success | error | cancelled
text?: 累积正文(流式及最终)
message?: 简短进度或错误
modelLabel?: 服务端模型标签
warnings?: string[]
```
所有数据使用 JSON 序列化,桥接正文仍为原始文本。页面通过受限 Markdown 渲染器显示标题、重点、列表和表格;原始 HTML 被转义,链接和图片只保留文字,不执行内容或加载外部资源。客户端截止时间 180 秒,超时发送取消;原生聊天同步有独立有界预算,AI 沿用现有 105 秒传输预算。取消请求由独立线程发送,GUI 不等待网络。停止只是停止本次接收,不冒称上游工作已确认停止。
## 界面与状态
- 宽屏右侧约 330 px 辅助区;窄屏可收起抽屉,独立滚动,操作和关闭可达。沿用现有蓝色、圆角和轻边框。
- AI loading 不阻塞 IM 输入、发送、加载更多和视频控制。
- 只有完整成功结果允许填入;追问与回复可填入,病情总结默认只读。非空草稿明确追加,超过 3000 字拒绝追加而不截断原文。
- 切换会话清空 AI 状态和草稿;迟到、重复、身份不匹配结果全部忽略。
- 异步手动发送成功仅清空当时提交且没有继续修改的草稿。
- 结果注明医生复核,展示服务端临床复核提示;不会自动发送、写病历或开处方。
## 验证要求
离线 fake repository 验证同步续拉、失败不生成、取消、终态、身份隔离、权限与关闭;Node 测试验证桥接状态/计时器、草稿追加和发送隔离;使用真实构建产物和合成消息验证宽屏与紧凑窗口。回归截图、录音收尾、通话权限与 IM 流程。验证不得访问真实患者、IM 或 AI 服务。
## 交付与验证结果(2026-10-29)
- `app.py` 在实际打开窗口时读取当前账号的 `tcm.diagnosis/aiAssistant` 权限,经 launcher 传入原生窗口;未配置权限时默认关闭,不采用聊天载荷中的权限标记。
- 三项功能已接入真实 Qt bridge 和现有流式服务;宽屏常驻侧栏,980 px 及以下窗口使用可收起抽屉。中文输入法选词时按 Enter 不发送,异步发送不会清除医生随后编辑的草稿。
- 合并后 Python 回归 **364 项通过**:包括新增 AI 服务/仓库/窗口、权限入口、IM、视频契约、截图、小窗关闭及大预览等。运行使用独立 `artifacts/im-ai/final-integration-tmp`,未访问外部服务。
- 前端 **45 项 Node 行为测试通过**,`vue-tsc` 与 Vite 构建通过;实际 dist 在 1120×760、980×640、540×356、440×300 下 **42 项界面检查通过**。离线夹具只使用合成消息和结果,外部请求尝试为 0。
- Ruff 与 `git diff --check` 通过。首次交付构建入口:`index-CnqWQu6O.js`、`index-BwArpbVK.css`。
- 截图、可复现夹具和详细报告存放在 `app/artifacts/im-ai/`。未验证真实账号的模型响应;未生成 Windows 安装包。源码运行需重启客户端,已安装版本需重新打包后安装。
停止接收后,原生端会独立请求取消;在旧请求实际退出前重试会提示稍后再试,避免重复模型请求。聊天同步失败时不使用旧归档静默生成,助手显示错误并允许重试。
## 结果排版与切换改进(2026-10-29)
- 模型输出的 Markdown 标题、加粗、列表、引用、代码和简单表格转换为安全的结构化展示;重点以加粗和浅蓝底色标记,结构标题使用蓝色引导线。不依据医学关键词自行推断重点。未闭合的流式片段保持可读原文。
- 三项操作在当前窗口会话内分别保留状态、正文、模型标签和复核提示。顶部按钮首次点击生成,之后只切换查看;底部“重新生成/重试”才会再发请求。生成中可以阅读其他已完成结果,后台更新不能覆盖当前阅读页。
- 每项保留独立滚动位置,换患者、关闭或重开会话时清空。结果仅保存在内存中,不增加本地患者内容持久化。
- 填入草稿前去除 Markdown 排版符号,保留原文、数值和列表含义,再执行 3000 字长度检查;仍不覆盖原稿、不自动发送。
- 本次前端 **61 项行为测试通过**,包含安全渲染、草稿转换、缓存、显式重生成、后台流式隔离及现有截图/通话守卫;TypeScript 与 Vite 构建通过。
- 真实 dist 配合生产 AI controller 源码和离线宿主替身,在四种窗口尺寸完成 **20 项可点击/布局检查**,以及切换不发请求、显式刷新、后台生成、独立阅读位置、原稿追加及注入防护断言。外部请求为 0。
- 证据:`artifacts/im-ai/refinements-visual-checks.json`、`render_im_ai_refinements.py` 与 `im-ai-styled-cached-*.png`。未打包安装程序。
## 可调宽度、首次总结与指标强调(2026-10-29)
- 聊天与助手之间增加可左右拖动的分隔线,宽屏保留至少 480 px 聊天区域;窄屏抽屉也可调整,最大宽度仍留出可操作的边缘。支持方向键、Home/End、Enter 或双击恢复默认,Escape 取消本次拖动。窗口变更、失焦与关闭释放指针捕获。宽屏和抽屉宽度分别记忆在当前窗口内。
- 进入有权限的 IM 会话后,首次总结等待当前登录、聊天、Qt 通道及原生 `imAiReady(sessionId)` 确认;准备等待最多 30 秒。每个会话只自动尝试一次,不因重连、标签切换、展开面板或失败自动重试。医生提前手动操作会取消待启动的自动总结。关闭和换会话清理计时器及旧结果。
- 普通文本中的血糖、血压、糖化血红蛋白、体重等指标名称及带单位的数值自动强调,支持范围、小数和血压组合。名称加深,数值加粗并显示浅蓝底色,不依赖模型输出 Markdown。原始数值、单位、否定和不确定表述保持不变;没有新增医学阈值判断或自动风险分级。
- **97 项前端测试、318 项客户端回归通过**,TypeScript、Vite 和 Ruff 检查通过,`git diff --check` 无问题。当前构建:`index-C_tMrPfb.js`、`index-CMN1CXTr.css`。
- 实际构建页面在 1634×1008、1120×760、980×640、540×356、440×300 下完成真实 Qt 鼠标拖动、键盘调整、取消、跨断点释放及指标样式检查,**15 项布局/点击检查通过**,自动选择与请求次数断言通过,外部请求为 0。
- 证据:`artifacts/im-ai/render_im_ai_resizing.py`、`resizing-visual-checks.json`、`im-ai-metrics-resizable-*.png`。本轮未生成安装包;原生就绪握手需重启使用最新源码的客户端。
+143
View File
@@ -0,0 +1,143 @@
"""Render the real native client with synthetic data for UI design review."""
from __future__ import annotations
import argparse
import html
import json
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("DOCTOR_MOTION", "off")
from PySide6.QtCore import QSettings, QThreadPool
from PySide6.QtGui import QImage
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
from doctor_workstation.ui.login import LoginWindow
ROUTES = {
"appointments": "挂号列表",
"consultations": "问诊列表",
"patients": "我的患者",
"prescription_library": "我的处方库",
"prescriptions": "已开处方",
"reception": "接诊台",
}
def settle(app: QApplication) -> None:
for _ in range(4):
QThreadPool.globalInstance().waitForDone(3000)
app.processEvents()
QTest.qWait(75)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=Path("artifacts/client-design"))
args = parser.parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
app = QApplication.instance() or QApplication([])
apply_theme(app)
repo = DemoDoctorRepository()
records = []
def capture(window, name: str, title: str) -> None:
settle(app)
filename = f"{name}.png"
if not window.grab().save(str(output / filename)):
raise RuntimeError(f"Could not save {filename}")
records.append({
"title": title, "file": filename,
"width": window.width(), "height": window.height(),
})
# Isolate preview settings from the user's saved account and credentials.
settings = QSettings(str(output / "preview-login.ini"), QSettings.Format.IniFormat)
settings.clear()
login = LoginWindow(repo, settings=settings)
login.show()
for width, height in ((1280, 800), (860, 640)):
login.resize(width, height)
capture(login, f"login-{width}", "登录工作站")
login.close()
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
shell = ShellWindow(
repo, {"session": session, "demo_mode": True}, permissions=session.permissions,
)
shell.show()
try:
for width, height in ((1440, 900), (1024, 640)):
shell.resize(width, height)
for route, title in ROUTES.items():
shell.navigate(route)
capture(shell, f"{route}-{width}", title)
finally:
settle(app)
shell.close()
# Include independently rendered dialog/media fixtures when available.
supplementary = [
("patient_orders_1366x768.png", "患者业务订单"),
("patient_progress_1366x768.png", "患者进度"),
("clinical-after/appointment_drawer_1440x900.png", "预约编辑"),
("clinical-after/diagnosis_edit_1440x900.png", "诊单编辑"),
("clinical-after/diagnosis_edit_1024x640.png", "诊单编辑 · 紧凑窗口"),
("clinical-after/diagnosis_index_1440x900.png", "诊单列表"),
("assistant-1440.png", "AI 问诊"),
("assistant-1080.png", "AI 问诊 · 紧凑窗口"),
("../subwindow_exact/prescription_editor_860x900.png", "处方编辑"),
("../subwindow_exact/prescription_ai_report_920x760.png", "处方报告"),
("video-call-1120x760.png", "视频面诊"),
("video-call-440x300.png", "视频面诊 · 小窗"),
("video-chat-440x300.png", "视频聊天 · 小窗"),
("video-preview-1120x760.png", "视频截图确认"),
]
for filename, title in supplementary:
preview = QImage(str(output / filename))
if not preview.isNull():
records.append({
"title": title, "file": filename,
"width": preview.width(), "height": preview.height(),
})
(output / "gallery.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8",
)
cards = []
for record in records:
filename = html.escape(record["file"])
title = html.escape(record["title"])
cards.append(
f'<section><h2>{title} <small>{record["width"]} × {record["height"]}</small>'
f'</h2><a href="{filename}"><img src="{filename}" alt="{title}" loading="lazy">'
'</a></section>'
)
(output / "index.html").write_text(
'<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>医生工作站 · 客户端设计验收</title><style>'
'body{margin:0;padding:32px;background:#F5F7FA;color:#223047;'
'font-family:"Microsoft YaHei",sans-serif}main{max-width:1440px;margin:auto}'
'h1{font-size:28px}p,small{color:#647287}small{font-weight:400;font-size:14px}'
'section{margin:32px 0 48px}h2{font-size:20px;display:flex;gap:20px;align-items:baseline}'
'img{width:100%;display:block;border:1px solid #E6EAF0;border-radius:16px}'
'a:focus-visible{outline:3px solid #6D8CCB}footer{padding:24px 0}'
'</style><main><h1>医生工作站 · 客户端设计验收</h1>'
'<p>使用演示数据渲染的真实客户端。点击图片查看原始尺寸。</p>'
+ "".join(cards)
+ '<footer>主窗口与紧凑窗口均按实际控件排版生成。</footer></main></html>',
encoding="utf-8",
)
print(output / "index.html")
if __name__ == "__main__":
main()
+6
View File
@@ -41,6 +41,7 @@ from doctor_workstation.ui.widgets import (
friendly_error,
gender_text,
get_value,
has_permission,
run_async,
set_authentication_expired_handler,
show_toast,
@@ -939,6 +940,11 @@ class ApplicationController(QObject):
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
can_ai_assistant=has_permission(
getattr(self.shell_window, "permissions", None),
"tcm.diagnosis/aiAssistant",
default=False,
),
on_open_diagnosis=lambda current_id=diagnosis_id, current_key=call_key: (
self._open_video_diagnosis(current_id, call_key=current_key)
),
@@ -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."""
@@ -692,8 +692,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."""
@@ -2602,12 +2604,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``."""
@@ -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,7 @@ from PySide6.QtWidgets import (
)
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from ..design_system import clinical_stylesheet
from ..diagnosis_drawer import (
DIAGNOSIS_QSS,
CaseGrid,
@@ -97,6 +98,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 +1543,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)
@@ -21,6 +21,8 @@ from typing import Any
from PySide6.QtGui import QFont
from ..design_system import PALETTE
DARK: dict[str, str] = {
# ground and cards
"canvas": "#080D18",
@@ -62,23 +64,23 @@ DARK: dict[str, str] = {
}
LIGHT: dict[str, str] = {
"canvas": "#F1F5FC",
"canvas_soft": "#E7EEFA",
"surface": "#FFFFFF",
"surface_2": "#F5F8FE",
"raised": "#E8EFFA",
"line": "#D5E1F2",
"line_soft": "#E3EBF8",
"heading": "#0A182E",
"text": "#2A3C56",
"muted": "#54697F",
"faint": "#7A8DA3",
"selected_text": "#0A182E",
"accent": "#1A5FD0",
"accent_text": "#124DAE",
"accent_pressed": "#0E3F90",
"selection": "#E8EFFA",
"selection_line": "#AEC7EE",
"canvas": PALETTE["canvas"],
"canvas_soft": PALETTE["surface_alt"],
"surface": PALETTE["surface"],
"surface_2": PALETTE["surface_alt"],
"raised": PALETTE["sidebar"],
"line": PALETTE["line_strong"],
"line_soft": PALETTE["line"],
"heading": PALETTE["heading"],
"text": PALETTE["text"],
"muted": PALETTE["muted"],
"faint": PALETTE["disabled"],
"selected_text": PALETTE["heading"],
"accent": PALETTE["accent"],
"accent_text": PALETTE["accent_hover"],
"accent_pressed": PALETTE["accent_pressed"],
"selection": PALETTE["accent_soft"],
"selection_line": PALETTE["focus"],
"qwen": "#0C89AC",
"qwen_text": "#086C88",
"qwen_dim": "#E2F5FA",
@@ -29,6 +29,7 @@ from ...services.local_audio_queue import (
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from ..design_system import clinical_stylesheet
from ..widgets import run_async
_STATUS_LABELS = {
@@ -174,7 +175,7 @@ class LocalAudioQueueDialog(QDialog):
self.setModal(True)
self.setMinimumSize(760, 480)
self.resize(1160, 620)
self.setStyleSheet(_LOCAL_AUDIO_QSS)
self.setStyleSheet(clinical_stylesheet(_LOCAL_AUDIO_QSS))
self._build_ui()
self.poll_timer = QTimer(self)
@@ -28,8 +28,8 @@ from PySide6.QtCore import (
QDate,
QEvent,
QIODevice,
QModelIndex,
QMarginsF,
QModelIndex,
QObject,
QPoint,
QRectF,
@@ -98,6 +98,7 @@ from PySide6.QtWidgets import (
)
from .. import icons
from ..design_system import clinical_stylesheet
from ..diagnosis_terms import shared_terms
from ..infinite_list import InfiniteList
from ..theme import main_window_target_rect
@@ -116,9 +117,9 @@ from ..widgets import (
)
from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog
PRESCRIPTION_DRAWER_QSS = r"""
PRESCRIPTION_DRAWER_QSS = clinical_stylesheet(r"""
QFrame#PrescriptionDrawerSurface {
color: #1A1C1F;
color: $ds_text;
background-color: #FFFFFF;
border-left: 1px solid #EDEDEE;
font-size: 14px;
@@ -145,7 +146,7 @@ QLabel#PrescriptionLockTag {
color: #BE4B58;
background-color: #FEF0F0;
border: 1px solid #FDE2E2;
border-radius: 4px;
border-radius: $ds_radius_small;
padding: 2px 8px;
font-size: 13px;
}
@@ -175,7 +176,7 @@ QLabel#PrescriptionDrawerMode {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #EDEDEE;
border-radius: 5px;
border-radius: $ds_radius_small;
padding: 4px 9px;
font-size: 13px;
font-weight: 500;
@@ -189,7 +190,7 @@ QPushButton#PrescriptionDrawerClose {
color: #606163;
background-color: transparent;
border: 0;
border-radius: 7px;
border-radius: $ds_radius_control;
font-size: 22px;
font-weight: 400;
}
@@ -208,12 +209,12 @@ QWidget#PrescriptionDrawerContent {
QFrame#PrescriptionFormSection {
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QLabel#PrescriptionSectionTitle {
color: #1A1C1F;
font-size: 15px;
font-weight: 500;
font-weight: 600;
}
QLabel#PrescriptionSectionHint {
color: #6A6B6D;
@@ -242,7 +243,7 @@ QLabel#PrescriptionUsageAux {
QFrame#PrescriptionRpToolbar {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QFrame#PrescriptionRpMarker {
background-color: #4F63D9;
@@ -262,12 +263,12 @@ QLabel#PrescriptionRpLock {
color: #BE4B58;
background-color: #FEF3F2;
border: 1px solid #FECDCA;
border-radius: 5px;
border-radius: $ds_radius_small;
padding: 5px 8px;
font-size: 13px;
}
QLabel#PrescriptionFormulaTag {
border-radius: 4px;
border-radius: $ds_radius_small;
padding: 3px 8px;
font-size: 13px;
font-weight: 500;
@@ -328,7 +329,7 @@ QFrame#PrescriptionDrawerSurface QDoubleSpinBox {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
padding: 0 11px;
selection-background-color: #C9D1F3;
selection-color: #1A1C1F; font-size: 14px;
@@ -338,7 +339,7 @@ QFrame#PrescriptionDrawerSurface QComboBox {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
padding-left: 11px;
padding-right: 28px;
selection-background-color: #C9D1F3;
@@ -379,7 +380,7 @@ QFrame#PrescriptionDatePicker {
min-height: 32px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
}
QFrame#PrescriptionDatePicker:hover {
border-color: #8B9AD9;
@@ -446,7 +447,7 @@ QFrame#PrescriptionDrawerSurface QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QFrame#PrescriptionDrawerSurface QPushButton:hover {
@@ -468,7 +469,7 @@ QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose {
color: #606163;
background-color: transparent;
border: 0;
border-radius: 7px;
border-radius: $ds_radius_control;
font-size: 22px;
font-weight: 400;
}
@@ -496,23 +497,23 @@ QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover {
border-color: #4156C4;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="success"] {
color: #FFFFFF;
background-color: #287B65;
border-color: #287B65;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_line;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="success"]:hover {
background-color: #287B65;
border-color: #287B65;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"] {
color: #A9691D;
color: $ds_accent;
background-color: #FFFFFF;
border-color: #A9691D;
border-color: $ds_accent;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"]:hover {
color: #FFFFFF;
background-color: #A9691D;
border-color: #A9691D;
color: $ds_accent_hover;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"] {
color: #4F63D9;
@@ -615,7 +616,8 @@ QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] {
QFrame#PrescriptionDrawerSurface QFrame#MessageBanner[kind="danger"] QLabel {
color: #BE4B58;
}
"""
QFrame#PrescriptionDrawerSurface QPushButton:focus { border: 1px solid $ds_focus; }
""")
_CHEVRON_PATH: Path | None = None
@@ -647,7 +649,7 @@ def _prescription_drawer_qss() -> str:
def _prescription_calendar_qss() -> str:
return """
return clinical_stylesheet("""
QCalendarWidget {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
@@ -672,13 +674,13 @@ QCalendarWidget QAbstractItemView:enabled {
selection-color: #1A1C1F;
outline: 0;
}
"""
""")
PRESCRIPTION_DIALOG_QSS = """
PRESCRIPTION_DIALOG_QSS = clinical_stylesheet("""
QDialog#PrescriptionDialogSurface {
color: #1A1C1F;
background-color: #F7F7F7;
background-color: $ds_canvas;
font-size: 14px;
}
QDialog#PrescriptionDialogSurface QLabel {
@@ -740,7 +742,7 @@ QDialog#PrescriptionDialogSurface QTextBrowser {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
selection-background-color: #EEF1FA;
selection-color: #1A1C1F; font-size: 14px;
}
@@ -768,7 +770,7 @@ QDialog#PrescriptionDialogSurface QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#PrescriptionDialogSurface QPushButton:hover {
@@ -798,7 +800,7 @@ QDialog#PrescriptionDialogSurface QPushButton:disabled {
QDialog#PrescriptionDialogSurface QTabWidget::pane {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 9px;
border-radius: $ds_radius_panel;
top: -1px;
}
QDialog#PrescriptionDialogSurface QTabBar::tab {
@@ -852,7 +854,8 @@ QDialog#PrescriptionDialogSurface QScrollBar::add-line:vertical,
QDialog#PrescriptionDialogSurface QScrollBar::sub-line:vertical {
height: 0;
}
"""
QDialog#PrescriptionDialogSurface QPushButton:focus { border: 1px solid $ds_focus; }
""")
def _apply_prescription_dialog_style(dialog: QDialog) -> None:
@@ -1531,9 +1534,9 @@ class RemoteMedicineComboBox(QComboBox):
popup = self._completer.popup()
popup.setItemDelegate(_MedicineSuggestionDelegate(popup))
popup.setStyleSheet(
"QListView { background-color: #FFFFFF; border: 1px solid #E4E7ED; border-radius: 6px;"
clinical_stylesheet("QListView { background-color: #FFFFFF; border: 1px solid #E4E7ED; border-radius: 6px;"
" padding: 4px 0; font-size: 14px; outline: 0; }"
"QListView::item:selected, QListView::item:hover { background-color: #F5F7FA; }"
"QListView::item:selected, QListView::item:hover { background-color: #F5F7FA; }")
)
self.lineEdit().setCompleter(self._completer)
self.lineEdit().installEventFilter(self)
@@ -5292,8 +5295,8 @@ class PrescriptionDetailDialog(QDialog):
self.preview.setObjectName("PrescriptionPaperPreview")
self.preview.setOpenExternalLinks(False)
self.preview.setStyleSheet(
"QTextBrowser#PrescriptionPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#PrescriptionPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.preview.setDocument(self.document)
@@ -5301,8 +5304,8 @@ class PrescriptionDetailDialog(QDialog):
self.user_preview.setObjectName("PrescriptionUserPaperPreview")
self.user_preview.setOpenExternalLinks(False)
self.user_preview.setStyleSheet(
"QTextBrowser#PrescriptionUserPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#PrescriptionUserPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.user_preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.user_preview.setDocument(self.user_document)
@@ -5320,8 +5323,8 @@ class PrescriptionDetailDialog(QDialog):
self.case_preview.setObjectName("CaseRecordPaperPreview")
self.case_preview.setOpenExternalLinks(False)
self.case_preview.setStyleSheet(
"QTextBrowser#CaseRecordPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}"
clinical_stylesheet("QTextBrowser#CaseRecordPaperPreview {"
"background-color:#F7F7F7; border:1px solid #E4E4E5; padding:0;}")
)
self.case_preview.setDocument(self.case_document)
case_index = self.tabs.addTab(self.case_preview, "详细病历")
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..design_system import clinical_stylesheet
from ..report_provenance import report_provenance_lines
from ..theme import apply_reading_rhythm
from ..widgets import (
@@ -169,10 +170,10 @@ def diagnosis_ai_task(prompt: str) -> str:
return "custom"
PRESCRIPTION_AI_QSS = """
PRESCRIPTION_AI_QSS = clinical_stylesheet("""
QDialog#PrescriptionAiDialog {
color: #1A1C1F;
background-color: #F7F7F7;
color: $ds_text;
background-color: $ds_canvas;
font-size: 13px;
}
@@ -182,7 +183,7 @@ QDialog#PrescriptionAiDialog QPushButton {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 7px;
border-radius: $ds_radius_control;
font-weight: 500;
}
QDialog#PrescriptionAiDialog QPushButton:hover {
@@ -224,7 +225,7 @@ QLabel#PrescriptionAiSubtitle {
QFrame#PrescriptionAiSnapshot {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
}
QLabel#PrescriptionAiSnapshotLabel {
color: #1A1C1F;
@@ -232,20 +233,20 @@ QLabel#PrescriptionAiSnapshotLabel {
font-weight: 600;
}
QLabel#PrescriptionAiSnapshotBody {
color: #1A1C1F;
font-size: 13px;
color: $ds_text;
font-size: 14px;
line-height: 1.65;
}
QFrame#PrescriptionAiSummary {
background-color: #F0F0F0;
background-color: $ds_accent_soft;
border: 0;
border-left: 3px solid #4F63D9;
border-radius: 0 9px 9px 0;
border-radius: $ds_radius_control;
}
QFrame#PrescriptionAiCaution {
background-color: #FFF9EE;
border: 1px solid #F3DFB5;
border-radius: 9px;
border-radius: $ds_radius_control;
}
QLabel#PrescriptionAiSectionTitle {
color: #1A1C1F;
@@ -253,17 +254,17 @@ QLabel#PrescriptionAiSectionTitle {
font-weight: 600;
}
QLabel#PrescriptionAiBody {
color: #1A1C1F;
font-size: 13px;
color: $ds_text;
font-size: 14px;
line-height: 1.75;
}
QTextBrowser#PrescriptionAiAnswer {
color: #1A1C1F;
color: $ds_text;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
padding: 12px 14px;
selection-color: #1A1C1F;
border-radius: $ds_radius_panel;
padding: 16px 18px;
selection-color: $ds_text;
selection-background-color: #EEF1FA;
}
QLabel#PrescriptionAiMuted {
@@ -279,7 +280,7 @@ QFrame#PrescriptionAiMeta {
QDialog#PrescriptionAiDialog QTabWidget::pane {
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 10px;
border-radius: $ds_radius_panel;
top: -1px;
}
QDialog#PrescriptionAiDialog QTabBar::tab {
@@ -309,7 +310,7 @@ QDialog#PrescriptionAiDialog QTextEdit {
color: #1A1C1F;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 8px;
border-radius: $ds_radius_control;
padding: 10px;
selection-background-color: #EEF1FA;
}
@@ -330,10 +331,12 @@ QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
height: 0;
}
"""
QDialog#PrescriptionAiDialog QPushButton:focus { border: 1px solid $ds_focus; }
QDialog#PrescriptionAiDialog QPushButton:disabled { color: $ds_disabled; background-color: $ds_disabled_surface; border-color: $ds_line; }
""")
_AI_ANSWER_DOCUMENT_CSS = (
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
_AI_ANSWER_DOCUMENT_CSS = clinical_stylesheet(
"body { color:$ds_text; font-size:14px; line-height:1.72; } "
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
@@ -801,7 +804,7 @@ class PrescriptionAiReportDialog(QDialog):
self.report_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.host = QWidget()
self.host_layout = QVBoxLayout(self.host)
self.host_layout.setContentsMargins(4, 4, 12, 8)
self.host_layout.setContentsMargins(18, 14, 20, 16)
self.host_layout.setSpacing(12)
self.report_scroll.setWidget(self.host)
self.tabs.widget(0).layout().addWidget(self.report_scroll)
@@ -1455,7 +1458,7 @@ class PrescriptionAiReportDialog(QDialog):
label.setTextFormat(Qt.TextFormat.PlainText)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
label.setWordWrap(True)
label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
label.setStyleSheet(clinical_stylesheet("color: $ds_muted; font-size: 12px; padding: 10px 14px; background: $ds_surface_alt; border: 1px solid $ds_line; border-radius: $ds_radius_control;"))
self.host_layout.addWidget(label)
def reject(self) -> None:
@@ -8,6 +8,7 @@ from PySide6.QtCore import QObject, QSize, Qt, Signal
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from . import icons
from .design_system import PALETTE, clinical_stylesheet
class FilterDisclosure(QObject):
@@ -35,17 +36,17 @@ class FilterDisclosure(QObject):
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
self.button.setFixedHeight(32)
self.button.setIconSize(QSize(14, 14))
self.button.setStyleSheet("""
self.button.setStyleSheet(clinical_stylesheet("""
QPushButton#FilterDisclosureButton {
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
border-radius: $ds_radius_control; padding: 0 11px; min-height: 30px; max-height: 30px;
min-width: 92px; font-size: 13px; font-weight: 400;
}
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
""")
QPushButton#FilterDisclosureButton:hover { background: $ds_canvas; border-color: $ds_line_strong; }
QPushButton#FilterDisclosureButton:checked { background: $ds_accent_soft; border-color: $ds_line_strong; }
QPushButton#FilterDisclosureButton:focus { border-color: $ds_focus; }
QPushButton#FilterDisclosureButton:disabled { color: $ds_disabled; border-color: $ds_line; }
"""))
self.button.toggled.connect(self.set_expanded)
self._apply()
@@ -78,4 +79,4 @@ class FilterDisclosure(QObject):
self.button.setAccessibleName(label)
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", PALETTE["accent"], 14))
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from .design_system import clinical_stylesheet
from .widgets import first_value, get_value, page_items, page_total, run_async
@@ -54,16 +55,16 @@ class InfiniteList(QWidget):
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 0, 12, 0)
self.summary_label = QLabel("", self)
self.summary_label.setStyleSheet(
self.summary_label.setStyleSheet(clinical_stylesheet(
"color: #5D6B80; font-size: 12px; background: transparent;"
)
))
layout.addWidget(self.summary_label)
layout.addStretch(1)
self.retry_button = QPushButton("加载失败,点击重试", self)
self.retry_button.setFlat(True)
self.retry_button.setStyleSheet(
self.retry_button.setStyleSheet(clinical_stylesheet(
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
)
))
self.retry_button.hide()
self.retry_button.clicked.connect(self.retry)
layout.addWidget(self.retry_button)
+79 -243
View File
@@ -5,26 +5,21 @@ from __future__ import annotations
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
from PySide6.QtGui import (
QBrush,
QColor,
QFont,
QIcon,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QPixmap,
QPolygonF,
QRadialGradient,
QResizeEvent,
)
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFrame,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QLayout,
@@ -45,6 +40,7 @@ from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path
from . import icons
from .design_system import PALETTE, clinical_stylesheet
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -127,7 +123,7 @@ def _round_pen(color: QColor | str, width: float) -> QPen:
class _BrandPanel(QWidget):
"""The quiet medical illustration and copy from the supplied login artwork."""
"""A flat, readable introduction to the clinician's daily workflow."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -137,228 +133,76 @@ class _BrandPanel(QWidget):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._brand_lockup = QPixmap(str(brand_lockup_path()))
@staticmethod
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
x, y = center.x(), center.y()
top = QPolygonF(
[
QPointF(x, y - size * 0.58),
QPointF(x + size * 0.48, y - size * 0.34),
QPointF(x, y - size * 0.10),
QPointF(x - size * 0.48, y - size * 0.34),
]
)
left = QPolygonF(
[
QPointF(x - size * 0.48, y - size * 0.34),
QPointF(x, y - size * 0.10),
QPointF(x, y + size * 0.48),
QPointF(x - size * 0.48, y + size * 0.24),
]
)
right = QPolygonF(
[
QPointF(x, y - size * 0.10),
QPointF(x + size * 0.48, y - size * 0.34),
QPointF(x + size * 0.48, y + size * 0.24),
QPointF(x, y + size * 0.48),
]
)
painter.setPen(QPen(QColor(185, 196, 247, 205), 1.2))
painter.setBrush(QColor(235, 239, 255, 218))
painter.drawPolygon(top)
painter.setBrush(QColor(210, 218, 255, 205))
painter.drawPolygon(left)
painter.setBrush(QColor(224, 230, 255, 220))
painter.drawPolygon(right)
painter.setPen(_round_pen(QColor(137, 154, 248, 135), 2.2))
if kind == "plus":
painter.drawLine(
QPointF(x - size * 0.12, y + size * 0.05),
QPointF(x + size * 0.15, y - size * 0.02),
)
painter.drawLine(
QPointF(x + size * 0.02, y - size * 0.13),
QPointF(x + size * 0.02, y + size * 0.17),
)
else:
shield = QPainterPath(QPointF(x, y - size * 0.16))
shield.lineTo(x + size * 0.15, y - size * 0.08)
shield.lineTo(x + size * 0.10, y + size * 0.17)
shield.lineTo(x, y + size * 0.26)
shield.lineTo(x - size * 0.10, y + size * 0.17)
shield.lineTo(x - size * 0.15, y - size * 0.08)
shield.closeSubpath()
painter.drawPath(shield)
def _draw_illustration(
self, painter: QPainter, width: float, height: float
) -> None:
ox = max(250.0, width - 500.0)
oy = max(365.0, height - 500.0)
glow = QRadialGradient(QPointF(width - 170, oy + 230), 280)
glow.setColorAt(0.0, QColor(102, 120, 255, 62))
glow.setColorAt(1.0, QColor(102, 120, 255, 0))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(glow)
painter.drawEllipse(QPointF(width - 170, oy + 230), 280, 280)
painter.setPen(QPen(QColor(166, 180, 241, 78), 1))
for offset in (0, 72, 144):
painter.drawLine(
QPointF(ox - 80, oy + 280 + offset),
QPointF(width + 20, oy + 170 + offset),
)
painter.drawLine(QPointF(ox + 10, oy + 400), QPointF(ox + 10, oy + 80))
painter.drawLine(QPointF(ox + 170, oy + 430), QPointF(ox + 170, oy + 35))
base = QRectF(ox + 80, oy + 365, 430, 88)
base_gradient = QLinearGradient(base.topLeft(), base.bottomRight())
base_gradient.setColorAt(0.0, QColor(246, 248, 255, 228))
base_gradient.setColorAt(1.0, QColor(186, 199, 255, 184))
painter.setBrush(base_gradient)
painter.setPen(QPen(QColor(166, 181, 248, 158), 1))
painter.drawEllipse(base)
painter.setBrush(QColor(249, 250, 255, 225))
painter.drawEllipse(QRectF(base.x() + 42, base.y() - 12, base.width() - 84, 70))
painter.save()
painter.translate(35, 40)
screen = QPolygonF(
[
QPointF(ox + 130, oy + 60),
QPointF(ox + 435, oy + 100),
QPointF(ox + 435, oy + 365),
QPointF(ox + 130, oy + 310),
]
)
painter.setBrush(QColor(218, 225, 255, 205))
painter.setPen(QPen(QColor(145, 163, 242, 170), 1.3))
painter.drawPolygon(screen)
inner = QPolygonF(
[
QPointF(ox + 141, oy + 80),
QPointF(ox + 425, oy + 117),
QPointF(ox + 425, oy + 345),
QPointF(ox + 141, oy + 295),
]
)
painter.setBrush(QColor(255, 255, 255, 225))
painter.drawPolygon(inner)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(174, 189, 252, 180))
painter.drawRoundedRect(QRectF(ox + 148, oy + 89, 270, 15), 7, 7)
for dot_x in (156, 173, 190):
painter.setBrush(QColor(255, 255, 255, 200))
painter.drawEllipse(QPointF(ox + dot_x, oy + 96.5), 3.5, 3.5)
cards = (
QRectF(ox + 150, oy + 135, 82, 80),
QRectF(ox + 241, oy + 142, 72, 73),
QRectF(ox + 322, oy + 151, 95, 72),
QRectF(ox + 151, oy + 232, 264, 50),
QRectF(ox + 151, oy + 291, 264, 42),
)
painter.setPen(QPen(QColor(188, 199, 246, 160), 1))
painter.setBrush(QColor(249, 250, 255, 228))
for card in cards:
painter.drawRoundedRect(card, 7, 7)
painter.setPen(_round_pen(QColor(117, 137, 236, 165), 4))
for idx, bar_height in enumerate((20, 36, 51)):
x = ox + 164 + idx * 17
painter.drawLine(QPointF(x, oy + 200), QPointF(x, oy + 200 - bar_height))
painter.setBrush(QColor(112, 132, 235, 160))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(ox + 277, oy + 167), 10, 10)
painter.drawRoundedRect(QRectF(ox + 259, oy + 180, 36, 20), 10, 10)
painter.setPen(_round_pen(QColor(137, 153, 235, 150), 5))
for y, length in ((170, 54), (186, 42), (249, 89), (307, 72)):
painter.drawLine(
QPointF(ox + 340, oy + y), QPointF(ox + 340 + length, oy + y + 5)
)
self._draw_cube(painter, QPointF(ox + 352, oy - 25), 76, "plus")
self._draw_cube(painter, QPointF(ox - 8, oy + 210), 76, "shield")
painter.restore()
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
background.setColorAt(0.0, QColor("#FFFFFF"))
background.setColorAt(0.7, QColor("#FFFFFF"))
background.setColorAt(1.0, QColor("#F7F7F7"))
painter.setBrush(background)
painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawRoundedRect(bounds, 24, 24)
width, height = float(self.width()), float(self.height())
self._draw_illustration(painter, width, height)
left = 64.0 if width >= 620 else 38.0
if not self._brand_lockup.isNull():
logo_width = 180.0
logo_height = logo_width * (
self._brand_lockup.height() / self._brand_lockup.width()
)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(
QRectF(left, 24, logo_width, logo_height),
self._brand_lockup,
QRectF(self._brand_lockup.rect()),
)
tag_rect = QRectF(left, 238, 119, 40)
bounds = QRectF(self.rect()).adjusted(1, 1, -1, -1)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF1FA"))
painter.drawRoundedRect(tag_rect, 11, 11)
painter.setFont(_font(17, QFont.Weight.Medium))
painter.setPen(QColor("#4F63D9"))
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
painter.setBrush(QColor(PALETTE["sidebar"]))
painter.drawRoundedRect(bounds, 20, 20)
left = 36.0
width = max(240.0, self.width() - 72.0)
if not self._brand_lockup.isNull():
brand = self._brand_lockup.scaled(
226, 58, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
painter.drawPixmap(QPointF(left, 36), brand)
else:
painter.setFont(_font(22, QFont.Weight.DemiBold))
painter.setPen(QColor(PALETTE["heading"]))
painter.drawText(QPointF(left, 70), "甄养堂 · 医生工作站")
copy_left = left + 4
painter.setFont(_font(48, QFont.Weight.Medium))
painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
painter.drawText(QPointF(copy_left, 424), "留在一个")
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
highlight = QLinearGradient(
copy_left + prefix_width,
0,
copy_left + prefix_width + 264,
0,
)
highlight.setColorAt(0.0, QColor("#4F63D9"))
highlight.setColorAt(1.0, QColor("#4F63D9"))
painter.setPen(QPen(QBrush(highlight), 1))
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
suffix_x = (
copy_left
+ prefix_width
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
)
painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(suffix_x, 424), "。")
heading_y = max(158.0, min(218.0, self.height() * .27))
painter.setPen(QColor(PALETTE["muted"]))
painter.setFont(_font(13, QFont.Weight.Medium))
painter.drawText(QPointF(left, heading_y - 36), "医 生 工 作 台")
painter.setPen(QColor(PALETTE["heading"]))
painter.setFont(_font(30, QFont.Weight.DemiBold))
painter.drawText(QPointF(left, heading_y), "让诊疗有序,")
painter.drawText(QPointF(left, heading_y + 44), "让沟通更专注。")
painter.setFont(_font(14))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left, heading_y + 82), "从接诊、病历到处方,在一个工作台完成。")
body_left = left + 6
painter.setFont(_font(20))
painter.setPen(QColor("#606163"))
painter.drawText(
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
)
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
row_y = heading_y + 122
for index, (title, detail) in enumerate((
("今日接诊", "快速找到待接诊患者与预约安排"),
("诊疗记录", "病历、随访与面诊资料集中查看"),
("处方协作", "开方与审核流程清晰衔接"),
), start=1):
row = QRectF(left, row_y, width, 66)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(PALETTE["surface"]))
painter.drawRoundedRect(row, 12, 12)
painter.setFont(_font(13, QFont.Weight.DemiBold))
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(QRectF(left + 16, row_y, 28, 66), Qt.AlignmentFlag.AlignCenter, f"0{index}")
painter.setFont(_font(15, QFont.Weight.Medium))
painter.setPen(QColor(PALETTE["heading"]))
painter.drawText(QPointF(left + 58, row_y + 27), title)
painter.setFont(_font(12))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left + 58, row_y + 47), detail)
row_y += 78
if self.height() > row_y + 50:
painter.setFont(_font(12))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(QPointF(left, self.height() - 28), "仅供获授权的医疗人员使用")
painter.end()
painter.setFont(_font(16))
painter.setPen(QColor("#6A6B6D"))
painter.drawText(
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
)
painter.drawText(QPointF(body_left, height - 59), "请勿在公共设备保存账号信息")
class _PasswordField(QFrame):
"""Keep the grouped password control visibly focused for keyboard users."""
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
self.setProperty("focused", event.type() == QEvent.Type.FocusIn)
self.style().unpolish(self)
self.style().polish(self)
self.update()
return super().eventFilter(watched, event)
class _RevealButton(QToolButton):
@@ -392,8 +236,8 @@ class _ServerButton(QPushButton):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
painter.setBrush(QColor(PALETTE["accent_soft"] if self.underMouse() else PALETTE["surface"]))
painter.setPen(QPen(QColor(PALETTE["focus"] if self.hasFocus() else PALETTE["line_strong"]), 1))
painter.drawRoundedRect(rect, 12, 12)
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
painter.setPen(_round_pen("#6A6B6D", 1.8))
@@ -536,7 +380,7 @@ class LoginWindow(QMainWindow):
border: 1px solid #E4E4E5;
border-radius: 12px;
}
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
QFrame#PasswordField[focused="true"] { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox {
color: #606163;
spacing: 13px;
@@ -606,6 +450,9 @@ class LoginWindow(QMainWindow):
self.login_root.addWidget(self.brand_panel, 60)
self.login_root.addWidget(self._build_login_area(), 40)
self._restore_settings()
for widget in (canvas, *canvas.findChildren(QWidget)):
if widget.styleSheet():
widget.setStyleSheet(clinical_stylesheet(widget.styleSheet()))
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
"""Preserve the reference composition while remaining usable on small screens."""
@@ -625,18 +472,15 @@ class LoginWindow(QMainWindow):
# Keep the form fully visible when the 60/40 desktop split would
# otherwise crop its fixed-width card. Small windows focus on login.
self.brand_panel.setVisible(width >= 1120)
compact_height = event.size().height() < 720
self.card.setMinimumHeight(580 if compact_height else 620)
self.login_area_layout.setContentsMargins(
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
0, 12 if compact_height else min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
)
super().resizeEvent(event)
def _build_brand_panel(self) -> QWidget:
panel = _BrandPanel()
shadow = QGraphicsDropShadowEffect(panel)
shadow.setBlurRadius(32)
shadow.setOffset(0, 10)
shadow.setColor(QColor(67, 82, 154, 18))
panel.setGraphicsEffect(shadow)
return panel
def _build_login_area(self) -> QWidget:
@@ -660,11 +504,6 @@ class LoginWindow(QMainWindow):
self.card.setFixedWidth(480)
self.card.setMinimumHeight(620)
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
card_shadow = QGraphicsDropShadowEffect(self.card)
card_shadow.setBlurRadius(38)
card_shadow.setOffset(0, 16)
card_shadow.setColor(QColor(59, 77, 139, 24))
self.card.setGraphicsEffect(card_shadow)
card_layout = QVBoxLayout(self.card)
card_layout.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint)
card_layout.setContentsMargins(47, 42, 47, 26)
@@ -673,13 +512,13 @@ class LoginWindow(QMainWindow):
title = QLabel("欢迎回来")
title.setObjectName("LoginTitle")
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
title.setStyleSheet("color:#1A1C1F; font-size:26px; font-weight:600;")
title.setContentsMargins(1, -3, 0, 3)
title.setFixedHeight(46)
card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted")
subtitle.setStyleSheet("color:#606163; font-size:18px;")
subtitle.setStyleSheet("color:#606163; font-size:15px;")
subtitle.setContentsMargins(1, 8, 0, 0)
subtitle.setFixedHeight(27)
card_layout.addWidget(subtitle)
@@ -710,7 +549,7 @@ class LoginWindow(QMainWindow):
password_label.setFixedHeight(24)
card_layout.addWidget(password_label)
card_layout.addSpacing(11)
password_field = QFrame()
password_field = _PasswordField()
password_field.setObjectName("PasswordField")
password_field.setFixedHeight(54)
password_row = QHBoxLayout(password_field)
@@ -721,6 +560,7 @@ class LoginWindow(QMainWindow):
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setAccessibleName("登录密码")
self.password_edit.installEventFilter(password_field)
self.password_edit.setTextMargins(0, -2, 0, 2)
self.password_edit.returnPressed.connect(self.submit)
password_row.addWidget(self.password_edit, 1)
@@ -730,6 +570,7 @@ class LoginWindow(QMainWindow):
self.reveal_button.setCheckable(True)
self.reveal_button.setFixedSize(87, 52)
self.reveal_button.setToolTip("显示或隐藏密码")
self.reveal_button.installEventFilter(password_field)
self.reveal_button.toggled.connect(self._toggle_password)
password_row.addWidget(self.reveal_button)
card_layout.addWidget(password_field)
@@ -758,11 +599,6 @@ class LoginWindow(QMainWindow):
self.login_button = QPushButton("登录工作站")
self.login_button.setProperty("variant", "primary")
self.login_button.setFixedHeight(58)
login_shadow = QGraphicsDropShadowEffect(self.login_button)
login_shadow.setBlurRadius(20)
login_shadow.setOffset(0, 7)
login_shadow.setColor(QColor(82, 101, 246, 45))
self.login_button.setGraphicsEffect(login_shadow)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
self.debug_settings_section = QWidget()
+160 -24
View File
@@ -37,8 +37,10 @@ from PySide6.QtCore import (
QTimer,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QAbstractScrollArea,
QGraphicsOpacityEffect,
QPlainTextEdit,
QStackedWidget,
QWidget,
)
@@ -75,15 +77,35 @@ def reduced_motion() -> bool:
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
def _stop(target: QObject, key: str) -> None:
"""Retire a replaced animation without firing its completion callback."""
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
previous = running.get(key)
running = getattr(target, "_doctor_motion", {})
previous = running.pop(key, None)
if previous is not None:
previous.stop()
previous.setParent(None)
previous.deleteLater()
def _own(target: QObject, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
_stop(target, key)
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", {})
running[key] = animation
target._doctor_motion = running
def release() -> None:
if running.get(key) is animation:
running.pop(key)
# A callback can hold the last Python reference to the owner. Detach
# first so releasing that callback during deferred deletion cannot
# recursively delete this animation through its former parent.
animation.setParent(None)
animation.deleteLater()
animation.finished.connect(release)
return animation
@@ -107,7 +129,10 @@ def animate(
from inside its own ``finished`` emission.
"""
animation_owner = owner if owner is not None else target
animation_key = key or prop.decode()
if reduced_motion():
_stop(animation_owner, animation_key)
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
if on_finished is not None:
on_finished()
@@ -117,18 +142,24 @@ def animate(
animation.setEasingCurve(easing)
animation.setStartValue(start)
animation.setEndValue(end)
_own(animation_owner, animation_key, animation)
if on_finished is not None:
animation.finished.connect(on_finished)
_own(owner if owner is not None else target, key or prop.decode(), animation)
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
return animation
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect | None:
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
# Do not replace a caller's shadow or opacity effect: Qt deletes it when
# another effect is attached. Only effects created here belong to a fade.
if effect is not None and effect is not getattr(widget, "_doctor_opacity_effect", None):
return None
if effect is None:
effect = QGraphicsOpacityEffect(widget)
effect.setOpacity(1.0)
widget.setGraphicsEffect(effect)
widget._doctor_opacity_effect = effect
effect.setEnabled(True)
return effect
@@ -146,9 +177,17 @@ def _drop_effect(widget: QWidget) -> None:
signal that is still being delivered.
"""
effect = getattr(widget, "_doctor_opacity_effect", None)
revision = getattr(widget, "_doctor_fade_revision", 0)
def detach() -> None:
try:
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
if (
effect is not None
and widget.graphicsEffect() is effect
and getattr(widget, "_doctor_fade_revision", 0) == revision
):
widget._doctor_opacity_effect = None
widget.setGraphicsEffect(None)
except RuntimeError: # the widget went away while the fade was running
pass
@@ -156,6 +195,13 @@ def _drop_effect(widget: QWidget) -> None:
QTimer.singleShot(0, detach)
def _restart_fade(widget: QWidget) -> None:
_stop(widget, "fade")
# A completed fade may still have deferred cleanup waiting in the queue.
# It must not remove an effect now being used by a subsequent fade.
widget._doctor_fade_revision = getattr(widget, "_doctor_fade_revision", 0) + 1
def fade_in(
widget: QWidget,
*,
@@ -165,10 +211,20 @@ def fade_in(
) -> None:
"""Fade a widget up to full opacity, showing it first if needed."""
existing = widget.graphicsEffect()
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
start = float(existing.opacity())
_restart_fade(widget)
if reduced_motion():
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
existing.setOpacity(1.0)
_drop_effect(widget)
widget.show()
return
effect = _opacity_effect(widget)
if effect is None:
widget.show()
return
effect.setOpacity(start)
widget.show()
animate(
@@ -193,9 +249,11 @@ def fade_out(
) -> None:
"""Fade a widget down, optionally hiding it when the fade completes."""
_restart_fade(widget)
if reduced_motion():
if hide:
widget.hide()
_drop_effect(widget)
if on_finished is not None:
on_finished()
return
@@ -208,6 +266,9 @@ def fade_out(
if on_finished is not None:
on_finished()
if effect is None:
done()
return
animate(
effect,
b"opacity",
@@ -229,13 +290,17 @@ def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
the user has to wait out.
"""
previous = getattr(widget, "_doctor_motion", {}).get("enter")
origin = previous.endValue() if previous is not None else widget.pos()
if reduced_motion():
widget.show()
_stop(widget, "enter")
widget.move(origin)
fade_in(widget, duration=duration)
return
fade_in(widget, duration=duration)
if rise:
origin = widget.pos()
widget.move(origin + QPoint(0, rise))
if previous is None:
widget.move(origin + QPoint(0, rise))
animate(
widget,
b"pos",
@@ -260,7 +325,7 @@ def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None
return
stack.setCurrentIndex(index)
page = stack.currentWidget()
if page is None or reduced_motion():
if page is None:
return
enter(page, rise=rise)
@@ -274,50 +339,121 @@ SCROLL_STEP = 120
SCROLL_MS = 190
class _ScrollAnimation(QPropertyAnimation):
"""Identify animation writes so direct scrollbar changes can interrupt us."""
writing = False
def updateCurrentValue(self, value: Any) -> None: # noqa: N802 - Qt API
self.writing = True
try:
super().updateCurrentValue(value)
finally:
self.writing = False
class _SmoothScroller(QObject):
"""Animate a scroll area's wheel movement instead of jumping to it."""
_INTERRUPT_EVENTS = frozenset(
{
QEvent.Type.KeyPress,
QEvent.Type.MouseButtonPress,
QEvent.Type.TouchBegin,
QEvent.Type.Hide,
}
)
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
super().__init__(area)
self._area = area
self._orientation = orientation
self._bar = (
area.verticalScrollBar()
if orientation is Qt.Orientation.Vertical
else area.horizontalScrollBar()
)
self._target = self._bar.value()
self._animation = QPropertyAnimation(self._bar, b"value", self)
self._animation = _ScrollAnimation(self._bar, b"value", self)
self._animation.setEasingCurve(EASE_ENTER)
self._animation.setDuration(SCROLL_MS)
# Keyboard, programmatic and drag movements must not be fought over: when
# nothing is animating, the wheel target follows wherever the bar went.
self._bar.valueChanged.connect(self._sync_target)
self._bar.sliderPressed.connect(self._cancel)
self._bar.actionTriggered.connect(self._cancel)
self._bar.rangeChanged.connect(self._cancel)
self._bar.installEventFilter(self)
area.installEventFilter(self)
area.viewport().installEventFilter(self)
def _cancel(self, *_: Any) -> None:
self._animation.stop()
self._target = self._bar.value()
def _sync_target(self, value: int) -> None:
if self._animation.state() != QAbstractAnimation.State.Running:
if not self._animation.writing:
self._animation.stop()
self._target = value
def _uses_pixels(self) -> bool:
if isinstance(self._area, QAbstractItemView):
mode = (
self._area.verticalScrollMode()
if self._orientation == Qt.Orientation.Vertical
else self._area.horizontalScrollMode()
)
return mode == QAbstractItemView.ScrollMode.ScrollPerPixel
# QPlainTextEdit's vertical scrollbar counts text blocks, not pixels.
return not (
isinstance(self._area, QPlainTextEdit) and self._orientation == Qt.Orientation.Vertical
)
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
del watched
if event.type() is not QEvent.Type.Wheel or reduced_motion():
event_type = event.type()
if event_type in self._INTERRUPT_EVENTS:
self._cancel()
return False
delta = event.angleDelta().y() or event.angleDelta().x()
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
if event_type != QEvent.Type.Wheel:
return False
horizontal = self._orientation == Qt.Orientation.Horizontal
shifted = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
delta = event.angleDelta().x() if horizontal else event.angleDelta().y()
if horizontal and shifted and not delta:
delta = event.angleDelta().y()
if (
watched is not self._area.viewport()
or reduced_motion()
or not self._uses_pixels()
or not event.pixelDelta().isNull()
or event.phase() != Qt.ScrollPhase.NoScrollPhase
or event.modifiers()
& (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier)
or (shifted and not horizontal)
or not delta
):
# Trackpads already provide continuous motion and native momentum.
# Mixing another easing curve into it adds lag and fights gestures.
self._cancel()
return False
lower, upper = self._bar.minimum(), self._bar.maximum()
# Re-clamp first: the range can shrink underneath a running animation
# when the content behind it reloads, which would otherwise leave the
# pending target past the end of the new content.
self._target = max(lower, min(upper, self._target))
target = self._target - round(delta / 120.0 * SCROLL_STEP)
current = self._bar.value()
step = -round(delta / 120.0 * SCROLL_STEP)
# Reverse immediately from the visible position, not the end of a
# backlog of wheel notches still travelling in the opposite direction.
origin = current if (self._target - current) * step < 0 else self._target
target = origin + step
target = max(lower, min(upper, target))
# At either end, hand the wheel back so an enclosing scroll area still
# gets it - swallowing it there is what makes nested panes feel stuck.
if target == self._target:
return False
return target != current
self._target = target
self._animation.stop()
self._animation.setStartValue(self._bar.value())
self._animation.setStartValue(current)
self._animation.setEndValue(target)
self._animation.start()
return True
@@ -328,7 +464,7 @@ def install_smooth_scroll(
*,
orientation: Qt.Orientation = Qt.Orientation.Vertical,
) -> None:
"""Give a scroll area eased wheel scrolling."""
"""Ease discrete wheels on pixel-based areas; preserve native gestures."""
if getattr(area, "_doctor_smooth_scroll", None) is not None:
return
+120 -119
View File
@@ -50,6 +50,7 @@ from ...core.appointment_modes import (
)
from .. import icons
from ..appointment_drawer import AppointmentDrawer
from ..design_system import PALETTE, clinical_stylesheet
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
@@ -110,97 +111,97 @@ FULFILLMENT_TEXT = {
}
_SEMANTIC_COLORS = {
"primary": "#4F63D9",
"success": "#287B65",
"warning": "#A9691D",
"danger": "#BE4B58",
"info": "#4F63D9",
"muted": "#606163",
"primary": PALETTE["accent"],
"success": PALETTE["success"],
"warning": PALETTE["warning"],
"danger": PALETTE["danger"],
"info": PALETTE["accent"],
"muted": PALETTE["muted"],
}
PATIENTS_LIGHT_QSS = """
PATIENTS_LIGHT_QSS = clinical_stylesheet("""
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #1A1C1F;
color: $ds_heading;
font-size: 20px;
font-weight: 600;
}
#PatientsPage QWidget#PageHeader QLabel[role="muted"] {
color: #606163;
color: $ds_muted;
font-size: 13px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 32px;
max-height: 32px;
color: #FFFFFF;
background-color: #4F63D9;
border: 1px solid #4F63D9;
color: $ds_surface;
background-color: $ds_accent;
border: 1px solid $ds_accent;
border-radius: 7px;
font-weight: 500;
}
#PatientsPage QPushButton[patientSearchAction="true"]:hover {
background-color: #4156C4;
border-color: #4156C4;
background-color: $ds_accent_hover;
border-color: $ds_accent_hover;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 88px;
max-height: 88px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 12px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 32px;
max-height: 32px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 7px; font-size: 14px;
}
#PatientsPage QFrame[patientListFilter="true"] QPushButton {
min-height: 32px; max-height: 32px;
}
#PatientsPage QFrame[patientListFilter="true"] QDateEdit:disabled {
color: #8E8F90; background-color: #F7F7F7;
color: $ds_disabled; background-color: $ds_surface_alt;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 10px;
color: #606163;
background-color: #F7F7F7;
color: $ds_muted;
background-color: $ds_surface_alt;
border: 1px solid transparent;
border-radius: 5px;
}
#PatientsPage QPushButton[patientStatusChip="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QPushButton[patientStatusChip="true"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border: 1px solid $ds_focus;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 9px;
color: #1A1C1F;
color: $ds_heading;
background-color: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
#PatientsPage QPushButton[patientQuickDate="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QPushButton[patientQuickDate="true"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border-color: #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
border: 0;
border-top: 1px solid #EDEDEE;
border-top: 1px solid $ds_line;
background-color: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
@@ -208,124 +209,124 @@ PATIENTS_LIGHT_QSS = """
min-height: 34px;
padding: 0 6px;
margin-right: 8px;
color: #606163;
color: $ds_muted;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-weight: 400;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #4F63D9;
border-bottom-color: #4F63D9;
color: $ds_accent;
border-bottom-color: $ds_accent;
font-weight: 500;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 42px;
max-height: 42px;
padding: 0 11px;
color: #1A1C1F;
background-color: #F7F7F7;
border: 1px solid #EDEDEE;
color: $ds_heading;
background-color: $ds_surface_alt;
border: 1px solid $ds_line;
border-radius: 9px;
text-align: left;
font-weight: 500;
}
#PatientsPage QPushButton[summaryCard="true"]:hover {
color: #4F63D9;
background-color: #EEF1FA;
border-color: #8B9AD9;
color: $ds_accent;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QPushButton[rowAction="true"] {
min-height: 26px;
max-height: 26px;
padding: 0 7px;
color: #4F63D9;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
color: $ds_accent;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 6px;
font-size: 13px;
font-weight: 400;
}
#PatientsPage QPushButton[rowAction="true"]:hover {
background-color: #EEF1FA;
border-color: #8B9AD9;
background-color: $ds_accent_soft;
border-color: $ds_focus;
}
#PatientsPage QPushButton[rowAction="true"][variant="danger"] {
color: #BE4B58;
background-color: #FFF7F8;
border-color: #F4C4CC;
color: $ds_danger;
background-color: $ds_danger_soft;
border-color: $ds_danger_soft;
}
#PatientsPage QTableWidget#PatientTable QHeaderView::section {
min-height: 34px;
background-color: #F7F7F7; font-size: 13px;
background-color: $ds_surface_alt; font-size: 13px;
}
#PatientsPage QTableWidget#PatientTable::item { padding: 5px 7px; }
#PatientsPage QTableWidget#PatientTable {
gridline-color: #EDEDEE;
selection-background-color: #EEF1FA;
selection-color: #1A1C1F; font-size: 14px;
gridline-color: $ds_line;
selection-background-color: $ds_accent_soft;
selection-color: $ds_heading; font-size: 14px;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator {
width: 13px;
height: 13px;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
background-color: $ds_surface;
border: 1px solid $ds_line;
border-radius: 2px;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator:checked {
background-color: #4F63D9;
border-color: #4F63D9;
background-color: $ds_accent;
border-color: $ds_accent;
}
#PatientsPage QPushButton[variant="ghost"]:checked {
color: #4F63D9;
background-color: #EEF1FA;
border: 1px solid #4F63D9;
color: $ds_accent;
background-color: $ds_accent_soft;
border: 1px solid $ds_accent;
}
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #EEF1FA; }
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: $ds_accent_soft; }
#PatientsPage QPushButton[statusKind="info"]:checked {
color: #4F63D9;
border-color: #4F63D9;
color: $ds_accent;
border-color: $ds_accent;
}
#PatientsPage QPushButton[statusKind="warning"]:checked {
color: #A9691D;
border-color: #A9691D;
color: $ds_warning;
border-color: $ds_warning;
}
#PatientsPage QPushButton[statusKind="success"]:checked {
color: #287B65;
border-color: #287B65;
color: $ds_success;
border-color: $ds_success;
}
#PatientsPage QPushButton[statusKind="danger"]:checked {
color: #BE4B58;
border-color: #BE4B58;
color: $ds_danger;
border-color: $ds_danger;
}
#PatientsPage QLabel[metricKind="primary"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="info"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="success"] { color: #287B65; }
#PatientsPage QLabel[metricKind="warning"] { color: #A9691D; }
#PatientsPage QLabel[metricKind="danger"] { color: #BE4B58; }
#PatientsPage QLabel[metricKind="primary"] { color: $ds_accent; }
#PatientsPage QLabel[metricKind="info"] { color: $ds_accent; }
#PatientsPage QLabel[metricKind="success"] { color: $ds_success; }
#PatientsPage QLabel[metricKind="warning"] { color: $ds_warning; }
#PatientsPage QLabel[metricKind="danger"] { color: $ds_danger; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
background-color: transparent;
border: 0;
top: -1px;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
color: #606163;
color: $ds_muted;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-weight: 400;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:hover {
color: #4F63D9;
background-color: #EEF1FA;
color: $ds_accent;
background-color: $ds_accent_soft;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #4F63D9;
color: $ds_accent;
background-color: transparent;
border-bottom-color: #4F63D9;
border-bottom-color: $ds_accent;
font-weight: 500;
}
"""
""")
def _style_table_cell(table: SortableTable, row: int, column: int, kind: str) -> None:
@@ -1282,7 +1283,7 @@ class _PatientDateEdit(QDateEdit):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 15)
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 15)
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 15) / 2, 15, 15), glyph, QRectF(glyph.rect()))
painter.end()
@@ -1300,18 +1301,18 @@ class _PatientSummaryButton(QPushButton):
label, _, count = self.text().partition("\n")
left = max(18, (self.width() - 204) // 2)
icon_rect = QRectF(left, (self.height() - 36) / 2, 36, 36)
glyph = icons.pixmap("calendar", "#1769E8", 36)
glyph = icons.pixmap("calendar", PALETTE["accent"], 36)
painter.drawPixmap(icon_rect, glyph, QRectF(glyph.rect()))
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#5D6B80"))
painter.setPen(QColor(PALETTE["muted"]))
text_rect = self.rect().adjusted(left + 46, 10, -12, -32)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
painter.fontMetrics().elidedText(label, Qt.TextElideMode.ElideRight, text_rect.width()))
font.setPixelSize(16)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
painter.drawText(self.rect().adjusted(left + 46, 31, -12, -9),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, count)
painter.end()
@@ -1324,11 +1325,11 @@ class _PatientInfoDelegate(QStyledItemDelegate):
painter.save()
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#EAF2FF" if selected else "#F6F9FE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
if index.column() in (0, 9):
painter.restore()
return
@@ -1337,9 +1338,9 @@ class _PatientInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
def draw(text: str, target: Any, color: str = "#273244", centered: bool = False) -> None:
def draw(text: str, target: Any, color: str = PALETTE["text"], centered: bool = False) -> None:
painter.setPen(QColor(color))
text = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, target.width())
alignment = Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignHCenter if centered else Qt.AlignmentFlag.AlignLeft)
@@ -1359,17 +1360,17 @@ class _PatientInfoDelegate(QStyledItemDelegate):
doctor = display_text(first_value(row, "appointment_doctor_name"), "")
status, _kind = _patient_status(row)
if not doctor:
draw(status, rect, "#5D6B80")
draw(status, rect, PALETTE["muted"])
else:
draw(doctor, top)
font.setPixelSize(13)
painter.setFont(font)
color, fill = ("#A9691D", "#FFF7EA") if status == "已过号" else ("#1769E8", "#F0F6FF")
color, fill = (PALETTE["warning"], PALETTE["warning_soft"]) if status == "已过号" else (PALETTE["accent"], PALETTE["accent_soft"])
if status == "已取消":
color, fill = "#5D6B80", "#F5F7FA"
color, fill = PALETTE["muted"], PALETTE["canvas"]
badge = QRectF(bottom.x(), bottom.y() + 1, min(bottom.width(), painter.fontMetrics().horizontalAdvance(status) + 10), 20)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QColor("#E7CDA8" if status == "已过号" else "#C7DAF7"))
painter.setPen(QColor(PALETTE["warning_soft"] if status == "已过号" else PALETTE["line_strong"]))
painter.setBrush(QColor(fill))
painter.drawRoundedRect(badge, 3, 3)
draw(status, badge.toRect(), color, True)
@@ -1385,7 +1386,7 @@ class _PatientInfoDelegate(QStyledItemDelegate):
draw(value, rect)
else:
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
draw(text, rect, "#A9691D" if index.column() == 6 and text == "待确认" else "#273244", index.column() == 5)
draw(text, rect, PALETTE["warning"] if index.column() == 6 and text == "待确认" else PALETTE["text"], index.column() == 5)
painter.restore()
@@ -1493,7 +1494,7 @@ class PatientListWorkspace(QWidget):
self.keyword_edit.setMaximumWidth(360)
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.returnPressed.connect(self.search)
search_row.addWidget(self.keyword_edit, 1)
self.status_label = QLabel("患者状态:")
@@ -1846,7 +1847,7 @@ class PatientListWorkspace(QWidget):
)
host = RowActions(row_actions, self.table)
if host.more_button is not None:
host.more_button.setIcon(icons.icon("chevron_down", "#5D6B80", 12))
host.more_button.setIcon(icons.icon("chevron_down", PALETTE["muted"], 12))
host.more_button.setIconSize(QSize(12, 12))
host.more_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
host.more_button.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
@@ -2079,7 +2080,7 @@ class _OrderComboBox(QComboBox):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2088,7 +2089,7 @@ class _OrderActionButton(QToolButton):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80" if self.isEnabled() else "#97A4B6", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
painter.drawPixmap(QRectF(self.width() - 23, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2107,18 +2108,18 @@ class _OrderInfoDelegate(QStyledItemDelegate):
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F5F8FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
rect = option.rect.adjusted(16, 0, -12, -1)
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
def draw(value: str, target: Any, color: str = "#273244", right: bool = False) -> None:
def draw(value: str, target: Any, color: str = PALETTE["text"], right: bool = False) -> None:
painter.setPen(QColor(color))
value = painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, target.width())
painter.drawText(target, Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignRight if right else Qt.AlignmentFlag.AlignLeft), value)
@@ -2138,7 +2139,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
font.setWeight(QFont.Weight.Normal)
font.setPixelSize(13)
painter.setFont(font)
draw(secondary, bottom, "#5D6B80")
draw(secondary, bottom, PALETTE["muted"])
else:
draw(text, rect)
elif column in (9, 11):
@@ -2147,7 +2148,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
draw(primary, top)
font.setPixelSize(13)
painter.setFont(font)
draw(("(" if column == 9 else "") + secondary, bottom, "#5D6B80")
draw(("(" if column == 9 else "") + secondary, bottom, PALETTE["muted"])
else:
draw(text, rect)
elif column in (4, 5, 6):
@@ -2158,10 +2159,10 @@ class _OrderInfoDelegate(QStyledItemDelegate):
else:
kind = "blue" if raw in {3, 5, 6, 7, 8, 11} else "danger" if raw in {4, 9, 10} else "warning" if raw in {1, 2, 12} else "neutral"
foreground, background, border = {
"blue": ("#1769E8", "#F2F7FF", "#CEE0FA"),
"warning": ("#AD640C", "#FFFAF2", "#F2DABD"),
"danger": ("#BE4657", "#FFF5F6", "#F0D0D7"),
"neutral": ("#5D6B80", "#F6F8FC", "#E2E9F2"),
"blue": (PALETTE["accent"], PALETTE["accent_soft"], PALETTE["line_strong"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"], PALETTE["danger_soft"]),
"neutral": (PALETTE["muted"], PALETTE["disabled_surface"], PALETTE["line"]),
}[kind]
font.setPixelSize(13)
painter.setFont(font)
@@ -2275,7 +2276,7 @@ class PatientOrdersWorkspace(QWidget):
self.keyword_edit.setMinimumWidth(240)
self.keyword_edit.setPlaceholderText("订单号 / 患者 / 手机号 / 处方ID / 诊单ID")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.returnPressed.connect(self.search)
self.filter_top_row.addWidget(self.keyword_edit, 1)
self.audit_host = QWidget()
@@ -2747,11 +2748,11 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
rect = option.rect.adjusted(16, 0, -16, -1)
font = QFont(body_family())
font.setPixelSize(14)
@@ -2762,20 +2763,20 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
painter.setFont(font)
raw_text = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if raw_text is None else str(raw_text)
color = "#273244"
color = PALETTE["text"]
if not self.schedule and index.column() == 5 and text and text != "—":
row = index.data(Qt.ItemDataRole.UserRole)
status = str(first_value(row, "queue_status", default="") or "").lower()
color = {
"consulting": "#1769E8", "next": "#1555B6",
"waiting": "#AD640C", "missed": "#BE4657",
}.get(status, "#5D6B80")
"consulting": PALETTE["accent"], "next": PALETTE["accent_hover"],
"waiting": PALETTE["warning"], "missed": PALETTE["danger"],
}.get(status, PALETTE["muted"])
painter.setPen(QColor(color))
alignment = Qt.AlignmentFlag.AlignVCenter
alignment |= Qt.AlignmentFlag.AlignHCenter if self.schedule and 1 <= index.column() <= 5 else Qt.AlignmentFlag.AlignLeft
painter.drawText(rect, alignment, painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width()))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
painter.restore()
@@ -2875,7 +2876,7 @@ class PatientProgressWorkspace(QWidget):
row = QHBoxLayout(frame)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(0)
divider = QFrame()
divider = QFrame(frame)
divider.setObjectName("ProgressMetricDivider")
divider.setFixedSize(1, 60)
divider.setVisible(key != "total")
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
)
from .. import motion
from ..design_system import PALETTE
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..filter_disclosure import FilterDisclosure
@@ -119,7 +120,7 @@ def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> Met
icon.setProperty("metricIcon", True)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(32, 32)
color = "#1769E8" if kind == "accent" else "#60789F"
color = PALETTE["accent"] if kind == "accent" else PALETTE["muted"]
icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
card.layout().addWidget(icon)
return card
@@ -543,7 +544,7 @@ class PrescriptionLibraryPage(QWidget):
name_item = self.table.item(row_index, 1)
if name_item is not None:
name_item.setForeground(QColor("#1A1C1F"))
name_item.setForeground(QColor(PALETTE["heading"]))
font = name_item.font()
font.setWeight(QFont.Weight.Medium)
name_item.setFont(font)
@@ -50,6 +50,7 @@ from PySide6.QtWidgets import (
)
from .. import icons, motion
from ..design_system import PALETTE, clinical_stylesheet
from ..dialogs.issued_prescription_ai import (
PrescriptionAiStatisticsDialog,
agreement_safe_batch,
@@ -229,11 +230,11 @@ _ROLE_AVATAR = Qt.ItemDataRole.UserRole + 11
_ROLE_LEAD_ICON = Qt.ItemDataRole.UserRole + 12
_TAG_PALETTE = {
"accent": ("#4F63D9", "#EEF1FA"),
"success": ("#287B65", "#E7F8F1"),
"warning": ("#A9691D", "#FFF5DF"),
"danger": ("#BE4B58", "#FFF0F2"),
"neutral": ("#606163", "#F7F7F7"),
"accent": (PALETTE["accent"], PALETTE["accent_soft"]),
"success": (PALETTE["success"], PALETTE["success_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]),
"danger": (PALETTE["danger"], PALETTE["danger_soft"]),
"neutral": (PALETTE["muted"], PALETTE["surface_alt"]),
}
@@ -293,7 +294,7 @@ class _RowDecorationDelegate(QStyledItemDelegate):
glyph,
QRectF(glyph.rect()),
)
painter.setPen(QColor("#1A1C1F"))
painter.setPen(QColor(PALETTE["heading"]))
painter.setFont(style_option.font)
text_rect = rect.adjusted(28, 0, -8, 0)
painter.drawText(
@@ -306,17 +307,17 @@ class _RowDecorationDelegate(QStyledItemDelegate):
else:
initial = str(avatar)[:1] or "医"
circle = QRectF(rect.left() + 8, rect.center().y() - 10, 20, 20)
painter.setPen(QPen(QColor("#EDEDEE"), 1.0))
painter.setBrush(QColor("#F7F7F7"))
painter.setPen(QPen(QColor(PALETTE["line"]), 1.0))
painter.setBrush(QColor(PALETTE["surface_alt"]))
painter.drawEllipse(circle.adjusted(0.5, 0.5, -0.5, -0.5))
initial_font = QFont(style_option.font)
initial_font.setPixelSize(10)
initial_font.setWeight(QFont.Weight.Medium)
painter.setFont(initial_font)
painter.setPen(QColor("#6A6B6D"))
painter.setPen(QColor(PALETTE["muted"]))
painter.drawText(circle, Qt.AlignmentFlag.AlignCenter, initial)
painter.setFont(style_option.font)
painter.setPen(QColor("#1A1C1F"))
painter.setPen(QColor(PALETTE["heading"]))
name_rect = rect.adjusted(35, 0, -8, 0)
painter.drawText(
name_rect,
@@ -509,11 +510,11 @@ class _PrescriptionNumberDelegate(QStyledItemDelegate):
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
document.setDefaultTextOption(text_option)
paragraphs = [
f'<p style="margin:0;color:#1A1C1F;font-size:14px;font-weight:500">{escape(number)}</p>',
f'<p style="margin:2px 0 0;color:#606163;font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
f'<p style="margin:0;color:{PALETTE["heading"]};font-size:14px;font-weight:500">{escape(number)}</p>',
f'<p style="margin:2px 0 0;color:{PALETTE["muted"]};font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
]
paragraphs.extend(
'<p style="margin:2px 0 0;color:#BE4B58;font-size:13px;font-weight:400">'
f'<p style="margin:2px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">'
+ escape(warning)
+ "</p>"
for warning in _order_warnings(row)
@@ -603,14 +604,14 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
rect = option.rect.adjusted(2, 1, -2, -1)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
painter.setBrush(QColor(PALETTE["accent_soft"] if checked else PALETTE["canvas"] if hovered else PALETTE["surface"]))
painter.drawRoundedRect(rect, 5, 5)
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
painter.setPen(QPen(QColor(PALETTE["accent"] if checked else PALETTE["line_strong"]), 1))
painter.setBrush(QColor(PALETTE["accent"] if checked else PALETTE["surface"]))
painter.drawRoundedRect(box, 3, 3)
if checked:
pen = QPen(QColor("#FFFFFF"), 2)
pen = QPen(QColor(PALETTE["surface"]), 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
x, y = int(box.x()), int(box.y())
@@ -619,16 +620,16 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
text_rect = rect.adjusted(38, 0, -54, 0)
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
if checked:
painter.setPen(QColor("#1769E8"))
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#ADC8F2"), 1))
painter.setPen(QPen(QColor(PALETTE["line_strong"]), 1))
painter.drawRoundedRect(rect, 5, 5)
painter.restore()
@@ -674,18 +675,18 @@ class _DoctorSelectionDialog(QDialog):
self.setObjectName("DoctorSelectionDialog")
self.setWindowTitle("选择医生")
self.resize(420, 500)
self.setStyleSheet("""
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
""")
self.setStyleSheet(clinical_stylesheet("""
QDialog#DoctorSelectionDialog { background: $ds_surface; color: $ds_text; }
QListWidget#DoctorOptionsList { background: $ds_surface; border: 1px solid $ds_line; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: $ds_accent; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid $ds_line; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: $ds_accent; }
QDialog#DoctorSelectionDialog QPushButton { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: $ds_canvas; border-color: $ds_line_strong; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: $ds_disabled; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: $ds_accent; color: $ds_surface; border-color: $ds_accent; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
"""))
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
@@ -793,9 +794,9 @@ class DoctorMultiSelect(QWidget):
self.button.setText("、".join(names[:2]) + (f" 等{len(names)}人" if len(names) > 2 else ""))
def _blue_prescription_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
def _blue_prescription_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
result = QIcon(icons.icon(kind, color, size))
result.addPixmap(icons.pixmap(kind, "#A4ADBA", size), QIcon.Mode.Disabled)
result.addPixmap(icons.pixmap(kind, PALETTE["disabled"], size), QIcon.Mode.Disabled)
return result
@@ -803,7 +804,7 @@ class _PrescriptionCombo(QComboBox):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -812,7 +813,7 @@ class _PrescriptionDateTime(QDateTimeEdit):
def paintEvent(self, event: Any) -> None:
super().paintEvent(event)
painter = QPainter(self)
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 14)
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -825,7 +826,7 @@ class _PrescriptionDoctorButton(QPushButton):
option.text = self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, max(1, self.width() - 48))
painter = QPainter(self)
self.style().drawControl(QStyle.ControlElement.CE_PushButton, option, painter, self)
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
painter.end()
@@ -837,13 +838,13 @@ class _PrescriptionDoctorButton(QPushButton):
def _prescription_cell_background(painter: QPainter, option: Any, index: QModelIndex) -> None:
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
painter.setPen(QColor("#E6EDF6"))
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
@@ -875,7 +876,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(14)
painter.setFont(font)
painter.setPen(QColor("#273244"))
painter.setPen(QColor(PALETTE["text"]))
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
column = index.column()
if column == 0:
@@ -888,9 +889,9 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
elif column in (3, 6):
kind = index.data(_ROLE_TAG_KIND)
foreground, background = {
"accent": ("#1769E8", "#EDF4FF"), "success": ("#1769E8", "#EDF4FF"),
"warning": ("#AD640C", "#FFF5E5"), "danger": ("#BE4657", "#FFF0F2"),
}.get(kind, ("#5D6B80", "#F5F8FD"))
"accent": (PALETTE["accent"], PALETTE["accent_soft"]), "success": (PALETTE["accent"], PALETTE["accent_soft"]),
"warning": (PALETTE["warning"], PALETTE["warning_soft"]), "danger": (PALETTE["danger"], PALETTE["danger_soft"]),
}.get(kind, (PALETTE["muted"], PALETTE["surface_alt"]))
font.setPixelSize(13)
painter.setFont(font)
label = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max(1, rect.width() - 12))
@@ -918,7 +919,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#5D6B80"))
painter.setPen(QColor(PALETTE["muted"]))
second_rect = first_rect.translated(0, 26)
painter.drawText(second_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(secondary, Qt.TextElideMode.ElideRight, rect.width()))
else:
@@ -942,11 +943,11 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
document.setDefaultTextOption(text_option)
paragraphs = [
f'<p style="margin:0;color:#273244;font-size:14px;font-weight:400">{escape(number)}</p>',
f'<p style="margin:6px 0 0;color:#5D6B80;font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
f'<p style="margin:0;color:{PALETTE["text"]};font-size:14px;font-weight:400">{escape(number)}</p>',
f'<p style="margin:6px 0 0;color:{PALETTE["muted"]};font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
]
paragraphs.extend(
'<p style="margin:6px 0 0;color:#BE4657;font-size:13px;font-weight:400">' + escape(warning) + "</p>"
f'<p style="margin:6px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">' + escape(warning) + "</p>"
for warning in _order_warnings(row)
)
document.setHtml("".join(paragraphs))
@@ -976,7 +977,7 @@ class _PrescriptionBlueTable(SortableTable):
for button in widget.findChildren(QPushButton):
kind = {"查看处方": "eye", "编辑处方": "pencil", "删除处方": "trash"}.get(button.accessibleName())
if kind:
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
class PrescriptionsPage(QWidget):
@@ -1060,7 +1061,7 @@ class PrescriptionsPage(QWidget):
self.add_button.setObjectName("PrescriptionAddButton")
self.add_button.setMinimumWidth(122)
self.add_button.setProperty("variant", "primary")
self.add_button.setIcon(_blue_prescription_icon("plus", "#FFFFFF"))
self.add_button.setIcon(_blue_prescription_icon("plus", PALETTE["surface"]))
self.add_button.setIconSize(QSize(15, 15))
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
@@ -1079,7 +1080,7 @@ class PrescriptionsPage(QWidget):
self.table_card = table_card
root.addWidget(table_card, 1)
for button, kind in ((self.view_button, "eye"), (self.edit_button, "pencil"), (self.delete_button, "trash")):
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
self._load_doctor_options()
def _build_filters(self) -> QWidget:
@@ -1226,7 +1227,7 @@ class PrescriptionsPage(QWidget):
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
refresh.setIcon(_blue_prescription_icon("refresh", PALETTE["muted"]))
refresh.setIconSize(QSize(15, 15))
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
@@ -1287,7 +1288,7 @@ class PrescriptionsPage(QWidget):
self.table.setItemDelegate(_PrescriptionInfoDelegate(self.table))
self.table.setItemDelegateForColumn(1, _PrescriptionNumberBlueDelegate(self.table))
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", PALETTE["disabled"], 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
@@ -1537,7 +1538,7 @@ class PrescriptionsPage(QWidget):
sn_item = self.table.item(row_index, 1)
if sn_item is not None:
sn_item.setForeground(QColor("#1A1C1F"))
sn_item.setForeground(QColor(PALETTE["heading"]))
font = sn_item.font()
font.setWeight(QFont.Weight.Normal)
sn_item.setFont(font)
@@ -75,6 +75,7 @@ from PySide6.QtWidgets import (
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from .. import icons
from ..design_system import PALETTE, clinical_stylesheet
from ..diagnosis_drawer import DailyRecordPanel
from ..diagnosis_editors import FlowLayout
from ..dialogs import DiagnosisDialog
@@ -2020,7 +2021,7 @@ def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
outline (bubble / chart page / capsule / clipboard / calendar-clock).
"""
return icons.icon(kind, "#5D6B80", size)
return icons.icon(kind, PALETTE["muted"], size)
def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon:
@@ -2882,7 +2883,7 @@ class _ReceptionSparkIcon(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
glyph = icons.pixmap("ai", "#1769E8", 18)
glyph = icons.pixmap("ai", PALETTE["accent"], 18)
painter.drawPixmap(QRectF(0, 0, 18, 18), glyph, QRectF(glyph.rect()))
painter.end()
@@ -2896,9 +2897,9 @@ class _ReceptionAiSectionIcon(QWidget):
#: Section tone -> (plate, glyph tint, shared glyph name).
_TONES = {
"diagnosis": ("#EAF2FF", "#1769E8", "report"),
"risk": ("#EAF2FF", "#1769E8", "alert"),
"treatment": ("#EAF2FF", "#1769E8", "meds"),
"diagnosis": (PALETTE["accent_soft"], PALETTE["accent"], "report"),
"risk": (PALETTE["accent_soft"], PALETTE["accent"], "alert"),
"treatment": (PALETTE["accent_soft"], PALETTE["accent"], "meds"),
}
def __init__(self, tone: str, parent: QWidget | None = None) -> None:
@@ -2949,9 +2950,9 @@ class _ReceptionAssistantIcon(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#1769E8"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(QRectF(1, 1, 22, 22), 4, 4)
painter.setPen(QColor("#FFFFFF"))
painter.setPen(QColor(PALETTE["surface"]))
font = painter.font()
font.setPixelSize(11)
font.setBold(True)
@@ -2975,7 +2976,7 @@ class _ReceptionSendButton(QPushButton):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(
QPen(
QColor("#FFFFFF"),
QColor(PALETTE["surface"]),
1.8,
Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap,
@@ -3053,7 +3054,7 @@ class _ReceptionDateButton(QPushButton):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#5D6B80")
color = QColor(PALETTE["muted"])
painter.setPen(
QPen(
color,
@@ -3087,7 +3088,7 @@ class _ReceptionAiExpandButton(QPushButton):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#1769E8" if self.isEnabled() else "#8E8F90")
color = QColor(PALETTE["accent"] if self.isEnabled() else PALETTE["disabled"])
painter.setPen(
QPen(
color,
@@ -3120,7 +3121,7 @@ class _ReceptionAiModelCombo(QComboBox):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#273244" if self.isEnabled() else "#8E8F90")
color = QColor(PALETTE["text"] if self.isEnabled() else PALETTE["disabled"])
painter.setPen(
QPen(
color,
@@ -3162,7 +3163,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
self.resize(920, 760)
self.setMinimumSize(720, 560)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setStyleSheet(RECEPTION_AI_DIALOG_QSS)
self.setStyleSheet(clinical_stylesheet(RECEPTION_AI_DIALOG_QSS))
root = QVBoxLayout(self)
root.setContentsMargins(18, 18, 18, 18)
@@ -3365,7 +3366,11 @@ class _ReceptionAiAnalysisDialog(QDialog):
source_label.setTextFormat(Qt.TextFormat.PlainText)
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
source_label.setWordWrap(True)
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
source_label.setStyleSheet(clinical_stylesheet(
"color: $ds_muted; font-size: 12px; padding: 10px 14px; "
"background: $ds_surface_alt; border: 1px solid $ds_line; "
"border-radius: $ds_radius_control;"
))
body_layout.addWidget(source_label)
self.source_label = source_label
body_layout.addStretch(1)
@@ -3614,9 +3619,9 @@ class _VitalMetricIcon(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.drawRoundedRect(QRectF(0, 0, 22, 22), 6, 6)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), "#1769E8", 15)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), PALETTE["accent"], 15)
painter.drawPixmap(QRectF(3.5, 3.5, 15, 15), glyph, QRectF(glyph.rect()))
painter.end()
@@ -3636,7 +3641,7 @@ class _LifestyleIcon(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), "#5D6B80", 16)
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), PALETTE["muted"], 16)
painter.drawPixmap(QRectF(0, 0, 16, 16), glyph, QRectF(glyph.rect()))
painter.end()
@@ -3672,7 +3677,7 @@ class _GenderMark(QWidget):
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor("#5D6B80"), 1.35)
pen = QPen(QColor(PALETTE["muted"]), 1.35)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
@@ -3737,7 +3742,7 @@ class _ReceptionQueueSpinner(QWidget):
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen(QColor("#1769E8"), 2.2)
pen = QPen(QColor(PALETTE["accent"]), 2.2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawArc(QRectF(2, 2, 14, 14), self._angle * 16, 250 * 16)
@@ -3873,15 +3878,15 @@ class QueueRow(QWidget):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
if self._selected:
painter.setBrush(QColor("#EAF2FF"))
painter.setPen(QPen(QColor("#E5ECF6"), 1))
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
else:
painter.setBrush(QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#E5ECF6"), 1))
painter.drawRoundedRect(rect, 6, 6)
painter.setBrush(QColor(PALETTE["surface"]))
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
painter.drawRoundedRect(rect, 10, 10)
if self._selected:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#1769E8"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(QRectF(1.2, 1, 3.2, self.height() - 2), 1.6, 1.6)
@@ -4074,7 +4079,7 @@ class ReceptionDailyRecordsPanel(DailyRecordPanel):
font = item.font()
font.setWeight(QFont.Weight.DemiBold if high else QFont.Weight.Normal)
item.setFont(font)
item.setForeground(QBrush(QColor("#BE4B58" if high else "#273244")))
item.setForeground(QBrush(QColor(PALETTE["danger"] if high else PALETTE["text"])))
item.setBackground(
QBrush(
QColor(
@@ -4103,9 +4108,11 @@ class ReceptionPage(QWidget):
super().__init__(parent)
self.setObjectName("ReceptionPage")
self.setStyleSheet(
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
.replace("$heading_family", heading_family())
.replace("$number_family", number_family())
clinical_stylesheet(
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
.replace("$heading_family", heading_family())
.replace("$number_family", number_family())
)
)
self.repository = repository
self.permissions = permissions
@@ -4262,7 +4269,7 @@ class ReceptionPage(QWidget):
header_layout.addWidget(title)
header_layout.addStretch(1)
self.refresh_button = QPushButton("", header)
self.refresh_button.setIcon(icons.icon("refresh", "#5D6B80", 18))
self.refresh_button.setIcon(icons.icon("refresh", PALETTE["muted"], 18))
self.refresh_button.setObjectName("ReceptionRefreshButton")
self.refresh_button.setFixedSize(34, 32)
self.refresh_button.setAccessibleName("刷新接诊台")
@@ -4454,7 +4461,7 @@ class ReceptionPage(QWidget):
self.video_button.clicked.connect(self._request_video)
actions.addWidget(self.video_button)
self.more_button = QPushButton(hero)
self.more_button.setIcon(icons.icon("more", "#5D6B80", 16))
self.more_button.setIcon(icons.icon("more", PALETTE["muted"], 16))
self.more_button.setIconSize(QSize(16, 16))
self.more_button.setToolTip("更多操作")
self.more_button.setAccessibleName("更多操作")
@@ -5286,7 +5293,9 @@ class ReceptionPage(QWidget):
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.ai_source_label.setWordWrap(True)
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
self.ai_source_label.setStyleSheet(clinical_stylesheet(
"color: $ds_muted; font-size: 12px;"
))
analysis_layout.addWidget(self.ai_source_label)
self.ai_detail_actions_host = QWidget(analysis_card)
@@ -1,119 +1,126 @@
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return Template(_ORDERS).substitute(body=body_family())
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: #273244; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return clinical_stylesheet(Template(_ORDERS).safe_substitute(body=body_family()))
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: $ds_text; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientOrdersWorkspace QFrame#OrderSummaryStrip { background: transparent; border: 0; }
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid transparent;
border-radius: $ds_radius_control; background: $ds_surface_alt; color: $ds_text;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: $ds_accent_soft; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton:pressed,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:pressed { background: $ds_accent_pressed; border-color: $ds_accent_pressed; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton:focus,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled,
#PatientOrdersWorkspace QPushButton#OrderSearchButton:disabled,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: $ds_focus; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: $ds_text;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: $ds_accent_soft; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: $ds_surface; alternate-background-color: $ds_surface;
color: $ds_text; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid $ds_line_strong; border-radius: 3px; background: $ds_surface;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: $ds_focus; }
#PatientOrdersWorkspace QCheckBox:focus { color: $ds_accent; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: $ds_line; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: $ds_muted; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: $ds_text; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: $ds_accent; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: $ds_surface; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: $ds_accent; background: $ds_accent_soft; }
#PatientOrdersWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -1,54 +1,55 @@
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return Template(_PROGRESS).substitute(body=body_family())
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: #273244; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return clinical_stylesheet(Template(_PROGRESS).safe_substitute(body=body_family()))
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: $ds_text; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: $ds_line; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: $ds_text; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: $ds_accent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: $ds_accent_soft; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientProgressWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
+154 -141
View File
@@ -1,141 +1,154 @@
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
from string import Template
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
def patient_list_stylesheet() -> str:
return Template(_LIST).substitute(body=body_family())
_CHROME = """
#PatientsPage { background: #F3F7FD; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: #273244; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: #5D6B80; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientSummaryStrip,
#PatientListWorkspace QFrame#PatientListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
}
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
}
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF;
}
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientListWorkspace QDateEdit:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
#PatientListWorkspace QTableWidget#PatientTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
"""Quiet clinical surfaces for the patient-list workspace."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return clinical_stylesheet(Template(_CHROME).safe_substitute(body=body_family(), heading=heading_family()))
def patient_list_stylesheet() -> str:
return clinical_stylesheet(Template(_LIST).safe_substitute(body=body_family()))
_CHROME = """
#PatientsPage { background: $ds_canvas; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: $ds_muted;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; }
#PatientsPage QPushButton#PatientRefreshButton:pressed { background: $ds_accent_soft; }
#PatientsPage QPushButton#PatientRefreshButton:focus { border-color: $ds_focus; }
#PatientsPage QPushButton#PatientRefreshButton:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: $ds_accent; background: $ds_accent_soft; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: $ds_text; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: $ds_muted; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientListCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PatientListWorkspace QFrame#PatientSummaryStrip { background: transparent; border: 0; }
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: $ds_accent; background: $ds_surface_alt; border-color: $ds_line_strong;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: $ds_focus; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: $ds_accent_hover; }
#PatientSearchToolbar QPushButton#PatientSearchButton:pressed { background: $ds_accent_pressed; }
#PatientSearchToolbar QPushButton#PatientSearchButton:focus { border-color: $ds_focus; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: $ds_surface;
border: 1px solid $ds_line; border-radius: $ds_radius_control; selection-background-color: $ds_accent_soft;
}
#PatientSearchToolbar QLineEdit:focus { border-color: $ds_focus; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: $ds_text; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: $ds_accent; background: $ds_surface_alt; }
#PatientListWorkspace QPushButton[patientStatusChip="true"]:focus { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: $ds_accent; background: $ds_accent_soft; border: 1px solid transparent; font-weight: 600;
}
#PatientListWorkspace QPushButton[patientQuickDate="true"]:focus,
#PatientListWorkspace QPushButton#PatientCustomDateButton:focus { border-color: $ds_focus; }
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
selection-background-color: $ds_accent_soft;
}
#PatientListWorkspace QDateEdit:focus { border-color: $ds_focus; }
#PatientListWorkspace QDateEdit:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent;
border: 1px solid transparent; border-radius: $ds_radius_control;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: $ds_surface_alt; }
#PatientListWorkspace QPushButton[summaryCard="true"]:focus { border-color: $ds_focus; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: $ds_line; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: $ds_text; }
#PatientListWorkspace QTableWidget#PatientTable {
background: $ds_surface; alternate-background-color: $ds_surface; border: 0; border-radius: 0;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-top: 1px solid $ds_line; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 1px solid transparent;
border-radius: $ds_radius_small; background: transparent; color: $ds_accent; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: $ds_text; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: $ds_accent_soft; }
#PatientListWorkspace QPushButton[rowAction="true"]:focus,
#PatientListWorkspace QToolButton#RowActionsMore:focus { border-color: $ds_focus; }
#PatientListWorkspace QMenu { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: $ds_accent_soft; color: $ds_accent; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: $ds_surface; border: 1px solid $ds_line_strong; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
#PatientListWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PatientListWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -1,410 +1,419 @@
"""Local technology-blue rendering for the approved prescription library."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, TableColumn, first_value
def library_stylesheet() -> str:
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, "#A4ADBA"),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Fit readable columns while preserving actions and selected template IDs."""
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(columns, parent)
self.horizontalHeader().setStretchLastSection(False)
self._minimum_widths = tuple(column.width for column in self.columns)
self._manual_widths: dict[int, int] = {}
self._fitting_columns = False
self._fit_timer = QTimer(self)
self._fit_timer.setSingleShot(True)
self._fit_timer.timeout.connect(self._fit_columns)
self.horizontalHeader().sectionResized.connect(self._column_resized)
self.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
self._schedule_column_fit()
return super().eventFilter(watched, event)
def _schedule_column_fit(self) -> None:
if not self._fit_timer.isActive():
self._fit_timer.start(0)
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
if self._fitting_columns:
return
self._manual_widths[column] = max(self._minimum_widths[column], size)
self._schedule_column_fit()
def _fit_columns(self) -> None:
widths = [self._manual_widths.get(column, minimum)
for column, minimum in enumerate(self._minimum_widths)]
extra = max(0, self.viewport().width() - sum(widths))
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
if column not in self._manual_widths]
# If every text column was manually sized, keep those preferences as
# minimums and let the herb preview absorb any remaining wide-screen space.
flexible = flexible or [(4, 1)]
total_weight = sum(weight for _column, weight in flexible)
for column, weight in flexible:
widths[column] += extra * weight // total_weight
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.setColumnWidth(column, width)
finally:
self._fitting_columns = False
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = "#BE4657" if button.property("danger") else "#1769E8"
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("、")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "、" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor("#5D6B80"))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor("#E6EDF6"))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor("#1769E8"))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor("#273244"))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.drawRoundedRect(pill, 4, 4)
painter.setPen(QColor("#1769E8"))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
#PrescriptionLibraryPage QFrame#MetricCard,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: #273244; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: #1555B6; background: #F2F7FF;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
"""Shared clinical tokens for the prescription library and its native delegates."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from string import Template
from typing import Any
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .design_system import PALETTE, RADII, clinical_stylesheet
from .reception_style import body_family, heading_family
from .widgets import SortableTable, TableColumn, first_value
def library_stylesheet() -> str:
return clinical_stylesheet(Template(_LIBRARY).safe_substitute(body=body_family(), heading=heading_family()))
def _library_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, PALETTE["disabled"]),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", PALETTE["muted"], 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Fit readable columns while preserving actions and selected template IDs."""
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
super().__init__(columns, parent)
self.horizontalHeader().setStretchLastSection(False)
self._minimum_widths = tuple(column.width for column in self.columns)
self._manual_widths: dict[int, int] = {}
self._fitting_columns = False
self._fit_timer = QTimer(self)
self._fit_timer.setSingleShot(True)
self._fit_timer.timeout.connect(self._fit_columns)
self.horizontalHeader().sectionResized.connect(self._column_resized)
self.viewport().installEventFilter(self)
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
self._schedule_column_fit()
return super().eventFilter(watched, event)
def _schedule_column_fit(self) -> None:
if not self._fit_timer.isActive():
self._fit_timer.start(0)
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
if self._fitting_columns:
return
self._manual_widths[column] = max(self._minimum_widths[column], size)
self._schedule_column_fit()
def _fit_columns(self) -> None:
widths = [self._manual_widths.get(column, minimum)
for column, minimum in enumerate(self._minimum_widths)]
extra = max(0, self.viewport().width() - sum(widths))
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
if column not in self._manual_widths]
# If every text column was manually sized, keep those preferences as
# minimums and let the herb preview absorb any remaining wide-screen space.
flexible = flexible or [(4, 1)]
total_weight = sum(weight for _column, weight in flexible)
for column, weight in flexible:
widths[column] += extra * weight // total_weight
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.setColumnWidth(column, width)
finally:
self._fitting_columns = False
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = PALETTE["danger"] if button.property("danger") else PALETTE["accent"]
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("、")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "、" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor(PALETTE["muted"]))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor(PALETTE["line"]))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor(PALETTE["accent"]))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor(PALETTE["focus"]))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor(PALETTE["text"]))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(PALETTE["accent_soft"]))
painter.drawRoundedRect(pill, RADII["small"], RADII["small"])
painter.setPen(QColor(PALETTE["accent"]))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, PALETTE["muted"], 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: $ds_canvas; color: $ds_text; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: $ds_muted; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionLibraryPage QFrame#MetricCard { background: $ds_surface; border: 0; border-radius: $ds_radius_panel; }
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: $ds_muted; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: $ds_text; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
}
#PrescriptionLibraryPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
#PrescriptionLibraryPage QPushButton:pressed { background: $ds_accent_soft; }
#PrescriptionLibraryPage QPushButton:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: $ds_accent; border-color: $ds_accent; color: $ds_surface;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: $ds_accent_hover; border-color: $ds_accent_hover;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:focus,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
#PrescriptionLibraryPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
#PrescriptionLibraryPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:disabled,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: $ds_text; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: $ds_accent; border-bottom-color: $ds_accent;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: $ds_accent; background: $ds_surface_alt;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:focus { background: $ds_accent_soft; }
#PrescriptionLibraryPage QTableWidget {
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: $ds_disabled; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
@@ -1,107 +1,115 @@
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
_PRESCRIPTIONS = """
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: #A4ADBA;
}
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .design_system import clinical_stylesheet
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return clinical_stylesheet(Template(_PRESCRIPTIONS).safe_substitute(body=body_family(), heading=heading_family()))
_PRESCRIPTIONS = """
#PrescriptionsPage { background: $ds_canvas; color: $ds_text; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: $ds_muted; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
}
#PrescriptionsPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
#PrescriptionsPage QPushButton:pressed { background: $ds_accent_soft; }
#PrescriptionsPage QPushButton:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: $ds_accent; color: $ds_surface; border-color: $ds_accent;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: $ds_accent_hover; border-color: $ds_accent_hover;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:focus,
#PrescriptionsPage QPushButton#PrescriptionQueryButton:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
#PrescriptionsPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
#PrescriptionsPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled,
#PrescriptionsPage QPushButton#PrescriptionAddButton:disabled,
#PrescriptionsPage QPushButton#PrescriptionQueryButton:disabled {
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: $ds_focus; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: $ds_text; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: $ds_text; background: $ds_surface; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
border: 0; border-bottom: 1px solid $ds_line;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: $ds_accent; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: $ds_disabled;
}
#PrescriptionsPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: $ds_accent; background: $ds_surface_alt; border: 1px solid $ds_line; border-radius: $ds_radius_panel; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
@@ -4,23 +4,24 @@ from __future__ import annotations
from PySide6.QtWidgets import QApplication
from .design_system import PALETTE
from .theme import _register_preferred_cjk_fonts
TECH_BLUE = {
"accent": "#1769E8",
"accent_hover": "#155BCC",
"accent_pressed": "#124EA9",
"selection": "#EAF2FF",
"selected_text": "#1555B6",
"canvas": "#F3F7FD",
"sidebar": "#EDF4FF",
"surface": "#FFFFFF",
"raised": "#F7FAFE",
"line": "#DBE5F2",
"text": "#273244",
"heading": "#202C3F",
"muted": "#5D6B80",
"focus": "#75A5F0",
"accent": PALETTE["accent"],
"accent_hover": PALETTE["accent_hover"],
"accent_pressed": PALETTE["accent_pressed"],
"selection": PALETTE["accent_soft"],
"selected_text": PALETTE["accent_hover"],
"canvas": PALETTE["canvas"],
"sidebar": PALETTE["sidebar"],
"surface": PALETTE["surface"],
"raised": PALETTE["surface_alt"],
"line": PALETTE["line"],
"text": PALETTE["text"],
"heading": PALETTE["heading"],
"muted": PALETTE["muted"],
"focus": PALETTE["focus"],
}
@@ -170,8 +170,8 @@ QWidget#ReceptionPage QStackedWidget#ReceptionNarrowWorkspace {
}
QWidget#ReceptionPage QFrame#ReceptionQueuePanel {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionQueueTitle {
font-size: 18px;
@@ -200,8 +200,8 @@ QWidget#ReceptionPage QFrame#ReceptionHero,
QWidget#ReceptionPage QFrame#ReceptionClinicalCard {
min-height: 0;
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientName {
font-size: 20px;
@@ -223,8 +223,8 @@ QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5ECF6;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab,
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab {
@@ -295,8 +295,8 @@ QWidget#ReceptionPage QFrame#ReceptionPatientSummaryCard,
QWidget#ReceptionPage QFrame#ReceptionVitalsCard,
QWidget#ReceptionPage QFrame#ReceptionLabsCard {
background-color: #FFFFFF;
border: 1px solid #E5ECF6;
border-radius: 8px;
border: 1px solid #EDF0F5;
border-radius: 16px;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue {
color: #24334A;
@@ -367,8 +367,8 @@ QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"][warning=
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard,
QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard {
background-color: #FFFFFF;
border: 1px solid #DCE5F0;
border-radius: 8px;
border: 1px solid $ds_line;
border-radius: $ds_radius_panel;
}
QWidget#ReceptionPage QLabel#ReceptionAiTitle,
QWidget#ReceptionPage QPushButton#ReceptionAiTitle {
@@ -405,4 +405,37 @@ QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::add-line:v
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::sub-line:vertical {
height: 0;
}
QWidget#ReceptionPage QLineEdit#ReceptionQueueSearch,
QWidget#ReceptionPage QPushButton#ReceptionDateButton,
QWidget#ReceptionPage QPushButton#ReceptionQueueFilterToggle,
QWidget#ReceptionPage QPushButton#ReceptionVideoButton,
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
QWidget#ReceptionPage QPushButton#ReceptionImButton,
QWidget#ReceptionPage QPushButton#ReceptionMoreButton,
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton,
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] {
border-radius: $ds_radius_control;
}
QWidget#ReceptionPage QPushButton:focus,
QWidget#ReceptionPage QLineEdit:focus,
QWidget#ReceptionPage QComboBox:focus,
QWidget#ReceptionPage QTextEdit:focus {
border-color: $ds_focus;
}
QWidget#ReceptionPage QPushButton#ReceptionVideoButton:pressed {
color: $ds_surface;
background-color: $ds_accent_pressed;
border-color: $ds_accent_pressed;
}
QWidget#ReceptionPage QLabel[receptionMetricState="true"] {
border-radius: $ds_radius_small;
}
QWidget#ReceptionPage QFrame#ReceptionLabCell,
QWidget#ReceptionPage QWidget#ReceptionCaseField {
border-bottom-color: $ds_line;
}
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue[missing="true"] {
color: $ds_muted;
}
"""
+11 -10
View File
@@ -59,6 +59,7 @@ from .chat_notifications import (
ChatNotification,
ChatNotificationCenter,
)
from .design_system import PALETTE, clinical_stylesheet
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.diagnosis import DiagnosisDialog
@@ -72,7 +73,7 @@ from .pages import (
ReceptionPage,
)
from .reception_style import TECH_BLUE, body_family, heading_family, number_family
from .theme import COLORS, TYPE, crisp_pixmap
from .theme import COLORS, METRICS, TYPE, crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
@@ -93,8 +94,8 @@ _SHELL_DESIGN_SIZE = QSize(1710, 920)
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
_RECEPTION_SIDEBAR_WIDTH = 208
_RECEPTION_TOPBAR_HEIGHT = 76
_RECEPTION_SIDEBAR_COLOR = "#F3F7FD"
_RECEPTION_CHROME_LINE = "#E7EDF7"
_RECEPTION_SIDEBAR_COLOR = PALETTE["sidebar"]
_RECEPTION_CHROME_LINE = PALETTE["line"]
# Approved pages share one chrome state, so navigation among them keeps it intact.
_TECH_BLUE_PAGES = frozenset({"appointments", "consultations", "patients", "prescriptions", "prescription_library", "reception"})
@@ -1005,7 +1006,7 @@ class ShellWindow(QMainWindow):
chrome_widgets.extend(self.topbar.findChildren(_PaintedIconButton))
chrome_widgets.append(self.tabs_menu_button)
self._legacy_chrome_styles = {
widget: widget.styleSheet() for widget in chrome_widgets
widget: clinical_stylesheet(widget.styleSheet()) for widget in chrome_widgets
}
self._register_pages()
self.search_shortcut = QShortcut(QKeySequence("Ctrl+K"), self)
@@ -1268,7 +1269,7 @@ class ShellWindow(QMainWindow):
color: #606163;
background-color: #FFFFFF;
border: 1px solid #EDEDEE;
border-radius: 999px;
border-radius: 10px;
padding: 4px 10px;
font-size: 13px;
font-weight: 500;
@@ -1693,7 +1694,7 @@ class ShellWindow(QMainWindow):
font-family: "{body_family()}"; font-weight: 400;
min-height: 44px; max-height: 44px;
border: 0; border-left: 5px solid transparent;
border-radius: 6px;
border-radius: {METRICS["r_md"]};
padding: 0 16px; font-size: {TYPE['fs_body']};
color: {TECH_BLUE['text']};
}}
@@ -1711,7 +1712,7 @@ class ShellWindow(QMainWindow):
}}
QPushButton#ShellNavButton[collapsed="true"] {{ padding: 0; }}
QFrame#ShellAssistantCard {{
background-color: {TECH_BLUE['surface']}; border-radius: 8px;
background-color: {TECH_BLUE['surface']}; border-radius: {METRICS["r_lg"]};
border: 1px solid {_RECEPTION_CHROME_LINE};
}}
QLabel#ShellAssistantName {{
@@ -1723,7 +1724,7 @@ class ShellWindow(QMainWindow):
QPushButton#ShellAssistantButton, QPushButton#ShellSettingsButton {{
font-family: "{body_family()}"; font-weight: 400; font-size: {TYPE['fs_body']};
}}
QPushButton#ShellAssistantButton {{ border-radius: 6px; }}
QPushButton#ShellAssistantButton {{ border-radius: {METRICS["r_md"]}; }}
QPushButton#ShellSettingsButton {{
min-height: 50px; max-height: 50px;
text-align: left; padding: 0 32px;
@@ -1738,7 +1739,7 @@ class ShellWindow(QMainWindow):
border-radius: 0;
border-bottom: 1px solid {_RECEPTION_CHROME_LINE};
}}
QFrame#ShellGlobalSearch {{ border: 1px solid {_RECEPTION_CHROME_LINE}; border-radius: 5px; }}
QFrame#ShellGlobalSearch {{ background-color: {PALETTE["surface_alt"]}; border: 1px solid transparent; border-radius: {METRICS["r_md"]}; }}
QFrame#ShellUserSeparator {{ background: {_RECEPTION_CHROME_LINE}; border: 0; }}
QLabel#ShellShortcutHint {{
font-family: "{number_family()}"; font-size: {TYPE['fs_small']};
@@ -1767,7 +1768,7 @@ class ShellWindow(QMainWindow):
if isinstance(widget, _PaintedIconButton):
side = 50 if widget in (self.refresh_button, self.notification_button, self.settings_button) else 34
stylesheet += f"QToolButton#ShellPaintedIconButton {{ min-width: {side - 2}px; max-width: {side - 2}px; min-height: {side - 2}px; max-height: {side - 2}px; }}"
widget.setStyleSheet(stylesheet)
widget.setStyleSheet(clinical_stylesheet(stylesheet))
self._set_reception_geometry(active)
for widget in (
+67 -70
View File
@@ -41,46 +41,46 @@ from doctor_workstation.resources import resource_path
# ``motion`` deliberately imports nothing from this module, so this stays a
# one-way dependency: tokens here, animation there.
from . import motion
from .design_system import PALETTE, RADII, SPACING, clinical_stylesheet
# Neutral reading colors follow the installed Codex light chrome defaults.
# Shared reading colors keep native pages and dialog chrome consistent.
# Brand accents are independent of reading ink; use opaque text for stable contrast.
# The legacy teal/ink aliases remain for existing callers.
COLORS = {
"canvas": "#F4F6FA",
"canvas_mid": "#FFFFFF",
"canvas_glow": "#EEF1FA",
"surface": "#FFFFFF",
"surface_alt": "#F7F7F7",
"raised": "#F7F7F7",
"glass": "#FFFFFF",
"glass_alt": "#F7F7F7",
"line": "#EDEDEE",
"line_soft": "#E4E4E5",
"text": "#1A1C1F",
"text_soft": "#606163",
"muted": "#6A6B6D",
"disabled_surface": "#F2F2F2",
"disabled_text": "#8E8F90",
"indigo": "#4F63D9",
"indigo_hover": "#4156C4",
"indigo_pressed": "#354BB4",
"indigo_pale": "#EEF1FA",
"focus": "#8B9AD9",
"selection": "#EEF1FA",
"success": "#287B65",
"success_pale": "#EEF7F3",
"warning": "#A9691D",
"warning_pale": "#FCF5E9",
"danger": "#BE4B58",
"danger_pale": "#FCF0F2",
"info": "#4F63D9",
"info_pale": "#EEF1FA",
# Backward-compatible names used by older UI code and integrations.
"ink": "#1A1C1F",
"ink_soft": "#606163",
"teal": "#4F63D9",
"teal_dark": "#354BB4",
"teal_pale": "#EEF1FA",
"canvas": PALETTE["canvas"],
"canvas_mid": PALETTE["surface"],
"canvas_glow": PALETTE["accent_soft"],
"surface": PALETTE["surface"],
"surface_alt": PALETTE["surface_alt"],
"raised": PALETTE["surface_alt"],
"glass": PALETTE["surface"],
"glass_alt": PALETTE["surface_alt"],
"line": PALETTE["line"],
"line_soft": PALETTE["line_strong"],
"text": PALETTE["heading"],
"text_soft": PALETTE["text"],
"muted": PALETTE["muted"],
"disabled_surface": PALETTE["disabled_surface"],
"disabled_text": PALETTE["disabled"],
"indigo": PALETTE["accent"],
"indigo_hover": PALETTE["accent_hover"],
"indigo_pressed": PALETTE["accent_pressed"],
"indigo_pale": PALETTE["accent_soft"],
"focus": PALETTE["focus"],
"selection": PALETTE["accent_soft"],
"success": PALETTE["success"],
"success_pale": PALETTE["success_soft"],
"warning": PALETTE["warning"],
"warning_pale": PALETTE["warning_soft"],
"danger": PALETTE["danger"],
"danger_pale": PALETTE["danger_soft"],
"info": PALETTE["accent"],
"info_pale": PALETTE["accent_soft"],
"ink": PALETTE["heading"],
"ink_soft": PALETTE["text"],
"teal": PALETTE["accent"],
"teal_dark": PALETTE["accent_pressed"],
"teal_pale": PALETTE["accent_soft"],
}
@@ -122,11 +122,11 @@ METRICS = {
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
# element is far enough from the corner that its own radius is free.
"r_xs": "4px", # chips, badges, tiny status pills
"r_sm": "6px", # inline row actions, tags
"r_md": "8px", # buttons, inputs, combo boxes
"r_lg": "12px", # cards, filter bars, panels
"r_xl": "16px", # the shell surfaces the cards sit on
"pad_control": "12px", # horizontal padding inside default controls
"r_sm": f"{RADII['small']}px", # inline row actions, tags
"r_md": f"{RADII['control']}px", # buttons, inputs, combo boxes
"r_lg": f"{RADII['panel']}px", # cards, filter bars, panels
"r_xl": f"{RADII['window']}px", # the shell surfaces the cards sit on
"pad_control": f"{SPACING['control']}px", # horizontal padding inside default controls
"pad_compact": "9px",
}
@@ -176,7 +176,7 @@ QDialog[businessDialog="true"] QFrame#DialogSurface,
QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
background-color: $surface;
border: 1px solid $line;
border-radius: 14px;
border-radius: $r_lg;
}
QDialog[businessDialog="true"] QFrame#DialogHeader,
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
@@ -275,18 +275,18 @@ QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
QFrame[glass="true"] {
background-color: $glass;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QFrame#FilterBar { background-color: $surface; }
QFrame#SubtleCard {
background-color: $glass_alt;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QFrame#MetricCard {
background-color: $surface;
border: 1px solid $line;
border-radius: 11px;
border-radius: $r_md;
}
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
@@ -297,7 +297,7 @@ QFrame#ReceptionAiCard {
min-height: 132px;
background-color: #FFFFFF;
border: 1px solid $line_soft;
border-radius: 13px;
border-radius: $r_lg;
}
QLabel#ReceptionAiTitle {
color: $indigo_pressed;
@@ -313,7 +313,7 @@ QLabel#MetricGlyph {
color: $indigo;
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 11px;
border-radius: $r_md;
font-size: $fs_section;
font-weight: $fw_heading;
}
@@ -329,7 +329,7 @@ QGroupBox {
margin-top: 12px;
padding-top: 12px;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
background-color: $glass;
font-weight: $fw_control;
}
@@ -351,7 +351,7 @@ QPushButton {
}
QPushButton:hover {
background-color: $raised;
border-color: $indigo_hover;
border-color: $line_soft;
}
QPushButton:pressed {
background-color: $indigo_pale;
@@ -401,7 +401,7 @@ QPushButton[variant="primary"]:disabled {
QPushButton[variant="secondary"] {
color: $indigo_hover;
background-color: $indigo_pale;
border-color: $line_soft;
border-color: $indigo_pale;
}
QPushButton[variant="secondary"]:hover {
color: $indigo_pressed;
@@ -462,7 +462,7 @@ QToolButton#RowActionsMore::menu-indicator { width: 0; height: 0; image: none; }
QFrame#NoteAttachmentTile {
background-color: $surface;
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
}
QPushButton#NoteAttachmentPreview {
min-height: 132px;
@@ -470,7 +470,7 @@ QPushButton#NoteAttachmentPreview {
color: $muted;
background-color: $surface_alt;
border: 1px solid $line;
border-radius: 8px;
border-radius: $r_md;
font-weight: $fw_control;
}
QPushButton#NoteAttachmentPreview:hover {
@@ -558,7 +558,7 @@ QPushButton[variant="chip"] {
color: $text_soft;
background-color: $surface_alt;
border-color: $line;
border-radius: 8px;
border-radius: $r_md;
}
QPushButton[variant="chip"]:hover {
color: $text;
@@ -653,7 +653,7 @@ QComboBox QAbstractItemView {
background-color: $raised;
alternate-background-color: $surface_alt;
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
padding: 5px;
outline: 0;
selection-background-color: $indigo_pressed;
@@ -692,7 +692,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
background-color: $surface;
alternate-background-color: $surface_alt;
border: 0;
border-radius: 12px;
border-radius: $r_lg;
gridline-color: $line;
selection-background-color: $selection;
selection-color: $text;
@@ -729,17 +729,17 @@ QListWidget::item:selected, QListView::item:selected,
QTreeWidget::item:selected, QTreeView::item:selected {
background-color: $indigo_pale;
color: $indigo_pressed;
border-radius: 8px;
border-radius: $r_md;
}
QListWidget::item:hover, QListView::item:hover,
QTreeWidget::item:hover, QTreeView::item:hover {
background-color: $surface_alt;
border-radius: 8px;
border-radius: $r_md;
}
QTabWidget::pane {
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
background-color: $glass;
}
QTabBar::tab {
@@ -749,7 +749,7 @@ QTabBar::tab {
color: $muted;
background-color: transparent;
border: 1px solid transparent;
border-radius: 9px;
border-radius: $r_md;
font-weight: $fw_control;
}
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
@@ -792,7 +792,7 @@ QMenu {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 10px;
border-radius: $r_md;
padding: 6px;
}
QMenu::item {
@@ -815,7 +815,7 @@ QCalendarWidget {
color: $text;
background-color: $surface;
border: 1px solid $line;
border-radius: 12px;
border-radius: $r_lg;
}
QCalendarWidget QWidget#qt_calendar_navigationbar {
background-color: $raised;
@@ -914,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
color: #FFFFFF;
background-color: $indigo;
border: 1px solid $indigo;
border-radius: 8px;
border-radius: $r_md;
font-size: $fs_caption;
font-weight: $fw_control;
}
@@ -937,7 +937,7 @@ QLabel#EmptyStateGlyph {
QFrame#MessageBanner {
border: 1px solid $line;
border-radius: 10px;
border-radius: $r_md;
color: $text_soft;
}
QFrame#MessageBanner QLabel { background-color: transparent; }
@@ -955,7 +955,7 @@ QLabel#Toast {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 11px;
border-radius: $r_md;
padding: 11px 16px;
font-weight: $fw_control;
}
@@ -995,13 +995,9 @@ QLabel#UserAvatar {
font-weight: $fw_heading;
}
QWidget#LoginBrandPanel {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $surface,
stop: 1 $canvas_glow
);
background-color: $surface_alt;
border: 1px solid $line_soft;
border-radius: 22px;
border-radius: $r_lg;
}
QFrame#LoginCard {
background-color: $glass;
@@ -1043,6 +1039,7 @@ QToolTip {
}
"""
).substitute(_QSS_TOKENS)
GLOBAL_QSS = clinical_stylesheet(GLOBAL_QSS)
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
+2 -1
View File
@@ -44,6 +44,7 @@ from doctor_workstation.core.errors import (
)
from . import icons, motion
from .design_system import PALETTE
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -798,7 +799,7 @@ class _BusyTrack(QWidget):
chunk_width = max(36, int(rect.width() * 0.32))
span = rect.width() + chunk_width
x = int(self._phase * span) - chunk_width
painter.setBrush(QColor("#4F63D9"))
painter.setBrush(QColor(PALETTE["accent"]))
painter.drawRoundedRect(x, 0, chunk_width, rect.height(), 3, 3)
+387
View File
@@ -0,0 +1,387 @@
"""Bounded, Qt-independent AI requests for one authenticated IM window.
Only server-archived clinical context is submitted. This module never sends IM
messages and never shares the ordered call/recording lifecycle worker.
"""
from __future__ import annotations
import re
from collections import deque
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from threading import BoundedSemaphore, Event, RLock, Thread
from time import monotonic
from typing import Any
from uuid import UUID, uuid4
MAX_RESULT_CHARS = 65_536
MAX_SYNC_STEPS = 24
SYNC_TIMEOUT_SECONDS = 45.0
_WORKER_SLOTS = BoundedSemaphore(4)
_CANCEL_SLOTS = BoundedSemaphore(4)
_ACTIONS = frozenset({"summary", "followup", "reply"})
def _integer(value: Any, *, minimum: int) -> int | None:
if isinstance(value, bool) or not isinstance(value, (int, str)):
return None
if isinstance(value, str) and re.fullmatch(r"0|[1-9][0-9]{0,18}", value) is None:
return None
number = int(value)
return number if minimum <= number < 2**63 else None
def _request_uuid(value: Any) -> bool:
if not isinstance(value, str) or len(value) != 36:
return False
try:
return str(UUID(value)) == value.lower()
except (ValueError, AttributeError):
return False
def task_prompt(action: str, doctor_account: str) -> tuple[str, str]:
"""Keep instructions separate from clinical source text and under API limits."""
if action not in _ACTIONS or not re.fullmatch(r"doctor_[1-9][0-9]{0,18}", doctor_account):
raise ValueError("AI 操作或当前医生账号无效")
shared = (
f"这是 IM 问诊辅助,本次医生 IM 账号为 {doctor_account}。"
"优先依据该医生与本患者最近已归档对话,结合服务端授权病历;"
"其他医生及历史记录仅作背景,不能当成本次已确认事实。"
"若未找到本次对话请明确说明,不猜测最新诉求。"
"区分已知、明确否认与未知,保留日期及不确定性;"
"不补问脉象,不编造事实、不自动确诊、不新增或更改药物剂量。"
)
instructions = {
"summary": "请用简短条目总结本次病情、病程、现用治疗与待核实事项,供医生交接复核。",
"followup": (
"请建议最多5条优先追问,直接给出可向患者询问的自然短句,"
"每条可附简短目的;避免重复已回答的问题,不做通用问卷。"
),
"reply": (
"请起草一段医生可审阅后发给患者的简短自然回复,围绕该对话最近的患者问题。"
"不冒称已检查、已预约或已开处方,不承诺疗效,不让患者自行停换药。"
"只给回复草稿,不写分析过程;任何缺失资料通过必要的问题核实。"
),
}
prompt = shared + instructions[action] + "此内容须由医生复核,不能自动发送。"
if len(prompt) > 500:
raise ValueError("AI 提示词超过接口长度限制")
return ("summary" if action == "summary" else "custom"), prompt
def _warnings(value: Any) -> list[str]:
if not isinstance(value, (list, tuple)):
return []
warnings = []
for row in value[:20]:
if isinstance(row, str):
text = row
elif isinstance(row, Mapping):
text = row.get("message") or row.get("label")
if not text and row.get("code"):
text = (
"请复核回答中的脉象判断;本流程不采集脉象。"
if row["code"] == "PULSE_EXCLUDED_FROM_ASSESSMENT"
else "请对照原始记录复核,回答中的事实、否认或未知状态可能不一致。"
)
else:
continue
if isinstance(text, str) and text.strip():
rendered = text.strip()[:400]
if rendered not in warnings:
warnings.append(rendered)
return warnings
class _RequestError(Exception):
"""A deliberately safe, user-facing failure message."""
@dataclass
class _Job:
identity: dict[str, Any]
action: str
cancelled: Event = field(default_factory=Event)
completed: bool = False
succeeded: bool = False
stream_started: bool = False
cancel_sent: bool = False
class ImAiController:
"""One live request, a coalesced mailbox, and bounded daemon workers.
Qt polls ``take_result`` every 60 ms. Worker threads only mutate plain data;
they never touch a QObject. A cancelled worker must retire before a retry
starts, so rapid clicks cannot create an unbounded collection of requests.
"""
def __init__(
self,
repository: Any,
*,
diagnosis_id: Any,
appointment_id: Any,
doctor_account: str,
enabled: bool = False,
clock: Callable[[], float] = monotonic,
max_sync_steps: int = MAX_SYNC_STEPS,
sync_timeout: float = SYNC_TIMEOUT_SECONDS,
) -> None:
self.repository = repository
self.diagnosis_id = _integer(diagnosis_id, minimum=1)
self.appointment_id = _integer(appointment_id, minimum=0)
self.doctor_account = str(doctor_account)
self.enabled = (
enabled is True
and repository is not None
and self.diagnosis_id is not None
and self.appointment_id is not None
and re.fullmatch(r"doctor_[1-9][0-9]{0,18}", self.doctor_account) is not None
)
self.session_id = str(uuid4())
self._clock = clock
self._max_sync_steps = max(1, min(MAX_SYNC_STEPS, max_sync_steps))
self._sync_timeout = max(0.01, min(SYNC_TIMEOUT_SECONDS, sync_timeout))
self._lock = RLock()
self._closed = False
self._current: _Job | None = None
self._running: _Job | None = None
self._worker: Thread | None = None
self._mailbox: dict[str, Any] | None = None
self._seen: deque[str] = deque(maxlen=256)
@property
def config(self) -> dict[str, Any]:
return {
"version": 1,
"enabled": self.enabled and not self._closed,
"sessionId": self.session_id,
**({} if self.enabled else {"reason": "当前账号或问诊暂不能使用 AI 辅助。"}),
}
def _valid_identity(self, message: Mapping[str, Any]) -> bool:
return (
self.enabled
and not self._closed
and message.get("source") == "doctor-call"
and message.get("sessionId") == self.session_id
and _request_uuid(message.get("requestId"))
and isinstance(message.get("contextKey"), str)
and 0 < len(message["contextKey"]) <= 128
and _integer(message.get("diagnosisId"), minimum=1) == self.diagnosis_id
and _integer(message.get("appointmentId"), minimum=0) == self.appointment_id
and isinstance(message.get("action"), str)
and message["action"] in _ACTIONS
)
def handle(self, message: Mapping[str, Any]) -> bool:
with self._lock:
if not self._valid_identity(message):
return False
event = message.get("event")
identity = {key: message[key] for key in (
"sessionId", "requestId", "contextKey", "diagnosisId", "appointmentId", "action"
)}
if event == "im-ai-cancel":
if self._current is None or self._current.identity != identity:
return False
self._cancel(self._current)
return True
if event != "im-ai-request" or message["requestId"] in self._seen:
return False
self._seen.append(message["requestId"])
job = _Job(identity, message["action"])
if self._running is not None:
self._cancel(self._running)
self._current = job
self._publish(job, "error", message="上次请求正在结束,请稍后重试。", terminal=True)
return True
self._current = job
if not _WORKER_SLOTS.acquire(blocking=False):
self._publish(job, "error", message="AI 正在处理其他请求,请稍后重试。", terminal=True)
return True
self._running = job
self._publish(job, "loading", message="正在同步当前聊天记录…")
self._worker = Thread(target=self._run, args=(job,), name="im-ai", daemon=True)
try:
self._worker.start()
except RuntimeError:
self._running = None
_WORKER_SLOTS.release()
self._publish(job, "error", message="AI 请求暂时无法启动,请稍后重试。", terminal=True)
return True
def _publish(self, job: _Job, status: str, *, terminal: bool = False, **fields: Any) -> None:
with self._lock:
if self._closed or self._current is not job or job.cancelled.is_set() or job.completed:
return
if terminal:
job.completed = True
job.succeeded = status == "success"
self._mailbox = {**job.identity, "status": status, **fields}
def take_result(self) -> dict[str, Any] | None:
with self._lock:
result, self._mailbox = self._mailbox, None
return None if self._closed else result
def _sync(self, job: _Job) -> bool:
deadline = self._clock() + self._sync_timeout
token = ""
for _ in range(self._max_sync_steps):
if job.cancelled.is_set():
return False
if self._clock() >= deadline:
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
result = self.repository.sync_im_chat_messages(
self.diagnosis_id, scope="current", sync_token=token
)
if job.cancelled.is_set():
return False
if self._clock() >= deadline:
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
if not isinstance(result, Mapping) or result.get("errors"):
raise _RequestError("当前聊天记录未能完整同步,尚未生成,请稍后重试。")
if result.get("completed") is True:
return True
token = result.get("sync_token", "")
if not isinstance(token, str) or re.fullmatch(r"[a-f0-9]{48}", token) is None:
raise _RequestError("聊天同步进度无效,请更新客户端或稍后重试。")
raise _RequestError("聊天记录仍在同步,尚未生成,请稍后重试。")
def _run(self, job: _Job) -> None:
try:
if not self._sync(job):
return
task, prompt = task_prompt(job.action, self.doctor_account)
with self._lock:
if job.cancelled.is_set() or self._closed:
return
job.stream_started = True
self._publish(job, "loading", message="聊天已同步,正在整理病历资料…")
if job.cancelled.is_set():
return
text = ""
events = self.repository.stream_diagnosis_ai(
self.diagnosis_id, prompt, task=task,
request_id=job.identity["requestId"], cancelled=job.cancelled.is_set,
)
try:
for event in events:
if job.cancelled.is_set():
return
if not isinstance(event, Mapping):
continue
kind = event.get("event")
if kind == "delta":
delta = event.get("text", "")
if not isinstance(delta, str) or len(text) + len(delta) > MAX_RESULT_CHARS:
raise _RequestError("AI 内容超过显示上限,已停止接收,请缩小问题后重试。")
text += delta
self._publish(job, "streaming", text=text, message="正在生成,内容尚未完成。")
elif kind in {"start", "progress"}:
message = str(event.get("message") or "正在整理资料并生成…")[:240]
self._publish(job, "streaming" if text else "loading", text=text, message=message)
elif kind == "done":
result_id = event.get("diagnosis_id")
if result_id is not None and _integer(result_id, minimum=1) != self.diagnosis_id:
raise _RequestError("AI 返回的问诊上下文不匹配,结果未采用。")
answer = event.get("answer", event.get("content", text))
if not isinstance(answer, str) or not answer.strip():
raise _RequestError("AI 未返回完整内容,请重试。")
if len(answer) > MAX_RESULT_CHARS:
raise _RequestError("AI 内容超过显示上限,结果未采用,请缩小问题后重试。")
self._publish(
job, "success", terminal=True, text=answer,
message="已生成,请由医生复核后使用。",
modelLabel=str(event.get("model_label") or "")[:100],
warnings=_warnings(event.get("clinical_review_warnings")),
)
return
elif kind == "error":
raise _RequestError("AI 请求未完成,请检查权限或稍后重试。")
if not job.cancelled.is_set():
raise _RequestError("AI 连接提前结束,内容未完成,请重试。")
finally:
close = getattr(events, "close", None)
if callable(close):
close()
except Exception as error:
message = str(error) if isinstance(error, _RequestError) else (
"AI 请求失败,请检查权限或稍后重试。" if job.stream_started
else "当前聊天同步失败,尚未生成,请检查权限或稍后重试。"
)
self._publish(job, "error", message=message, terminal=True)
if job.stream_started and not job.cancelled.is_set() and not job.succeeded:
self._request_cancel(job)
finally:
with self._lock:
if self._running is job:
self._running = None
_WORKER_SLOTS.release()
def _cancel(self, job: _Job) -> None:
if job.completed or job.cancelled.is_set():
return
job.cancelled.set()
if self._current is job and not self._closed:
self._mailbox = {
**job.identity, "status": "cancelled",
"message": ("已停止本次接收,上游是否结束尚未确认。" if job.stream_started
else "已取消,尚未向 AI 提交请求。"),
}
if job.stream_started:
self._request_cancel(job)
def _request_cancel(self, job: _Job) -> None:
with self._lock:
if job.cancel_sent:
return
job.cancel_sent = True
if not _CANCEL_SLOTS.acquire(blocking=False):
return
def send() -> None:
try:
result = self.repository.cancel_diagnosis_ai(self.diagnosis_id, job.identity["requestId"])
if not isinstance(result, Mapping):
return
message = "已发送取消请求,上游是否结束尚未确认。"
terminal = result.get("upstream_terminal_status")
if result.get("upstream_terminal_confirmed") is True and terminal in {"stopped", "succeeded", "failed"}:
message = "本次接收已停止,上游工作流已结束。"
elif result.get("upstream_stop_confirmed") is True:
message = "上游已接受停止请求,是否结束尚未确认。"
with self._lock:
if not self._closed and self._current is job and job.cancelled.is_set():
self._mailbox = {**job.identity, "status": "cancelled", "message": message}
except Exception:
pass # Keep the visible unconfirmed cancellation; never expose transport details.
finally:
_CANCEL_SLOTS.release()
try:
Thread(target=send, name="im-ai-cancel", daemon=True).start()
except RuntimeError:
_CANCEL_SLOTS.release()
def close(self) -> None:
with self._lock:
if self._closed:
return
self._closed = True
self._mailbox = None
if self._running is not None:
self._cancel(self._running)
def wait(self, timeout: float = 1.0) -> bool:
"""Bounded diagnostic/test helper; never called by the Qt GUI."""
worker = self._worker
if worker is not None:
worker.join(max(0.0, min(timeout, 5.0)))
return worker is None or not worker.is_alive()
@@ -413,6 +413,7 @@ class VideoCallLauncher:
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> Any:
request = self.prepare(
ticket,
@@ -432,6 +433,7 @@ class VideoCallLauncher:
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant is True,
)
@@ -450,6 +452,7 @@ def launch_video_call(
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
can_ai_assistant: bool = False,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -468,4 +471,5 @@ def launch_video_call(
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant is True,
)
@@ -28,6 +28,7 @@ from ..services.local_audio_queue import (
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .im_ai import ImAiController
from .launcher import (
VideoCallRequest,
VideoTicketError,
@@ -320,6 +321,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
@@ -349,6 +352,18 @@ 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._screenshot_preview_size = ScreenshotPreviewSize()
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
@@ -483,6 +498,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"""
@@ -524,11 +540,23 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._injected = result is not False
if not self._injected:
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 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):
@@ -622,6 +650,35 @@ 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 _open_diagnosis_safely(self) -> None:
if self._closing or self._on_open_diagnosis is None:
return
@@ -1197,6 +1254,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
_EmbeddedVideoWindow._close_im_ai(self)
self._close_reason = reason
if self._closing or self._released:
return
@@ -1241,6 +1299,7 @@ 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)
self._on_compact_close = None
self._closing = True
self._media_active = False
@@ -1342,6 +1401,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:
@@ -1360,6 +1420,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(
@@ -1391,6 +1452,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")
@@ -1436,6 +1499,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."""
@@ -1452,6 +1516,7 @@ def open_video_call(
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
can_ai_assistant=can_ai_assistant,
).open()
+3 -2
View File
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
@@ -2306,14 +2307,14 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
assert "#BE4B58" in marker
assert PALETTE["danger"] in marker
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
assert "color: #1a1c1f" in body.lower()
assert f'color: {PALETTE["text"]}' in body
assert "font-size: 14px" in body
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
assert "font-size: 14px" in block
+33 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtCore import QDate, QSize, Qt
from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer
from doctor_workstation.ui.design_system import PALETTE
@pytest.fixture(scope="module")
@@ -565,8 +566,39 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
assert f"border-color: {PALETTE['focus']};" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
application.processEvents()
@pytest.mark.parametrize("button_kind", ["date", "slot"])
def test_appointment_hover_keeps_hit_target_stable_after_relayout(
application: QApplication, button_kind: str,
) -> None:
"""Hovering must not move a time choice or restore a stale layout position."""
from PySide6.QtCore import QEvent, QPoint, QPointF
from PySide6.QtGui import QEnterEvent
host, drawer = _show_drawer(
application, (1440, 900), repository=_VisualRepository(), autoload=True,
)
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
button = drawer.date_buttons[tomorrow] if button_kind == "date" else drawer.slot_buttons["09:30-10:00"]
original = button.geometry()
try:
for _ in range(3):
application.sendEvent(button, QEnterEvent(QPointF(10, 10), QPointF(30, 34), QPointF(30, 34)))
assert button.geometry() == original
assert button.graphicsEffect() is None
relocated = original.topLeft() + QPoint(12, 16)
button.move(relocated) # A resize/reflow while the pointer stays inside.
application.sendEvent(button, QEvent(QEvent.Type.Leave))
assert button.pos() == relocated
button.setGeometry(original)
button.click()
assert button.isChecked()
finally:
drawer.close()
host.close()
+4 -3
View File
@@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.diagnosis_drawer import (
DiagnosisChoiceButtons,
DiagnosisComboBox,
@@ -1012,14 +1013,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#eef1fa"
assert enabled_color.name().lower() == PALETTE["accent_soft"].lower()
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#eef1fa"
assert disabled_color.name().lower() == PALETTE["accent_soft"].lower()
dialog.close()
application.processEvents()
@@ -1039,7 +1040,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#eef1fa"
assert color.name().lower() == PALETTE["accent_soft"].lower()
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
+6 -5
View File
@@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.diagnosis_index_widgets import (
DIAGNOSIS_INDEX_QSS,
DiagnosisItemDelegate,
@@ -424,8 +425,8 @@ def test_empty_loading_and_compact_footer_keep_the_table_shell(
@pytest.mark.parametrize(
("record", "stripe", "channel"),
[
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), PALETTE["warning"].lower(), "warning"),
(_row(702, has_appointment=0, appointments=[]), PALETTE["accent"].lower(), "info"),
],
)
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
@@ -452,9 +453,9 @@ def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
assert image.pixelColor(0, 20).name() == stripe
assert image.pixelColor(1, 20).name() == stripe
assert image.pixelColor(2, 20).name() == stripe
assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
assert image.pixelColor(6, 20).name() == "#f7f7f7"
assert image.pixelColor(40, 20).name() == "#f7f7f7"
assert image.pixelColor(3, 20).name() == PALETTE["surface_alt"].lower(), f"{channel} stripe widened"
assert image.pixelColor(6, 20).name() == PALETTE["surface_alt"].lower()
assert image.pixelColor(40, 20).name() == PALETTE["surface_alt"].lower()
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
+83 -3
View File
@@ -9,10 +9,17 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QFont
from PySide6.QtCore import QPoint, QRect, Qt
from PySide6.QtGui import QFont, QImage, QPainter
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QApplication,
QLabel,
QStyleOptionViewItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from doctor_workstation.ui.diagnosis_index_widgets import (
DiagnosisTableHost,
@@ -115,6 +122,79 @@ def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QA
legacy.close()
@pytest.mark.parametrize(
"size,column_width,row_height",
[((1024, 640), 84, 75), ((1440, 900), 126, 72)],
)
def test_followup_paint_keeps_time_whole_and_preserves_density(
application: QApplication, size: tuple[int, int], column_width: int, row_height: int,
) -> None:
class RecordingPainter(QPainter):
def __init__(self, image: QImage) -> None:
super().__init__(image)
self.lines: list[tuple[QRect, int, str]] = []
def drawText(self, rect: QRect, flags: int, text: str) -> None: # noqa: N802
self.lines.append((QRect(rect), flags, text))
super().drawText(rect, flags, text)
row = {
"id": 501, "has_prescription": 1,
"followup_time_text": "2026-08-17 09:00", "followup_doctor_name": "陈医生",
}
original = copy.deepcopy(row)
host = DiagnosisTableHost(tech_blue=True)
host.resize(*size)
host.set_rows([row])
host.show()
_settle(application)
try:
assert host.main.columnWidth(6) == column_width
assert host.main.rowHeight(0) == row_height
assert host.fixed.rowHeight(0) == row_height
index = host.model.index(0, 6)
full_text = "2026-08-17 09:00\n陈医生"
assert index.data() == full_text
assert index.data(Qt.ItemDataRole.ToolTipRole) == full_text
option = QStyleOptionViewItem()
option.rect = QRect(0, 0, column_width, row_height)
image = QImage(column_width, row_height, QImage.Format.Format_ARGB32)
painter = RecordingPainter(image)
host.main.itemDelegate().paint(painter, option, index)
painter.end()
expected = ["2026-08-17", "09:00", "陈医生"] if size[0] == 1024 else [
"2026-08-17 09:00", "陈医生",
]
assert [line[2] for line in painter.lines] == expected
content = option.rect.adjusted(8, 8, -8, -8)
for rect, flags, _text in painter.lines:
assert content.contains(rect)
assert flags & Qt.TextFlag.TextSingleLine
assert not flags & Qt.TextFlag.TextWrapAnywhere
assert row == original
finally:
host.close()
_settle(application)
def test_followup_long_metadata_has_complete_tooltip(application: QApplication) -> None:
host = DiagnosisTableHost(tech_blue=True)
row = {
"id": 502, "has_prescription": 1,
"followup_time_text": "2026-08-17 09:00-09:30",
"followup_doctor_name": "陈医生(内分泌科专家门诊)", "followup_rx_voided": True,
}
original = copy.deepcopy(row)
host.set_rows([row])
index = host.model.index(0, 6)
assert index.data(Qt.ItemDataRole.ToolTipRole) == (
"2026-08-17 09:00-09:30\n陈医生(内分泌科专家门诊)\n处方已作废"
)
assert index.data() == index.data(Qt.ItemDataRole.ToolTipRole)
assert row == original
host.close()
@pytest.mark.parametrize("tech_blue", [False, True])
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
application: QApplication, tech_blue: bool,
+2 -1
View File
@@ -8,6 +8,7 @@ from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
from doctor_workstation.ui.theme import apply_theme
@@ -74,7 +75,7 @@ def test_checked_row_blue_and_unchecked_white(dialog, application):
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
int(rect.center().y() * scale)).name())
assert colors == ["#eaf2ff", "#ffffff"]
assert colors == [PALETTE["accent_soft"].lower(), PALETTE["surface"].lower()]
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
+42
View File
@@ -0,0 +1,42 @@
"""Current-peer archive paging preserves old callers and request identities."""
import pytest
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
class Client:
def __init__(self):
self.calls = []
def post(self, endpoint, payload, *, timeout=None):
self.calls.append((endpoint, payload, timeout))
return {"completed": False, "sync_token": "a" * 48, "errors": []}
def test_sync_optional_parameters_and_timeout_preserve_exact_legacy_payload():
client = Client()
repository = RemoteDoctorRepository(client)
repository.sync_im_chat_messages(501)
result = repository.sync_im_chat_messages(501, scope="current", sync_token="a" * 48)
assert client.calls == [
("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": 501}, 15.0),
("tcm.diagnosis/triggerImChatSync",
{"diagnosis_id": 501, "scope": "current", "sync_token": "a" * 48}, 15.0),
]
assert result["completed"] is False
@pytest.mark.parametrize("kwargs", [{"scope": "other"}, {"sync_token": "bad"}, {"sync_token": None}])
def test_sync_rejects_invalid_options_without_network(kwargs):
client = Client()
with pytest.raises(ValueError):
RemoteDoctorRepository(client).sync_im_chat_messages(501, **kwargs)
assert not client.calls
def test_mock_sync_supports_paging_contract_and_legacy_receipt():
repository = DemoDoctorRepository()
assert repository.sync_im_chat_messages(501)["queued"] is True
assert repository.sync_im_chat_messages(501, scope="current")["completed"] is True
+270
View File
@@ -0,0 +1,270 @@
"""Offline tests for native IM AI authorization, synchronization and races."""
from __future__ import annotations
from collections import deque
from threading import BoundedSemaphore, Event
from uuid import uuid4
import pytest
from doctor_workstation.video import im_ai
class Repository:
def __init__(self, pages=None, events=None):
self.pages = deque(pages or [{"completed": True, "errors": []}])
self.events = events or [{"event": "done", "diagnosis_id": 501, "answer": "合成回复"}]
self.sync_calls = []
self.stream_calls = []
self.cancel_calls = []
self.cancel_called = Event()
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
self.sync_calls.append((diagnosis_id, kwargs))
return self.pages.popleft()
def stream_diagnosis_ai(self, diagnosis_id, prompt, **kwargs):
self.stream_calls.append((diagnosis_id, prompt, kwargs))
yield from self.events
def cancel_diagnosis_ai(self, diagnosis_id, request_id):
self.cancel_calls.append((diagnosis_id, request_id))
self.cancel_called.set()
return {"cancel_requested": True}
def controller(repository=None, **kwargs):
return im_ai.ImAiController(
repository or Repository(), diagnosis_id=501, appointment_id=101,
doctor_account="doctor_7", enabled=True, **kwargs,
)
def request(service, **changes):
return {
"source": "doctor-call", "event": "im-ai-request", "sessionId": service.session_id,
"requestId": str(uuid4()), "contextKey": "view-1", "diagnosisId": "501",
"appointmentId": 101, "action": "summary", **changes,
}
@pytest.mark.parametrize("action,task", [("summary", "summary"), ("followup", "custom"), ("reply", "custom")])
def test_current_peer_sync_completes_before_ai_and_keeps_identity(action, task):
token = "a" * 48
repository = Repository(pages=[
{"completed": False, "phase": "checking_accounts", "sync_token": token, "errors": []},
{"completed": False, "phase": "syncing", "sync_token": token, "errors": []},
{"completed": True, "errors": []},
], events=[
{"event": "start"}, {"event": "delta", "text": "合成"},
{"event": "done", "diagnosis_id": 501, "answer": "合成完整回复", "model_label": "模型",
"clinical_review_warnings": [{"code": "PULSE_EXCLUDED_FROM_ASSESSMENT", "source_id": "report"}]},
])
service = controller(repository)
message = request(service, action=action)
assert service.handle(message)
assert service.wait()
result = service.take_result()
assert result["status"] == "success"
assert result["text"] == "合成完整回复"
assert result["requestId"] == message["requestId"]
assert result["modelLabel"] == "模型"
assert "不采集脉象" in result["warnings"][0]
assert len(repository.sync_calls) == 3
assert repository.sync_calls[0] == (501, {"scope": "current", "sync_token": ""})
assert repository.sync_calls[1][1]["sync_token"] == token
assert len(repository.stream_calls) == 1
diagnosis_id, prompt, options = repository.stream_calls[0]
assert diagnosis_id == 501 and options["task"] == task
assert options["request_id"] == message["requestId"]
assert "doctor_7" in prompt and len(prompt) <= 500
assert "不补问脉象" in prompt and "不自动确诊" in prompt
assert not repository.cancel_calls
assert service.take_result() is None
assert not service.handle(message) # A replay cannot submit another billable request.
@pytest.mark.parametrize("changes", [
{"sessionId": "other"}, {"diagnosisId": "502"}, {"diagnosisId": True},
{"diagnosisId": "0501"}, {"appointmentId": 102}, {"requestId": "invalid"},
{"contextKey": ""}, {"contextKey": "x" * 129}, {"action": "prescription"},
{"action": {}}, {"source": "another-page"}, {"event": "send-message"},
])
def test_invalid_identity_or_action_cannot_read_patient_context(changes):
repository = Repository()
service = controller(repository)
assert not service.handle(request(service, **changes))
assert not repository.sync_calls and not repository.stream_calls
def test_permission_default_denied_and_each_host_has_unique_session():
repository = Repository()
disabled = im_ai.ImAiController(
repository, diagnosis_id=501, appointment_id=101, doctor_account="doctor_7"
)
assert disabled.config["enabled"] is False
assert not disabled.handle(request(disabled))
assert controller().session_id != controller().session_id
@pytest.mark.parametrize("page", [
{"queued": True}, {"completed": "true"}, {"completed": True, "errors": ["private detail"]},
{"completed": False, "sync_token": "bad"}, None,
])
def test_incomplete_or_failed_sync_never_generates(page):
repository = Repository(pages=[page])
service = controller(repository)
assert service.handle(request(service))
assert service.wait()
result = service.take_result()
assert result["status"] == "error"
assert "private detail" not in str(result)
assert not repository.stream_calls and not repository.cancel_calls
def test_sync_step_and_time_budget_are_bounded():
page = {"completed": False, "sync_token": "b" * 48, "errors": []}
repository = Repository(pages=[page] * 10)
service = controller(repository, max_sync_steps=2)
service.handle(request(service))
assert service.wait()
assert len(repository.sync_calls) == 2
assert service.take_result()["status"] == "error"
assert not repository.stream_calls
times = iter([0, 0, 46])
repository = Repository()
service = controller(repository, clock=lambda: next(times))
service.handle(request(service))
assert service.wait()
assert "超时" in service.take_result()["message"]
assert not repository.stream_calls
def test_cancel_during_sync_stops_before_model_submission():
entered, release = Event(), Event()
class SlowSync(Repository):
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
entered.set()
assert release.wait(2)
return super().sync_im_chat_messages(diagnosis_id, **kwargs)
repository = SlowSync()
service = controller(repository)
message = request(service)
service.handle(message)
try:
assert entered.wait(1)
assert service.handle({**message, "event": "im-ai-cancel"})
assert "尚未向 AI" in service.take_result()["message"]
finally:
release.set()
assert service.wait()
assert not repository.stream_calls and not repository.cancel_calls
assert service.take_result() is None
def test_cancel_is_independent_and_late_response_cannot_overwrite_next_request():
entered, release, cancel_release = Event(), Event(), Event()
class SlowStream(Repository):
def stream_diagnosis_ai(self, *args, **kwargs):
self.stream_calls.append((args, kwargs))
yield {"event": "delta", "text": "尚未完成"}
entered.set()
assert release.wait(2)
yield {"event": "done", "answer": "迟到的旧结果"}
def cancel_diagnosis_ai(self, *args):
result = super().cancel_diagnosis_ai(*args)
assert cancel_release.wait(2)
return result
repository = SlowStream()
service = controller(repository)
first = request(service)
service.handle(first)
try:
assert entered.wait(1)
assert service.take_result()["text"] == "尚未完成"
service.handle({**first, "event": "im-ai-cancel"})
assert repository.cancel_called.wait(1) # Independent HTTP starts while stream is blocked.
assert service.take_result()["status"] == "cancelled"
second = request(service, contextKey="view-2")
assert service.handle(second)
result = service.take_result()
assert result["requestId"] == second["requestId"] and result["status"] == "error"
assert len(repository.stream_calls) == 1
finally:
release.set()
cancel_release.set()
assert service.wait()
assert service.take_result() is None
def test_close_invalidates_mailbox_and_cancels_without_waiting():
entered, release = Event(), Event()
class SlowStream(Repository):
def stream_diagnosis_ai(self, *args, **kwargs):
entered.set()
assert release.wait(2)
yield {"event": "done", "answer": "旧患者回复"}
repository = SlowStream()
service = controller(repository)
message = request(service)
service.handle(message)
try:
assert entered.wait(1)
service.close()
assert repository.cancel_called.wait(1)
assert service.take_result() is None
assert not service.handle(request(service))
finally:
release.set()
assert service.wait()
assert service.take_result() is None
@pytest.mark.parametrize("events", [
[{"event": "delta", "text": "不完整"}],
[{"event": "done", "answer": ""}],
[{"event": "done", "diagnosis_id": 502, "answer": "其他患者"}],
[{"event": "delta", "text": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
[{"event": "done", "answer": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
])
def test_incomplete_mismatched_and_oversized_results_are_not_usable(events):
repository = Repository(events=events)
service = controller(repository)
service.handle(request(service))
assert service.wait()
assert service.take_result()["status"] == "error"
assert repository.cancel_called.wait(1)
assert len(repository.stream_calls) == 1 # No transport/validation retry.
def test_thousands_of_deltas_are_coalesced_into_one_mailbox_result():
events = [{"event": "delta", "text": "字"}] * 4000 + [{"event": "done"}]
service = controller(Repository(events=events))
service.handle(request(service))
assert service.wait()
result = service.take_result()
assert result["status"] == "success" and result["text"] == "字" * 4000
assert service.take_result() is None
def test_capacity_rejection_does_not_spawn_or_sync(monkeypatch):
slots = BoundedSemaphore(1)
slots.acquire()
monkeypatch.setattr(im_ai, "_WORKER_SLOTS", slots)
repository = Repository()
service = controller(repository)
try:
service.handle(request(service))
assert service.take_result()["status"] == "error"
assert not repository.sync_calls
finally:
slots.release()
+157
View File
@@ -0,0 +1,157 @@
"""Exercise bridge trust and JSON delivery without starting WebEngine or HTTP."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from doctor_workstation.video import window
from doctor_workstation.video.security import TrustedDocumentPolicy
pytestmark = pytest.mark.skipif(not window.WEBENGINE_AVAILABLE, reason="optional QtWebEngine")
TRUSTED = "https://video.example.test/im/index.html"
def host(**changes):
item = SimpleNamespace(
_closing=False, _released=False, _shutdown_requested=False, _injected=True,
_policy=TrustedDocumentPolicy.from_url(TRUSTED, is_local=False),
_page=Mock(), _im_ai=Mock(), _im_ai_timer=Mock(),
)
item._page.url.return_value.toString.return_value = TRUSTED
item._im_ai_context_is_trusted = lambda: window._EmbeddedVideoWindow._im_ai_context_is_trusted(item)
for key, value in changes.items():
setattr(item, key, value)
return item
@pytest.mark.parametrize("changes", [
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True}, {"_injected": False},
])
def test_inactive_or_closing_page_cannot_submit_or_receive_ai(changes):
item = host(**changes)
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
window._EmbeddedVideoWindow._flush_im_ai(item)
item._im_ai.handle.assert_not_called()
item._im_ai.take_result.assert_not_called()
item._page.runJavaScript.assert_not_called()
@pytest.mark.parametrize("url", ["about:blank", "https://evil.example.test/im/index.html", TRUSTED + "?other=1"])
def test_document_navigation_cannot_reuse_authenticated_ai_bridge(url):
item = host()
item._page.url.return_value.toString.return_value = url
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
window._EmbeddedVideoWindow._flush_im_ai(item)
item._im_ai.handle.assert_not_called()
item._page.runJavaScript.assert_not_called()
def test_trusted_bridge_routes_only_to_identity_checked_controller():
item = host()
message = {"source": "doctor-call", "event": "im-ai-request", "diagnosisId": 501}
window._EmbeddedVideoWindow._handle_im_ai_message(item, message)
item._im_ai.handle.assert_called_once_with(message)
def test_result_content_is_json_encoded_and_no_javascript_is_executed():
item = host()
payload = {"status": "success", "text": '\");window.bad=true;//\n</script>\u2028', "warnings": ["合成提示"]}
item._im_ai.take_result.return_value = payload
window._EmbeddedVideoWindow._flush_im_ai(item)
script = item._page.runJavaScript.call_args.args[0]
prefix, suffix = "window.doctorConsultation?.imAiResult?.(", ");"
assert script.startswith(prefix) and script.endswith(suffix)
assert json.loads(script[len(prefix):-len(suffix)]) == payload
assert "\\u2028" in script
def test_host_acknowledges_initial_summary_only_after_request_gate_opens():
item = host(_injected=False)
item._im_ai.session_id = 'synthetic-session-\\"\u2028'
def verify_gate(script):
assert item._im_ai_context_is_trusted() is True
prefix, suffix = "window.doctorConsultation?.imAiReady?.(", ");"
assert script.startswith(prefix) and script.endswith(suffix)
assert json.loads(script[len(prefix):-len(suffix)]) == item._im_ai.session_id
item._page.runJavaScript.side_effect = verify_gate
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_called_once()
@pytest.mark.parametrize("changes", [
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True},
])
def test_late_injection_cannot_acknowledge_a_closed_ai_context(changes):
item = host(_injected=False, **changes)
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_not_called()
def test_navigation_cannot_receive_ai_readiness_acknowledgement():
item = host(_injected=False)
item._page.url.return_value.toString.return_value = "https://evil.example.test/im/index.html"
window._EmbeddedVideoWindow._after_injection(item, True)
item._page.runJavaScript.assert_not_called()
def test_failed_injection_closes_without_ai_readiness_acknowledgement():
item = host(_injected=False, _request_companion_shutdown=Mock())
window._EmbeddedVideoWindow._after_injection(item, False)
item._request_companion_shutdown.assert_called_once_with("bridge-api-missing")
item._page.runJavaScript.assert_not_called()
def test_shutdown_cancels_ai_before_waiting_for_media_or_destroying_page():
item = host(
_start_requested=True, _call_cycle_closed=False, _companion_ended=False,
_shutdown_timer=Mock(), close=Mock(),
)
window._EmbeddedVideoWindow._request_companion_shutdown(item, "window-closed")
item._im_ai.close.assert_called_once()
item._im_ai_timer.stop.assert_called_once()
assert item._shutdown_requested is True
item.close.assert_not_called() # Recording shutdown still gets its existing grace period.
item._shutdown_timer.start.assert_called_once_with(190_000)
def test_qt_notify_rejects_oversized_or_wrong_source_payloads():
callback = Mock()
bridge = window._QtVideoBridge(callback, Mock())
bridge.notify("x" * 16_385)
bridge.notify(json.dumps({"source": "evil", "event": "im-ai-request"}))
bridge.notify("[]")
callback.assert_not_called()
message = {"source": "doctor-call", "event": "im-ai-cancel"}
bridge.notify(json.dumps(message))
callback.assert_called_once_with(message)
def test_video_module_remains_importable_without_optional_qt():
source = Path(__file__).resolve().parents[1] / "src"
script = """
import builtins
original_import = builtins.__import__
def without_qt(name, *args, **kwargs):
if name == 'PySide6' or name.startswith('PySide6.'):
raise ImportError('synthetic core-only installation')
return original_import(name, *args, **kwargs)
builtins.__import__ = without_qt
from doctor_workstation.video.window import WEBENGINE_AVAILABLE, VideoCallWindow
from doctor_workstation.video.im_ai import ImAiController
assert WEBENGINE_AVAILABLE is False
"""
result = subprocess.run(
[sys.executable, "-c", script], env={**os.environ, "PYTHONPATH": str(source)},
capture_output=True, text=True, timeout=10, check=False,
)
assert result.returncode == 0, result.stderr
+295 -12
View File
@@ -12,17 +12,16 @@ from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# The module disables itself under the offscreen platform so widget grabs in the
# other suites capture settled frames; these tests are about the animation, so
# they opt back in.
os.environ["DOCTOR_MOTION"] = "on"
import pytest
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtCore import QAbstractAnimation, QEvent, QPoint, QPointF, QPropertyAnimation, Qt
from PySide6.QtGui import QKeyEvent, QWheelEvent
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QGraphicsDropShadowEffect,
QLabel,
QListWidget,
QPlainTextEdit,
QScrollArea,
QStackedWidget,
QVBoxLayout,
@@ -32,6 +31,13 @@ from PySide6.QtWidgets import (
from doctor_workstation.ui import motion
@pytest.fixture(autouse=True)
def enable_motion(monkeypatch: pytest.MonkeyPatch) -> None:
# Opt in per test so collecting this module does not animate other suites'
# screenshots or leak motion settings across the shared QApplication.
monkeypatch.setenv("DOCTOR_MOTION", "on")
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@@ -122,15 +128,22 @@ def _scroll_area(application: QApplication) -> QScrollArea:
return area
def _wheel(delta: int) -> QWheelEvent:
def _wheel(
delta: int,
*,
horizontal: int = 0,
pixels: int = 0,
modifiers: Qt.KeyboardModifier = Qt.KeyboardModifier.NoModifier,
phase: Qt.ScrollPhase = Qt.ScrollPhase.NoScrollPhase,
) -> QWheelEvent:
return QWheelEvent(
QPointF(50, 50),
QPointF(50, 50),
QPoint(0, 0),
QPoint(0, delta),
QPoint(0, pixels),
QPoint(horizontal, delta),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
modifiers,
phase,
False,
)
@@ -226,3 +239,273 @@ def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
assert getattr(area, "_doctor_smooth_scroll", None) is not None
area.close()
def test_repeated_fade_continues_from_current_opacity(application: QApplication) -> None:
widget = QWidget()
motion.fade_in(widget)
first = widget._doctor_motion["fade"]
first.setCurrentTime(motion.BASE // 2)
opacity = widget.graphicsEffect().opacity()
motion.fade_in(widget)
assert widget.graphicsEffect().opacity() == pytest.approx(opacity)
assert widget._doctor_motion["fade"].startValue() == pytest.approx(opacity)
assert first.state() == QAbstractAnimation.State.Stopped
widget.close()
def test_fade_out_starts_at_full_opacity(application: QApplication) -> None:
widget = QWidget()
widget.show()
motion.fade_out(widget)
assert widget.graphicsEffect().opacity() == pytest.approx(1.0)
widget.close()
def test_reversing_fade_does_not_run_cancelled_hide_callback(application: QApplication) -> None:
widget = QWidget()
widget.show()
completed = []
motion.fade_out(widget, on_finished=lambda: completed.append(True))
outgoing = widget._doctor_motion["fade"]
outgoing.setCurrentTime(motion.FAST // 2)
opacity = widget.graphicsEffect().opacity()
motion.fade_in(widget)
incoming = widget._doctor_motion["fade"]
assert incoming.startValue() == pytest.approx(opacity)
incoming.setCurrentTime(incoming.duration())
application.processEvents()
assert widget.isVisible()
assert not completed
assert widget.graphicsEffect() is None
widget.close()
def test_previous_fade_cleanup_does_not_delete_reentrant_fade(application: QApplication) -> None:
widget = QWidget()
widget.show()
motion.fade_out(widget, on_finished=lambda: motion.fade_in(widget))
outgoing = widget._doctor_motion["fade"]
outgoing.setCurrentTime(outgoing.duration())
incoming = widget._doctor_motion["fade"]
effect = widget.graphicsEffect()
application.processEvents()
assert widget.graphicsEffect() is effect
assert incoming.state() == QAbstractAnimation.State.Running
incoming.setCurrentTime(incoming.duration())
application.processEvents()
assert widget.graphicsEffect() is None
assert widget.isVisible()
widget.close()
def test_replaced_and_completed_animations_are_released(application: QApplication) -> None:
widget = QWidget()
destroyed = []
for _ in range(10):
motion.fade_in(widget)
widget._doctor_motion["fade"].destroyed.connect(lambda: destroyed.append(True))
fade = widget._doctor_motion["fade"]
fade.setCurrentTime(fade.duration())
application.processEvents()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not widget._doctor_motion
assert not widget.findChildren(QPropertyAnimation)
assert len(destroyed) == 10
widget.close()
def test_fades_preserve_existing_graphics_effects(application: QApplication) -> None:
widget = QWidget()
shadow = QGraphicsDropShadowEffect(widget)
widget.setGraphicsEffect(shadow)
motion.fade_in(widget)
assert widget.graphicsEffect() is shadow
motion.fade_out(widget)
application.processEvents()
assert widget.graphicsEffect() is shadow
assert widget.isHidden()
widget.close()
def test_repeated_enter_does_not_accumulate_position_offset(application: QApplication) -> None:
host = QWidget()
widget = QWidget(host)
origin = QPoint(10, 20)
widget.move(origin)
motion.enter(widget)
widget._doctor_motion["enter"].setCurrentTime(motion.BASE // 3)
position = widget.pos()
motion.enter(widget)
assert widget.pos() == position
arrival = widget._doctor_motion["enter"]
assert arrival.endValue() == origin
arrival.setCurrentTime(arrival.duration())
assert widget.pos() == origin
host.close()
def test_disabling_motion_settles_an_active_entry(
application: QApplication, monkeypatch: pytest.MonkeyPatch
) -> None:
host = QWidget()
widget = QWidget(host)
origin = QPoint(10, 20)
widget.move(origin)
motion.enter(widget)
monkeypatch.setenv("DOCTOR_MOTION", "off")
motion.enter(widget)
application.processEvents()
assert widget.pos() == origin
assert widget.graphicsEffect() is None
assert not widget._doctor_motion
host.close()
def test_wheel_reversal_discards_pending_travel(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
bar.setValue(500)
for _ in range(4):
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
position = bar.value()
scroller.eventFilter(area.viewport(), _wheel(120))
assert scroller._animation.startValue() == position
assert scroller._animation.endValue() == position - motion.SCROLL_STEP
scroller._animation.setCurrentTime(60)
assert bar.value() < position
area.close()
@pytest.mark.parametrize("interaction", ["set_value", "keyboard", "slider_press", "range_change"])
def test_direct_scroll_interaction_cancels_pending_wheel(
application: QApplication, interaction: str
) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
if interaction == "set_value":
bar.setValue(500)
elif interaction == "keyboard":
application.sendEvent(
area,
QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Down, Qt.KeyboardModifier.NoModifier),
)
elif interaction == "slider_press":
bar.setSliderDown(True)
else:
bar.setRange(0, 50)
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
assert scroller._target == bar.value()
if interaction == "set_value":
scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller._animation.endValue() == 500 + motion.SCROLL_STEP
area.close()
@pytest.mark.parametrize(
"wheel_options",
[
{"pixels": -8},
{"phase": Qt.ScrollPhase.ScrollUpdate},
{"phase": Qt.ScrollPhase.ScrollMomentum},
{"modifiers": Qt.KeyboardModifier.ControlModifier},
{"modifiers": Qt.KeyboardModifier.ShiftModifier},
{"horizontal": -120},
],
)
def test_native_gestures_cancel_pending_wheel(
application: QApplication, wheel_options: dict
) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(30)
delta = 0 if "horizontal" in wheel_options else -120
assert not scroller.eventFilter(area.viewport(), _wheel(delta, **wheel_options))
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
assert scroller._target == area.verticalScrollBar().value()
area.close()
@pytest.mark.parametrize("kind", ["items", "text_blocks"])
def test_non_pixel_scrollbars_keep_native_wheel_steps(application: QApplication, kind: str) -> None:
if kind == "items":
area = QListWidget()
area.addItems([f"row {index}" for index in range(300)])
area.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
else:
area = QPlainTextEdit("\n".join(f"line {index}" for index in range(300)))
area.resize(300, 200)
area.show()
application.processEvents()
motion.install_smooth_scroll(area)
assert not area._doctor_smooth_scroll.eventFilter(area.viewport(), _wheel(-120))
application.sendEvent(area.viewport(), _wheel(-120))
assert 0 < area.verticalScrollBar().value() < motion.SCROLL_STEP
area.close()
def test_pending_scroll_reaches_edge_before_handing_wheel_back(application: QApplication) -> None:
area = _scroll_area(application)
motion.install_smooth_scroll(area)
scroller = area._doctor_smooth_scroll
bar = area.verticalScrollBar()
bar.setValue(bar.maximum() - 20)
assert scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller.eventFilter(area.viewport(), _wheel(-120))
scroller._animation.setCurrentTime(scroller._animation.duration())
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
area.close()
def test_horizontal_scroller_honors_horizontal_and_shift_wheel(application: QApplication) -> None:
area = QScrollArea()
content = QWidget()
content.resize(2000, 1000)
area.setWidget(content)
area.resize(300, 200)
motion.install_smooth_scroll(area, orientation=Qt.Orientation.Horizontal)
area.show()
application.processEvents()
scroller = area._doctor_smooth_scroll
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
assert scroller.eventFilter(area.viewport(), _wheel(0, horizontal=-120))
scroller._animation.setCurrentTime(scroller._animation.duration())
assert area.horizontalScrollBar().value() == motion.SCROLL_STEP
assert area.verticalScrollBar().value() == 0
assert scroller.eventFilter(
area.viewport(), _wheel(-120, modifiers=Qt.KeyboardModifier.ShiftModifier)
)
assert scroller._animation.endValue() == motion.SCROLL_STEP * 2
area.close()
+10 -3
View File
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import icons
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
@@ -222,7 +223,7 @@ def test_sorted_native_row_view_button_targets_visible_record(
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
kind, color, size = "pencil", "#1769E8", 19
kind, color, size = "pencil", PALETTE["accent"], 19
shared = icons.icon(kind, color, size)
original_key = shared.cacheKey()
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
@@ -230,13 +231,19 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
assert blue is not shared
assert icons.icon(kind, color, size) is shared
assert shared.cacheKey() == original_key
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, PALETTE["disabled"], size).toImage()
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
# Both surfaces share the disabled colour; changing the returned icon still
# must not mutate the cached icon used by another page.
blue.addPixmap(icons.pixmap(kind, PALETTE["danger"], size), QIcon.Mode.Disabled)
assert shared.cacheKey() == original_key
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
+5 -4
View File
@@ -6,6 +6,7 @@ from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.design_system import PALETTE
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import ReceptionPage
from doctor_workstation.ui.theme import apply_theme
@@ -43,12 +44,12 @@ def page(application, monkeypatch):
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
page.video_button.setEnabled(True)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == PALETTE["accent"].lower()
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["surface"].lower()
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == PALETTE["text"].lower()
page.video_button.setEnabled(False)
settle(application)
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["disabled"].lower()
def test_typography_preserves_regular_body_and_distinct_values(page):
+53
View File
@@ -164,6 +164,59 @@ def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
@pytest.mark.parametrize("permissions, expected", [
(None, False),
([], False),
(["tcm.diagnosis/read"], False),
(["tcm.diagnosis.aiAssistant"], False),
(["tcm.diagnosis/aiAssistant"], True),
(["tcm.diagnosis/*"], True),
(["*"], True),
])
def test_im_ai_capability_comes_from_current_shell_permissions(chat_controller, permissions, expected):
case = chat_controller
case.controller.shell_window.permissions = permissions
case.controller._request_video({
"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456,
"mode": "im", "can_ai_assistant": True,
})
case.complete(0)
assert case.launched[0][1]["can_ai_assistant"] is expected
def test_im_ai_permission_is_evaluated_after_ticket_load(chat_controller):
case = chat_controller
case.controller.shell_window.permissions = ["tcm.diagnosis/aiAssistant"]
case.request(456)
case.controller.shell_window.permissions = []
case.complete(0)
assert case.launched[0][1]["can_ai_assistant"] is False
@pytest.mark.parametrize("capability, expected", [(True, True), (False, False), (None, False), (1, False), ("true", False)])
def test_video_launcher_forwards_only_explicit_ai_capability(monkeypatch, capability, expected):
from doctor_workstation.video import window as window_module
from doctor_workstation.video.launcher import launch_video_call
launches = []
sentinel = object()
def capture(request, **kwargs):
launches.append((request, kwargs))
return sentinel
monkeypatch.setattr(window_module, "open_video_call", capture)
result = launch_video_call(
ticket(), repository=object(), open_im=True, can_ai_assistant=capability,
)
assert result is sentinel
request, options = launches[0]
assert request.diagnosis_id == 123
assert request.patient_id == 8
assert request.appointment_id == 456
assert options["can_ai_assistant"] is expected
@pytest.mark.parametrize("second_diagnosis", [123, 124])
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
+42
View File
@@ -295,6 +295,48 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
application.processEvents()
def test_login_password_group_keeps_focus_when_reveal_is_keyboard_focused(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
window = LoginWindow(
object(), settings=QSettings(str(tmp_path / "focus.ini"), QSettings.Format.IniFormat),
)
window.show()
application.processEvents()
group = window.password_edit.parentWidget()
geometry = group.geometry()
try:
for control in (window.password_edit, window.reveal_button):
control.setFocus()
application.processEvents()
assert group.property("focused") is True
assert group.geometry() == geometry
window.login_button.setFocus()
application.processEvents()
assert group.property("focused") is False
assert group.geometry() == geometry
finally:
window.close()
def test_compact_login_keeps_the_whole_form_visible(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
window = LoginWindow(
object(), settings=QSettings(str(tmp_path / "compact.ini"), QSettings.Format.IniFormat),
)
window.resize(860, 640)
window.show()
application.processEvents()
try:
assert not window.brand_panel.isVisible()
assert window.login_scroll.verticalScrollBar().maximum() == 0
assert window.card.height() >= window.card.minimumSizeHint().height()
assert window.login_button.isVisibleTo(window)
finally:
window.close()
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings_path = tmp_path / "remember-password.ini"
+4
View File
@@ -38,6 +38,8 @@ def page(application, monkeypatch):
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
# The responsive workspace exposes insights in its second tab.
page.workbench.mode_tabs.setCurrentIndex(1)
settle(application)
for button in page.ai_prompt_buttons:
assert button.width() >= button.minimumSizeHint().width()
@@ -48,6 +50,8 @@ def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, applicati
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
# Force real overflow: a short case is allowed to fill the viewport.
page.case_labels["present"].setText("Long clinical history\n" * 80)
page.detail_tabs.setCurrentIndex(0)
settle(application)
consultation_height = page.clinical_pages.height()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-B4Apl_Sl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D8_VIZ6e.css">
<script type="module" crossorigin src="./assets/index-C_tMrPfb.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CMN1CXTr.css">
</head>
<body>
<div id="app"></div>
+193 -7
View File
@@ -3,6 +3,10 @@ import { TUICallKit } from '@trtc/calls-uikit-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 { appendAiDraft, shouldClearSubmittedDraft } from './im-ai'
import type { ImAiAction, ImAiState } from './im-ai'
import { aiPanelBounds, clampAiPanelWidth, resizeAiPanelByKey } from './im-ai-layout'
interface ChatMessage {
id: string
@@ -68,6 +72,11 @@ const props = defineProps<{
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>
@@ -79,6 +88,94 @@ const props = defineProps<{
}>()
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)
@@ -221,14 +318,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]
@@ -284,8 +424,8 @@ watch(
</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">
@@ -388,21 +528,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"
@@ -416,6 +564,44 @@ watch(
</footer>
</section>
<ImAiPanel
v-if="isChat"
:state="imAiState.value"
:open="aiOpen"
:drawer="aiDrawer"
:draft-has-text="Boolean(draft)"
@request="onRequestImAi"
@regenerate="onRegenerateImAi"
@cancel="onCancelImAi"
@apply="applyAiDraft"
@close="closeAi"
>
<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>
<section
v-if="videoVisible"
class="video-layer"
+174
View File
@@ -0,0 +1,174 @@
<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
}>()
const emit = defineEmits<{
request: [action: ImAiAction]
regenerate: [action: ImAiAction]
cancel: []
apply: []
close: []
}>()
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 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>
+3
View File
@@ -23,6 +23,7 @@ interface DoctorCallConfig {
patientName?: string
patientCase?: DoctorPatientCase
mode?: 'chat' | 'video'
imAi?: import('./im-ai').ImAiCapabilities
}
interface DoctorPatientCase {
@@ -54,6 +55,8 @@ interface DoctorCallApi {
}
interface DoctorConsultationApi {
imAiResult(payload: import('./im-ai').ImAiResult): void
imAiReady(sessionId: string): void
open(config: DoctorCallConfig): Promise<void>
close(): Promise<void>
startVideo(): Promise<void>
+23
View File
@@ -0,0 +1,23 @@
export const AI_SPLITTER_WIDTH = 8
/** Keep the composer usable; a compact drawer always leaves its handle visible. */
export function aiPanelBounds(viewport: number, drawer: boolean) {
const available = Number.isFinite(viewport) ? Math.max(0, viewport) : 0
const maximum = drawer ? Math.max(0, available - 24) : Math.max(0, Math.min(760, available - 480 - AI_SPLITTER_WIDTH))
const minimum = Math.min(300, maximum)
const initial = Math.min(maximum, Math.max(minimum, drawer ? 360 : 330))
return { minimum, maximum, initial }
}
export function clampAiPanelWidth(width: number, viewport: number, drawer: boolean): number {
const { minimum, maximum, initial } = aiPanelBounds(viewport, drawer)
return Math.round(Math.max(minimum, Math.min(maximum, Number.isFinite(width) ? width : initial)))
}
export function resizeAiPanelByKey(key: string, width: number, viewport: number, drawer: boolean, largeStep = false): number | null {
const { minimum, maximum, initial } = aiPanelBounds(viewport, drawer)
const step = largeStep ? 40 : 20
const target = key === 'ArrowLeft' ? width + step : key === 'ArrowRight' ? width - step
: key === 'Home' ? minimum : key === 'End' ? maximum : key === 'Enter' ? initial : null
return target === null ? null : clampAiPanelWidth(target, viewport, drawer)
}
+325
View File
@@ -0,0 +1,325 @@
/** A deliberately small, inert Markdown subset for untrusted model output. */
type Block =
| { type: 'paragraph' | 'heading'; text: string; level?: number }
| { type: 'code'; text: string }
| { type: 'quote'; children: Block[] }
| { type: 'list'; ordered: boolean; items: ListItem[] }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
interface ListItem { marker: string; children: Block[] }
interface ListMarker { indent: number; contentIndent: number; marker: string; text: string; ordered: boolean }
interface InlinePart { html: string; plain: string }
// Recursion is bounded, including deeply nested malformed/streaming input.
const MAX_DEPTH = 12
const SPECIAL = new Set(['\\', '`', '*', '_', '[', '!'])
const ESCAPABLE = /^[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]$/
// These are presentation tokens, not medical rules: no thresholds or severity are inferred.
// Limit digit/space runs so a long malformed stream cannot cause repeated unbounded scans.
const METRIC_NUMBER = '[+-]?(?:\\d{1,12}(?:\\.\\d{1,8})?|\\.\\d{1,8})'
const METRIC_SPACE = '[ \\t]{0,8}'
const METRIC_RANGE = `${METRIC_NUMBER}(?:${METRIC_SPACE}[-–—~~至到]${METRIC_SPACE}${METRIC_NUMBER})?`
const METRIC_UNIT = `(?:mmol${METRIC_SPACE}/${METRIC_SPACE}L|mg${METRIC_SPACE}/${METRIC_SPACE}dL|mmHg|kg|cm|bpm|℃|°C|°F|%|%|斤|次${METRIC_SPACE}/${METRIC_SPACE}(?:分钟|分))`
const METRIC_TOKENS = new RegExp(
`(糖化血红蛋白|空腹血糖|餐后血糖|血氧饱和度|收缩压|舒张压|血糖|血压|体重|身高|血氧|体温|心率|HbA1c|BMI|SpO2)`
+ `|(${METRIC_NUMBER}${METRIC_SPACE}/${METRIC_SPACE}${METRIC_NUMBER}${METRIC_SPACE}mmHg|${METRIC_RANGE}${METRIC_SPACE}${METRIC_UNIT})`,
'gi',
)
function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
}
function literal(text: string): InlinePart {
return { html: escapeHtml(text), plain: text }
}
/** Wrap only escaped source text, never inspect or rewrite generated HTML. */
function metricText(text: string, before: string, after: string): InlinePart {
const html: string[] = []
let cursor = 0
for (const match of text.matchAll(METRIC_TOKENS)) {
const start = match.index!
const end = start + match[0].length
const previous = start ? text[start - 1] : before
const next = end < text.length ? text[end] : after
const isLabel = Boolean(match[1])
// Do not color fragments of identifiers, malformed numbers, or incomplete units.
if (isLabel ? /^[a-z]/i.test(match[0]) && /[a-z0-9_]/i.test(previous + next)
: /[a-z0-9_./+–—~~-]/i.test(previous) || /[a-z0-9_/]/i.test(next)) continue
html.push(escapeHtml(text.slice(cursor, start)))
html.push(`<span class="${isLabel ? 'im-ai-metric-label' : 'im-ai-metric-value'}">${escapeHtml(match[0])}</span>`)
cursor = end
}
html.push(escapeHtml(text.slice(cursor)))
return { html: html.join(''), plain: text }
}
/** Index balanced brackets and equal-length code runs once, avoiding repeated suffix searches. */
function inlinePairs(text: string): { brackets: Map<number, number>; code: Map<number, number> } {
const brackets = new Map<number, number>()
const code = new Map<number, number>()
const square: number[] = []
const round: number[] = []
const codeRuns: { index: number; length: number }[] = []
for (let index = 0; index < text.length; index++) {
const character = text[index]
if (character === '\\' && ESCAPABLE.test(text[index + 1] || '')) { index++; continue }
if (character === '`') {
const start = index
while (text[index + 1] === '`') index++
codeRuns.push({ index: start, length: index - start + 1 })
} else if (character === '[') square.push(index)
else if (character === '(') round.push(index)
else if (character === ']' && square.length) brackets.set(square.pop()!, index)
else if (character === ')' && round.length) brackets.set(round.pop()!, index)
}
const nextRun = new Map<number, number>()
for (let index = codeRuns.length - 1; index >= 0; index--) {
const run = codeRuns[index]
const next = nextRun.get(run.length)
if (next !== undefined) code.set(run.index, next)
nextRun.set(run.length, run.index)
}
return { brackets, code }
}
function inline(text: string, depth = 0): InlinePart {
if (depth >= MAX_DEPTH) return literal(text)
const pairs = inlinePairs(text)
const parts: InlinePart[] = []
const openings: { marker: string; part: number; end: number }[] = []
const latest = new Map<string, number>()
for (let index = 0; index < text.length;) {
const character = text[index]
if (character === '\\' && ESCAPABLE.test(text[index + 1] || '')) {
parts.push(literal(text[index + 1])); index += 2; continue
}
if (character === '`') {
let end = index + 1
while (text[end] === '`') end++
const close = pairs.code.get(index)
if (close !== undefined) {
const content = text.slice(end, close)
parts.push({ html: `<code>${escapeHtml(content)}</code>`, plain: content })
index = close + end - index
} else { parts.push(literal(text.slice(index, end))); index = end }
continue
}
const labelStart = character === '[' ? index : character === '!' && text[index + 1] === '[' ? index + 1 : -1
if (labelStart >= 0) {
const labelEnd = pairs.brackets.get(labelStart)
const targetStart = labelEnd === undefined ? -1 : labelEnd + 1
const targetEnd = text[targetStart] === '(' ? pairs.brackets.get(targetStart) : undefined
if (labelEnd !== undefined && targetEnd !== undefined) {
// Destinations are discarded. No anchor, image, attribute or resource request is created.
parts.push(inline(text.slice(labelStart + 1, labelEnd), depth + 1))
index = targetEnd + 1
continue
}
}
if (character === '*' || character === '_') {
let end = index + 1
while (text[end] === character) end++
const marker = text.slice(index, end)
const internalUnderscore = character === '_' && /[\p{L}\p{N}]/u.test(text[index - 1] || '') && /[\p{L}\p{N}]/u.test(text[end] || '')
const previous = latest.get(marker)
if (marker.length <= 3 && !internalUnderscore && previous !== undefined && index > openings[previous].end) {
const opening = openings[previous]
const tags = marker.length === 1 ? ['<em>', '</em>'] : marker.length === 2 ? ['<strong>', '</strong>'] : ['<strong><em>', '</em></strong>']
parts[opening.part] = { html: tags[0], plain: '' }
parts.push({ html: tags[1], plain: '' })
// Crossing/unclosed inner markers stay literal; every opening is removed at most once.
while (openings.length > previous) latest.delete(openings.pop()!.marker)
} else {
if (marker.length <= 3 && !internalUnderscore && /\S/.test(text[end] || '')) {
latest.set(marker, openings.length)
openings.push({ marker, part: parts.length, end })
}
parts.push(literal(marker))
}
index = end
continue
}
let end = index + 1
while (end < text.length && !SPECIAL.has(text[end])) end++
parts.push(metricText(text.slice(index, end), text[index - 1] || '', text[end] || ''))
index = end
}
return { html: parts.map((part) => part.html).join(''), plain: parts.map((part) => part.plain).join('') }
}
function listMarker(line: string): ListMarker | null {
const match = /^([ \t]*)([-+*]|\d{1,9}[.)])([ \t]+)(.*)$/.exec(line)
if (!match) return null
return { indent: match[1].length, contentIndent: match[1].length + match[2].length + match[3].length, marker: match[2], text: match[4], ordered: /^\d/.test(match[2]) }
}
function fence(line: string): RegExpExecArray | null { return /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line) }
function heading(line: string): RegExpExecArray | null {
const match = /^ {0,3}(#{1,6})[ \t]+(.+)$/.exec(line)
if (match) {
let value = match[2].trimEnd()
let end = value.length
while (value[end - 1] === '#') end--
if (end < value.length && /[ \t]/.test(value[end - 1] || '')) value = value.slice(0, end).trimEnd()
match[2] = value
}
return match?.[2] ? match : null
}
function isRule(line: string): boolean { return /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(line) }
function isQuote(line: string): boolean { return /^ {0,3}>/.test(line) }
function startsBlock(line: string): boolean { return Boolean(fence(line) || heading(line) || listMarker(line) || isQuote(line) || isRule(line)) }
function tableCells(line: string): string[] {
const value = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
const cells: string[] = []
const code = inlinePairs(value).code
let start = 0
for (let index = 0; index < value.length; index++) {
if (value[index] === '\\') { index++; continue }
if (value[index] === '`') {
let end = index + 1
while (value[end] === '`') end++
const close = code.get(index)
if (close !== undefined) { index = close + end - index - 1; continue }
index = end - 1
}
if (value[index] === '|') { cells.push(value.slice(start, index).trim()); start = index + 1 }
}
cells.push(value.slice(start).trim())
return cells
}
function tableHeaders(lines: string[], index: number): string[] | null {
if (!lines[index]?.includes('|') || !lines[index + 1]?.includes('|')) return null
const headers = tableCells(lines[index])
const separators = tableCells(lines[index + 1])
return headers.length >= 2 && headers.length === separators.length && separators.every((cell) => /^:?-{3,}:?$/.test(cell)) ? headers : null
}
function parseBlocks(lines: string[], depth = 0): Block[] {
if (depth >= MAX_DEPTH) return [{ type: 'paragraph', text: lines.join('\n') }]
const blocks: Block[] = []
for (let index = 0; index < lines.length;) {
if (!lines[index].trim()) { index++; continue }
const openingFence = fence(lines[index])
if (openingFence && index + 1 < lines.length) {
const content: string[] = []
index++
while (index < lines.length) {
const closingFence = fence(lines[index])
if (closingFence && closingFence[1][0] === openingFence[1][0] && closingFence[1].length >= openingFence[1].length && !closingFence[2].trim()) { index++; break }
content.push(lines[index++])
}
blocks.push({ type: 'code', text: content.join('\n') })
continue
}
const title = heading(lines[index])
if (title) { blocks.push({ type: 'heading', level: title[1].length, text: title[2] }); index++; continue }
if (isRule(lines[index])) { blocks.push({ type: 'rule' }); index++; continue }
if (isQuote(lines[index])) {
const content: string[] = []
while (index < lines.length && isQuote(lines[index])) content.push(lines[index++].replace(/^ {0,3}>[ \t]?/, ''))
blocks.push({ type: 'quote', children: parseBlocks(content, depth + 1) })
continue
}
const first = listMarker(lines[index])
if (first) {
const items: ListItem[] = []
while (index < lines.length) {
const current = listMarker(lines[index])
if (!current || current.indent !== first.indent || current.ordered !== first.ordered) break
const content = [current.text]
index++
while (index < lines.length) {
const line = lines[index]
const indentation = /^[ \t]*/.exec(line)![0].length
const next = listMarker(line)
if (next && next.indent <= first.indent) break
if (!line.trim()) {
// A blank line belongs to this item only when followed by an indented continuation.
let following = index + 1
while (following < lines.length && !lines[following].trim()) following++
if (following >= lines.length || /^[ \t]*/.exec(lines[following])![0].length <= first.indent) break
content.push(''); index = following; continue
}
if (indentation > first.indent) content.push(line.slice(Math.min(indentation, current.contentIndent)))
else if (!startsBlock(line)) content.push(line)
else break
index++
}
items.push({ marker: current.marker, children: parseBlocks(content, depth + 1) })
// Keep a spaced list in one list, without absorbing the following paragraph.
let following = index
while (following < lines.length && !lines[following].trim()) following++
const next = following < lines.length ? listMarker(lines[following]) : null
if (next && next.indent === first.indent && next.ordered === first.ordered) index = following
}
blocks.push({ type: 'list', ordered: first.ordered, items })
continue
}
const headers = tableHeaders(lines, index)
if (headers) {
const rows: string[][] = []
index += 2
while (index < lines.length && lines[index].trim() && lines[index].includes('|') && !startsBlock(lines[index])) rows.push(tableCells(lines[index++]))
blocks.push({ type: 'table', headers, rows })
continue
}
const content = [lines[index++]]
while (index < lines.length && lines[index].trim() && !startsBlock(lines[index]) && !tableHeaders(lines, index)) content.push(lines[index++])
blocks.push({ type: 'paragraph', text: content.join('\n') })
}
return blocks
}
function blockHtml(block: Block): string {
if (block.type === 'rule') return '<hr>'
if (block.type === 'code') return `<pre><code>${escapeHtml(block.text)}</code></pre>`
if (block.type === 'quote') return `<blockquote>${block.children.map(blockHtml).join('')}</blockquote>`
if (block.type === 'list') {
const tag = block.ordered ? 'ol' : 'ul'
const items = block.items.map((item) => {
// The sole dynamic attribute is a number from the already bounded digits-only list marker.
const value = block.ordered ? ` value="${Number(item.marker.slice(0, -1))}"` : ''
return `<li${value}>${item.children.map(blockHtml).join('')}</li>`
}).join('')
return `<${tag}>${items}</${tag}>`
}
if (block.type === 'table') {
const headers = block.headers.map((cell) => `<th>${inline(cell).html}</th>`).join('')
const rows = block.rows.map((row) => `<tr>${row.map((cell) => `<td>${inline(cell).html}</td>`).join('')}</tr>`).join('')
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
}
const content = inline(block.text).html.replace(/\n/g, '<br>')
// The heading lookup keeps every emitted tag fixed, independent of source content.
const tag = block.type === 'heading' ? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'][(block.level || 1) - 1] : 'p'
return `<${tag}>${content}</${tag}>`
}
function blocksPlain(blocks: Block[]): string {
return blocks.map((block): string => {
if (block.type === 'rule') return ''
if (block.type === 'code') return block.text
if (block.type === 'quote') return blocksPlain(block.children)
if (block.type === 'list') return block.items.map((item) => {
const prefix = block.ordered ? `${item.marker} ` : '• '
return blocksPlain(item.children).split('\n').map((line, index) => `${index ? line ? ' '.repeat(prefix.length) : '' : prefix}${line}`).join('\n')
}).join('\n')
if (block.type === 'table') return [block.headers, ...block.rows].map((row) => row.map((cell) => inline(cell).plain).join(' | ')).join('\n')
return inline(block.text).plain
}).filter((text) => text !== '').join('\n\n')
}
/** Safe for v-html: source HTML is escaped; no active elements or external resources are emitted. */
export function renderImAiMarkdown(text: string): string {
return parseBlocks(text.replace(/\r\n?/g, '\n').split('\n')).map(blockHtml).join('')
}
/** Readable composer text, preserving clinical wording/values and explicit list numbering. */
export function imAiMarkdownToPlainText(text: string): string {
return blocksPlain(parseBlocks(text.replace(/\r\n?/g, '\n').split('\n'))).trim()
}
+309
View File
@@ -0,0 +1,309 @@
import { imAiMarkdownToPlainText } from './im-ai-markdown'
export type ImAiAction = 'summary' | 'followup' | 'reply'
export type ImAiStatus = 'unavailable' | 'idle' | 'loading' | 'streaming' | 'success' | 'error' | 'cancelled'
export interface ImAiCapabilities {
version: 1
enabled: boolean
sessionId: string
reason?: string
}
export interface ImAiContext {
sessionId: string
contextKey: string
diagnosisId: string
appointmentId: number
enabled: boolean
reason?: string
}
export interface ImAiIdentity {
sessionId: string
requestId: string
contextKey: string
diagnosisId: string
appointmentId: number
action: ImAiAction
}
export interface ImAiRequest extends ImAiIdentity {
source: 'doctor-call'
event: 'im-ai-request' | 'im-ai-cancel'
}
export interface ImAiResult extends ImAiIdentity {
status: 'loading' | 'streaming' | 'success' | 'error' | 'cancelled'
text?: string
message?: string
modelLabel?: string
warnings?: string[]
}
export interface ImAiState {
available: boolean
status: ImAiStatus
contextKey: string
requestId: string
action: ImAiAction | null
text: string
message: string
modelLabel: string
warnings: readonly string[]
completedActions: readonly ImAiAction[]
pendingAction: ImAiAction | null
}
export const IM_AI_DEADLINE_MS = 180_000
export const IM_AI_INITIAL_READY_DEADLINE_MS = 30_000
export const DRAFT_MAX_LENGTH = 3000
const ACTIONS: readonly ImAiAction[] = ['summary', 'followup', 'reply']
const RESULT_STATUSES = ['loading', 'streaming', 'success', 'error', 'cancelled']
export function emptyImAiState(context?: ImAiContext | null): ImAiState {
const available = Boolean(context?.enabled && context.sessionId && context.contextKey)
return {
available,
status: available ? 'idle' : 'unavailable',
contextKey: context?.contextKey || '',
requestId: '',
action: null,
text: '',
message: available ? '' : context?.reason || '当前客户端尚未启用 AI 问诊助手',
modelLabel: '',
warnings: [],
completedActions: [],
pendingAction: null,
}
}
export function normalizeImAiCapabilities(value: unknown): ImAiCapabilities {
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : ''
return {
version: 1,
enabled: data.version === 1 && data.enabled === true && Boolean(sessionId),
sessionId,
reason: typeof data.reason === 'string' ? data.reason.slice(0, 300) : undefined,
}
}
export function newImAiRequestId(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID()
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`
}
interface ControllerOptions {
notify: (message: ImAiRequest) => void
onState: (state: ImAiState) => void
createRequestId: () => string
setTimeout: (callback: () => void, delay: number) => number
clearTimeout: (handle: number) => void
}
export interface ImAiReadiness {
sessionId: string
contextKey: string
mode: 'chat' | 'video'
hostReady: boolean
bridgeReady: boolean
chatReady: boolean
loginReady: boolean
}
/** Owns only the AI bridge. It has no chat SDK, draft, credentials or network access. */
export function createImAiController(options: ControllerOptions) {
let context: ImAiContext | null = null
let state = emptyImAiState()
let pending: ImAiIdentity | null = null
let timer: number | null = null
let selectedAction: ImAiAction | null = null
let initialSummaryTimer: number | null = null
let initialSummaryWaiting = false
const results = new Map<ImAiAction, ImAiState>()
function dismissInitialSummary(): void {
initialSummaryWaiting = false
if (initialSummaryTimer !== null) options.clearTimeout(initialSummaryTimer)
initialSummaryTimer = null
}
function updateReadiness(ready: ImAiReadiness): void {
if (!initialSummaryWaiting || !context
|| ready.sessionId !== context.sessionId || ready.contextKey !== context.contextKey) return
if (ready.mode !== 'chat' || !ready.hostReady || !ready.bridgeReady || !ready.chatReady || !ready.loginReady) return
// Consume before dispatch: errors, cancellation and reconnects never retry.
dismissInitialSummary()
if (!pending && !selectedAction && results.size === 0) regenerate('summary')
}
function showSelected(): void {
const selected = selectedAction ? results.get(selectedAction) : undefined
state = {
...(selected || emptyImAiState(context)),
pendingAction: pending?.action || null,
completedActions: ACTIONS.filter((action) => results.get(action)?.status === 'success'),
}
options.onState(state)
}
function publish(next: ImAiState): void {
if (next.action) results.set(next.action, next)
showSelected()
}
function release(): ImAiIdentity | null {
const previous = pending
pending = null
if (timer !== null) options.clearTimeout(timer)
timer = null
return previous
}
function notifyCancel(identity: ImAiIdentity): void {
try {
options.notify({ source: 'doctor-call', event: 'im-ai-cancel', ...identity })
} catch {
// Local cancellation is final even when the host has already detached.
}
}
function cancel(): void {
dismissInitialSummary()
const previous = release()
if (!previous) return
const result = results.get(previous.action)!
publish({ ...result, status: 'cancelled', text: '', message: '已停止本次接收,可重新生成。', warnings: [] })
notifyCancel(previous)
}
function reset(next: ImAiContext | null, { autoSummary = false } = {}): void {
dismissInitialSummary()
const previous = release()
context = next ? { ...next } : null
results.clear()
selectedAction = null
showSelected()
if (previous) notifyCancel(previous)
if (autoSummary && state.available) {
initialSummaryWaiting = true
const expectedContext = context
initialSummaryTimer = options.setTimeout(() => {
if (context === expectedContext) dismissInitialSummary()
}, IM_AI_INITIAL_READY_DEADLINE_MS)
}
}
function request(action: ImAiAction): void {
if (!state.available || !context || !ACTIONS.includes(action)) return
dismissInitialSummary()
// A tab is navigation once visited. Only the explicit regenerate action
// may replace an existing result or retry a failed/cancelled request.
if (results.has(action)) {
selectedAction = action
showSelected()
return
}
regenerate(action)
}
function regenerate(action: ImAiAction): void {
if (ACTIONS.includes(action)) dismissInitialSummary()
if (pending || !state.available || !context || !ACTIONS.includes(action)) return
selectedAction = action
let requestId: string
try {
requestId = options.createRequestId()
} catch {
publish({ ...emptyImAiState(context), action, status: 'error', message: '无法创建安全请求,请重新打开问诊窗口。' })
return
}
const identity: ImAiIdentity = {
sessionId: context.sessionId,
contextKey: context.contextKey,
diagnosisId: context.diagnosisId,
appointmentId: context.appointmentId,
requestId,
action,
}
pending = identity
publish({ ...emptyImAiState(context), action, requestId, status: 'loading', message: '正在同步本次问诊的聊天记录…' })
timer = options.setTimeout(() => {
if (pending !== identity) return
release()
publish({ ...results.get(action)!, status: 'error', text: '', message: '生成等待超时,已停止本次接收。请稍后重试。', warnings: [] })
notifyCancel(identity)
}, IM_AI_DEADLINE_MS)
try {
options.notify({ source: 'doctor-call', event: 'im-ai-request', ...identity })
} catch {
if (pending !== identity) return
release()
publish({ ...results.get(action)!, status: 'error', message: 'AI 通信通道不可用,请重新打开问诊窗口。' })
}
}
function receive(payload: unknown): void {
if (!pending || !payload || typeof payload !== 'object') return
const data = payload as Record<string, unknown>
const identity = pending
if (
data.sessionId !== identity.sessionId
|| data.requestId !== identity.requestId
|| data.contextKey !== identity.contextKey
|| String(data.diagnosisId ?? '') !== identity.diagnosisId
|| data.appointmentId !== identity.appointmentId
|| data.action !== identity.action
|| !RESULT_STATUSES.includes(String(data.status))
) return
// The doctor may be reading a different cached tab while this request streams.
const result = results.get(identity.action)!
const status = data.status as ImAiResult['status']
if (status === 'loading' && result.status === 'streaming') return
const text = typeof data.text === 'string' ? data.text : result.text
const message = typeof data.message === 'string' ? data.message.slice(0, 600) : ''
const modelLabel = typeof data.modelLabel === 'string' ? data.modelLabel.slice(0, 120) : result.modelLabel
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((warning): warning is string => typeof warning === 'string').slice(0, 20).map((warning) => warning.slice(0, 1000))
: result.warnings
if (status === 'success' || status === 'error' || status === 'cancelled') release()
if (status === 'success' && !text.trim()) {
publish({ ...result, status: 'error', text: '', message: 'AI 未返回可用内容,请重试。', modelLabel, warnings })
return
}
publish({
...result, status, modelLabel, warnings,
text: status === 'error' || status === 'cancelled' ? '' : text,
message: message || (status === 'error' ? '生成失败,请稍后重试。' : status === 'cancelled' ? '已停止本次接收,可重新生成。' : ''),
})
}
return { request, regenerate, receive, cancel, reset, updateReadiness, dismissInitialSummary, getState: () => state }
}
/** The only AI-to-composer operation; it cannot send or overwrite existing text. */
export function appendAiDraft(draft: string, result: ImAiState, contextKey: string): string {
if (!contextKey || result.contextKey !== contextKey) throw new Error('当前问诊已切换,请重新生成。')
if (result.status !== 'success' || !['followup', 'reply'].includes(result.action || '') || !result.text.trim()) {
throw new Error('请等待追问或回复完整生成后再填入。')
}
const text = imAiMarkdownToPlainText(result.text)
if (!text) throw new Error('AI 内容中没有可填入的文字,请重新生成。')
const combined = draft ? `${draft}\n\n${text}` : text
if (combined.length > DRAFT_MAX_LENGTH) throw new Error('填入后将超过 3000 字,请先精简草稿或 AI 内容。')
return combined
}
export function shouldClearSubmittedDraft(
current: { text: string; contextKey: string; revision: number },
submitted: { text: string; contextKey: string; revision: number },
): boolean {
return current.contextKey === submitted.contextKey
&& current.text === submitted.text
&& current.revision === submitted.revision
}
+105 -7
View File
@@ -11,6 +11,8 @@ import {
import App from './App.vue'
import { assertCallPolicy, installAppointmentCallGuard } from './appointment-call-guard'
import { createImAiController, emptyImAiState, newImAiRequestId, normalizeImAiCapabilities } from './im-ai'
import type { ImAiCapabilities, ImAiState } from './im-ai'
import './style.css'
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
@@ -31,6 +33,7 @@ interface NormalizedCallConfig {
patientName: string
patientCase: UiPatientCase
mode: CompanionMode
imAi: ImAiCapabilities
}
interface UiChatMessage {
@@ -197,8 +200,25 @@ const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
const patientCase = ref<UiPatientCase>(emptyPatientCase())
const contextKey = ref('')
const imAiState = ref<ImAiState>(emptyImAiState())
const imAi = createImAiController({
notify: (message) => {
const bridge = window.qtVideoBridge
if (typeof bridge?.notify !== 'function') throw new Error('AI 通信通道不可用')
// Clinical context stays in the authenticated host; never use postMessage fallback.
bridge.notify(JSON.stringify(message))
},
onState: (state) => { imAiState.value = state },
createRequestId: newImAiRequestId,
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (handle) => window.clearTimeout(handle),
})
let activeConfig: NormalizedCallConfig | null = null
let imAiOpeningSessionId = ''
let imAiHostReadySessionId = ''
let imAiLoginContextKey = ''
let chat: any = null
let chatReadyPromise: Promise<void> | null = null
let resolveChatReady: (() => void) | null = null
@@ -253,6 +273,28 @@ let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
function syncInitialSummaryReadiness(): void {
if (!activeConfig || !contextKey.value) return
imAi.updateReadiness({
sessionId: activeConfig.imAi.sessionId,
contextKey: contextKey.value,
mode: activeConfig.mode,
hostReady: imAiHostReadySessionId === activeConfig.imAi.sessionId,
bridgeReady: typeof window.qtVideoBridge?.notify === 'function',
chatReady: chatReady.value,
loginReady: imAiLoginContextKey === contextKey.value
&& Boolean(chat?.isReady?.()) && chat?.getLoginUser?.() === activeConfig.userID,
})
}
function imAiReady(sessionId: string): void {
const expectedSessionId = imAiOpeningSessionId || activeConfig?.imAi.sessionId
if (!sessionId || sessionId !== expectedSessionId) return
imAiHostReadySessionId = sessionId
syncInitialSummaryReadiness()
}
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
const QWebChannel = window.QWebChannel
@@ -263,6 +305,7 @@ function initializeQtWebChannel(): void {
const bridge = channel.objects.qtVideoBridge
if (bridge) window.qtVideoBridge = bridge
emit({ source: 'doctor-call', event: 'ready' })
syncInitialSummaryReadiness()
})
} catch {
console.warn('[doctor-consultation] Qt 通信通道初始化失败')
@@ -408,6 +451,7 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
patientName: normalizedPatientName,
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
mode: config.mode === 'chat' ? 'chat' : 'video',
imAi: normalizeImAiCapabilities(config.imAi),
}
}
@@ -587,6 +631,7 @@ function onSdkReady(): void {
resolveChatReady?.()
resolveChatReady = null
rejectChatReady = null
syncInitialSummaryReadiness()
}
function onSdkNotReady(event: any): void {
@@ -618,6 +663,10 @@ function onNetStateChange(event: any): void {
async function loginChat(): Promise<void> {
if (!activeConfig) throw new Error('问诊配置尚未准备好')
const current = activeConfig
const generation = contextGeneration
const loginContextKey = contextKey.value
imAiLoginContextKey = ''
chatReady.value = false
statusText.value = '正在连接患者 IM'
chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID })
@@ -632,15 +681,29 @@ async function loginChat(): Promise<void> {
resolveChatReady = resolve
rejectChatReady = reject
})
await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig })
await Promise.race([
chatReadyPromise,
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)),
])
const client = chat
await client.login({ userID: current.userID, userSig: current.userSig })
if (activeConfig !== current || generation !== contextGeneration || chat !== client) return
let readyTimeout: number | undefined
try {
await Promise.race([
chatReadyPromise,
new Promise<void>((_, reject) => {
readyTimeout = window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)
}),
])
} finally {
if (readyTimeout !== undefined) window.clearTimeout(readyTimeout)
}
if (activeConfig !== current || generation !== contextGeneration || chat !== client) return
await loadMessages(false)
if (activeConfig !== current || generation !== contextGeneration || chat !== client) return
imAiLoginContextKey = loginContextKey
syncInitialSummaryReadiness()
}
async function logoutChat(): Promise<void> {
imAiLoginContextKey = ''
if (!chat) return
try {
chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
@@ -1917,10 +1980,27 @@ function roomBindingResult(roomId: string, ok: boolean, message: string): void {
}
async function open(config: DoctorCallConfig): Promise<void> {
if (activeConfig) await close()
activeConfig = normalizeConfig(config)
const nextConfig = normalizeConfig(config)
if (activeConfig) {
const closing = close()
// The host may acknowledge the new injection while the previous context
// is still finishing asynchronous IM/media cleanup.
imAiOpeningSessionId = nextConfig.imAi.sessionId
await closing
}
activeConfig = nextConfig
imAiOpeningSessionId = ''
const current = activeConfig
const generation = ++contextGeneration
contextKey.value = `${current.imAi.sessionId || 'consultation'}:${generation}`
imAi.reset({
sessionId: current.imAi.sessionId,
contextKey: contextKey.value,
diagnosisId: String(current.diagnosisId),
appointmentId: current.appointmentId,
enabled: current.mode === 'chat' && current.imAi.enabled,
reason: current.imAi.reason,
}, { autoSummary: true })
syncCallPolicy(current.policy)
releaseCallGuard = registerCallGuard(async (type) => {
const assertCurrent = () => {
@@ -1973,6 +2053,8 @@ async function open(config: DoctorCallConfig): Promise<void> {
try {
await loginChat()
} catch (error) {
if (activeConfig !== current || generation !== contextGeneration) return
imAi.dismissInitialSummary()
const message = safeErrorMessage(error, 'IM 连接失败')
phase.value = 'error'
statusText.value = message
@@ -1988,6 +2070,15 @@ async function close(): Promise<void> {
releaseCallGuard?.()
releaseCallGuard = undefined
contextGeneration += 1
contextKey.value = ''
imAiOpeningSessionId = ''
imAiHostReadySessionId = ''
imAiLoginContextKey = ''
imAi.reset(null)
// Release a pending SDK-ready wait so its timeout is cleared during close.
resolveChatReady?.()
resolveChatReady = null
rejectChatReady = null
for (const resolve of pendingCallPolicies.values()) resolve({})
pendingCallPolicies.clear()
resolveHostCallReady?.(false)
@@ -2023,6 +2114,8 @@ window.doctorConsultation = {
screenshotResult,
transcriptionResult,
localRecordingResult,
imAiResult: imAi.receive,
imAiReady,
}
window.doctorCall = { start: open, hangup }
initializeQtWebChannel()
@@ -2043,6 +2136,11 @@ createApp(App, {
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
patientCase: readonly(patientCase),
contextKey: readonly(contextKey),
imAiState: readonly(imAiState),
onRequestImAi: imAi.request,
onRegenerateImAi: imAi.regenerate,
onCancelImAi: imAi.cancel,
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const target = { exports: {} }
const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/im-ai-layout.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
new Function('module', 'exports', compiled)(target, target.exports)
const { aiPanelBounds, clampAiPanelWidth, resizeAiPanelByKey } = target.exports
test('wide resizing always reserves a usable composer and is bounded on huge screens', () => {
for (const width of [981, 1120, 1366, 1920, 3840]) {
const bounds = aiPanelBounds(width, false)
assert.equal(bounds.minimum, 300)
assert.ok(width - bounds.maximum - 8 >= 480)
assert.ok(bounds.maximum <= 760)
assert.equal(clampAiPanelWidth(-500, width, false), bounds.minimum)
assert.equal(clampAiPanelWidth(99999, width, false), bounds.maximum)
}
})
test('drawer retains an on-screen drag handle even at maximum width', () => {
for (const width of [280, 320, 440, 540, 980]) {
const bounds = aiPanelBounds(width, true)
assert.ok(bounds.maximum <= width - 24)
assert.ok(bounds.minimum <= bounds.initial && bounds.initial <= bounds.maximum)
assert.equal(clampAiPanelWidth(Infinity, width, true), bounds.initial)
assert.equal(clampAiPanelWidth(1, width, true), bounds.minimum)
}
assert.equal(clampAiPanelWidth(360, 440, true), 360)
assert.equal(clampAiPanelWidth(330, 1120, false), 330)
})
test('keyboard movement follows the left edge and offers limits and reset', () => {
assert.equal(resizeAiPanelByKey('ArrowLeft', 400, 1120, false), 420)
assert.equal(resizeAiPanelByKey('ArrowRight', 400, 1120, false, true), 360)
assert.equal(resizeAiPanelByKey('Home', 400, 1120, false), 300)
assert.equal(resizeAiPanelByKey('End', 400, 1120, false), 632)
assert.equal(resizeAiPanelByKey('Enter', 400, 1120, false), 330)
assert.equal(resizeAiPanelByKey('Enter', 400, 540, true), 360)
assert.equal(resizeAiPanelByKey('ArrowRight', 300, 1120, false), 300)
assert.equal(resizeAiPanelByKey('Escape', 400, 1120, false), null)
})
@@ -0,0 +1,164 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../src/im-ai-markdown.ts'), 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const target = { exports: {} }
new Function('module', 'exports', compiled)(target, target.exports)
const { renderImAiMarkdown: render, imAiMarkdownToPlainText: plain } = target.exports
const label = (text) => `<span class="im-ai-metric-label">${text}</span>`
const value = (text) => `<span class="im-ai-metric-value">${text}</span>`
test('renders headings, source emphasis, paragraph breaks and trailing whitespace inside AI emphasis', () => {
const text = '### 问诊摘要\n\n**主诉: **咳嗽 3 天;*需要补充*。\n体温 **38.5 ℃**,`SpO2 95%`。'
assert.equal(render(text), `<h3>问诊摘要</h3><p><strong>主诉: </strong>咳嗽 3 天;<em>需要补充</em>。<br>${label('体温')} <strong>${value('38.5 ℃')}</strong>,<code>SpO2 95%</code>。</p>`)
assert.equal(plain(text), '问诊摘要\n\n主诉: 咳嗽 3 天;需要补充。\n体温 38.5 ℃,SpO2 95%。')
assert.equal(render('普通胸痛、呼吸困难文字'), '<p>普通胸痛、呼吸困难文字</p>', 'no importance is invented from clinical keywords')
})
test('all source HTML, entities, scripts and attributes remain escaped text', () => {
const text = '<script>alert("x")</script>\n<img src=x onerror=alert(1)> &lt;b&gt; \'quote\'\n**<svg onload=alert(2)>**'
const html = render(text)
assert.equal(html, '<p>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;<br>&lt;img src=x onerror=alert(1)&gt; &amp;lt;b&amp;gt; &#39;quote&#39;<br><strong>&lt;svg onload=alert(2)&gt;</strong></p>')
assert.doesNotMatch(html, /<(?:script|img|svg|a|iframe)\b/i)
assert.equal(plain(text), text.replaceAll('**', ''))
})
test('inline links and images retain their labels without executable elements or destinations', () => {
const text = '[**复诊建议**](javascript:alert(1))、![报告](https://example.invalid/private.png) [普通文字](https://example.invalid/a_(b))'
assert.equal(render(text), '<p><strong>复诊建议</strong>、报告 普通文字</p>')
assert.equal(plain(text), '复诊建议、报告 普通文字')
assert.doesNotMatch(render(text), /href|src|javascript:|https:|<a\b|<img\b/)
assert.equal(render('https://example.invalid <https://example.invalid>'), '<p>https://example.invalid &lt;https://example.invalid&gt;</p>')
})
test('unordered, ordered, nested and continued lists preserve explicit numbering and clinical values', () => {
const text = '- **症状**\n - 咳嗽 3 天\n - 体温 38.5 ℃\n- 用药 0.5 mg\n\n3. 请补充病史\n 持续多久?\n7) 是否存在过敏?'
assert.equal(render(text), `<ul><li><p><strong>症状</strong></p><ul><li><p>咳嗽 3 天</p></li><li><p>${label('体温')} ${value('38.5 ℃')}</p></li></ul></li><li><p>用药 0.5 mg</p></li></ul><ol><li value="3"><p>请补充病史<br>持续多久?</p></li><li value="7"><p>是否存在过敏?</p></li></ol>`)
assert.equal(plain(text), '• 症状\n\n • 咳嗽 3 天\n • 体温 38.5 ℃\n• 用药 0.5 mg\n\n3. 请补充病史\n 持续多久?\n7) 是否存在过敏?')
assert.equal(plain('01. 第一项\n02. 第二项'), '01. 第一项\n02. 第二项')
})
test('blockquote, fences and inline code preserve source content as inert text', () => {
const text = '> **提示**\n> 第二行\n\n```html\n<script>0.05 mg</script>\n**保留代码**\n```\n\n``值 ` 38.50``'
assert.equal(render(text), '<blockquote><p><strong>提示</strong><br>第二行</p></blockquote><pre><code>&lt;script&gt;0.05 mg&lt;/script&gt;\n**保留代码**</code></pre><p><code>值 ` 38.50</code></p>')
assert.equal(plain(text), '提示\n第二行\n\n<script>0.05 mg</script>\n**保留代码**\n\n值 ` 38.50')
})
test('tables render structured cells and copy readable values without separator syntax', () => {
const text = '| 项目 | 记录 |\n| :--- | ---: |\n| 体温 | **38.50 ℃** |\n| 检查 | A\\|B |'
assert.equal(render(text), `<table><thead><tr><th>项目</th><th>记录</th></tr></thead><tbody><tr><td>${label('体温')}</td><td><strong>${value('38.50 ℃')}</strong></td></tr><tr><td>检查</td><td>A|B</td></tr></tbody></table>`)
assert.equal(plain(text), '项目 | 记录\n体温 | 38.50 ℃\n检查 | A|B')
assert.match(render('| 项目 | 记录 |\n| --- | --- |\n| 代码 | `A|B` |'), /<td>代码<\/td><td><code>A\|B<\/code><\/td>/)
})
test('unfinished streaming fragments remain readable with no lost text or raw active HTML', () => {
for (const text of ['**尚未结束', '*', '###', '[病史](https://', '![报告', '`0.5 mg', '```html', '结束\\']) {
assert.equal(plain(text), text)
assert.equal(render(text), `<p>${text}</p>`)
}
assert.equal(render('### 摘要\n**咳嗽'), '<h3>摘要</h3><p>**咳嗽</p>')
assert.equal(render('```\n<未结束>'), '<pre><code>&lt;未结束&gt;</code></pre>')
assert.equal(plain('```\n<未结束>'), '<未结束>')
assert.equal(render('**完整** 后有 **未完成'), '<p><strong>完整</strong> 后有 **未完成</p>')
})
test('literal escapes, underscore identifiers and nested emphasis are preserved correctly', () => {
assert.equal(render('\\*\\*不是重点\\*\\* 与 patient_id_value'), '<p>**不是重点** 与 patient_id_value</p>')
assert.equal(render('**重点与 *强调* 内容**,***同时强调***'), '<p><strong>重点与 <em>强调</em> 内容</strong>,<strong><em>同时强调</em></strong></p>')
assert.equal(plain('**重点与 *强调* 内容**,***同时强调***'), '重点与 强调 内容,同时强调')
assert.equal(plain('### C# 与 症状#\n\n### 正文 ###'), 'C# 与 症状#\n\n正文')
})
test('spaced list items do not consume a following paragraph and only Markdown syntax is removed', () => {
assert.equal(render('- 第一项\n\n- 第二项\n\n后续内容'), '<ul><li><p>第一项</p></li><li><p>第二项</p></li></ul><p>后续内容</p>')
assert.equal(plain('## 值\r\n\r\n- Na+ 135 mmol/L\r\n- 1/2 片,0.50 mg\r\n\r\n---\r\n\r\nA & B'), '值\n\n• Na+ 135 mmol/L\n• 1/2 片,0.50 mg\n\nA & B')
assert.equal(render(''), '')
assert.equal(plain(''), '')
})
test('mixed untrusted markup emits only inert structural tags and numeric list values', () => {
const payloads = [
'### <img src=x onerror=alert(1)>\n\n**<script>evil()</script>**',
'> <iframe srcdoc="<script>evil()</script>">\n> [x](data:text/html,evil)',
'999999999. <a href=javascript:evil()>x</a>\n - ![x](https://invalid.test/a)',
'| <svg/onload=evil()> | \" onmouseover=evil() |\n| --- | --- |\n| &lt;script&gt; | <style>@import url(x)</style> |',
'~~~\n</code><script>evil()</script>\n~~~',
'<span class="danger" onclick="evil()">血糖 2.00–30.00 mmol/L</span>',
]
for (const payload of payloads) {
const html = render(payload)
for (const match of html.matchAll(/<([^>]+)>/g)) {
assert.match(match[1], /^(?:\/?(?:p|h[1-6]|strong|em|code|pre|blockquote|ul|ol|li|table|thead|tbody|tr|th|td)|\/span|span class="im-ai-metric-(?:label|value)"|li value="\d{1,9}"|br|hr)$/)
}
}
})
test('plain screenshot bullets emphasize measurements without requiring Markdown bold', () => {
const text = '- 血糖波动 2.00-30.00 mmol/L\n- 餐后40.00 mmol/L,记录待核实\n- 体重下降10斤'
assert.equal(render(text), `<ul><li><p>${label('血糖')}波动 ${value('2.00-30.00 mmol/L')}</p></li><li><p>餐后${value('40.00 mmol/L')},记录待核实</p></li><li><p>${label('体重')}下降${value('10斤')}</p></li></ul>`)
assert.equal(plain(text), '• 血糖波动 2.00-30.00 mmol/L\n• 餐后40.00 mmol/L,记录待核实\n• 体重下降10斤')
})
test('measurement ranges, blood pressure pairs and units preserve exact source values', () => {
const measurements = ['2.00–30.00 mmol/L', '2.00 — 30.00 mmol/L', '2.00~30.00 mmol/L', '2.00至30.00 mmol/L', '130/80 mmHg', '130 / 80 mmHg', '6.50%', '98%', '60.00 kg', '170.5cm', '10斤', '36.50℃', '36.5 °C', '72 次/分', '72次/分钟', '72 bpm', '95 mg/dL']
for (const measurement of measurements) {
assert.equal(render(measurement), `<p>${value(measurement)}</p>`)
assert.equal(plain(measurement), measurement)
}
const labels = ['空腹血糖', '餐后血糖', '糖化血红蛋白', 'HbA1c', '血压', '收缩压', '舒张压', '体重', 'BMI', '血氧', '血氧饱和度', '体温', '心率']
for (const measurement of labels) assert.equal(render(measurement), `<p>${label(measurement)}</p>`)
assert.equal(render('**血压 130/80 mmHg**'), `<p><strong>${label('血压')} ${value('130/80 mmHg')}</strong></p>`)
assert.equal(plain('**血压 130/80 mmHg**'), '血压 130/80 mmHg')
})
test('emphasis neither manufactures clinical interpretation nor colors arbitrary identifiers and years', () => {
const text = '2026年,患者ID 13080,编号 ABC123;未见危急表现,无胸痛或呼吸困难。'
assert.equal(render(text), `<p>${text}</p>`)
assert.equal(plain(text), text)
const qualified = '血糖 2.00 mmol/L(患者自述,单位待核实);未测血压,体重无下降。'
assert.equal(render(qualified), `<p>${label('血糖')} ${value('2.00 mmol/L')}(患者自述,单位待核实);未测${label('血压')},${label('体重')}无下降。</p>`)
assert.equal(plain(qualified), qualified)
for (const identifier of ['patient_BMI_value', 'HbA1c_record', 'ABC130mmHg', 'ID_130mmHg', '130mmHgx', '10kg/m2', '2.00.30.00 mmol/L']) {
assert.equal(render(identifier), `<p>${identifier}</p>`)
assert.equal(plain(identifier), identifier)
}
})
test('measurement emphasis leaves code and discarded destinations inert and copy text equivalent', () => {
const text = '```\n血糖 2.00 mmol/L <svg onload="evil()">\n```\n\n`血压 130/80 mmHg`\n\n[**血糖 2.00 mmol/L**](javascript:evil(40.00mmol/L))\n\n<svg onload="evil()">体温 36.5℃</svg>'
assert.equal(render(text), `<pre><code>血糖 2.00 mmol/L &lt;svg onload=&quot;evil()&quot;&gt;</code></pre><p><code>血压 130/80 mmHg</code></p><p><strong>${label('血糖')} ${value('2.00 mmol/L')}</strong></p><p>&lt;svg onload=&quot;evil()&quot;&gt;${label('体温')} ${value('36.5℃')}&lt;/svg&gt;</p>`)
assert.equal(plain(text), '血糖 2.00 mmol/L <svg onload="evil()">\n\n血压 130/80 mmHg\n\n血糖 2.00 mmol/L\n\n<svg onload="evil()">体温 36.5℃</svg>')
assert.doesNotMatch(render(text), /javascript:|40\.00|<svg\b|<a\b/)
})
test('streaming and malformed measurement fragments remain readable without altering copy text', () => {
for (const text of ['血糖 2.', '血糖 2.00–', '血糖 2.00–30.', '血糖 2.00–30.00 mmol/', '**血糖 2.00–30.00 mmol/L', '血压 130/', '体重 10k']) {
assert.equal(plain(text), text)
assert.equal(render(text).replace(/<[^>]+>/g, ''), text)
}
assert.equal(render('**血糖 2.00–30.00 mmol/L'), `<p>**${label('血糖')} ${value('2.00–30.00 mmol/L')}</p>`)
})
test('pathological unmatched markers and deep nesting finish within bounded time without truncating content', { timeout: 5000 }, () => {
const repeated = '[x]('.repeat(30000) + '<END>'
const start = performance.now()
const html = render(repeated)
assert.equal(plain(repeated), repeated)
assert.ok(html.endsWith('&lt;END&gt;</p>'))
const markerRuns = '`'.repeat(100000) + ' tail'
assert.equal(plain(markerRuns), markerRuns)
const nesting = '> '.repeat(20000) + '最终 0.50 mg'
assert.match(render(nesting), /最终 0\.50 mg/)
assert.match(render('- 内容\n' + '\n'.repeat(30000) + ' 续行'), /续行/)
assert.equal(render('### 标题' + ' '.repeat(100000)), '<h3>标题</h3>')
const malformedMeasurement = '9'.repeat(100000) + ' mmol/' + ' '.repeat(100000) + 'mmol/L'
assert.equal(render(malformedMeasurement), `<p>${malformedMeasurement}</p>`)
assert.equal(plain(malformedMeasurement), malformedMeasurement)
assert.ok(performance.now() - start < 4000, 'unmatched delimiters must not repeatedly rescan the entire remaining suffix')
})
+643
View File
@@ -0,0 +1,643 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../src/im-ai.ts'), 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const target = { exports: {} }
function localRequire(name) {
assert.equal(name, './im-ai-markdown')
const markdown = { exports: {} }
const code = ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/im-ai-markdown.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
new Function('module', 'exports', code)(markdown, markdown.exports)
return markdown.exports
}
new Function('module', 'exports', 'require', compiled)(target, target.exports, localRequire)
const { createImAiController, normalizeImAiCapabilities, appendAiDraft, shouldClearSubmittedDraft, IM_AI_DEADLINE_MS, IM_AI_INITIAL_READY_DEADLINE_MS } = target.exports
const context = {
sessionId: '4219b17d-3104-4306-a4d3-747287ae7417', contextKey: 'session:1',
diagnosisId: '501', appointmentId: 101, enabled: true,
}
function harness(t, notify) {
const messages = []
const states = []
const timers = new Map()
let sequence = 0
let timerSequence = 0
const controller = createImAiController({
notify(message) { messages.push(message); notify?.(message) },
onState(state) { states.push(state) },
createRequestId: () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
setTimeout(callback, delay) { const handle = ++timerSequence; timers.set(handle, { callback, delay }); return handle },
clearTimeout(handle) { timers.delete(handle) },
})
controller.reset(context)
t.after(() => { controller.reset(null); assert.equal(timers.size, 0) })
return {
...controller, messages, states, timers,
result(status, text = '', extra = {}) {
const request = messages.findLast((message) => message.event === 'im-ai-request')
return { ...request, status, text, ...extra }
},
}
}
for (const action of ['summary', 'followup', 'reply']) {
test(`${action} sends only bound identity, never chat, unsent draft or credentials`, (t) => {
const h = harness(t)
h.request(action)
assert.equal(h.getState().status, 'loading')
assert.deepEqual(Object.keys(h.messages[0]).sort(), ['action', 'appointmentId', 'contextKey', 'diagnosisId', 'event', 'requestId', 'sessionId', 'source'].sort())
assert.equal(h.messages[0].action, action)
assert.match(h.messages[0].requestId, /^[0-9a-f-]{36}$/)
h.receive(h.result('success', '合成测试内容'))
assert.equal(h.getState().status, 'success')
assert.equal(h.getState().text, '合成测试内容')
assert.equal(h.timers.size, 0)
assert.equal(h.messages.length, 1, 'generation cannot send an IM message')
})
}
test('capability defaults closed and requires actual true, version and session', () => {
for (const value of [undefined, {}, { version: 1, enabled: true }, { version: 2, enabled: true, sessionId: 'x' }, { version: 1, enabled: 'true', sessionId: 'x' }]) {
assert.equal(normalizeImAiCapabilities(value).enabled, false)
}
assert.equal(normalizeImAiCapabilities({ version: 1, enabled: true, sessionId: 'a' }).enabled, true)
})
test('unavailable or unsupported action never dispatches', (t) => {
const h = harness(t)
h.request('prescribe')
h.reset({ ...context, enabled: false, reason: '无 AI 权限' })
h.request('reply')
assert.equal(h.getState().status, 'unavailable')
assert.equal(h.getState().message, '无 AI 权限')
assert.equal(h.messages.length, 0)
assert.equal(h.timers.size, 0)
})
test('one active request, cumulative streaming, no regression or partial insertion', (t) => {
const h = harness(t)
h.request('reply')
h.request('followup')
assert.equal(h.messages.length, 1)
h.receive(h.result('streaming', '您好'))
h.receive(h.result('streaming', '您好,请补充'))
h.receive(h.result('loading', '', { message: '迟到的准备状态' }))
assert.equal(h.getState().text, '您好,请补充')
assert.equal(h.getState().status, 'streaming')
assert.throws(() => appendAiDraft('原文', h.getState(), context.contextKey), /完整生成/)
h.receive(h.result('success', '您好,请补充症状。', { modelLabel: '本地合成模型', warnings: ['请复核。', 12] }))
assert.deepEqual(h.getState().warnings, ['请复核。'])
assert.equal(h.getState().modelLabel, '本地合成模型')
assert.equal(h.timers.size, 0)
})
for (const [key, value] of [
['sessionId', 'different-session'], ['requestId', 'old-request'], ['contextKey', 'different-context'],
['diagnosisId', '502'], ['appointmentId', 102], ['action', 'summary'],
]) {
test(`mismatched ${key} cannot complete the current request`, (t) => {
const h = harness(t)
h.request('reply')
h.receive(h.result('success', '其他患者内容', { [key]: value }))
assert.equal(h.getState().status, 'loading')
assert.equal(h.getState().text, '')
h.receive(h.result('success', '本次内容'))
h.receive(h.result('success', '重复终态'))
assert.equal(h.getState().text, '本次内容')
})
}
test('cancel is locally terminal, not an assertion of upstream cancellation', (t) => {
const h = harness(t)
h.request('reply')
const late = h.result('success', '迟到正文')
h.receive(h.result('streaming', '部分正文'))
h.cancel()
assert.equal(h.getState().status, 'cancelled')
assert.match(h.getState().message, /停止本次接收/)
assert.equal(h.getState().text, '')
assert.equal(h.messages[1].event, 'im-ai-cancel')
assert.equal(h.messages[1].requestId, late.requestId)
assert.equal(h.timers.size, 0)
h.receive(late)
h.cancel()
assert.equal(h.getState().status, 'cancelled')
assert.equal(h.messages.length, 2)
h.regenerate('reply')
assert.notEqual(h.messages[2].requestId, late.requestId)
h.receive(late)
assert.equal(h.getState().status, 'loading')
})
test('deadline sends cancel, rejects late completion and releases timer', (t) => {
const h = harness(t)
h.request('summary')
const [timer] = h.timers.values()
assert.equal(timer.delay, IM_AI_DEADLINE_MS)
assert.equal(timer.delay, 180000)
const late = h.result('success', '超时正文')
timer.callback()
assert.equal(h.getState().status, 'error')
assert.match(h.getState().message, /超时/)
assert.equal(h.messages.at(-1).event, 'im-ai-cancel')
assert.equal(h.timers.size, 0)
h.receive(late)
assert.equal(h.getState().text, '')
})
test('reset cancels old identity before new patient responses are accepted', (t) => {
const h = harness(t)
h.request('reply')
const late = h.result('success', '上一位患者')
h.reset({ ...context, contextKey: 'session:2', diagnosisId: '502' })
assert.equal(h.messages.at(-1).diagnosisId, '501')
assert.equal(h.messages.at(-1).event, 'im-ai-cancel')
assert.equal(h.getState().status, 'idle')
assert.equal(h.timers.size, 0)
h.request('followup')
h.receive(late)
assert.equal(h.getState().text, '')
h.receive(h.result('success', '当前患者'))
assert.equal(h.getState().text, '当前患者')
})
test('missing bridge or detached transport releases request immediately', (t) => {
const h = harness(t, () => { throw new Error('detached') })
h.request('reply')
assert.equal(h.getState().status, 'error')
assert.match(h.getState().message, /通信通道/)
assert.equal(h.timers.size, 0)
})
test('error, empty success and host cancellation remain non-insertable and retryable', (t) => {
const h = harness(t)
for (const status of ['error', 'success', 'cancelled']) {
h.regenerate('reply')
h.receive(h.result(status, ''))
assert.notEqual(h.getState().status, 'success')
assert.throws(() => appendAiDraft('', h.getState(), context.contextKey))
assert.equal(h.timers.size, 0)
}
})
test('only completed followup/reply can fill or append, never overwrite or truncate', (t) => {
const h = harness(t)
h.request('reply')
h.receive(h.result('success', ' 新回复 '))
const result = h.getState()
assert.equal(appendAiDraft('', result, context.contextKey), '新回复')
assert.equal(appendAiDraft('医生未发送的原稿', result, context.contextKey), '医生未发送的原稿\n\n新回复')
assert.throws(() => appendAiDraft('原稿', result, 'other'), /问诊已切换/)
assert.throws(() => appendAiDraft('原'.repeat(2998), result, context.contextKey), /3000/)
assert.equal(appendAiDraft('原'.repeat(2995), result, context.contextKey).length, 3000)
assert.throws(() => appendAiDraft('', { ...result, action: 'summary' }, context.contextKey), /完整生成/)
assert.equal(h.messages.length, 1, 'filling or appending cannot send through the bridge')
})
test('successful manual send clears only unchanged draft in the same context and revision', () => {
const submitted = { text: '当前草稿', contextKey: 'one', revision: 5 }
assert.equal(shouldClearSubmittedDraft({ ...submitted }, submitted), true)
assert.equal(shouldClearSubmittedDraft({ ...submitted, text: '稍后编辑内容' }, submitted), false)
assert.equal(shouldClearSubmittedDraft({ ...submitted, contextKey: 'two' }, submitted), false)
assert.equal(shouldClearSubmittedDraft({ ...submitted, revision: 7 }, submitted), false, 'typing then restoring the same text still counts as an edit')
})
test('completed tabs retain all three results and never regenerate on selection', (t) => {
const h = harness(t)
for (const action of ['summary', 'followup', 'reply']) {
h.request(action)
h.receive(h.result('success', `${action} 已生成`, { modelLabel: action, warnings: [`${action} 提示`] }))
}
for (const action of ['summary', 'reply', 'followup', 'followup', 'summary']) {
h.request(action)
assert.equal(h.getState().text, `${action} 已生成`)
assert.equal(h.getState().modelLabel, action)
assert.deepEqual(h.getState().warnings, [`${action} 提示`])
}
assert.equal(h.messages.length, 3)
assert.equal(h.timers.size, 0)
assert.deepEqual(h.getState().completedActions, ['summary', 'followup', 'reply'])
h.regenerate('summary')
assert.equal(h.messages.length, 4)
assert.equal(h.getState().status, 'loading')
h.receive(h.result('success', '新的总结'))
h.request('reply')
assert.equal(h.getState().text, 'reply 已生成')
h.request('summary')
assert.equal(h.getState().text, '新的总结')
})
test('reading a cached tab does not cancel, regenerate or receive another tab stream', (t) => {
const h = harness(t)
h.request('summary')
h.receive(h.result('success', '原总结'))
h.request('reply')
h.receive(h.result('streaming', '回复生成中'))
h.request('summary')
assert.equal(h.getState().text, '原总结')
assert.equal(h.getState().pendingAction, 'reply')
h.receive(h.result('streaming', '回复更多内容'))
assert.equal(h.getState().text, '原总结')
h.regenerate('summary')
h.request('followup')
assert.equal(h.messages.length, 2)
h.request('reply')
assert.equal(h.getState().text, '回复更多内容')
h.request('summary')
h.receive(h.result('success', '完整回复'))
assert.equal(h.getState().text, '原总结')
assert.equal(h.getState().pendingAction, null)
h.request('reply')
assert.equal(h.getState().text, '完整回复')
assert.equal(h.messages.length, 2)
})
test('failure and cancellation belong to the generating tab while cached results survive', (t) => {
const h = harness(t)
h.request('summary')
h.receive(h.result('success', '已审阅总结'))
h.request('followup')
h.request('summary')
h.receive(h.result('error', '', { message: '同步失败' }))
assert.equal(h.getState().text, '已审阅总结')
h.request('followup')
assert.equal(h.getState().message, '同步失败')
assert.equal(h.messages.length, 2, 'failed tab selection must not automatically retry')
h.regenerate('followup')
h.request('summary')
const late = h.result('success', '应忽略')
h.cancel()
h.receive(late)
assert.equal(h.getState().text, '已审阅总结')
h.request('followup')
assert.equal(h.getState().status, 'cancelled')
assert.equal(h.messages.length, 4, 'three requests and one cancellation only')
})
test('timeout updates background tab only and cached results clear on session reset', (t) => {
const h = harness(t)
h.request('summary')
h.receive(h.result('success', '旧病情'))
h.request('reply')
h.request('summary')
h.timers.values().next().value.callback()
assert.equal(h.getState().text, '旧病情')
assert.equal(h.getState().pendingAction, null)
h.request('reply')
assert.match(h.getState().message, /超时/)
h.reset({ ...context, contextKey: 'new-session' })
assert.deepEqual(h.getState().completedActions, [])
h.request('summary')
assert.equal(h.getState().text, '')
assert.equal(h.getState().status, 'loading')
assert.equal(h.messages.filter((message) => message.event === 'im-ai-request').length, 3)
})
test('formatted results insert readable text and enforce limits after conversion', (t) => {
const h = harness(t)
h.request('reply')
h.receive(h.result('success', '### 回复\n\n您好,请补充**当前用药**。\n\n1. 剂量是多少?\n2. 已服用多久?'))
const result = h.getState()
const text = appendAiDraft('原稿', result, context.contextKey)
assert.equal(text, '原稿\n\n回复\n\n您好,请补充当前用药。\n\n1. 剂量是多少?\n2. 已服用多久?')
assert.equal(result.text.includes('**'), true, 'display source stays unchanged in its tab cache')
assert.equal(appendAiDraft('', { ...result, text: `**${'字'.repeat(3000)}**` }, context.contextKey).length, 3000)
assert.throws(() => appendAiDraft('', { ...result, text: `**${'字'.repeat(3001)}**` }, context.contextKey), /3000/)
assert.throws(() => appendAiDraft('', { ...result, text: '---' }, context.contextKey), /没有可填入/)
})
const ready = {
sessionId: context.sessionId, contextKey: context.contextKey, mode: 'chat',
hostReady: true, bridgeReady: true, chatReady: true, loginReady: true,
}
for (const lastGate of ['hostReady', 'bridgeReady', 'chatReady', 'loginReady']) {
test(`initial summary waits for ${lastGate} regardless of readiness order`, (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
h.updateReadiness({ ...ready, [lastGate]: false })
assert.equal(h.messages.length, 0)
assert.equal(h.getState().status, 'idle', 'waiting for transport does not start a loading result')
assert.equal(h.timers.size, 1)
h.updateReadiness(ready)
assert.equal(h.messages.length, 1)
assert.equal(h.messages[0].action, 'summary')
assert.equal(h.getState().status, 'loading')
assert.equal(h.timers.size, 1, 'only the generation deadline remains')
assert.equal([...h.timers.values()][0].delay, IM_AI_DEADLINE_MS)
h.receive(h.result('success', '首次总结'))
h.updateReadiness({ ...ready, chatReady: false })
h.updateReadiness(ready)
h.request('summary')
h.updateReadiness(ready)
assert.equal(h.messages.length, 1, 'reconnects and tab selections cannot repeat the automatic request')
assert.equal(h.getState().text, '首次总结')
assert.equal(h.timers.size, 0)
})
}
test('disabled AI and video contexts never generate an initial summary', (t) => {
const h = harness(t)
h.reset({ ...context, enabled: false }, { autoSummary: true })
h.updateReadiness(ready)
assert.equal(h.timers.size, 0)
h.reset(context, { autoSummary: true })
h.updateReadiness({ ...ready, mode: 'video' })
assert.equal(h.messages.length, 0)
})
test('stale session, context and expired readiness callbacks cannot start or cancel a new summary', (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
const expiredCallback = [...h.timers.values()][0].callback
const nextContext = { ...context, sessionId: 'new-session', contextKey: 'new-context', diagnosisId: '502' }
h.reset(nextContext, { autoSummary: true })
h.updateReadiness(ready)
h.updateReadiness({ ...ready, sessionId: nextContext.sessionId })
h.updateReadiness({ ...ready, contextKey: nextContext.contextKey })
expiredCallback()
assert.equal(h.messages.length, 0)
assert.equal(h.timers.size, 1)
h.updateReadiness({ ...ready, sessionId: nextContext.sessionId, contextKey: nextContext.contextKey })
assert.equal(h.messages.length, 1)
assert.equal(h.messages[0].diagnosisId, '502')
})
for (const action of ['summary', 'followup', 'reply']) {
test(`manual ${action} before readiness keeps its selection and result without a later automatic request`, (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
h.request(action)
h.updateReadiness(ready)
assert.equal(h.messages.length, 1)
assert.equal(h.getState().action, action)
h.receive(h.result('success', '医生主动生成的内容'))
h.updateReadiness(ready)
assert.equal(h.messages.length, 1)
assert.equal(h.getState().text, '医生主动生成的内容')
assert.equal(h.getState().action, action)
assert.equal(h.timers.size, 0)
})
}
for (const finish of ['error', 'cancelled', 'cancel', 'timeout', 'empty-success']) {
test(`automatic ${finish} is terminal until the doctor explicitly regenerates`, (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
h.updateReadiness(ready)
if (finish === 'cancel') h.cancel()
else if (finish === 'timeout') [...h.timers.values()][0].callback()
else h.receive(h.result(finish === 'empty-success' ? 'success' : finish, ''))
const previousMessages = h.messages.length
h.updateReadiness(ready)
h.request('summary')
assert.equal(h.messages.length, previousMessages)
assert.equal(h.timers.size, 0)
h.regenerate('summary')
assert.equal(h.messages.length, previousMessages + 1)
h.receive(h.result('success', '主动重试'))
assert.equal(h.getState().text, '主动重试')
})
}
for (const finish of ['close', 'cancel', 'dismiss', 'deadline']) {
test(`waiting initial summary is bounded and does not revive after ${finish}`, (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
const waitingTimer = [...h.timers.values()][0]
assert.equal(waitingTimer.delay, IM_AI_INITIAL_READY_DEADLINE_MS)
assert.equal(waitingTimer.delay, 30000)
if (finish === 'close') h.reset(null)
else if (finish === 'cancel') h.cancel()
else if (finish === 'dismiss') h.dismissInitialSummary()
else waitingTimer.callback()
h.updateReadiness(ready)
assert.equal(h.messages.length, 0)
assert.equal(h.timers.size, 0)
})
}
test('closing an automatic generation rejects late results and clears all deadlines', (t) => {
const h = harness(t)
h.reset(context, { autoSummary: true })
h.updateReadiness(ready)
const late = h.result('success', '已关闭的总结')
h.reset(null)
h.receive(late)
h.updateReadiness(ready)
assert.equal(h.getState().status, 'unavailable')
assert.equal(h.getState().text, '')
assert.equal(h.messages.length, 2)
assert.equal(h.messages[1].event, 'im-ai-cancel')
assert.equal(h.timers.size, 0)
})
test('automatic transport failure releases deadlines and never retries itself', (t) => {
const h = harness(t, () => { throw new Error('synthetic detached bridge') })
h.reset(context, { autoSummary: true })
h.updateReadiness(ready)
h.updateReadiness(ready)
assert.equal(h.getState().status, 'error')
assert.equal(h.messages.length, 1)
assert.equal(h.timers.size, 0)
})
// Exercise the real application entry points with an entirely local SDK/host.
const mainCode = ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/main.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const chatConfig = {
SDKAppID: 1, userID: 'doctor_1', userSig: 'synthetic-only', targetUserId: 'patient_2',
diagnosisId: 501, appointmentId: 101, mode: 'chat',
imAi: { version: 1, enabled: true, sessionId: context.sessionId },
}
function appHarness(t) {
const timers = new Map()
const messages = []
const clients = []
let timerSequence = 0
let bridgeCallback
let props
const window = {
qt: { webChannelTransport: {} },
QWebChannel: function (_transport, callback) { bridgeCallback = callback },
setTimeout(callback, delay) { const handle = ++timerSequence; timers.set(handle, { callback, delay }); return handle },
clearTimeout(handle) { timers.delete(handle) },
clearInterval() {},
}
const sdk = {
EVENT: Object.fromEntries(['SDK_READY', 'SDK_NOT_READY', 'KICKED_OUT', 'NET_STATE_CHANGE', 'MESSAGE_RECEIVED'].map((key) => [key, key])),
create() {
const listeners = new Map()
const client = {
ready: false, user: '', loginResolve: null,
setLogLevel() {}, registerPlugin() {},
on(event, handler) { listeners.set(event, handler) },
off(event) { listeners.delete(event) },
fire(event) { listeners.get(event)?.() },
login() { return new Promise((resolve) => { client.loginResolve = resolve }) },
completeLogin(user = 'doctor_1') {
client.user = user
client.ready = true
client.fire('SDK_READY')
client.loginResolve()
},
isReady() { return client.ready },
getLoginUser() { return client.user },
async logout() { client.ready = false },
async getMessageList() { return { data: { messageList: [], isCompleted: true } } },
async setMessageRead() {},
}
clients.push(client)
return client
},
}
const dependencies = {
'@tencentcloud/lite-chat': { default: sdk },
'tim-upload-plugin': { default: {} },
vue: {
ref: (value) => ({ value }), readonly: (value) => value,
nextTick: () => Promise.resolve(),
createApp(_component, appProps) { props = appProps; return { mount() {} } },
},
'@trtc/calls-uikit-vue': {
NAME: { ROOM_ID: 'room' }, StoreName: { CALL: 'call' }, TUIStore: { watch() {} },
TUICallKitAPI: { setCallback() {}, setLanguage() {}, enableFloatWindow() {} },
},
'./App.vue': { default: {} },
'./appointment-call-guard': { installAppointmentCallGuard: () => () => () => {} },
'./im-ai': target.exports,
'./style.css': {},
}
const load = (name) => {
assert.ok(Object.hasOwn(dependencies, name), `unexpected dependency: ${name}`)
return dependencies[name]
}
new Function('require', 'exports', 'window', 'document', 'console', mainCode)(load, {}, window, {}, { info() {}, warn() {} })
t.after(async () => { await window.doctorConsultation.close(); assert.equal(timers.size, 0) })
return {
api: window.doctorConsultation, props, clients, messages, timers,
connectBridge() { bridgeCallback({ objects: { qtVideoBridge: { notify: (text) => messages.push(JSON.parse(text)) } } }) },
requests: () => messages.filter((message) => message.event === 'im-ai-request'),
}
}
for (const lastReady of ['host', 'bridge', 'login']) {
test(`application waits when ${lastReady} is last and requests exactly one initial summary`, async (t) => {
const h = appHarness(t)
const opening = h.api.open(chatConfig)
if (lastReady !== 'bridge') h.connectBridge()
if (lastReady !== 'host') h.api.imAiReady(chatConfig.imAi.sessionId)
if (lastReady !== 'login') {
h.clients[0].completeLogin()
await opening
}
assert.equal(h.requests().length, 0)
if (lastReady === 'bridge') h.connectBridge()
if (lastReady === 'host') h.api.imAiReady(chatConfig.imAi.sessionId)
if (lastReady === 'login') h.clients[0].completeLogin()
await opening
assert.equal(h.requests().length, 1)
assert.equal(h.requests()[0].action, 'summary')
assert.equal(h.props.imAiState.value.status, 'loading')
h.api.imAiResult({ ...h.requests()[0], status: 'success', text: '自动总结' })
h.clients[0].fire('SDK_READY')
h.api.imAiReady(chatConfig.imAi.sessionId)
h.props.onRequestImAi('summary')
assert.equal(h.requests().length, 1)
assert.equal(h.timers.size, 0)
})
}
test('application verifies current SDK login identity before automatic generation', async (t) => {
const h = appHarness(t)
h.connectBridge()
const opening = h.api.open(chatConfig)
h.api.imAiReady(chatConfig.imAi.sessionId)
h.clients[0].completeLogin('different_doctor')
await opening
assert.equal(h.requests().length, 0)
h.api.imAiReady('stale-host-session')
assert.equal(h.requests().length, 0)
})
test('application keeps manual work selected when host/login readiness arrives later', async (t) => {
const h = appHarness(t)
h.connectBridge()
const opening = h.api.open(chatConfig)
h.props.onRequestImAi('reply')
h.api.imAiResult({ ...h.requests()[0], status: 'success', text: '手动回复' })
h.api.imAiReady(chatConfig.imAi.sessionId)
h.clients[0].completeLogin()
await opening
assert.equal(h.requests().length, 1)
assert.equal(h.props.imAiState.value.action, 'reply')
assert.equal(h.props.imAiState.value.text, '手动回复')
assert.equal(h.timers.size, 0)
})
test('application clears pending SDK/automatic timers on close and ignores late login', async (t) => {
const h = appHarness(t)
h.connectBridge()
const opening = h.api.open(chatConfig)
h.api.imAiReady(chatConfig.imAi.sessionId)
h.clients[0].loginResolve()
await Promise.resolve()
assert.equal([...h.timers.values()].some((timer) => timer.delay === 15000), true)
await h.api.close()
await opening
h.clients[0].completeLogin()
h.api.imAiReady(chatConfig.imAi.sessionId)
assert.equal(h.requests().length, 0)
assert.equal(h.timers.size, 0)
})
test('application ignores an older login and host session after a new context opens', async (t) => {
const h = appHarness(t)
h.connectBridge()
const oldOpening = h.api.open(chatConfig)
await h.api.close()
const next = { ...chatConfig, diagnosisId: 502, imAi: { ...chatConfig.imAi, sessionId: 'new-host-session' } }
const newOpening = h.api.open(next)
h.api.imAiReady(chatConfig.imAi.sessionId)
h.clients[0].completeLogin()
await oldOpening
h.clients[1].completeLogin()
await newOpening
assert.equal(h.requests().length, 0)
h.api.imAiReady(next.imAi.sessionId)
assert.equal(h.requests().length, 1)
assert.equal(h.requests()[0].diagnosisId, '502')
assert.equal(h.requests()[0].sessionId, 'new-host-session')
})
test('application retains the new host acknowledgement while replacing an open context', async (t) => {
const h = appHarness(t)
h.connectBridge()
const oldOpening = h.api.open(chatConfig)
h.clients[0].completeLogin()
await oldOpening
const next = { ...chatConfig, diagnosisId: 502, imAi: { ...chatConfig.imAi, sessionId: 'replacement-host' } }
const newOpening = h.api.open(next)
h.api.imAiReady(next.imAi.sessionId)
h.api.imAiReady(chatConfig.imAi.sessionId)
// Allow the old close's asynchronous cleanup to finish, without wall-clock timers.
for (let turn = 0; turn < 10 && h.clients.length < 2; turn += 1) await Promise.resolve()
assert.equal(h.clients.length, 2)
h.clients[1].completeLogin()
await newOpening
assert.equal(h.requests().length, 1)
assert.equal(h.requests()[0].sessionId, 'replacement-host')
})