更新
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,72 @@
|
||||
# 医生工作站客户端设计规范
|
||||
|
||||
本轮覆盖现有 PySide6 客户端与 Vue 视频伴随窗口。按 `redesign-existing-projects` 审查现有页面,结合 `minimalist-ui` 的扁平化原则实施;医疗工作台保留信息密度,不套用营销页面的大留白、装饰动效或布局模板。
|
||||
|
||||
## 审查结论
|
||||
|
||||
- 主框架与业务页面原先并存科技蓝、紫蓝及多套中性色;登录、AI、诊单的控件也各自定义配色。
|
||||
- 页面内圆角、边框和层级不统一,部分阴影、悬浮位移和过渡使操作显得跳动。
|
||||
- 小窗口的表格、诊单与视频操作需要分别验证,不能依靠整体缩小解决。
|
||||
- 键盘焦点、禁用与选择状态应始终可识别,不通过改变控件几何位置表达状态。
|
||||
|
||||
## 视觉系统
|
||||
|
||||
设计变量以 `src/doctor_workstation/ui/design_system.py` 为准。旧页面通过 `clinical_stylesheet` 迁移已知的浅色主题颜色,新样式使用命名变量。医学数据曲线、模型标识色和深色主题不进行全局替色。
|
||||
|
||||
| 角色 | 值 | 使用场景 |
|
||||
| --- | --- | --- |
|
||||
| 主色 | `#4267B2` | 主操作、选中项、重要链接 |
|
||||
| 主色浅底 | `#EAF0FB` | 选中背景、轻量状态 |
|
||||
| 画布 | `#F5F7FA` | 主窗口背景 |
|
||||
| 表面 | `#FFFFFF` | 内容区、对话框 |
|
||||
| 次级表面 | `#F7F9FC` | 筛选栏、分组、只读区域 |
|
||||
| 侧栏 | `#F0F3F8` | 导航区域 |
|
||||
| 标题 / 正文 | `#223047` / `#344258` | 文字层级 |
|
||||
| 次级文字 | `#647287` | 说明、辅助信息 |
|
||||
| 边框 / 强边框 | `#E6EAF0` / `#D4DCE8` | 低对比结构线、输入边界 |
|
||||
| 成功 / 警示 / 危险 | `#287B65` / `#96601D` / `#B54A59` | 保留明确语义,不只依赖颜色 |
|
||||
|
||||
- 圆角:标签 6 px、输入与按钮 10 px、内容面板 16 px、独立窗口 20 px。
|
||||
- 间距:8 / 12 / 16 / 24 px,根据现有表格密度调整,不整体放大行高。
|
||||
- 字体:继续使用打包的中文字体与现有可读字号;标题层级通过字重、字号和间距体现。
|
||||
- 表面:纯色为主,轻边框划分区域,减少嵌套卡片与装饰阴影。
|
||||
- 按钮:每个操作组主次清晰;次要操作使用浅底或边框;终止、删除等危险操作保留语义色。
|
||||
|
||||
## 交互规范
|
||||
|
||||
- 悬浮、按下和焦点反馈不移动布局、不改变控件尺寸。
|
||||
- 动画短而克制;滚动可被键盘、拖动、程序定位与反向滚动即时打断;尊重减少动画设置。
|
||||
- 切换页面保留原有路由、权限和数据加载机制。
|
||||
- 登录表单保留密码存储、服务器设置与提交保护;组合密码框使用实际 Qt 焦点状态。
|
||||
- 大小窗口都必须能找到主要操作,优先局部滚动而非把全部内容压小。
|
||||
- 视频保留原有截图捕获、确认上传、大预览和小窗关闭返回大窗逻辑;视觉样式不改变媒体内容或尺寸计算。
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
1. 登录与品牌区:扁平品牌面板、简洁功能说明、清晰表单与焦点。
|
||||
2. 主框架:导航、顶部搜索、工具按钮、标签与 AI 入口。
|
||||
3. 列表:挂号、接诊记录、患者、处方库、已开处方、业务订单及患者进度。
|
||||
4. 诊疗:接诊台、预约抽屉、诊单编辑与只读、指标和保存状态。
|
||||
5. 辅助窗口:AI 问诊、处方编辑、处方解读与报告控制台。
|
||||
6. 视频:通话控制、聊天、录制提示与截图确认。
|
||||
|
||||
## 验收方式
|
||||
|
||||
`scripts/render_client_design.py` 使用演示仓库生成 1440 × 900、1024 × 640 的真实页面,以及 1280 × 800、860 × 640 的登录页;输出 `artifacts/client-design/index.html` 与原尺寸 PNG。诊疗和 AI 弹窗使用现有专用渲染脚本验证。
|
||||
|
||||
检查重点:小窗操作可达、中文可读、表格信息密度、长文本换行、键盘焦点、布局稳定、登录与页面路由契约、视频截图与窗口状态回归。生产服务与真实患者资料不用于视觉验收。
|
||||
|
||||
本轮共享组件还覆盖筛选展开按钮、列表加载状态、聊天通知、更新提示、本机录音队列、媒体预览和上传进度。860 × 640 登录表单完整显示,不需要为纯留白滚动。
|
||||
|
||||
## 验证记录与边界
|
||||
|
||||
- 主框架、登录、遮罩与动效首轮 100 项通过;新增组合密码框键盘焦点检查通过。
|
||||
- 小窗登录与焦点补测 2 项、最终登录及主框架复查 68 项通过。
|
||||
- 筛选、无限列表、通知、更新窗口与本机录音队列 37 项通过。
|
||||
- 原生视频截图、截图预览尺寸、小窗关闭与视频契约 199 项通过。
|
||||
- 视频前端 26 项检查通过,生产 dist 已重新构建。
|
||||
- 当前视频 dist 在 1120 × 760、540 × 356、440 × 300 的合成画面中完成 27 项布局检查,截图与关闭事件正常,未访问外部服务。
|
||||
- 临床页面、AI、处方及图表分别完成专项测试和真实控件渲染,详细记录位于 `artifacts/client-design/*-report.md`。
|
||||
- 最终修改文件的 Ruff 与 `git diff --check` 均通过;AI 工作区及快捷操作权限最后复查 24 项通过。
|
||||
|
||||
此记录不表示整仓所有历史测试全部通过。扩展测试中存在已用原始 HEAD 复现的 AI 流式/延迟生命周期失败、共享 QApplication 样式引发的图表测试顺序依赖,以及 Windows Qt 网络模块异常;本轮没有改变相关业务行为。未连接真实面诊服务,也未重新打包或安装 Windows 客户端。
|
||||
@@ -0,0 +1,75 @@
|
||||
# IM 问诊 AI 辅助
|
||||
|
||||
用户确认范围:在现有 IM 问诊窗口内增加「总结病情」「建议追问」「起草回复」。结果供医生审阅,医生主动填入输入框后编辑并手动发送。
|
||||
|
||||
## 集成契约
|
||||
|
||||
- 复用 `stream_diagnosis_ai`:总结使用 `summary`,追问和回复使用 `custom`。不新增模型配置、不把凭据传到浏览器、不新增服务端路由。
|
||||
- 每次生成前,通过 `sync_im_chat_messages(scope="current", sync_token=...)` 完成当前账号对话归档;响应 `completed=true` 且没有错误后才能调用 AI。准备失败明确报错,不静默降级为旧聊天。服务器按授权诊单聚合临床资料与归档 IM,提示词限定本次医生账号的近期对话。
|
||||
- 三项提示词保持 500 字以内;不在提示词中挤入、截断完整聊天或未发送草稿。
|
||||
- AI 独立后台 worker,不占用通话、录音和消息发送的队列。
|
||||
|
||||
原生配置 `DoctorCallConfig.imAi`:`{version:1, enabled:boolean, sessionId:string, reason?:string}`。宿主生成 sessionId,每个新窗口独立。上层将 AI 权限布尔值贯穿 launcher;默认拒绝。
|
||||
|
||||
浏览器到原生:
|
||||
|
||||
```json
|
||||
{"source":"doctor-call","event":"im-ai-request","sessionId":"host-uuid","requestId":"uuid","contextKey":"frontend-context","diagnosisId":"501","appointmentId":101,"action":"summary"}
|
||||
```
|
||||
|
||||
取消使用同一组身份字段及 `event="im-ai-cancel"`。只允许 `summary | followup | reply`。宿主核验可信文档、窗口未关闭、AI 权限、会话、诊单、挂号和请求格式;实际 repository 调用始终使用宿主绑定诊单。
|
||||
|
||||
原生通过 `window.doctorConsultation.imAiResult(payload)` 返回相同身份字段与:
|
||||
|
||||
```text
|
||||
status: loading | streaming | success | error | cancelled
|
||||
text?: 累积正文(流式及最终)
|
||||
message?: 简短进度或错误
|
||||
modelLabel?: 服务端模型标签
|
||||
warnings?: string[]
|
||||
```
|
||||
|
||||
所有数据使用 JSON 序列化,桥接正文仍为原始文本。页面通过受限 Markdown 渲染器显示标题、重点、列表和表格;原始 HTML 被转义,链接和图片只保留文字,不执行内容或加载外部资源。客户端截止时间 180 秒,超时发送取消;原生聊天同步有独立有界预算,AI 沿用现有 105 秒传输预算。取消请求由独立线程发送,GUI 不等待网络。停止只是停止本次接收,不冒称上游工作已确认停止。
|
||||
|
||||
## 界面与状态
|
||||
|
||||
- 宽屏右侧约 330 px 辅助区;窄屏可收起抽屉,独立滚动,操作和关闭可达。沿用现有蓝色、圆角和轻边框。
|
||||
- AI loading 不阻塞 IM 输入、发送、加载更多和视频控制。
|
||||
- 只有完整成功结果允许填入;追问与回复可填入,病情总结默认只读。非空草稿明确追加,超过 3000 字拒绝追加而不截断原文。
|
||||
- 切换会话清空 AI 状态和草稿;迟到、重复、身份不匹配结果全部忽略。
|
||||
- 异步手动发送成功仅清空当时提交且没有继续修改的草稿。
|
||||
- 结果注明医生复核,展示服务端临床复核提示;不会自动发送、写病历或开处方。
|
||||
|
||||
## 验证要求
|
||||
|
||||
离线 fake repository 验证同步续拉、失败不生成、取消、终态、身份隔离、权限与关闭;Node 测试验证桥接状态/计时器、草稿追加和发送隔离;使用真实构建产物和合成消息验证宽屏与紧凑窗口。回归截图、录音收尾、通话权限与 IM 流程。验证不得访问真实患者、IM 或 AI 服务。
|
||||
|
||||
## 交付与验证结果(2026-10-29)
|
||||
|
||||
- `app.py` 在实际打开窗口时读取当前账号的 `tcm.diagnosis/aiAssistant` 权限,经 launcher 传入原生窗口;未配置权限时默认关闭,不采用聊天载荷中的权限标记。
|
||||
- 三项功能已接入真实 Qt bridge 和现有流式服务;宽屏常驻侧栏,980 px 及以下窗口使用可收起抽屉。中文输入法选词时按 Enter 不发送,异步发送不会清除医生随后编辑的草稿。
|
||||
- 合并后 Python 回归 **364 项通过**:包括新增 AI 服务/仓库/窗口、权限入口、IM、视频契约、截图、小窗关闭及大预览等。运行使用独立 `artifacts/im-ai/final-integration-tmp`,未访问外部服务。
|
||||
- 前端 **45 项 Node 行为测试通过**,`vue-tsc` 与 Vite 构建通过;实际 dist 在 1120×760、980×640、540×356、440×300 下 **42 项界面检查通过**。离线夹具只使用合成消息和结果,外部请求尝试为 0。
|
||||
- Ruff 与 `git diff --check` 通过。首次交付构建入口:`index-CnqWQu6O.js`、`index-BwArpbVK.css`。
|
||||
- 截图、可复现夹具和详细报告存放在 `app/artifacts/im-ai/`。未验证真实账号的模型响应;未生成 Windows 安装包。源码运行需重启客户端,已安装版本需重新打包后安装。
|
||||
|
||||
停止接收后,原生端会独立请求取消;在旧请求实际退出前重试会提示稍后再试,避免重复模型请求。聊天同步失败时不使用旧归档静默生成,助手显示错误并允许重试。
|
||||
|
||||
## 结果排版与切换改进(2026-10-29)
|
||||
|
||||
- 模型输出的 Markdown 标题、加粗、列表、引用、代码和简单表格转换为安全的结构化展示;重点以加粗和浅蓝底色标记,结构标题使用蓝色引导线。不依据医学关键词自行推断重点。未闭合的流式片段保持可读原文。
|
||||
- 三项操作在当前窗口会话内分别保留状态、正文、模型标签和复核提示。顶部按钮首次点击生成,之后只切换查看;底部“重新生成/重试”才会再发请求。生成中可以阅读其他已完成结果,后台更新不能覆盖当前阅读页。
|
||||
- 每项保留独立滚动位置,换患者、关闭或重开会话时清空。结果仅保存在内存中,不增加本地患者内容持久化。
|
||||
- 填入草稿前去除 Markdown 排版符号,保留原文、数值和列表含义,再执行 3000 字长度检查;仍不覆盖原稿、不自动发送。
|
||||
- 本次前端 **61 项行为测试通过**,包含安全渲染、草稿转换、缓存、显式重生成、后台流式隔离及现有截图/通话守卫;TypeScript 与 Vite 构建通过。
|
||||
- 真实 dist 配合生产 AI controller 源码和离线宿主替身,在四种窗口尺寸完成 **20 项可点击/布局检查**,以及切换不发请求、显式刷新、后台生成、独立阅读位置、原稿追加及注入防护断言。外部请求为 0。
|
||||
- 证据:`artifacts/im-ai/refinements-visual-checks.json`、`render_im_ai_refinements.py` 与 `im-ai-styled-cached-*.png`。未打包安装程序。
|
||||
|
||||
## 可调宽度、首次总结与指标强调(2026-10-29)
|
||||
|
||||
- 聊天与助手之间增加可左右拖动的分隔线,宽屏保留至少 480 px 聊天区域;窄屏抽屉也可调整,最大宽度仍留出可操作的边缘。支持方向键、Home/End、Enter 或双击恢复默认,Escape 取消本次拖动。窗口变更、失焦与关闭释放指针捕获。宽屏和抽屉宽度分别记忆在当前窗口内。
|
||||
- 进入有权限的 IM 会话后,首次总结等待当前登录、聊天、Qt 通道及原生 `imAiReady(sessionId)` 确认;准备等待最多 30 秒。每个会话只自动尝试一次,不因重连、标签切换、展开面板或失败自动重试。医生提前手动操作会取消待启动的自动总结。关闭和换会话清理计时器及旧结果。
|
||||
- 普通文本中的血糖、血压、糖化血红蛋白、体重等指标名称及带单位的数值自动强调,支持范围、小数和血压组合。名称加深,数值加粗并显示浅蓝底色,不依赖模型输出 Markdown。原始数值、单位、否定和不确定表述保持不变;没有新增医学阈值判断或自动风险分级。
|
||||
- **97 项前端测试、318 项客户端回归通过**,TypeScript、Vite 和 Ruff 检查通过,`git diff --check` 无问题。当前构建:`index-C_tMrPfb.js`、`index-CMN1CXTr.css`。
|
||||
- 实际构建页面在 1634×1008、1120×760、980×640、540×356、440×300 下完成真实 Qt 鼠标拖动、键盘调整、取消、跨断点释放及指标样式检查,**15 项布局/点击检查通过**,自动选择与请求次数断言通过,外部请求为 0。
|
||||
- 证据:`artifacts/im-ai/render_im_ai_resizing.py`、`resizing-visual-checks.json`、`im-ai-metrics-resizable-*.png`。本轮未生成安装包;原生就绪握手需重启使用最新源码的客户端。
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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)
|
||||
|
||||
@@ -5,26 +5,21 @@ from __future__ import annotations
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF, QRectF, QSettings, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QBrush,
|
||||
QColor,
|
||||
QFont,
|
||||
QIcon,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygonF,
|
||||
QRadialGradient,
|
||||
QResizeEvent,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
@@ -45,6 +40,7 @@ from doctor_workstation import __version__
|
||||
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
||||
|
||||
from . import icons
|
||||
from .design_system import PALETTE, clinical_stylesheet
|
||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||
|
||||
|
||||
@@ -127,7 +123,7 @@ def _round_pen(color: QColor | str, width: float) -> QPen:
|
||||
|
||||
|
||||
class _BrandPanel(QWidget):
|
||||
"""The quiet medical illustration and copy from the supplied login artwork."""
|
||||
"""A flat, readable introduction to the clinician's daily workflow."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -137,228 +133,76 @@ class _BrandPanel(QWidget):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self._brand_lockup = QPixmap(str(brand_lockup_path()))
|
||||
|
||||
@staticmethod
|
||||
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
|
||||
x, y = center.x(), center.y()
|
||||
top = QPolygonF(
|
||||
[
|
||||
QPointF(x, y - size * 0.58),
|
||||
QPointF(x + size * 0.48, y - size * 0.34),
|
||||
QPointF(x, y - size * 0.10),
|
||||
QPointF(x - size * 0.48, y - size * 0.34),
|
||||
]
|
||||
)
|
||||
left = QPolygonF(
|
||||
[
|
||||
QPointF(x - size * 0.48, y - size * 0.34),
|
||||
QPointF(x, y - size * 0.10),
|
||||
QPointF(x, y + size * 0.48),
|
||||
QPointF(x - size * 0.48, y + size * 0.24),
|
||||
]
|
||||
)
|
||||
right = QPolygonF(
|
||||
[
|
||||
QPointF(x, y - size * 0.10),
|
||||
QPointF(x + size * 0.48, y - size * 0.34),
|
||||
QPointF(x + size * 0.48, y + size * 0.24),
|
||||
QPointF(x, y + size * 0.48),
|
||||
]
|
||||
)
|
||||
painter.setPen(QPen(QColor(185, 196, 247, 205), 1.2))
|
||||
painter.setBrush(QColor(235, 239, 255, 218))
|
||||
painter.drawPolygon(top)
|
||||
painter.setBrush(QColor(210, 218, 255, 205))
|
||||
painter.drawPolygon(left)
|
||||
painter.setBrush(QColor(224, 230, 255, 220))
|
||||
painter.drawPolygon(right)
|
||||
painter.setPen(_round_pen(QColor(137, 154, 248, 135), 2.2))
|
||||
if kind == "plus":
|
||||
painter.drawLine(
|
||||
QPointF(x - size * 0.12, y + size * 0.05),
|
||||
QPointF(x + size * 0.15, y - size * 0.02),
|
||||
)
|
||||
painter.drawLine(
|
||||
QPointF(x + size * 0.02, y - size * 0.13),
|
||||
QPointF(x + size * 0.02, y + size * 0.17),
|
||||
)
|
||||
else:
|
||||
shield = QPainterPath(QPointF(x, y - size * 0.16))
|
||||
shield.lineTo(x + size * 0.15, y - size * 0.08)
|
||||
shield.lineTo(x + size * 0.10, y + size * 0.17)
|
||||
shield.lineTo(x, y + size * 0.26)
|
||||
shield.lineTo(x - size * 0.10, y + size * 0.17)
|
||||
shield.lineTo(x - size * 0.15, y - size * 0.08)
|
||||
shield.closeSubpath()
|
||||
painter.drawPath(shield)
|
||||
|
||||
def _draw_illustration(
|
||||
self, painter: QPainter, width: float, height: float
|
||||
) -> None:
|
||||
ox = max(250.0, width - 500.0)
|
||||
oy = max(365.0, height - 500.0)
|
||||
|
||||
glow = QRadialGradient(QPointF(width - 170, oy + 230), 280)
|
||||
glow.setColorAt(0.0, QColor(102, 120, 255, 62))
|
||||
glow.setColorAt(1.0, QColor(102, 120, 255, 0))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(glow)
|
||||
painter.drawEllipse(QPointF(width - 170, oy + 230), 280, 280)
|
||||
|
||||
painter.setPen(QPen(QColor(166, 180, 241, 78), 1))
|
||||
for offset in (0, 72, 144):
|
||||
painter.drawLine(
|
||||
QPointF(ox - 80, oy + 280 + offset),
|
||||
QPointF(width + 20, oy + 170 + offset),
|
||||
)
|
||||
painter.drawLine(QPointF(ox + 10, oy + 400), QPointF(ox + 10, oy + 80))
|
||||
painter.drawLine(QPointF(ox + 170, oy + 430), QPointF(ox + 170, oy + 35))
|
||||
|
||||
base = QRectF(ox + 80, oy + 365, 430, 88)
|
||||
base_gradient = QLinearGradient(base.topLeft(), base.bottomRight())
|
||||
base_gradient.setColorAt(0.0, QColor(246, 248, 255, 228))
|
||||
base_gradient.setColorAt(1.0, QColor(186, 199, 255, 184))
|
||||
painter.setBrush(base_gradient)
|
||||
painter.setPen(QPen(QColor(166, 181, 248, 158), 1))
|
||||
painter.drawEllipse(base)
|
||||
painter.setBrush(QColor(249, 250, 255, 225))
|
||||
painter.drawEllipse(QRectF(base.x() + 42, base.y() - 12, base.width() - 84, 70))
|
||||
|
||||
painter.save()
|
||||
painter.translate(35, 40)
|
||||
screen = QPolygonF(
|
||||
[
|
||||
QPointF(ox + 130, oy + 60),
|
||||
QPointF(ox + 435, oy + 100),
|
||||
QPointF(ox + 435, oy + 365),
|
||||
QPointF(ox + 130, oy + 310),
|
||||
]
|
||||
)
|
||||
painter.setBrush(QColor(218, 225, 255, 205))
|
||||
painter.setPen(QPen(QColor(145, 163, 242, 170), 1.3))
|
||||
painter.drawPolygon(screen)
|
||||
inner = QPolygonF(
|
||||
[
|
||||
QPointF(ox + 141, oy + 80),
|
||||
QPointF(ox + 425, oy + 117),
|
||||
QPointF(ox + 425, oy + 345),
|
||||
QPointF(ox + 141, oy + 295),
|
||||
]
|
||||
)
|
||||
painter.setBrush(QColor(255, 255, 255, 225))
|
||||
painter.drawPolygon(inner)
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(174, 189, 252, 180))
|
||||
painter.drawRoundedRect(QRectF(ox + 148, oy + 89, 270, 15), 7, 7)
|
||||
for dot_x in (156, 173, 190):
|
||||
painter.setBrush(QColor(255, 255, 255, 200))
|
||||
painter.drawEllipse(QPointF(ox + dot_x, oy + 96.5), 3.5, 3.5)
|
||||
|
||||
cards = (
|
||||
QRectF(ox + 150, oy + 135, 82, 80),
|
||||
QRectF(ox + 241, oy + 142, 72, 73),
|
||||
QRectF(ox + 322, oy + 151, 95, 72),
|
||||
QRectF(ox + 151, oy + 232, 264, 50),
|
||||
QRectF(ox + 151, oy + 291, 264, 42),
|
||||
)
|
||||
painter.setPen(QPen(QColor(188, 199, 246, 160), 1))
|
||||
painter.setBrush(QColor(249, 250, 255, 228))
|
||||
for card in cards:
|
||||
painter.drawRoundedRect(card, 7, 7)
|
||||
painter.setPen(_round_pen(QColor(117, 137, 236, 165), 4))
|
||||
for idx, bar_height in enumerate((20, 36, 51)):
|
||||
x = ox + 164 + idx * 17
|
||||
painter.drawLine(QPointF(x, oy + 200), QPointF(x, oy + 200 - bar_height))
|
||||
painter.setBrush(QColor(112, 132, 235, 160))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(QPointF(ox + 277, oy + 167), 10, 10)
|
||||
painter.drawRoundedRect(QRectF(ox + 259, oy + 180, 36, 20), 10, 10)
|
||||
painter.setPen(_round_pen(QColor(137, 153, 235, 150), 5))
|
||||
for y, length in ((170, 54), (186, 42), (249, 89), (307, 72)):
|
||||
painter.drawLine(
|
||||
QPointF(ox + 340, oy + y), QPointF(ox + 340 + length, oy + y + 5)
|
||||
)
|
||||
|
||||
self._draw_cube(painter, QPointF(ox + 352, oy - 25), 76, "plus")
|
||||
self._draw_cube(painter, QPointF(ox - 8, oy + 210), 76, "shield")
|
||||
painter.restore()
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
||||
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#FFFFFF"))
|
||||
background.setColorAt(0.7, QColor("#FFFFFF"))
|
||||
background.setColorAt(1.0, QColor("#F7F7F7"))
|
||||
painter.setBrush(background)
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||
painter.drawRoundedRect(bounds, 24, 24)
|
||||
|
||||
width, height = float(self.width()), float(self.height())
|
||||
self._draw_illustration(painter, width, height)
|
||||
|
||||
left = 64.0 if width >= 620 else 38.0
|
||||
if not self._brand_lockup.isNull():
|
||||
logo_width = 180.0
|
||||
logo_height = logo_width * (
|
||||
self._brand_lockup.height() / self._brand_lockup.width()
|
||||
)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.drawPixmap(
|
||||
QRectF(left, 24, logo_width, logo_height),
|
||||
self._brand_lockup,
|
||||
QRectF(self._brand_lockup.rect()),
|
||||
)
|
||||
|
||||
tag_rect = QRectF(left, 238, 119, 40)
|
||||
bounds = QRectF(self.rect()).adjusted(1, 1, -1, -1)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EEF1FA"))
|
||||
painter.drawRoundedRect(tag_rect, 11, 11)
|
||||
painter.setFont(_font(17, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#4F63D9"))
|
||||
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
|
||||
painter.setBrush(QColor(PALETTE["sidebar"]))
|
||||
painter.drawRoundedRect(bounds, 20, 20)
|
||||
left = 36.0
|
||||
width = max(240.0, self.width() - 72.0)
|
||||
if not self._brand_lockup.isNull():
|
||||
brand = self._brand_lockup.scaled(
|
||||
226, 58, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
painter.drawPixmap(QPointF(left, 36), brand)
|
||||
else:
|
||||
painter.setFont(_font(22, QFont.Weight.DemiBold))
|
||||
painter.setPen(QColor(PALETTE["heading"]))
|
||||
painter.drawText(QPointF(left, 70), "甄养堂 · 医生工作站")
|
||||
|
||||
copy_left = left + 4
|
||||
painter.setFont(_font(48, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
|
||||
painter.drawText(QPointF(copy_left, 424), "留在一个")
|
||||
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
|
||||
highlight = QLinearGradient(
|
||||
copy_left + prefix_width,
|
||||
0,
|
||||
copy_left + prefix_width + 264,
|
||||
0,
|
||||
)
|
||||
highlight.setColorAt(0.0, QColor("#4F63D9"))
|
||||
highlight.setColorAt(1.0, QColor("#4F63D9"))
|
||||
painter.setPen(QPen(QBrush(highlight), 1))
|
||||
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
|
||||
suffix_x = (
|
||||
copy_left
|
||||
+ prefix_width
|
||||
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
|
||||
)
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(suffix_x, 424), "。")
|
||||
heading_y = max(158.0, min(218.0, self.height() * .27))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
painter.setFont(_font(13, QFont.Weight.Medium))
|
||||
painter.drawText(QPointF(left, heading_y - 36), "医 生 工 作 台")
|
||||
painter.setPen(QColor(PALETTE["heading"]))
|
||||
painter.setFont(_font(30, QFont.Weight.DemiBold))
|
||||
painter.drawText(QPointF(left, heading_y), "让诊疗有序,")
|
||||
painter.drawText(QPointF(left, heading_y + 44), "让沟通更专注。")
|
||||
painter.setFont(_font(14))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
painter.drawText(QPointF(left, heading_y + 82), "从接诊、病历到处方,在一个工作台完成。")
|
||||
|
||||
body_left = left + 6
|
||||
painter.setFont(_font(20))
|
||||
painter.setPen(QColor("#606163"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
|
||||
)
|
||||
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
|
||||
row_y = heading_y + 122
|
||||
for index, (title, detail) in enumerate((
|
||||
("今日接诊", "快速找到待接诊患者与预约安排"),
|
||||
("诊疗记录", "病历、随访与面诊资料集中查看"),
|
||||
("处方协作", "开方与审核流程清晰衔接"),
|
||||
), start=1):
|
||||
row = QRectF(left, row_y, width, 66)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(PALETTE["surface"]))
|
||||
painter.drawRoundedRect(row, 12, 12)
|
||||
painter.setFont(_font(13, QFont.Weight.DemiBold))
|
||||
painter.setPen(QColor(PALETTE["accent"]))
|
||||
painter.drawText(QRectF(left + 16, row_y, 28, 66), Qt.AlignmentFlag.AlignCenter, f"0{index}")
|
||||
painter.setFont(_font(15, QFont.Weight.Medium))
|
||||
painter.setPen(QColor(PALETTE["heading"]))
|
||||
painter.drawText(QPointF(left + 58, row_y + 27), title)
|
||||
painter.setFont(_font(12))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
painter.drawText(QPointF(left + 58, row_y + 47), detail)
|
||||
row_y += 78
|
||||
if self.height() > row_y + 50:
|
||||
painter.setFont(_font(12))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
painter.drawText(QPointF(left, self.height() - 28), "仅供获授权的医疗人员使用")
|
||||
painter.end()
|
||||
|
||||
painter.setFont(_font(16))
|
||||
painter.setPen(QColor("#6A6B6D"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
|
||||
)
|
||||
painter.drawText(QPointF(body_left, height - 59), "请勿在公共设备保存账号信息")
|
||||
|
||||
class _PasswordField(QFrame):
|
||||
"""Keep the grouped password control visibly focused for keyboard users."""
|
||||
|
||||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
|
||||
if event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
|
||||
self.setProperty("focused", event.type() == QEvent.Type.FocusIn)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
|
||||
class _RevealButton(QToolButton):
|
||||
@@ -392,8 +236,8 @@ class _ServerButton(QPushButton):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
||||
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
|
||||
painter.setBrush(QColor(PALETTE["accent_soft"] if self.underMouse() else PALETTE["surface"]))
|
||||
painter.setPen(QPen(QColor(PALETTE["focus"] if self.hasFocus() else PALETTE["line_strong"]), 1))
|
||||
painter.drawRoundedRect(rect, 12, 12)
|
||||
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
|
||||
painter.setPen(_round_pen("#6A6B6D", 1.8))
|
||||
@@ -536,7 +380,7 @@ class LoginWindow(QMainWindow):
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
|
||||
QFrame#PasswordField[focused="true"] { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QCheckBox {
|
||||
color: #606163;
|
||||
spacing: 13px;
|
||||
@@ -606,6 +450,9 @@ class LoginWindow(QMainWindow):
|
||||
self.login_root.addWidget(self.brand_panel, 60)
|
||||
self.login_root.addWidget(self._build_login_area(), 40)
|
||||
self._restore_settings()
|
||||
for widget in (canvas, *canvas.findChildren(QWidget)):
|
||||
if widget.styleSheet():
|
||||
widget.setStyleSheet(clinical_stylesheet(widget.styleSheet()))
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||||
"""Preserve the reference composition while remaining usable on small screens."""
|
||||
@@ -625,18 +472,15 @@ class LoginWindow(QMainWindow):
|
||||
# Keep the form fully visible when the 60/40 desktop split would
|
||||
# otherwise crop its fixed-width card. Small windows focus on login.
|
||||
self.brand_panel.setVisible(width >= 1120)
|
||||
compact_height = event.size().height() < 720
|
||||
self.card.setMinimumHeight(580 if compact_height else 620)
|
||||
self.login_area_layout.setContentsMargins(
|
||||
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
|
||||
0, 12 if compact_height else min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
|
||||
)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def _build_brand_panel(self) -> QWidget:
|
||||
panel = _BrandPanel()
|
||||
shadow = QGraphicsDropShadowEffect(panel)
|
||||
shadow.setBlurRadius(32)
|
||||
shadow.setOffset(0, 10)
|
||||
shadow.setColor(QColor(67, 82, 154, 18))
|
||||
panel.setGraphicsEffect(shadow)
|
||||
return panel
|
||||
|
||||
def _build_login_area(self) -> QWidget:
|
||||
@@ -660,11 +504,6 @@ class LoginWindow(QMainWindow):
|
||||
self.card.setFixedWidth(480)
|
||||
self.card.setMinimumHeight(620)
|
||||
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
card_shadow = QGraphicsDropShadowEffect(self.card)
|
||||
card_shadow.setBlurRadius(38)
|
||||
card_shadow.setOffset(0, 16)
|
||||
card_shadow.setColor(QColor(59, 77, 139, 24))
|
||||
self.card.setGraphicsEffect(card_shadow)
|
||||
card_layout = QVBoxLayout(self.card)
|
||||
card_layout.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint)
|
||||
card_layout.setContentsMargins(47, 42, 47, 26)
|
||||
@@ -673,13 +512,13 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
title = QLabel("欢迎回来")
|
||||
title.setObjectName("LoginTitle")
|
||||
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
|
||||
title.setStyleSheet("color:#1A1C1F; font-size:26px; font-weight:600;")
|
||||
title.setContentsMargins(1, -3, 0, 3)
|
||||
title.setFixedHeight(46)
|
||||
card_layout.addWidget(title)
|
||||
subtitle = QLabel("使用医生账号登录工作站")
|
||||
subtitle.setProperty("role", "muted")
|
||||
subtitle.setStyleSheet("color:#606163; font-size:18px;")
|
||||
subtitle.setStyleSheet("color:#606163; font-size:15px;")
|
||||
subtitle.setContentsMargins(1, 8, 0, 0)
|
||||
subtitle.setFixedHeight(27)
|
||||
card_layout.addWidget(subtitle)
|
||||
@@ -710,7 +549,7 @@ class LoginWindow(QMainWindow):
|
||||
password_label.setFixedHeight(24)
|
||||
card_layout.addWidget(password_label)
|
||||
card_layout.addSpacing(11)
|
||||
password_field = QFrame()
|
||||
password_field = _PasswordField()
|
||||
password_field.setObjectName("PasswordField")
|
||||
password_field.setFixedHeight(54)
|
||||
password_row = QHBoxLayout(password_field)
|
||||
@@ -721,6 +560,7 @@ class LoginWindow(QMainWindow):
|
||||
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.password_edit.setPlaceholderText("请输入密码")
|
||||
self.password_edit.setAccessibleName("登录密码")
|
||||
self.password_edit.installEventFilter(password_field)
|
||||
self.password_edit.setTextMargins(0, -2, 0, 2)
|
||||
self.password_edit.returnPressed.connect(self.submit)
|
||||
password_row.addWidget(self.password_edit, 1)
|
||||
@@ -730,6 +570,7 @@ class LoginWindow(QMainWindow):
|
||||
self.reveal_button.setCheckable(True)
|
||||
self.reveal_button.setFixedSize(87, 52)
|
||||
self.reveal_button.setToolTip("显示或隐藏密码")
|
||||
self.reveal_button.installEventFilter(password_field)
|
||||
self.reveal_button.toggled.connect(self._toggle_password)
|
||||
password_row.addWidget(self.reveal_button)
|
||||
card_layout.addWidget(password_field)
|
||||
@@ -758,11 +599,6 @@ class LoginWindow(QMainWindow):
|
||||
self.login_button = QPushButton("登录工作站")
|
||||
self.login_button.setProperty("variant", "primary")
|
||||
self.login_button.setFixedHeight(58)
|
||||
login_shadow = QGraphicsDropShadowEffect(self.login_button)
|
||||
login_shadow.setBlurRadius(20)
|
||||
login_shadow.setOffset(0, 7)
|
||||
login_shadow.setColor(QColor(82, 101, 246, 45))
|
||||
self.login_button.setGraphicsEffect(login_shadow)
|
||||
self.login_button.clicked.connect(self.submit)
|
||||
card_layout.addWidget(self.login_button)
|
||||
self.debug_settings_section = QWidget()
|
||||
|
||||
@@ -37,8 +37,10 @@ from PySide6.QtCore import (
|
||||
QTimer,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractScrollArea,
|
||||
QGraphicsOpacityEffect,
|
||||
QPlainTextEdit,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
@@ -75,15 +77,35 @@ def reduced_motion() -> bool:
|
||||
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
||||
|
||||
|
||||
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||
"""Park an animation on its target so Python does not collect it early."""
|
||||
def _stop(target: QObject, key: str) -> None:
|
||||
"""Retire a replaced animation without firing its completion callback."""
|
||||
|
||||
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
||||
previous = running.get(key)
|
||||
running = getattr(target, "_doctor_motion", {})
|
||||
previous = running.pop(key, None)
|
||||
if previous is not None:
|
||||
previous.stop()
|
||||
previous.setParent(None)
|
||||
previous.deleteLater()
|
||||
|
||||
|
||||
def _own(target: QObject, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||
"""Park an animation on its target so Python does not collect it early."""
|
||||
|
||||
_stop(target, key)
|
||||
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", {})
|
||||
running[key] = animation
|
||||
target._doctor_motion = running
|
||||
|
||||
def release() -> None:
|
||||
if running.get(key) is animation:
|
||||
running.pop(key)
|
||||
# A callback can hold the last Python reference to the owner. Detach
|
||||
# first so releasing that callback during deferred deletion cannot
|
||||
# recursively delete this animation through its former parent.
|
||||
animation.setParent(None)
|
||||
animation.deleteLater()
|
||||
|
||||
animation.finished.connect(release)
|
||||
return animation
|
||||
|
||||
|
||||
@@ -107,7 +129,10 @@ def animate(
|
||||
from inside its own ``finished`` emission.
|
||||
"""
|
||||
|
||||
animation_owner = owner if owner is not None else target
|
||||
animation_key = key or prop.decode()
|
||||
if reduced_motion():
|
||||
_stop(animation_owner, animation_key)
|
||||
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
@@ -117,18 +142,24 @@ def animate(
|
||||
animation.setEasingCurve(easing)
|
||||
animation.setStartValue(start)
|
||||
animation.setEndValue(end)
|
||||
_own(animation_owner, animation_key, animation)
|
||||
if on_finished is not None:
|
||||
animation.finished.connect(on_finished)
|
||||
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
||||
return animation
|
||||
|
||||
|
||||
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
||||
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect | None:
|
||||
effect = widget.graphicsEffect()
|
||||
if not isinstance(effect, QGraphicsOpacityEffect):
|
||||
# Do not replace a caller's shadow or opacity effect: Qt deletes it when
|
||||
# another effect is attached. Only effects created here belong to a fade.
|
||||
if effect is not None and effect is not getattr(widget, "_doctor_opacity_effect", None):
|
||||
return None
|
||||
if effect is None:
|
||||
effect = QGraphicsOpacityEffect(widget)
|
||||
effect.setOpacity(1.0)
|
||||
widget.setGraphicsEffect(effect)
|
||||
widget._doctor_opacity_effect = effect
|
||||
effect.setEnabled(True)
|
||||
return effect
|
||||
|
||||
@@ -146,9 +177,17 @@ def _drop_effect(widget: QWidget) -> None:
|
||||
signal that is still being delivered.
|
||||
"""
|
||||
|
||||
effect = getattr(widget, "_doctor_opacity_effect", None)
|
||||
revision = getattr(widget, "_doctor_fade_revision", 0)
|
||||
|
||||
def detach() -> None:
|
||||
try:
|
||||
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
||||
if (
|
||||
effect is not None
|
||||
and widget.graphicsEffect() is effect
|
||||
and getattr(widget, "_doctor_fade_revision", 0) == revision
|
||||
):
|
||||
widget._doctor_opacity_effect = None
|
||||
widget.setGraphicsEffect(None)
|
||||
except RuntimeError: # the widget went away while the fade was running
|
||||
pass
|
||||
@@ -156,6 +195,13 @@ def _drop_effect(widget: QWidget) -> None:
|
||||
QTimer.singleShot(0, detach)
|
||||
|
||||
|
||||
def _restart_fade(widget: QWidget) -> None:
|
||||
_stop(widget, "fade")
|
||||
# A completed fade may still have deferred cleanup waiting in the queue.
|
||||
# It must not remove an effect now being used by a subsequent fade.
|
||||
widget._doctor_fade_revision = getattr(widget, "_doctor_fade_revision", 0) + 1
|
||||
|
||||
|
||||
def fade_in(
|
||||
widget: QWidget,
|
||||
*,
|
||||
@@ -165,10 +211,20 @@ def fade_in(
|
||||
) -> None:
|
||||
"""Fade a widget up to full opacity, showing it first if needed."""
|
||||
|
||||
existing = widget.graphicsEffect()
|
||||
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
|
||||
start = float(existing.opacity())
|
||||
_restart_fade(widget)
|
||||
if reduced_motion():
|
||||
if existing is not None and existing is getattr(widget, "_doctor_opacity_effect", None):
|
||||
existing.setOpacity(1.0)
|
||||
_drop_effect(widget)
|
||||
widget.show()
|
||||
return
|
||||
effect = _opacity_effect(widget)
|
||||
if effect is None:
|
||||
widget.show()
|
||||
return
|
||||
effect.setOpacity(start)
|
||||
widget.show()
|
||||
animate(
|
||||
@@ -193,9 +249,11 @@ def fade_out(
|
||||
) -> None:
|
||||
"""Fade a widget down, optionally hiding it when the fade completes."""
|
||||
|
||||
_restart_fade(widget)
|
||||
if reduced_motion():
|
||||
if hide:
|
||||
widget.hide()
|
||||
_drop_effect(widget)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return
|
||||
@@ -208,6 +266,9 @@ def fade_out(
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
if effect is None:
|
||||
done()
|
||||
return
|
||||
animate(
|
||||
effect,
|
||||
b"opacity",
|
||||
@@ -229,13 +290,17 @@ def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
||||
the user has to wait out.
|
||||
"""
|
||||
|
||||
previous = getattr(widget, "_doctor_motion", {}).get("enter")
|
||||
origin = previous.endValue() if previous is not None else widget.pos()
|
||||
if reduced_motion():
|
||||
widget.show()
|
||||
_stop(widget, "enter")
|
||||
widget.move(origin)
|
||||
fade_in(widget, duration=duration)
|
||||
return
|
||||
fade_in(widget, duration=duration)
|
||||
if rise:
|
||||
origin = widget.pos()
|
||||
widget.move(origin + QPoint(0, rise))
|
||||
if previous is None:
|
||||
widget.move(origin + QPoint(0, rise))
|
||||
animate(
|
||||
widget,
|
||||
b"pos",
|
||||
@@ -260,7 +325,7 @@ def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None
|
||||
return
|
||||
stack.setCurrentIndex(index)
|
||||
page = stack.currentWidget()
|
||||
if page is None or reduced_motion():
|
||||
if page is None:
|
||||
return
|
||||
enter(page, rise=rise)
|
||||
|
||||
@@ -274,50 +339,121 @@ SCROLL_STEP = 120
|
||||
SCROLL_MS = 190
|
||||
|
||||
|
||||
class _ScrollAnimation(QPropertyAnimation):
|
||||
"""Identify animation writes so direct scrollbar changes can interrupt us."""
|
||||
|
||||
writing = False
|
||||
|
||||
def updateCurrentValue(self, value: Any) -> None: # noqa: N802 - Qt API
|
||||
self.writing = True
|
||||
try:
|
||||
super().updateCurrentValue(value)
|
||||
finally:
|
||||
self.writing = False
|
||||
|
||||
|
||||
class _SmoothScroller(QObject):
|
||||
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
||||
|
||||
_INTERRUPT_EVENTS = frozenset(
|
||||
{
|
||||
QEvent.Type.KeyPress,
|
||||
QEvent.Type.MouseButtonPress,
|
||||
QEvent.Type.TouchBegin,
|
||||
QEvent.Type.Hide,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
||||
super().__init__(area)
|
||||
self._area = area
|
||||
self._orientation = orientation
|
||||
self._bar = (
|
||||
area.verticalScrollBar()
|
||||
if orientation is Qt.Orientation.Vertical
|
||||
else area.horizontalScrollBar()
|
||||
)
|
||||
self._target = self._bar.value()
|
||||
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
||||
self._animation = _ScrollAnimation(self._bar, b"value", self)
|
||||
self._animation.setEasingCurve(EASE_ENTER)
|
||||
self._animation.setDuration(SCROLL_MS)
|
||||
# Keyboard, programmatic and drag movements must not be fought over: when
|
||||
# nothing is animating, the wheel target follows wherever the bar went.
|
||||
self._bar.valueChanged.connect(self._sync_target)
|
||||
self._bar.sliderPressed.connect(self._cancel)
|
||||
self._bar.actionTriggered.connect(self._cancel)
|
||||
self._bar.rangeChanged.connect(self._cancel)
|
||||
self._bar.installEventFilter(self)
|
||||
area.installEventFilter(self)
|
||||
area.viewport().installEventFilter(self)
|
||||
|
||||
def _cancel(self, *_: Any) -> None:
|
||||
self._animation.stop()
|
||||
self._target = self._bar.value()
|
||||
|
||||
def _sync_target(self, value: int) -> None:
|
||||
if self._animation.state() != QAbstractAnimation.State.Running:
|
||||
if not self._animation.writing:
|
||||
self._animation.stop()
|
||||
self._target = value
|
||||
|
||||
def _uses_pixels(self) -> bool:
|
||||
if isinstance(self._area, QAbstractItemView):
|
||||
mode = (
|
||||
self._area.verticalScrollMode()
|
||||
if self._orientation == Qt.Orientation.Vertical
|
||||
else self._area.horizontalScrollMode()
|
||||
)
|
||||
return mode == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
# QPlainTextEdit's vertical scrollbar counts text blocks, not pixels.
|
||||
return not (
|
||||
isinstance(self._area, QPlainTextEdit) and self._orientation == Qt.Orientation.Vertical
|
||||
)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||
del watched
|
||||
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
||||
event_type = event.type()
|
||||
if event_type in self._INTERRUPT_EVENTS:
|
||||
self._cancel()
|
||||
return False
|
||||
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
if event_type != QEvent.Type.Wheel:
|
||||
return False
|
||||
horizontal = self._orientation == Qt.Orientation.Horizontal
|
||||
shifted = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
|
||||
delta = event.angleDelta().x() if horizontal else event.angleDelta().y()
|
||||
if horizontal and shifted and not delta:
|
||||
delta = event.angleDelta().y()
|
||||
if (
|
||||
watched is not self._area.viewport()
|
||||
or reduced_motion()
|
||||
or not self._uses_pixels()
|
||||
or not event.pixelDelta().isNull()
|
||||
or event.phase() != Qt.ScrollPhase.NoScrollPhase
|
||||
or event.modifiers()
|
||||
& (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier)
|
||||
or (shifted and not horizontal)
|
||||
or not delta
|
||||
):
|
||||
# Trackpads already provide continuous motion and native momentum.
|
||||
# Mixing another easing curve into it adds lag and fights gestures.
|
||||
self._cancel()
|
||||
return False
|
||||
|
||||
lower, upper = self._bar.minimum(), self._bar.maximum()
|
||||
# Re-clamp first: the range can shrink underneath a running animation
|
||||
# when the content behind it reloads, which would otherwise leave the
|
||||
# pending target past the end of the new content.
|
||||
self._target = max(lower, min(upper, self._target))
|
||||
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
||||
current = self._bar.value()
|
||||
step = -round(delta / 120.0 * SCROLL_STEP)
|
||||
# Reverse immediately from the visible position, not the end of a
|
||||
# backlog of wheel notches still travelling in the opposite direction.
|
||||
origin = current if (self._target - current) * step < 0 else self._target
|
||||
target = origin + step
|
||||
target = max(lower, min(upper, target))
|
||||
# At either end, hand the wheel back so an enclosing scroll area still
|
||||
# gets it - swallowing it there is what makes nested panes feel stuck.
|
||||
if target == self._target:
|
||||
return False
|
||||
return target != current
|
||||
self._target = target
|
||||
self._animation.stop()
|
||||
self._animation.setStartValue(self._bar.value())
|
||||
self._animation.setStartValue(current)
|
||||
self._animation.setEndValue(target)
|
||||
self._animation.start()
|
||||
return True
|
||||
@@ -328,7 +464,7 @@ def install_smooth_scroll(
|
||||
*,
|
||||
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
||||
) -> None:
|
||||
"""Give a scroll area eased wheel scrolling."""
|
||||
"""Ease discrete wheels on pixel-based areas; preserve native gestures."""
|
||||
|
||||
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
||||
return
|
||||
|
||||
@@ -50,6 +50,7 @@ from ...core.appointment_modes import (
|
||||
)
|
||||
from .. import icons
|
||||
from ..appointment_drawer import AppointmentDrawer
|
||||
from ..design_system import PALETTE, clinical_stylesheet
|
||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||
from ..dialogs.ai_consult import can_open_ai_consult
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
@@ -110,97 +111,97 @@ FULFILLMENT_TEXT = {
|
||||
}
|
||||
|
||||
_SEMANTIC_COLORS = {
|
||||
"primary": "#4F63D9",
|
||||
"success": "#287B65",
|
||||
"warning": "#A9691D",
|
||||
"danger": "#BE4B58",
|
||||
"info": "#4F63D9",
|
||||
"muted": "#606163",
|
||||
"primary": PALETTE["accent"],
|
||||
"success": PALETTE["success"],
|
||||
"warning": PALETTE["warning"],
|
||||
"danger": PALETTE["danger"],
|
||||
"info": PALETTE["accent"],
|
||||
"muted": PALETTE["muted"],
|
||||
}
|
||||
|
||||
PATIENTS_LIGHT_QSS = """
|
||||
PATIENTS_LIGHT_QSS = clinical_stylesheet("""
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
color: #1A1C1F;
|
||||
color: $ds_heading;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="muted"] {
|
||||
color: #606163;
|
||||
color: $ds_muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
#PatientsPage QPushButton[patientSearchAction="true"] {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
color: #FFFFFF;
|
||||
background-color: #4F63D9;
|
||||
border: 1px solid #4F63D9;
|
||||
color: $ds_surface;
|
||||
background-color: $ds_accent;
|
||||
border: 1px solid $ds_accent;
|
||||
border-radius: 7px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#PatientsPage QPushButton[patientSearchAction="true"]:hover {
|
||||
background-color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: $ds_accent_hover;
|
||||
border-color: $ds_accent_hover;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] {
|
||||
min-height: 88px;
|
||||
max-height: 88px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #EDEDEE;
|
||||
background-color: $ds_surface;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: 12px;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
|
||||
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #EDEDEE;
|
||||
background-color: $ds_surface;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: 7px; font-size: 14px;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] QPushButton {
|
||||
min-height: 32px; max-height: 32px;
|
||||
}
|
||||
#PatientsPage QFrame[patientListFilter="true"] QDateEdit:disabled {
|
||||
color: #8E8F90; background-color: #F7F7F7;
|
||||
color: $ds_disabled; background-color: $ds_surface_alt;
|
||||
}
|
||||
#PatientsPage QPushButton[patientStatusChip="true"] {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #606163;
|
||||
background-color: #F7F7F7;
|
||||
color: $ds_muted;
|
||||
background-color: $ds_surface_alt;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#PatientsPage QPushButton[patientStatusChip="true"]:hover {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
}
|
||||
#PatientsPage QPushButton[patientStatusChip="true"]:checked {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #8B9AD9;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
border: 1px solid $ds_focus;
|
||||
}
|
||||
#PatientsPage QPushButton[patientQuickDate="true"] {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 9px;
|
||||
color: #1A1C1F;
|
||||
color: $ds_heading;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
#PatientsPage QPushButton[patientQuickDate="true"]:hover {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
}
|
||||
#PatientsPage QPushButton[patientQuickDate="true"]:checked {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
border-color: $ds_focus;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
|
||||
border: 0;
|
||||
border-top: 1px solid #EDEDEE;
|
||||
border-top: 1px solid $ds_line;
|
||||
background-color: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
|
||||
@@ -208,124 +209,124 @@ PATIENTS_LIGHT_QSS = """
|
||||
min-height: 34px;
|
||||
padding: 0 6px;
|
||||
margin-right: 8px;
|
||||
color: #606163;
|
||||
color: $ds_muted;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: 400;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
|
||||
color: #4F63D9;
|
||||
border-bottom-color: #4F63D9;
|
||||
color: $ds_accent;
|
||||
border-bottom-color: $ds_accent;
|
||||
font-weight: 500;
|
||||
}
|
||||
#PatientsPage QPushButton[summaryCard="true"] {
|
||||
min-height: 42px;
|
||||
max-height: 42px;
|
||||
padding: 0 11px;
|
||||
color: #1A1C1F;
|
||||
background-color: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
color: $ds_heading;
|
||||
background-color: $ds_surface_alt;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: 9px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
#PatientsPage QPushButton[summaryCard="true"]:hover {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
border-color: $ds_focus;
|
||||
}
|
||||
#PatientsPage QPushButton[rowAction="true"] {
|
||||
min-height: 26px;
|
||||
max-height: 26px;
|
||||
padding: 0 7px;
|
||||
color: #4F63D9;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #EDEDEE;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_surface;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
#PatientsPage QPushButton[rowAction="true"]:hover {
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
background-color: $ds_accent_soft;
|
||||
border-color: $ds_focus;
|
||||
}
|
||||
#PatientsPage QPushButton[rowAction="true"][variant="danger"] {
|
||||
color: #BE4B58;
|
||||
background-color: #FFF7F8;
|
||||
border-color: #F4C4CC;
|
||||
color: $ds_danger;
|
||||
background-color: $ds_danger_soft;
|
||||
border-color: $ds_danger_soft;
|
||||
}
|
||||
#PatientsPage QTableWidget#PatientTable QHeaderView::section {
|
||||
min-height: 34px;
|
||||
background-color: #F7F7F7; font-size: 13px;
|
||||
background-color: $ds_surface_alt; font-size: 13px;
|
||||
}
|
||||
#PatientsPage QTableWidget#PatientTable::item { padding: 5px 7px; }
|
||||
#PatientsPage QTableWidget#PatientTable {
|
||||
gridline-color: #EDEDEE;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F; font-size: 14px;
|
||||
gridline-color: $ds_line;
|
||||
selection-background-color: $ds_accent_soft;
|
||||
selection-color: $ds_heading; font-size: 14px;
|
||||
}
|
||||
#PatientsPage QCheckBox[patientSelector="true"]::indicator {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #EDEDEE;
|
||||
background-color: $ds_surface;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#PatientsPage QCheckBox[patientSelector="true"]::indicator:checked {
|
||||
background-color: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
background-color: $ds_accent;
|
||||
border-color: $ds_accent;
|
||||
}
|
||||
#PatientsPage QPushButton[variant="ghost"]:checked {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #4F63D9;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
border: 1px solid $ds_accent;
|
||||
}
|
||||
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #EEF1FA; }
|
||||
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: $ds_accent_soft; }
|
||||
#PatientsPage QPushButton[statusKind="info"]:checked {
|
||||
color: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
color: $ds_accent;
|
||||
border-color: $ds_accent;
|
||||
}
|
||||
#PatientsPage QPushButton[statusKind="warning"]:checked {
|
||||
color: #A9691D;
|
||||
border-color: #A9691D;
|
||||
color: $ds_warning;
|
||||
border-color: $ds_warning;
|
||||
}
|
||||
#PatientsPage QPushButton[statusKind="success"]:checked {
|
||||
color: #287B65;
|
||||
border-color: #287B65;
|
||||
color: $ds_success;
|
||||
border-color: $ds_success;
|
||||
}
|
||||
#PatientsPage QPushButton[statusKind="danger"]:checked {
|
||||
color: #BE4B58;
|
||||
border-color: #BE4B58;
|
||||
color: $ds_danger;
|
||||
border-color: $ds_danger;
|
||||
}
|
||||
#PatientsPage QLabel[metricKind="primary"] { color: #4F63D9; }
|
||||
#PatientsPage QLabel[metricKind="info"] { color: #4F63D9; }
|
||||
#PatientsPage QLabel[metricKind="success"] { color: #287B65; }
|
||||
#PatientsPage QLabel[metricKind="warning"] { color: #A9691D; }
|
||||
#PatientsPage QLabel[metricKind="danger"] { color: #BE4B58; }
|
||||
#PatientsPage QLabel[metricKind="primary"] { color: $ds_accent; }
|
||||
#PatientsPage QLabel[metricKind="info"] { color: $ds_accent; }
|
||||
#PatientsPage QLabel[metricKind="success"] { color: $ds_success; }
|
||||
#PatientsPage QLabel[metricKind="warning"] { color: $ds_warning; }
|
||||
#PatientsPage QLabel[metricKind="danger"] { color: $ds_danger; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
top: -1px;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
|
||||
color: #606163;
|
||||
color: $ds_muted;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: 400;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:hover {
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
color: $ds_accent;
|
||||
background-color: $ds_accent_soft;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
|
||||
color: #4F63D9;
|
||||
color: $ds_accent;
|
||||
background-color: transparent;
|
||||
border-bottom-color: #4F63D9;
|
||||
border-bottom-color: $ds_accent;
|
||||
font-weight: 500;
|
||||
}
|
||||
"""
|
||||
""")
|
||||
|
||||
|
||||
def _style_table_cell(table: SortableTable, row: int, column: int, kind: str) -> None:
|
||||
@@ -1282,7 +1283,7 @@ class _PatientDateEdit(QDateEdit):
|
||||
def paintEvent(self, event: Any) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 15)
|
||||
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 15)
|
||||
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 15) / 2, 15, 15), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -1300,18 +1301,18 @@ class _PatientSummaryButton(QPushButton):
|
||||
label, _, count = self.text().partition("\n")
|
||||
left = max(18, (self.width() - 204) // 2)
|
||||
icon_rect = QRectF(left, (self.height() - 36) / 2, 36, 36)
|
||||
glyph = icons.pixmap("calendar", "#1769E8", 36)
|
||||
glyph = icons.pixmap("calendar", PALETTE["accent"], 36)
|
||||
painter.drawPixmap(icon_rect, glyph, QRectF(glyph.rect()))
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#5D6B80"))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
text_rect = self.rect().adjusted(left + 46, 10, -12, -32)
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
painter.fontMetrics().elidedText(label, Qt.TextElideMode.ElideRight, text_rect.width()))
|
||||
font.setPixelSize(16)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
painter.setPen(QColor(PALETTE["text"]))
|
||||
painter.drawText(self.rect().adjusted(left + 46, 31, -12, -9),
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, count)
|
||||
painter.end()
|
||||
@@ -1324,11 +1325,11 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
painter.save()
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.fillRect(option.rect, QColor("#EAF2FF" if selected else "#F6F9FE" if hovered else "#FFFFFF"))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
|
||||
painter.setPen(QColor(PALETTE["line"]))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
|
||||
if index.column() in (0, 9):
|
||||
painter.restore()
|
||||
return
|
||||
@@ -1337,9 +1338,9 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(14)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
painter.setPen(QColor(PALETTE["text"]))
|
||||
|
||||
def draw(text: str, target: Any, color: str = "#273244", centered: bool = False) -> None:
|
||||
def draw(text: str, target: Any, color: str = PALETTE["text"], centered: bool = False) -> None:
|
||||
painter.setPen(QColor(color))
|
||||
text = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, target.width())
|
||||
alignment = Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignHCenter if centered else Qt.AlignmentFlag.AlignLeft)
|
||||
@@ -1359,17 +1360,17 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
doctor = display_text(first_value(row, "appointment_doctor_name"), "")
|
||||
status, _kind = _patient_status(row)
|
||||
if not doctor:
|
||||
draw(status, rect, "#5D6B80")
|
||||
draw(status, rect, PALETTE["muted"])
|
||||
else:
|
||||
draw(doctor, top)
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
color, fill = ("#A9691D", "#FFF7EA") if status == "已过号" else ("#1769E8", "#F0F6FF")
|
||||
color, fill = (PALETTE["warning"], PALETTE["warning_soft"]) if status == "已过号" else (PALETTE["accent"], PALETTE["accent_soft"])
|
||||
if status == "已取消":
|
||||
color, fill = "#5D6B80", "#F5F7FA"
|
||||
color, fill = PALETTE["muted"], PALETTE["canvas"]
|
||||
badge = QRectF(bottom.x(), bottom.y() + 1, min(bottom.width(), painter.fontMetrics().horizontalAdvance(status) + 10), 20)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(QColor("#E7CDA8" if status == "已过号" else "#C7DAF7"))
|
||||
painter.setPen(QColor(PALETTE["warning_soft"] if status == "已过号" else PALETTE["line_strong"]))
|
||||
painter.setBrush(QColor(fill))
|
||||
painter.drawRoundedRect(badge, 3, 3)
|
||||
draw(status, badge.toRect(), color, True)
|
||||
@@ -1385,7 +1386,7 @@ class _PatientInfoDelegate(QStyledItemDelegate):
|
||||
draw(value, rect)
|
||||
else:
|
||||
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
|
||||
draw(text, rect, "#A9691D" if index.column() == 6 and text == "待确认" else "#273244", index.column() == 5)
|
||||
draw(text, rect, PALETTE["warning"] if index.column() == 6 and text == "待确认" else PALETTE["text"], index.column() == 5)
|
||||
painter.restore()
|
||||
|
||||
|
||||
@@ -1493,7 +1494,7 @@ class PatientListWorkspace(QWidget):
|
||||
self.keyword_edit.setMaximumWidth(360)
|
||||
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
|
||||
self.keyword_edit.setClearButtonEnabled(True)
|
||||
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||
self.keyword_edit.returnPressed.connect(self.search)
|
||||
search_row.addWidget(self.keyword_edit, 1)
|
||||
self.status_label = QLabel("患者状态:")
|
||||
@@ -1846,7 +1847,7 @@ class PatientListWorkspace(QWidget):
|
||||
)
|
||||
host = RowActions(row_actions, self.table)
|
||||
if host.more_button is not None:
|
||||
host.more_button.setIcon(icons.icon("chevron_down", "#5D6B80", 12))
|
||||
host.more_button.setIcon(icons.icon("chevron_down", PALETTE["muted"], 12))
|
||||
host.more_button.setIconSize(QSize(12, 12))
|
||||
host.more_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
||||
host.more_button.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
|
||||
@@ -2079,7 +2080,7 @@ class _OrderComboBox(QComboBox):
|
||||
def paintEvent(self, event: Any) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
|
||||
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
|
||||
painter.drawPixmap(QRectF(self.width() - 24, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -2088,7 +2089,7 @@ class _OrderActionButton(QToolButton):
|
||||
def paintEvent(self, event: Any) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
glyph = icons.pixmap("chevron_down", "#5D6B80" if self.isEnabled() else "#97A4B6", 14)
|
||||
glyph = icons.pixmap("chevron_down", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
|
||||
painter.drawPixmap(QRectF(self.width() - 23, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -2107,18 +2108,18 @@ class _OrderInfoDelegate(QStyledItemDelegate):
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.fillRect(option.rect, QColor("#F5F8FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
|
||||
painter.setPen(QColor(PALETTE["line"]))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
|
||||
rect = option.rect.adjusted(16, 0, -12, -1)
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(14)
|
||||
painter.setFont(font)
|
||||
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
|
||||
|
||||
def draw(value: str, target: Any, color: str = "#273244", right: bool = False) -> None:
|
||||
def draw(value: str, target: Any, color: str = PALETTE["text"], right: bool = False) -> None:
|
||||
painter.setPen(QColor(color))
|
||||
value = painter.fontMetrics().elidedText(value, Qt.TextElideMode.ElideRight, target.width())
|
||||
painter.drawText(target, Qt.AlignmentFlag.AlignVCenter | (Qt.AlignmentFlag.AlignRight if right else Qt.AlignmentFlag.AlignLeft), value)
|
||||
@@ -2138,7 +2139,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
|
||||
font.setWeight(QFont.Weight.Normal)
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
draw(secondary, bottom, "#5D6B80")
|
||||
draw(secondary, bottom, PALETTE["muted"])
|
||||
else:
|
||||
draw(text, rect)
|
||||
elif column in (9, 11):
|
||||
@@ -2147,7 +2148,7 @@ class _OrderInfoDelegate(QStyledItemDelegate):
|
||||
draw(primary, top)
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
draw(("(" if column == 9 else "") + secondary, bottom, "#5D6B80")
|
||||
draw(("(" if column == 9 else "") + secondary, bottom, PALETTE["muted"])
|
||||
else:
|
||||
draw(text, rect)
|
||||
elif column in (4, 5, 6):
|
||||
@@ -2158,10 +2159,10 @@ class _OrderInfoDelegate(QStyledItemDelegate):
|
||||
else:
|
||||
kind = "blue" if raw in {3, 5, 6, 7, 8, 11} else "danger" if raw in {4, 9, 10} else "warning" if raw in {1, 2, 12} else "neutral"
|
||||
foreground, background, border = {
|
||||
"blue": ("#1769E8", "#F2F7FF", "#CEE0FA"),
|
||||
"warning": ("#AD640C", "#FFFAF2", "#F2DABD"),
|
||||
"danger": ("#BE4657", "#FFF5F6", "#F0D0D7"),
|
||||
"neutral": ("#5D6B80", "#F6F8FC", "#E2E9F2"),
|
||||
"blue": (PALETTE["accent"], PALETTE["accent_soft"], PALETTE["line_strong"]),
|
||||
"warning": (PALETTE["warning"], PALETTE["warning_soft"], PALETTE["warning_soft"]),
|
||||
"danger": (PALETTE["danger"], PALETTE["danger_soft"], PALETTE["danger_soft"]),
|
||||
"neutral": (PALETTE["muted"], PALETTE["disabled_surface"], PALETTE["line"]),
|
||||
}[kind]
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
@@ -2275,7 +2276,7 @@ class PatientOrdersWorkspace(QWidget):
|
||||
self.keyword_edit.setMinimumWidth(240)
|
||||
self.keyword_edit.setPlaceholderText("订单号 / 患者 / 手机号 / 处方ID / 诊单ID")
|
||||
self.keyword_edit.setClearButtonEnabled(True)
|
||||
self.keyword_edit.addAction(icons.icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||
self.keyword_edit.addAction(icons.icon("search", PALETTE["muted"], 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||
self.keyword_edit.returnPressed.connect(self.search)
|
||||
self.filter_top_row.addWidget(self.keyword_edit, 1)
|
||||
self.audit_host = QWidget()
|
||||
@@ -2747,11 +2748,11 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
|
||||
painter.setPen(QColor(PALETTE["line"]))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
|
||||
rect = option.rect.adjusted(16, 0, -16, -1)
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(14)
|
||||
@@ -2762,20 +2763,20 @@ class _ProgressInfoDelegate(QStyledItemDelegate):
|
||||
painter.setFont(font)
|
||||
raw_text = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
text = "" if raw_text is None else str(raw_text)
|
||||
color = "#273244"
|
||||
color = PALETTE["text"]
|
||||
if not self.schedule and index.column() == 5 and text and text != "—":
|
||||
row = index.data(Qt.ItemDataRole.UserRole)
|
||||
status = str(first_value(row, "queue_status", default="") or "").lower()
|
||||
color = {
|
||||
"consulting": "#1769E8", "next": "#1555B6",
|
||||
"waiting": "#AD640C", "missed": "#BE4657",
|
||||
}.get(status, "#5D6B80")
|
||||
"consulting": PALETTE["accent"], "next": PALETTE["accent_hover"],
|
||||
"waiting": PALETTE["warning"], "missed": PALETTE["danger"],
|
||||
}.get(status, PALETTE["muted"])
|
||||
painter.setPen(QColor(color))
|
||||
alignment = Qt.AlignmentFlag.AlignVCenter
|
||||
alignment |= Qt.AlignmentFlag.AlignHCenter if self.schedule and 1 <= index.column() <= 5 else Qt.AlignmentFlag.AlignLeft
|
||||
painter.drawText(rect, alignment, painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, rect.width()))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#75A5F0"))
|
||||
painter.setPen(QColor(PALETTE["focus"]))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
painter.restore()
|
||||
@@ -2875,7 +2876,7 @@ class PatientProgressWorkspace(QWidget):
|
||||
row = QHBoxLayout(frame)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(0)
|
||||
divider = QFrame()
|
||||
divider = QFrame(frame)
|
||||
divider.setObjectName("ProgressMetricDivider")
|
||||
divider.setFixedSize(1, 60)
|
||||
divider.setVisible(key != "total")
|
||||
|
||||
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import motion
|
||||
from ..design_system import PALETTE
|
||||
from ..dialogs.prescription import PrescriptionTemplateDialog
|
||||
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
|
||||
from ..filter_disclosure import FilterDisclosure
|
||||
@@ -119,7 +120,7 @@ def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> Met
|
||||
icon.setProperty("metricIcon", True)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
icon.setFixedSize(32, 32)
|
||||
color = "#1769E8" if kind == "accent" else "#60789F"
|
||||
color = PALETTE["accent"] if kind == "accent" else PALETTE["muted"]
|
||||
icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
|
||||
card.layout().addWidget(icon)
|
||||
return card
|
||||
@@ -543,7 +544,7 @@ class PrescriptionLibraryPage(QWidget):
|
||||
|
||||
name_item = self.table.item(row_index, 1)
|
||||
if name_item is not None:
|
||||
name_item.setForeground(QColor("#1A1C1F"))
|
||||
name_item.setForeground(QColor(PALETTE["heading"]))
|
||||
font = name_item.font()
|
||||
font.setWeight(QFont.Weight.Medium)
|
||||
name_item.setFont(font)
|
||||
|
||||
@@ -50,6 +50,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from .. import icons, motion
|
||||
from ..design_system import PALETTE, clinical_stylesheet
|
||||
from ..dialogs.issued_prescription_ai import (
|
||||
PrescriptionAiStatisticsDialog,
|
||||
agreement_safe_batch,
|
||||
@@ -229,11 +230,11 @@ _ROLE_AVATAR = Qt.ItemDataRole.UserRole + 11
|
||||
_ROLE_LEAD_ICON = Qt.ItemDataRole.UserRole + 12
|
||||
|
||||
_TAG_PALETTE = {
|
||||
"accent": ("#4F63D9", "#EEF1FA"),
|
||||
"success": ("#287B65", "#E7F8F1"),
|
||||
"warning": ("#A9691D", "#FFF5DF"),
|
||||
"danger": ("#BE4B58", "#FFF0F2"),
|
||||
"neutral": ("#606163", "#F7F7F7"),
|
||||
"accent": (PALETTE["accent"], PALETTE["accent_soft"]),
|
||||
"success": (PALETTE["success"], PALETTE["success_soft"]),
|
||||
"warning": (PALETTE["warning"], PALETTE["warning_soft"]),
|
||||
"danger": (PALETTE["danger"], PALETTE["danger_soft"]),
|
||||
"neutral": (PALETTE["muted"], PALETTE["surface_alt"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +294,7 @@ class _RowDecorationDelegate(QStyledItemDelegate):
|
||||
glyph,
|
||||
QRectF(glyph.rect()),
|
||||
)
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.setPen(QColor(PALETTE["heading"]))
|
||||
painter.setFont(style_option.font)
|
||||
text_rect = rect.adjusted(28, 0, -8, 0)
|
||||
painter.drawText(
|
||||
@@ -306,17 +307,17 @@ class _RowDecorationDelegate(QStyledItemDelegate):
|
||||
else:
|
||||
initial = str(avatar)[:1] or "医"
|
||||
circle = QRectF(rect.left() + 8, rect.center().y() - 10, 20, 20)
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1.0))
|
||||
painter.setBrush(QColor("#F7F7F7"))
|
||||
painter.setPen(QPen(QColor(PALETTE["line"]), 1.0))
|
||||
painter.setBrush(QColor(PALETTE["surface_alt"]))
|
||||
painter.drawEllipse(circle.adjusted(0.5, 0.5, -0.5, -0.5))
|
||||
initial_font = QFont(style_option.font)
|
||||
initial_font.setPixelSize(10)
|
||||
initial_font.setWeight(QFont.Weight.Medium)
|
||||
painter.setFont(initial_font)
|
||||
painter.setPen(QColor("#6A6B6D"))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
painter.drawText(circle, Qt.AlignmentFlag.AlignCenter, initial)
|
||||
painter.setFont(style_option.font)
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.setPen(QColor(PALETTE["heading"]))
|
||||
name_rect = rect.adjusted(35, 0, -8, 0)
|
||||
painter.drawText(
|
||||
name_rect,
|
||||
@@ -509,11 +510,11 @@ class _PrescriptionNumberDelegate(QStyledItemDelegate):
|
||||
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
||||
document.setDefaultTextOption(text_option)
|
||||
paragraphs = [
|
||||
f'<p style="margin:0;color:#1A1C1F;font-size:14px;font-weight:500">{escape(number)}</p>',
|
||||
f'<p style="margin:2px 0 0;color:#606163;font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||
f'<p style="margin:0;color:{PALETTE["heading"]};font-size:14px;font-weight:500">{escape(number)}</p>',
|
||||
f'<p style="margin:2px 0 0;color:{PALETTE["muted"]};font-size:12px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||
]
|
||||
paragraphs.extend(
|
||||
'<p style="margin:2px 0 0;color:#BE4B58;font-size:13px;font-weight:400">'
|
||||
f'<p style="margin:2px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">'
|
||||
+ escape(warning)
|
||||
+ "</p>"
|
||||
for warning in _order_warnings(row)
|
||||
@@ -603,14 +604,14 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
|
||||
rect = option.rect.adjusted(2, 1, -2, -1)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
|
||||
painter.setBrush(QColor(PALETTE["accent_soft"] if checked else PALETTE["canvas"] if hovered else PALETTE["surface"]))
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
|
||||
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
|
||||
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
|
||||
painter.setPen(QPen(QColor(PALETTE["accent"] if checked else PALETTE["line_strong"]), 1))
|
||||
painter.setBrush(QColor(PALETTE["accent"] if checked else PALETTE["surface"]))
|
||||
painter.drawRoundedRect(box, 3, 3)
|
||||
if checked:
|
||||
pen = QPen(QColor("#FFFFFF"), 2)
|
||||
pen = QPen(QColor(PALETTE["surface"]), 2)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
x, y = int(box.x()), int(box.y())
|
||||
@@ -619,16 +620,16 @@ class _DoctorOptionDelegate(QStyledItemDelegate):
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
painter.setPen(QColor(PALETTE["text"]))
|
||||
text_rect = rect.adjusted(38, 0, -54, 0)
|
||||
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
|
||||
if checked:
|
||||
painter.setPen(QColor("#1769E8"))
|
||||
painter.setPen(QColor(PALETTE["accent"]))
|
||||
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#ADC8F2"), 1))
|
||||
painter.setPen(QPen(QColor(PALETTE["line_strong"]), 1))
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
painter.restore()
|
||||
|
||||
@@ -674,18 +675,18 @@ class _DoctorSelectionDialog(QDialog):
|
||||
self.setObjectName("DoctorSelectionDialog")
|
||||
self.setWindowTitle("选择医生")
|
||||
self.resize(420, 500)
|
||||
self.setStyleSheet("""
|
||||
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
|
||||
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
|
||||
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
|
||||
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
|
||||
""")
|
||||
self.setStyleSheet(clinical_stylesheet("""
|
||||
QDialog#DoctorSelectionDialog { background: $ds_surface; color: $ds_text; }
|
||||
QListWidget#DoctorOptionsList { background: $ds_surface; border: 1px solid $ds_line; border-radius: 6px; padding: 3px; }
|
||||
QLabel#DoctorSelectionCount { color: $ds_accent; font-size: 13px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid $ds_line; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: $ds_accent; }
|
||||
QDialog#DoctorSelectionDialog QPushButton { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; border-radius: 6px; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:hover { background: $ds_canvas; border-color: $ds_line_strong; }
|
||||
QDialog#DoctorSelectionDialog QPushButton:disabled { color: $ds_disabled; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: $ds_accent; color: $ds_surface; border-color: $ds_accent; }
|
||||
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
|
||||
"""))
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
@@ -793,9 +794,9 @@ class DoctorMultiSelect(QWidget):
|
||||
self.button.setText("、".join(names[:2]) + (f" 等{len(names)}人" if len(names) > 2 else ""))
|
||||
|
||||
|
||||
def _blue_prescription_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||
def _blue_prescription_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
|
||||
result = QIcon(icons.icon(kind, color, size))
|
||||
result.addPixmap(icons.pixmap(kind, "#A4ADBA", size), QIcon.Mode.Disabled)
|
||||
result.addPixmap(icons.pixmap(kind, PALETTE["disabled"], size), QIcon.Mode.Disabled)
|
||||
return result
|
||||
|
||||
|
||||
@@ -803,7 +804,7 @@ class _PrescriptionCombo(QComboBox):
|
||||
def paintEvent(self, event: Any) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
|
||||
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
|
||||
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -812,7 +813,7 @@ class _PrescriptionDateTime(QDateTimeEdit):
|
||||
def paintEvent(self, event: Any) -> None:
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
glyph = icons.pixmap("calendar", "#5D6B80" if self.isEnabled() else "#8A97A9", 14)
|
||||
glyph = icons.pixmap("calendar", PALETTE["muted"] if self.isEnabled() else PALETTE["disabled"], 14)
|
||||
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -825,7 +826,7 @@ class _PrescriptionDoctorButton(QPushButton):
|
||||
option.text = self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, max(1, self.width() - 48))
|
||||
painter = QPainter(self)
|
||||
self.style().drawControl(QStyle.ControlElement.CE_PushButton, option, painter, self)
|
||||
glyph = icons.pixmap("chevron_down", "#5D6B80", 14)
|
||||
glyph = icons.pixmap("chevron_down", PALETTE["muted"], 14)
|
||||
painter.drawPixmap(QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -837,13 +838,13 @@ class _PrescriptionDoctorButton(QPushButton):
|
||||
def _prescription_cell_background(painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
painter.fillRect(option.rect, QColor("#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.fillRect(option.rect, QColor(PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]))
|
||||
painter.setPen(QColor(PALETTE["line"]))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor("#1769E8"))
|
||||
painter.fillRect(option.rect.adjusted(0, 0, -option.rect.width() + 3, 0), QColor(PALETTE["accent"]))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#75A5F0"))
|
||||
painter.setPen(QColor(PALETTE["focus"]))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
|
||||
@@ -875,7 +876,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(14)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
painter.setPen(QColor(PALETTE["text"]))
|
||||
text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
|
||||
column = index.column()
|
||||
if column == 0:
|
||||
@@ -888,9 +889,9 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
|
||||
elif column in (3, 6):
|
||||
kind = index.data(_ROLE_TAG_KIND)
|
||||
foreground, background = {
|
||||
"accent": ("#1769E8", "#EDF4FF"), "success": ("#1769E8", "#EDF4FF"),
|
||||
"warning": ("#AD640C", "#FFF5E5"), "danger": ("#BE4657", "#FFF0F2"),
|
||||
}.get(kind, ("#5D6B80", "#F5F8FD"))
|
||||
"accent": (PALETTE["accent"], PALETTE["accent_soft"]), "success": (PALETTE["accent"], PALETTE["accent_soft"]),
|
||||
"warning": (PALETTE["warning"], PALETTE["warning_soft"]), "danger": (PALETTE["danger"], PALETTE["danger_soft"]),
|
||||
}.get(kind, (PALETTE["muted"], PALETTE["surface_alt"]))
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
label = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max(1, rect.width() - 12))
|
||||
@@ -918,7 +919,7 @@ class _PrescriptionInfoDelegate(QStyledItemDelegate):
|
||||
font = QFont(body_family())
|
||||
font.setPixelSize(13)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#5D6B80"))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
second_rect = first_rect.translated(0, 26)
|
||||
painter.drawText(second_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, painter.fontMetrics().elidedText(secondary, Qt.TextElideMode.ElideRight, rect.width()))
|
||||
else:
|
||||
@@ -942,11 +943,11 @@ class _PrescriptionNumberBlueDelegate(_PrescriptionNumberDelegate):
|
||||
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
||||
document.setDefaultTextOption(text_option)
|
||||
paragraphs = [
|
||||
f'<p style="margin:0;color:#273244;font-size:14px;font-weight:400">{escape(number)}</p>',
|
||||
f'<p style="margin:6px 0 0;color:#5D6B80;font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||
f'<p style="margin:0;color:{PALETTE["text"]};font-size:14px;font-weight:400">{escape(number)}</p>',
|
||||
f'<p style="margin:6px 0 0;color:{PALETTE["muted"]};font-size:13px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||
]
|
||||
paragraphs.extend(
|
||||
'<p style="margin:6px 0 0;color:#BE4657;font-size:13px;font-weight:400">' + escape(warning) + "</p>"
|
||||
f'<p style="margin:6px 0 0;color:{PALETTE["danger"]};font-size:13px;font-weight:400">' + escape(warning) + "</p>"
|
||||
for warning in _order_warnings(row)
|
||||
)
|
||||
document.setHtml("".join(paragraphs))
|
||||
@@ -976,7 +977,7 @@ class _PrescriptionBlueTable(SortableTable):
|
||||
for button in widget.findChildren(QPushButton):
|
||||
kind = {"查看处方": "eye", "编辑处方": "pencil", "删除处方": "trash"}.get(button.accessibleName())
|
||||
if kind:
|
||||
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
|
||||
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
|
||||
|
||||
|
||||
class PrescriptionsPage(QWidget):
|
||||
@@ -1060,7 +1061,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.add_button.setObjectName("PrescriptionAddButton")
|
||||
self.add_button.setMinimumWidth(122)
|
||||
self.add_button.setProperty("variant", "primary")
|
||||
self.add_button.setIcon(_blue_prescription_icon("plus", "#FFFFFF"))
|
||||
self.add_button.setIcon(_blue_prescription_icon("plus", PALETTE["surface"]))
|
||||
self.add_button.setIconSize(QSize(15, 15))
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
|
||||
@@ -1079,7 +1080,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.table_card = table_card
|
||||
root.addWidget(table_card, 1)
|
||||
for button, kind in ((self.view_button, "eye"), (self.edit_button, "pencil"), (self.delete_button, "trash")):
|
||||
button.setIcon(_blue_prescription_icon(kind, "#BE4657" if kind == "trash" else "#1769E8"))
|
||||
button.setIcon(_blue_prescription_icon(kind, PALETTE["danger"] if kind == "trash" else PALETTE["accent"]))
|
||||
self._load_doctor_options()
|
||||
|
||||
def _build_filters(self) -> QWidget:
|
||||
@@ -1226,7 +1227,7 @@ class PrescriptionsPage(QWidget):
|
||||
refresh = QPushButton("刷新")
|
||||
refresh.setProperty("variant", "ghost")
|
||||
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
refresh.setIcon(_blue_prescription_icon("refresh", "#5D6B80"))
|
||||
refresh.setIcon(_blue_prescription_icon("refresh", PALETTE["muted"]))
|
||||
refresh.setIconSize(QSize(15, 15))
|
||||
refresh.clicked.connect(self.refresh)
|
||||
toolbar.addWidget(refresh)
|
||||
@@ -1287,7 +1288,7 @@ class PrescriptionsPage(QWidget):
|
||||
self.table.setItemDelegate(_PrescriptionInfoDelegate(self.table))
|
||||
self.table.setItemDelegateForColumn(1, _PrescriptionNumberBlueDelegate(self.table))
|
||||
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
|
||||
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", "#8A97A9", 14))
|
||||
self.table.horizontalHeaderItem(0).setIcon(_blue_prescription_icon("checkbox", PALETTE["disabled"], 14))
|
||||
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
||||
@@ -1537,7 +1538,7 @@ class PrescriptionsPage(QWidget):
|
||||
|
||||
sn_item = self.table.item(row_index, 1)
|
||||
if sn_item is not None:
|
||||
sn_item.setForeground(QColor("#1A1C1F"))
|
||||
sn_item.setForeground(QColor(PALETTE["heading"]))
|
||||
font = sn_item.font()
|
||||
font.setWeight(QFont.Weight.Normal)
|
||||
sn_item.setFont(font)
|
||||
|
||||
@@ -75,6 +75,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...core.appointment_modes import appointment_type_description, appointment_type_value
|
||||
from .. import icons
|
||||
from ..design_system import PALETTE, clinical_stylesheet
|
||||
from ..diagnosis_drawer import DailyRecordPanel
|
||||
from ..diagnosis_editors import FlowLayout
|
||||
from ..dialogs import DiagnosisDialog
|
||||
@@ -2020,7 +2021,7 @@ def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
|
||||
outline (bubble / chart page / capsule / clipboard / calendar-clock).
|
||||
"""
|
||||
|
||||
return icons.icon(kind, "#5D6B80", size)
|
||||
return icons.icon(kind, PALETTE["muted"], size)
|
||||
|
||||
|
||||
def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon:
|
||||
@@ -2882,7 +2883,7 @@ class _ReceptionSparkIcon(QWidget):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
glyph = icons.pixmap("ai", "#1769E8", 18)
|
||||
glyph = icons.pixmap("ai", PALETTE["accent"], 18)
|
||||
painter.drawPixmap(QRectF(0, 0, 18, 18), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -2896,9 +2897,9 @@ class _ReceptionAiSectionIcon(QWidget):
|
||||
|
||||
#: Section tone -> (plate, glyph tint, shared glyph name).
|
||||
_TONES = {
|
||||
"diagnosis": ("#EAF2FF", "#1769E8", "report"),
|
||||
"risk": ("#EAF2FF", "#1769E8", "alert"),
|
||||
"treatment": ("#EAF2FF", "#1769E8", "meds"),
|
||||
"diagnosis": (PALETTE["accent_soft"], PALETTE["accent"], "report"),
|
||||
"risk": (PALETTE["accent_soft"], PALETTE["accent"], "alert"),
|
||||
"treatment": (PALETTE["accent_soft"], PALETTE["accent"], "meds"),
|
||||
}
|
||||
|
||||
def __init__(self, tone: str, parent: QWidget | None = None) -> None:
|
||||
@@ -2949,9 +2950,9 @@ class _ReceptionAssistantIcon(QWidget):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#1769E8"))
|
||||
painter.setBrush(QColor(PALETTE["accent"]))
|
||||
painter.drawRoundedRect(QRectF(1, 1, 22, 22), 4, 4)
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
painter.setPen(QColor(PALETTE["surface"]))
|
||||
font = painter.font()
|
||||
font.setPixelSize(11)
|
||||
font.setBold(True)
|
||||
@@ -2975,7 +2976,7 @@ class _ReceptionSendButton(QPushButton):
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(
|
||||
QPen(
|
||||
QColor("#FFFFFF"),
|
||||
QColor(PALETTE["surface"]),
|
||||
1.8,
|
||||
Qt.PenStyle.SolidLine,
|
||||
Qt.PenCapStyle.RoundCap,
|
||||
@@ -3053,7 +3054,7 @@ class _ReceptionDateButton(QPushButton):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#5D6B80")
|
||||
color = QColor(PALETTE["muted"])
|
||||
painter.setPen(
|
||||
QPen(
|
||||
color,
|
||||
@@ -3087,7 +3088,7 @@ class _ReceptionAiExpandButton(QPushButton):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#1769E8" if self.isEnabled() else "#8E8F90")
|
||||
color = QColor(PALETTE["accent"] if self.isEnabled() else PALETTE["disabled"])
|
||||
painter.setPen(
|
||||
QPen(
|
||||
color,
|
||||
@@ -3120,7 +3121,7 @@ class _ReceptionAiModelCombo(QComboBox):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#273244" if self.isEnabled() else "#8E8F90")
|
||||
color = QColor(PALETTE["text"] if self.isEnabled() else PALETTE["disabled"])
|
||||
painter.setPen(
|
||||
QPen(
|
||||
color,
|
||||
@@ -3162,7 +3163,7 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
||||
self.resize(920, 760)
|
||||
self.setMinimumSize(720, 560)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self.setStyleSheet(RECEPTION_AI_DIALOG_QSS)
|
||||
self.setStyleSheet(clinical_stylesheet(RECEPTION_AI_DIALOG_QSS))
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(18, 18, 18, 18)
|
||||
@@ -3365,7 +3366,11 @@ class _ReceptionAiAnalysisDialog(QDialog):
|
||||
source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
source_label.setWordWrap(True)
|
||||
source_label.setStyleSheet("color: #555B68; font-size: 12px; padding: 10px 14px; background: #F7F8FC; border: 1px solid #E4E8F2; border-radius: 8px;")
|
||||
source_label.setStyleSheet(clinical_stylesheet(
|
||||
"color: $ds_muted; font-size: 12px; padding: 10px 14px; "
|
||||
"background: $ds_surface_alt; border: 1px solid $ds_line; "
|
||||
"border-radius: $ds_radius_control;"
|
||||
))
|
||||
body_layout.addWidget(source_label)
|
||||
self.source_label = source_label
|
||||
body_layout.addStretch(1)
|
||||
@@ -3614,9 +3619,9 @@ class _VitalMetricIcon(QWidget):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF"))
|
||||
painter.setBrush(QColor(PALETTE["accent_soft"]))
|
||||
painter.drawRoundedRect(QRectF(0, 0, 22, 22), 6, 6)
|
||||
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), "#1769E8", 15)
|
||||
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "pulse"), PALETTE["accent"], 15)
|
||||
painter.drawPixmap(QRectF(3.5, 3.5, 15, 15), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -3636,7 +3641,7 @@ class _LifestyleIcon(QWidget):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), "#5D6B80", 16)
|
||||
glyph = icons.pixmap(self._GLYPHS.get(self._kind, "exercise"), PALETTE["muted"], 16)
|
||||
painter.drawPixmap(QRectF(0, 0, 16, 16), glyph, QRectF(glyph.rect()))
|
||||
painter.end()
|
||||
|
||||
@@ -3672,7 +3677,7 @@ class _GenderMark(QWidget):
|
||||
return
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(QColor("#5D6B80"), 1.35)
|
||||
pen = QPen(QColor(PALETTE["muted"]), 1.35)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
@@ -3737,7 +3742,7 @@ class _ReceptionQueueSpinner(QWidget):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(QColor("#1769E8"), 2.2)
|
||||
pen = QPen(QColor(PALETTE["accent"]), 2.2)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.drawArc(QRectF(2, 2, 14, 14), self._angle * 16, 250 * 16)
|
||||
@@ -3873,15 +3878,15 @@ class QueueRow(QWidget):
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
if self._selected:
|
||||
painter.setBrush(QColor("#EAF2FF"))
|
||||
painter.setPen(QPen(QColor("#E5ECF6"), 1))
|
||||
painter.setBrush(QColor(PALETTE["accent_soft"]))
|
||||
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
|
||||
else:
|
||||
painter.setBrush(QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#E5ECF6"), 1))
|
||||
painter.drawRoundedRect(rect, 6, 6)
|
||||
painter.setBrush(QColor(PALETTE["surface"]))
|
||||
painter.setPen(QPen(QColor(PALETTE["line"]), 1))
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
if self._selected:
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#1769E8"))
|
||||
painter.setBrush(QColor(PALETTE["accent"]))
|
||||
painter.drawRoundedRect(QRectF(1.2, 1, 3.2, self.height() - 2), 1.6, 1.6)
|
||||
|
||||
|
||||
@@ -4074,7 +4079,7 @@ class ReceptionDailyRecordsPanel(DailyRecordPanel):
|
||||
font = item.font()
|
||||
font.setWeight(QFont.Weight.DemiBold if high else QFont.Weight.Normal)
|
||||
item.setFont(font)
|
||||
item.setForeground(QBrush(QColor("#BE4B58" if high else "#273244")))
|
||||
item.setForeground(QBrush(QColor(PALETTE["danger"] if high else PALETTE["text"])))
|
||||
item.setBackground(
|
||||
QBrush(
|
||||
QColor(
|
||||
@@ -4103,9 +4108,11 @@ class ReceptionPage(QWidget):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("ReceptionPage")
|
||||
self.setStyleSheet(
|
||||
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
|
||||
.replace("$heading_family", heading_family())
|
||||
.replace("$number_family", number_family())
|
||||
clinical_stylesheet(
|
||||
(RECEPTION_QSS + RECEPTION_WORKSPACE_QSS).replace("$body_family", body_family())
|
||||
.replace("$heading_family", heading_family())
|
||||
.replace("$number_family", number_family())
|
||||
)
|
||||
)
|
||||
self.repository = repository
|
||||
self.permissions = permissions
|
||||
@@ -4262,7 +4269,7 @@ class ReceptionPage(QWidget):
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch(1)
|
||||
self.refresh_button = QPushButton("", header)
|
||||
self.refresh_button.setIcon(icons.icon("refresh", "#5D6B80", 18))
|
||||
self.refresh_button.setIcon(icons.icon("refresh", PALETTE["muted"], 18))
|
||||
self.refresh_button.setObjectName("ReceptionRefreshButton")
|
||||
self.refresh_button.setFixedSize(34, 32)
|
||||
self.refresh_button.setAccessibleName("刷新接诊台")
|
||||
@@ -4454,7 +4461,7 @@ class ReceptionPage(QWidget):
|
||||
self.video_button.clicked.connect(self._request_video)
|
||||
actions.addWidget(self.video_button)
|
||||
self.more_button = QPushButton(hero)
|
||||
self.more_button.setIcon(icons.icon("more", "#5D6B80", 16))
|
||||
self.more_button.setIcon(icons.icon("more", PALETTE["muted"], 16))
|
||||
self.more_button.setIconSize(QSize(16, 16))
|
||||
self.more_button.setToolTip("更多操作")
|
||||
self.more_button.setAccessibleName("更多操作")
|
||||
@@ -5286,7 +5293,9 @@ class ReceptionPage(QWidget):
|
||||
self.ai_source_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.ai_source_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.ai_source_label.setWordWrap(True)
|
||||
self.ai_source_label.setStyleSheet("color: #5B6474; font-size: 11px;")
|
||||
self.ai_source_label.setStyleSheet(clinical_stylesheet(
|
||||
"color: $ds_muted; font-size: 12px;"
|
||||
))
|
||||
analysis_layout.addWidget(self.ai_source_label)
|
||||
|
||||
self.ai_detail_actions_host = QWidget(analysis_card)
|
||||
|
||||
@@ -1,119 +1,126 @@
|
||||
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_orders_stylesheet() -> str:
|
||||
return Template(_ORDERS).substitute(body=body_family())
|
||||
|
||||
|
||||
_ORDERS = """
|
||||
#PatientOrdersWorkspace { background: transparent; color: #273244; }
|
||||
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
|
||||
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
|
||||
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
|
||||
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||
#PatientOrdersWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PatientOrdersWorkspace QCalendarWidget {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||
font-family: "$body"; font-size: 13px; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
|
||||
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
|
||||
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; padding: 5px;
|
||||
}
|
||||
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .design_system import clinical_stylesheet
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_orders_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_ORDERS).safe_substitute(body=body_family()))
|
||||
|
||||
|
||||
_ORDERS = """
|
||||
#PatientOrdersWorkspace { background: transparent; color: $ds_text; }
|
||||
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
}
|
||||
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
|
||||
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PatientOrdersWorkspace QFrame#OrderSummaryStrip { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid transparent;
|
||||
border-radius: $ds_radius_control; background: $ds_surface_alt; color: $ds_text;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||
background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: $ds_accent_soft; }
|
||||
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: $ds_focus; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||
color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: $ds_accent_hover; border-color: $ds_accent_hover; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:pressed,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:pressed { background: $ds_accent_pressed; border-color: $ds_accent_pressed; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:focus,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:focus { border-color: $ds_focus; }
|
||||
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled,
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:disabled,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:disabled {
|
||||
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||
#PatientOrdersWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||
#PatientOrdersWorkspace QDateEdit:focus { border-color: $ds_focus; }
|
||||
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
|
||||
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QDateEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
|
||||
#PatientOrdersWorkspace QCalendarWidget {
|
||||
font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||
font-family: "$body"; font-size: 13px; color: $ds_text;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: $ds_accent_soft; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; background: $ds_surface; alternate-background-color: $ds_surface;
|
||||
color: $ds_text; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||
width: 15px; height: 15px; border: 1px solid $ds_line_strong; border-radius: 3px; background: $ds_surface;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: $ds_focus; }
|
||||
#PatientOrdersWorkspace QCheckBox:focus { color: $ds_accent; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: $ds_line; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: $ds_muted; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: $ds_text; font-size: 18px; font-weight: 500; }
|
||||
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: $ds_accent; }
|
||||
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
|
||||
}
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||
min-height: 44px; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
|
||||
border: 0; border-bottom: 1px solid $ds_line;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientOrdersWorkspace QWidget#OrderActionBar { background: $ds_surface; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
|
||||
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
|
||||
border: 1px solid $ds_line; padding: 5px;
|
||||
}
|
||||
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: $ds_accent; background: $ds_accent_soft; }
|
||||
#PatientOrdersWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
@@ -1,54 +1,55 @@
|
||||
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_progress_stylesheet() -> str:
|
||||
return Template(_PROGRESS).substitute(body=body_family())
|
||||
|
||||
|
||||
_PROGRESS = """
|
||||
#PatientProgressWorkspace { background: transparent; color: #273244; }
|
||||
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||
#PatientProgressWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
|
||||
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
|
||||
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
|
||||
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||
#PatientProgressWorkspace QHeaderView::section {
|
||||
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .design_system import clinical_stylesheet
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_progress_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_PROGRESS).safe_substitute(body=body_family()))
|
||||
|
||||
|
||||
_PROGRESS = """
|
||||
#PatientProgressWorkspace { background: transparent; color: $ds_text; }
|
||||
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||
#PatientProgressWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
}
|
||||
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: $ds_muted; }
|
||||
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
|
||||
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressOverviewCard { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: $ds_line; border: 0; }
|
||||
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: $ds_text; }
|
||||
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: $ds_accent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: $ds_accent_soft; }
|
||||
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||
border: 0; border-radius: 0; background: $ds_surface; alternate-background-color: $ds_surface;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
|
||||
}
|
||||
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||
#PatientProgressWorkspace QHeaderView::section {
|
||||
min-height: 0; padding: 0 16px; background: $ds_surface_alt; color: $ds_muted;
|
||||
border: 0; border-bottom: 1px solid $ds_line;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientProgressWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
|
||||
#PatientProgressWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
@@ -1,141 +1,154 @@
|
||||
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def patients_chrome_stylesheet() -> str:
|
||||
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def patient_list_stylesheet() -> str:
|
||||
return Template(_LIST).substitute(body=body_family())
|
||||
|
||||
|
||||
_CHROME = """
|
||||
#PatientsPage { background: #F3F7FD; }
|
||||
#PatientsPage QWidget#PageHeader QLabel {
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
|
||||
}
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
|
||||
"""
|
||||
|
||||
_LIST = """
|
||||
#PatientListWorkspace { background: transparent; color: #273244; }
|
||||
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||
#PatientSearchToolbar QPushButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||
#PatientListWorkspace QFrame#PatientSummaryStrip,
|
||||
#PatientListWorkspace QFrame#PatientListCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
|
||||
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
|
||||
#PatientSearchToolbar QLineEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientListWorkspace QDateEdit:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
|
||||
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
|
||||
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
|
||||
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
|
||||
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
|
||||
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
|
||||
}
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
"""Quiet clinical surfaces for the patient-list workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .design_system import clinical_stylesheet
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def patients_chrome_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_CHROME).safe_substitute(body=body_family(), heading=heading_family()))
|
||||
|
||||
|
||||
def patient_list_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_LIST).safe_substitute(body=body_family()))
|
||||
|
||||
|
||||
_CHROME = """
|
||||
#PatientsPage { background: $ds_canvas; }
|
||||
#PatientsPage QWidget#PageHeader QLabel {
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400; color: $ds_muted;
|
||||
}
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; }
|
||||
#PatientsPage QPushButton#PatientRefreshButton:pressed { background: $ds_accent_soft; }
|
||||
#PatientsPage QPushButton#PatientRefreshButton:focus { border-color: $ds_focus; }
|
||||
#PatientsPage QPushButton#PatientRefreshButton:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: $ds_accent; background: $ds_accent_soft; }
|
||||
"""
|
||||
|
||||
_LIST = """
|
||||
#PatientListWorkspace { background: transparent; color: $ds_text; }
|
||||
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||
#PatientSearchToolbar QPushButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
}
|
||||
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||
color: $ds_muted; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||
#PatientListWorkspace QFrame#PatientListCard {
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PatientListWorkspace QFrame#PatientSummaryStrip { background: transparent; border: 0; }
|
||||
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||
border: 1px solid transparent; border-radius: $ds_radius_control; background: $ds_surface_alt;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||
color: $ds_accent; background: $ds_surface_alt; border-color: $ds_line_strong;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: $ds_accent_soft; }
|
||||
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: $ds_focus; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||
min-height: 38px; max-height: 38px; color: $ds_surface; background: $ds_accent; border-color: $ds_accent;
|
||||
}
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: $ds_accent_hover; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:pressed { background: $ds_accent_pressed; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:focus { border-color: $ds_focus; }
|
||||
#PatientSearchToolbar QLineEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; background: $ds_surface;
|
||||
border: 1px solid $ds_line; border-radius: $ds_radius_control; selection-background-color: $ds_accent_soft;
|
||||
}
|
||||
#PatientSearchToolbar QLineEdit:focus { border-color: $ds_focus; }
|
||||
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||
color: $ds_text; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||
color: $ds_accent; border-bottom-color: $ds_accent; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: $ds_accent; background: $ds_surface_alt; }
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:focus { background: $ds_accent_soft; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||
color: $ds_accent; background: $ds_accent_soft; border: 1px solid transparent; font-weight: 600;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"]:focus,
|
||||
#PatientListWorkspace QPushButton#PatientCustomDateButton:focus { border-color: $ds_focus; }
|
||||
#PatientListWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_control;
|
||||
selection-background-color: $ds_accent_soft;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit:focus { border-color: $ds_focus; }
|
||||
#PatientListWorkspace QDateEdit:disabled {
|
||||
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||
min-height: 64px; max-height: 64px; padding: 0; background: transparent;
|
||||
border: 1px solid transparent; border-radius: $ds_radius_control;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: $ds_surface_alt; }
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"]:focus { border-color: $ds_focus; }
|
||||
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: $ds_line; }
|
||||
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: $ds_text; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable {
|
||||
background: $ds_surface; alternate-background-color: $ds_surface; border: 0; border-radius: 0;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
|
||||
}
|
||||
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||
min-height: 41px; padding: 0 10px; background: $ds_surface_alt; color: $ds_muted;
|
||||
border: 0; border-top: 1px solid $ds_line; border-bottom: 1px solid $ds_line;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||
min-height: 28px; max-height: 28px; padding: 0 5px; border: 1px solid transparent;
|
||||
border-radius: $ds_radius_small; background: transparent; color: $ds_accent; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QToolButton#RowActionsMore { color: $ds_text; padding-right: 16px; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: $ds_accent_soft; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"]:focus,
|
||||
#PatientListWorkspace QToolButton#RowActionsMore:focus { border-color: $ds_focus; }
|
||||
#PatientListWorkspace QMenu { background: $ds_surface; color: $ds_text; border: 1px solid $ds_line; padding: 4px; }
|
||||
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||
#PatientListWorkspace QMenu::item:selected { background: $ds_accent_soft; color: $ds_accent; }
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||
width: 13px; height: 13px; background: $ds_surface; border: 1px solid $ds_line_strong; border-radius: 3px;
|
||||
}
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: $ds_accent; border-color: $ds_accent; }
|
||||
#PatientListWorkspace QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
|
||||
#PatientListWorkspace QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
@@ -1,410 +1,419 @@
|
||||
"""Local technology-blue rendering for the approved prescription library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QPushButton,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
from .widgets import SortableTable, TableColumn, first_value
|
||||
|
||||
|
||||
def library_stylesheet() -> str:
|
||||
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||
# Defer the page helper lookup so importing this module alone does not
|
||||
# recurse through pages.__init__ and the library page's own style import.
|
||||
from .pages.prescriptions import _painted_icon
|
||||
|
||||
result = QIcon()
|
||||
for mode, tint in (
|
||||
(QIcon.Mode.Normal, color),
|
||||
(QIcon.Mode.Active, color),
|
||||
(QIcon.Mode.Selected, color),
|
||||
(QIcon.Mode.Disabled, "#A4ADBA"),
|
||||
):
|
||||
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||
for state in (QIcon.State.Off, QIcon.State.On):
|
||||
result.addPixmap(pixmap, mode, state)
|
||||
return result
|
||||
|
||||
|
||||
class LibraryComboBox(QComboBox):
|
||||
"""Keep native combo interaction while painting the local caret."""
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
try:
|
||||
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
class LibraryTable(SortableTable):
|
||||
"""Fit readable columns while preserving actions and selected template IDs."""
|
||||
|
||||
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
|
||||
|
||||
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
|
||||
super().__init__(columns, parent)
|
||||
self.horizontalHeader().setStretchLastSection(False)
|
||||
self._minimum_widths = tuple(column.width for column in self.columns)
|
||||
self._manual_widths: dict[int, int] = {}
|
||||
self._fitting_columns = False
|
||||
self._fit_timer = QTimer(self)
|
||||
self._fit_timer.setSingleShot(True)
|
||||
self._fit_timer.timeout.connect(self._fit_columns)
|
||||
self.horizontalHeader().sectionResized.connect(self._column_resized)
|
||||
self.viewport().installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
|
||||
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
|
||||
self._schedule_column_fit()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _schedule_column_fit(self) -> None:
|
||||
if not self._fit_timer.isActive():
|
||||
self._fit_timer.start(0)
|
||||
|
||||
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
|
||||
if self._fitting_columns:
|
||||
return
|
||||
self._manual_widths[column] = max(self._minimum_widths[column], size)
|
||||
self._schedule_column_fit()
|
||||
|
||||
def _fit_columns(self) -> None:
|
||||
widths = [self._manual_widths.get(column, minimum)
|
||||
for column, minimum in enumerate(self._minimum_widths)]
|
||||
extra = max(0, self.viewport().width() - sum(widths))
|
||||
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
|
||||
if column not in self._manual_widths]
|
||||
# If every text column was manually sized, keep those preferences as
|
||||
# minimums and let the herb preview absorb any remaining wide-screen space.
|
||||
flexible = flexible or [(4, 1)]
|
||||
total_weight = sum(weight for _column, weight in flexible)
|
||||
for column, weight in flexible:
|
||||
widths[column] += extra * weight // total_weight
|
||||
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
|
||||
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
|
||||
return
|
||||
self._fitting_columns = True
|
||||
try:
|
||||
for column, width in enumerate(widths):
|
||||
self.setColumnWidth(column, width)
|
||||
finally:
|
||||
self._fitting_columns = False
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||
super().set_rows(rows)
|
||||
# The shared table restores an unsorted row index after enabling sort.
|
||||
# Locate the same source object in the finished visual order instead.
|
||||
if selected_id is not None:
|
||||
for row_index in range(self.rowCount()):
|
||||
item = self.item(row_index, 0)
|
||||
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
row_id = first_value(row, "id", "template_id", default=None)
|
||||
if row_id is not None and str(row_id) == str(selected_id):
|
||||
self.selectRow(row_index)
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentCell(-1, -1)
|
||||
|
||||
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||
super().setCellWidget(row, column, widget)
|
||||
if widget is None or column != 9:
|
||||
return
|
||||
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||
widget.setAutoFillBackground(False)
|
||||
glyphs = {
|
||||
"查看处方模板": "eye",
|
||||
"AI 解释": "spark",
|
||||
"编辑处方模板": "pencil",
|
||||
"删除处方模板": "trash",
|
||||
}
|
||||
for button in widget.findChildren(QPushButton):
|
||||
if not button.property("rowAction"):
|
||||
continue
|
||||
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||
if glyph is not None:
|
||||
color = "#BE4657" if button.property("danger") else "#1769E8"
|
||||
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||
|
||||
|
||||
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||
font = QFont(heading_family() if medium else body_family())
|
||||
font.setPixelSize(size)
|
||||
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||
return font
|
||||
|
||||
|
||||
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||
|
||||
|
||||
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||
entries = text.split("、")
|
||||
first = entries[0]
|
||||
next_index = 1
|
||||
while next_index < min(2, len(entries)):
|
||||
candidate = first + "、" + entries[next_index]
|
||||
if metrics.horizontalAdvance(candidate) > width:
|
||||
break
|
||||
first = candidate
|
||||
next_index += 1
|
||||
lines = [_elide(first, metrics, width)]
|
||||
if next_index < len(entries):
|
||||
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||
return lines
|
||||
|
||||
|
||||
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
if metrics.horizontalAdvance(text) <= width:
|
||||
return [text]
|
||||
split = 1
|
||||
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||
split += 1
|
||||
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||
|
||||
|
||||
class LibraryItemDelegate(QStyledItemDelegate):
|
||||
"""Paint the ten existing columns without changing display or source roles."""
|
||||
|
||||
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||
size = super().sizeHint(option, index)
|
||||
size.setHeight(78)
|
||||
return size
|
||||
|
||||
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||
if event is not None and index.isValid() and index.column() != 9:
|
||||
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPos(), str(text), view)
|
||||
return True
|
||||
return super().helpEvent(event, view, option, index)
|
||||
|
||||
@staticmethod
|
||||
def _paint_lines(
|
||||
painter: QPainter,
|
||||
rect: QRectF,
|
||||
lines: list[str],
|
||||
*,
|
||||
secondary_muted: bool = False,
|
||||
) -> None:
|
||||
line_height = 26
|
||||
top = rect.center().y() - len(lines) * line_height / 2
|
||||
for line_index, line in enumerate(lines):
|
||||
if secondary_muted and line_index:
|
||||
painter.setFont(_library_font(13))
|
||||
painter.setPen(QColor("#5D6B80"))
|
||||
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||
|
||||
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
painter.save()
|
||||
try:
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
|
||||
painter.fillRect(option.rect, QColor(background))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||
painter.fillRect(stripe, QColor("#1769E8"))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#75A5F0"))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
|
||||
column = index.column()
|
||||
if column == 9:
|
||||
return
|
||||
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||
if rect.width() <= 0:
|
||||
return
|
||||
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
text = "" if value is None else str(value)
|
||||
font = _library_font(medium=column == 1)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
metrics = QFontMetrics(font)
|
||||
width = int(rect.width())
|
||||
|
||||
if column == 2:
|
||||
painter.setFont(_library_font(13))
|
||||
metrics = painter.fontMetrics()
|
||||
label = _elide(text, metrics, width - 12)
|
||||
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF"))
|
||||
painter.drawRoundedRect(pill, 4, 4)
|
||||
painter.setPen(QColor("#1769E8"))
|
||||
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||
elif column == 6:
|
||||
glyph = "users" if text == "所有人可见" else "lock"
|
||||
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
|
||||
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||
painter.drawText(
|
||||
label_rect,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
_elide(text, metrics, int(label_rect.width())),
|
||||
)
|
||||
elif column == 4:
|
||||
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||
elif column == 8:
|
||||
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||
lines = [_elide(parts[0], metrics, width)]
|
||||
if len(parts) > 1:
|
||||
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||
elif column == 1:
|
||||
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||
else:
|
||||
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||
finally:
|
||||
painter.restore()
|
||||
|
||||
|
||||
_LIBRARY = """
|
||||
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||
background: transparent; border: 0;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#MetricCard,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||
border: 0; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
|
||||
#PrescriptionLibraryPage QPushButton:disabled,
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; color: #273244; font-size: 14px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||
color: #1555B6; background: #F2F7FF;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||
#PrescriptionLibraryPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
|
||||
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||
"""Shared clinical tokens for the prescription library and its native delegates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QModelIndex, QRectF, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QPushButton,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .design_system import PALETTE, RADII, clinical_stylesheet
|
||||
from .reception_style import body_family, heading_family
|
||||
from .widgets import SortableTable, TableColumn, first_value
|
||||
|
||||
|
||||
def library_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_LIBRARY).safe_substitute(body=body_family(), heading=heading_family()))
|
||||
|
||||
|
||||
def _library_icon(kind: str, color: str = PALETTE["accent"], size: int = 15) -> QIcon:
|
||||
# Defer the page helper lookup so importing this module alone does not
|
||||
# recurse through pages.__init__ and the library page's own style import.
|
||||
from .pages.prescriptions import _painted_icon
|
||||
|
||||
result = QIcon()
|
||||
for mode, tint in (
|
||||
(QIcon.Mode.Normal, color),
|
||||
(QIcon.Mode.Active, color),
|
||||
(QIcon.Mode.Selected, color),
|
||||
(QIcon.Mode.Disabled, PALETTE["disabled"]),
|
||||
):
|
||||
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||
for state in (QIcon.State.Off, QIcon.State.On):
|
||||
result.addPixmap(pixmap, mode, state)
|
||||
return result
|
||||
|
||||
|
||||
class LibraryComboBox(QComboBox):
|
||||
"""Keep native combo interaction while painting the local caret."""
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
try:
|
||||
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||
_library_icon("chevron_down", PALETTE["muted"], 14).paint(painter, rect.toRect(), mode=mode)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
class LibraryTable(SortableTable):
|
||||
"""Fit readable columns while preserving actions and selected template IDs."""
|
||||
|
||||
_FLEX_COLUMNS = ((1, 2), (4, 5), (5, 2))
|
||||
|
||||
def __init__(self, columns: Sequence[TableColumn], parent: QWidget | None = None) -> None:
|
||||
super().__init__(columns, parent)
|
||||
self.horizontalHeader().setStretchLastSection(False)
|
||||
self._minimum_widths = tuple(column.width for column in self.columns)
|
||||
self._manual_widths: dict[int, int] = {}
|
||||
self._fitting_columns = False
|
||||
self._fit_timer = QTimer(self)
|
||||
self._fit_timer.setSingleShot(True)
|
||||
self._fit_timer.timeout.connect(self._fit_columns)
|
||||
self.horizontalHeader().sectionResized.connect(self._column_resized)
|
||||
self.viewport().installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802
|
||||
if watched is self.viewport() and event.type() in {QEvent.Type.Resize, QEvent.Type.Show}:
|
||||
self._schedule_column_fit()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _schedule_column_fit(self) -> None:
|
||||
if not self._fit_timer.isActive():
|
||||
self._fit_timer.start(0)
|
||||
|
||||
def _column_resized(self, column: int, _old_size: int, size: int) -> None:
|
||||
if self._fitting_columns:
|
||||
return
|
||||
self._manual_widths[column] = max(self._minimum_widths[column], size)
|
||||
self._schedule_column_fit()
|
||||
|
||||
def _fit_columns(self) -> None:
|
||||
widths = [self._manual_widths.get(column, minimum)
|
||||
for column, minimum in enumerate(self._minimum_widths)]
|
||||
extra = max(0, self.viewport().width() - sum(widths))
|
||||
flexible = [(column, weight) for column, weight in self._FLEX_COLUMNS
|
||||
if column not in self._manual_widths]
|
||||
# If every text column was manually sized, keep those preferences as
|
||||
# minimums and let the herb preview absorb any remaining wide-screen space.
|
||||
flexible = flexible or [(4, 1)]
|
||||
total_weight = sum(weight for _column, weight in flexible)
|
||||
for column, weight in flexible:
|
||||
widths[column] += extra * weight // total_weight
|
||||
widths[flexible[0][0]] += max(0, self.viewport().width() - sum(widths))
|
||||
if widths == [self.columnWidth(column) for column in range(self.columnCount())]:
|
||||
return
|
||||
self._fitting_columns = True
|
||||
try:
|
||||
for column, width in enumerate(widths):
|
||||
self.setColumnWidth(column, width)
|
||||
finally:
|
||||
self._fitting_columns = False
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||
super().set_rows(rows)
|
||||
# The shared table restores an unsorted row index after enabling sort.
|
||||
# Locate the same source object in the finished visual order instead.
|
||||
if selected_id is not None:
|
||||
for row_index in range(self.rowCount()):
|
||||
item = self.item(row_index, 0)
|
||||
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
row_id = first_value(row, "id", "template_id", default=None)
|
||||
if row_id is not None and str(row_id) == str(selected_id):
|
||||
self.selectRow(row_index)
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentCell(-1, -1)
|
||||
|
||||
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||
super().setCellWidget(row, column, widget)
|
||||
if widget is None or column != 9:
|
||||
return
|
||||
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||
widget.setAutoFillBackground(False)
|
||||
glyphs = {
|
||||
"查看处方模板": "eye",
|
||||
"AI 解释": "spark",
|
||||
"编辑处方模板": "pencil",
|
||||
"删除处方模板": "trash",
|
||||
}
|
||||
for button in widget.findChildren(QPushButton):
|
||||
if not button.property("rowAction"):
|
||||
continue
|
||||
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||
if glyph is not None:
|
||||
color = PALETTE["danger"] if button.property("danger") else PALETTE["accent"]
|
||||
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||
|
||||
|
||||
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||
font = QFont(heading_family() if medium else body_family())
|
||||
font.setPixelSize(size)
|
||||
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||
return font
|
||||
|
||||
|
||||
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||
|
||||
|
||||
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||
entries = text.split("、")
|
||||
first = entries[0]
|
||||
next_index = 1
|
||||
while next_index < min(2, len(entries)):
|
||||
candidate = first + "、" + entries[next_index]
|
||||
if metrics.horizontalAdvance(candidate) > width:
|
||||
break
|
||||
first = candidate
|
||||
next_index += 1
|
||||
lines = [_elide(first, metrics, width)]
|
||||
if next_index < len(entries):
|
||||
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||
return lines
|
||||
|
||||
|
||||
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
if metrics.horizontalAdvance(text) <= width:
|
||||
return [text]
|
||||
split = 1
|
||||
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||
split += 1
|
||||
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||
|
||||
|
||||
class LibraryItemDelegate(QStyledItemDelegate):
|
||||
"""Paint the ten existing columns without changing display or source roles."""
|
||||
|
||||
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||
size = super().sizeHint(option, index)
|
||||
size.setHeight(78)
|
||||
return size
|
||||
|
||||
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||
if event is not None and index.isValid() and index.column() != 9:
|
||||
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPos(), str(text), view)
|
||||
return True
|
||||
return super().helpEvent(event, view, option, index)
|
||||
|
||||
@staticmethod
|
||||
def _paint_lines(
|
||||
painter: QPainter,
|
||||
rect: QRectF,
|
||||
lines: list[str],
|
||||
*,
|
||||
secondary_muted: bool = False,
|
||||
) -> None:
|
||||
line_height = 26
|
||||
top = rect.center().y() - len(lines) * line_height / 2
|
||||
for line_index, line in enumerate(lines):
|
||||
if secondary_muted and line_index:
|
||||
painter.setFont(_library_font(13))
|
||||
painter.setPen(QColor(PALETTE["muted"]))
|
||||
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||
|
||||
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
painter.save()
|
||||
try:
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
background = PALETTE["accent_soft"] if selected else PALETTE["surface_alt"] if hovered else PALETTE["surface"]
|
||||
painter.fillRect(option.rect, QColor(background))
|
||||
painter.setPen(QColor(PALETTE["line"]))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||
painter.fillRect(stripe, QColor(PALETTE["accent"]))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor(PALETTE["focus"]))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
|
||||
column = index.column()
|
||||
if column == 9:
|
||||
return
|
||||
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||
if rect.width() <= 0:
|
||||
return
|
||||
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
text = "" if value is None else str(value)
|
||||
font = _library_font(medium=column == 1)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(PALETTE["text"]))
|
||||
metrics = QFontMetrics(font)
|
||||
width = int(rect.width())
|
||||
|
||||
if column == 2:
|
||||
painter.setFont(_library_font(13))
|
||||
metrics = painter.fontMetrics()
|
||||
label = _elide(text, metrics, width - 12)
|
||||
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(PALETTE["accent_soft"]))
|
||||
painter.drawRoundedRect(pill, RADII["small"], RADII["small"])
|
||||
painter.setPen(QColor(PALETTE["accent"]))
|
||||
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||
elif column == 6:
|
||||
glyph = "users" if text == "所有人可见" else "lock"
|
||||
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||
_library_icon(glyph, PALETTE["muted"], 14).paint(painter, icon_rect.toRect())
|
||||
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||
painter.drawText(
|
||||
label_rect,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
_elide(text, metrics, int(label_rect.width())),
|
||||
)
|
||||
elif column == 4:
|
||||
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||
elif column == 8:
|
||||
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||
lines = [_elide(parts[0], metrics, width)]
|
||||
if len(parts) > 1:
|
||||
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||
elif column == 1:
|
||||
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||
else:
|
||||
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||
finally:
|
||||
painter.restore()
|
||||
|
||||
|
||||
_LIBRARY = """
|
||||
#PrescriptionLibraryPage { background: $ds_canvas; color: $ds_text; }
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||
background: transparent; border: 0;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="muted"] { color: $ds_muted; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard { background: $ds_surface; border: 0; border-radius: $ds_radius_panel; }
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||
font-family: "$body"; color: $ds_muted; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||
font-family: "$heading"; color: $ds_text; font-size: 18px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||
border: 0; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
|
||||
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
|
||||
#PrescriptionLibraryPage QPushButton:pressed { background: $ds_accent_soft; }
|
||||
#PrescriptionLibraryPage QPushButton:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||
background: $ds_accent; border-color: $ds_accent; color: $ds_surface;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||
background: $ds_accent_hover; border-color: $ds_accent_hover;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:focus,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QPushButton:disabled,
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:disabled,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:disabled {
|
||||
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
|
||||
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QComboBox:disabled { color: $ds_disabled; background: $ds_disabled_surface; }
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
|
||||
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
|
||||
}
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; color: $ds_text; font-size: 14px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||
color: $ds_accent; border-bottom-color: $ds_accent;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||
color: $ds_accent; background: $ds_surface_alt;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:focus { background: $ds_accent_soft; }
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||
#PrescriptionLibraryPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
|
||||
border: 0; border-bottom: 1px solid $ds_line;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: $ds_disabled; }
|
||||
#PrescriptionLibraryPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
|
||||
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: $ds_accent; background: $ds_surface_alt;
|
||||
border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionLibraryPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||
|
||||
@@ -1,107 +1,115 @@
|
||||
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def prescriptions_stylesheet() -> str:
|
||||
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_PRESCRIPTIONS = """
|
||||
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
|
||||
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
|
||||
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
|
||||
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||
#PrescriptionsPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||
background: transparent; border-color: transparent; color: #A4ADBA;
|
||||
}
|
||||
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
|
||||
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .design_system import clinical_stylesheet
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def prescriptions_stylesheet() -> str:
|
||||
return clinical_stylesheet(Template(_PRESCRIPTIONS).safe_substitute(body=body_family(), heading=heading_family()))
|
||||
|
||||
|
||||
_PRESCRIPTIONS = """
|
||||
#PrescriptionsPage { background: $ds_canvas; color: $ds_text; }
|
||||
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: $ds_text;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: $ds_heading;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||
color: $ds_muted; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||
background: $ds_surface; border: 1px solid $ds_line; border-radius: $ds_radius_panel;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: $ds_radius_control;
|
||||
border: 1px solid transparent; background: $ds_surface_alt; color: $ds_text;
|
||||
}
|
||||
#PrescriptionsPage QPushButton:hover { background: $ds_surface_alt; border-color: $ds_line_strong; color: $ds_accent; }
|
||||
#PrescriptionsPage QPushButton:pressed { background: $ds_accent_soft; }
|
||||
#PrescriptionsPage QPushButton:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||
background: $ds_accent; color: $ds_surface; border-color: $ds_accent;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||
background: $ds_accent_hover; border-color: $ds_accent_hover;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||
background: $ds_accent_pressed; border-color: $ds_accent_pressed;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:focus,
|
||||
#PrescriptionsPage QPushButton#PrescriptionQueryButton:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QPushButton[variant="danger"] { color: $ds_danger; background: $ds_danger_soft; border-color: transparent; }
|
||||
#PrescriptionsPage QPushButton[variant="danger"]:hover { border-color: $ds_danger; }
|
||||
#PrescriptionsPage QPushButton[variant="danger"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled,
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:disabled,
|
||||
#PrescriptionsPage QPushButton#PrescriptionQueryButton:disabled {
|
||||
color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid $ds_line;
|
||||
border-radius: $ds_radius_control; background: $ds_surface; color: $ds_text;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionsPage QDateTimeEdit:disabled { color: $ds_disabled; background: $ds_disabled_surface; border-color: $ds_line; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: $ds_text; background: $ds_surface;
|
||||
border: 1px solid $ds_line; outline: 0; selection-background-color: $ds_accent_soft; selection-color: $ds_accent;
|
||||
}
|
||||
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: $ds_surface; color: $ds_text; }
|
||||
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: $ds_surface_alt; }
|
||||
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: $ds_text; background: transparent; }
|
||||
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: $ds_text; background: $ds_surface; selection-background-color: $ds_accent; selection-color: $ds_surface; outline: 0; }
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||
border: 0; background: $ds_surface; alternate-background-color: $ds_surface;
|
||||
selection-background-color: $ds_accent_soft; selection-color: $ds_text; gridline-color: $ds_line;
|
||||
}
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||
#PrescriptionsPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: $ds_surface_alt; color: $ds_muted;
|
||||
border: 0; border-bottom: 1px solid $ds_line;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: $ds_radius_small; background: transparent;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||
min-width: 64px; max-width: 64px; color: $ds_accent; font-size: 13px; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: $ds_accent_soft; border-color: $ds_line_strong; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: $ds_danger_soft; border-color: $ds_danger_soft; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:focus { border-color: $ds_focus; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||
background: transparent; border-color: transparent; color: $ds_disabled;
|
||||
}
|
||||
#PrescriptionsPage QWidget#InfiniteList { background: $ds_surface; border-top: 1px solid $ds_line; }
|
||||
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: $ds_accent; background: $ds_surface_alt; border: 1px solid $ds_line; border-radius: $ds_radius_panel; }
|
||||
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionsPage QScrollBar:vertical { background: $ds_canvas; width: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar:horizontal { background: $ds_canvas; height: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar::handle { background: $ds_line_strong; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
@@ -4,23 +4,24 @@ from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .design_system import PALETTE
|
||||
from .theme import _register_preferred_cjk_fonts
|
||||
|
||||
TECH_BLUE = {
|
||||
"accent": "#1769E8",
|
||||
"accent_hover": "#155BCC",
|
||||
"accent_pressed": "#124EA9",
|
||||
"selection": "#EAF2FF",
|
||||
"selected_text": "#1555B6",
|
||||
"canvas": "#F3F7FD",
|
||||
"sidebar": "#EDF4FF",
|
||||
"surface": "#FFFFFF",
|
||||
"raised": "#F7FAFE",
|
||||
"line": "#DBE5F2",
|
||||
"text": "#273244",
|
||||
"heading": "#202C3F",
|
||||
"muted": "#5D6B80",
|
||||
"focus": "#75A5F0",
|
||||
"accent": PALETTE["accent"],
|
||||
"accent_hover": PALETTE["accent_hover"],
|
||||
"accent_pressed": PALETTE["accent_pressed"],
|
||||
"selection": PALETTE["accent_soft"],
|
||||
"selected_text": PALETTE["accent_hover"],
|
||||
"canvas": PALETTE["canvas"],
|
||||
"sidebar": PALETTE["sidebar"],
|
||||
"surface": PALETTE["surface"],
|
||||
"raised": PALETTE["surface_alt"],
|
||||
"line": PALETTE["line"],
|
||||
"text": PALETTE["text"],
|
||||
"heading": PALETTE["heading"],
|
||||
"muted": PALETTE["muted"],
|
||||
"focus": PALETTE["focus"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@ QWidget#ReceptionPage QStackedWidget#ReceptionNarrowWorkspace {
|
||||
}
|
||||
QWidget#ReceptionPage QFrame#ReceptionQueuePanel {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5ECF6;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #EDF0F5;
|
||||
border-radius: 16px;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionQueueTitle {
|
||||
font-size: 18px;
|
||||
@@ -200,8 +200,8 @@ QWidget#ReceptionPage QFrame#ReceptionHero,
|
||||
QWidget#ReceptionPage QFrame#ReceptionClinicalCard {
|
||||
min-height: 0;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5ECF6;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #EDF0F5;
|
||||
border-radius: 16px;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionPatientName {
|
||||
font-size: 20px;
|
||||
@@ -223,8 +223,8 @@ QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs {
|
||||
background-color: #FFFFFF;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E5ECF6;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs::tab,
|
||||
QWidget#ReceptionPage QTabBar#ReceptionWorkspaceTabs::tab {
|
||||
@@ -295,8 +295,8 @@ QWidget#ReceptionPage QFrame#ReceptionPatientSummaryCard,
|
||||
QWidget#ReceptionPage QFrame#ReceptionVitalsCard,
|
||||
QWidget#ReceptionPage QFrame#ReceptionLabsCard {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5ECF6;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #EDF0F5;
|
||||
border-radius: 16px;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue {
|
||||
color: #24334A;
|
||||
@@ -367,8 +367,8 @@ QWidget#ReceptionPage QLabel[receptionMetricState="true"][alert="true"][warning=
|
||||
QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard,
|
||||
QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE5F0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $ds_line;
|
||||
border-radius: $ds_radius_panel;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionAiTitle,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionAiTitle {
|
||||
@@ -405,4 +405,37 @@ QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::add-line:v
|
||||
QWidget#ReceptionPage QScrollArea#ReceptionInsightsScroll QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
QWidget#ReceptionPage QLineEdit#ReceptionQueueSearch,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionDateButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionQueueFilterToggle,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionVideoButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionImButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionMoreButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton,
|
||||
QWidget#ReceptionPage QPushButton[receptionAiPrompt="true"] {
|
||||
border-radius: $ds_radius_control;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton:focus,
|
||||
QWidget#ReceptionPage QLineEdit:focus,
|
||||
QWidget#ReceptionPage QComboBox:focus,
|
||||
QWidget#ReceptionPage QTextEdit:focus {
|
||||
border-color: $ds_focus;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionVideoButton:pressed {
|
||||
color: $ds_surface;
|
||||
background-color: $ds_accent_pressed;
|
||||
border-color: $ds_accent_pressed;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel[receptionMetricState="true"] {
|
||||
border-radius: $ds_radius_small;
|
||||
}
|
||||
QWidget#ReceptionPage QFrame#ReceptionLabCell,
|
||||
QWidget#ReceptionPage QWidget#ReceptionCaseField {
|
||||
border-bottom-color: $ds_line;
|
||||
}
|
||||
QWidget#ReceptionPage QLabel#ReceptionPatientSummaryValue[missing="true"] {
|
||||
color: $ds_muted;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -59,6 +59,7 @@ from .chat_notifications import (
|
||||
ChatNotification,
|
||||
ChatNotificationCenter,
|
||||
)
|
||||
from .design_system import PALETTE, clinical_stylesheet
|
||||
from .dialogs.ai_consult import can_open_ai_consult
|
||||
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
||||
from .dialogs.diagnosis import DiagnosisDialog
|
||||
@@ -72,7 +73,7 @@ from .pages import (
|
||||
ReceptionPage,
|
||||
)
|
||||
from .reception_style import TECH_BLUE, body_family, heading_family, number_family
|
||||
from .theme import COLORS, TYPE, crisp_pixmap
|
||||
from .theme import COLORS, METRICS, TYPE, crisp_pixmap
|
||||
from .widgets import (
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
@@ -93,8 +94,8 @@ _SHELL_DESIGN_SIZE = QSize(1710, 920)
|
||||
_SHELL_MINIMUM_SIZE = QSize(1024, 640)
|
||||
_RECEPTION_SIDEBAR_WIDTH = 208
|
||||
_RECEPTION_TOPBAR_HEIGHT = 76
|
||||
_RECEPTION_SIDEBAR_COLOR = "#F3F7FD"
|
||||
_RECEPTION_CHROME_LINE = "#E7EDF7"
|
||||
_RECEPTION_SIDEBAR_COLOR = PALETTE["sidebar"]
|
||||
_RECEPTION_CHROME_LINE = PALETTE["line"]
|
||||
# Approved pages share one chrome state, so navigation among them keeps it intact.
|
||||
_TECH_BLUE_PAGES = frozenset({"appointments", "consultations", "patients", "prescriptions", "prescription_library", "reception"})
|
||||
|
||||
@@ -1005,7 +1006,7 @@ class ShellWindow(QMainWindow):
|
||||
chrome_widgets.extend(self.topbar.findChildren(_PaintedIconButton))
|
||||
chrome_widgets.append(self.tabs_menu_button)
|
||||
self._legacy_chrome_styles = {
|
||||
widget: widget.styleSheet() for widget in chrome_widgets
|
||||
widget: clinical_stylesheet(widget.styleSheet()) for widget in chrome_widgets
|
||||
}
|
||||
self._register_pages()
|
||||
self.search_shortcut = QShortcut(QKeySequence("Ctrl+K"), self)
|
||||
@@ -1268,7 +1269,7 @@ class ShellWindow(QMainWindow):
|
||||
color: #606163;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 999px;
|
||||
border-radius: 10px;
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
@@ -1693,7 +1694,7 @@ class ShellWindow(QMainWindow):
|
||||
font-family: "{body_family()}"; font-weight: 400;
|
||||
min-height: 44px; max-height: 44px;
|
||||
border: 0; border-left: 5px solid transparent;
|
||||
border-radius: 6px;
|
||||
border-radius: {METRICS["r_md"]};
|
||||
padding: 0 16px; font-size: {TYPE['fs_body']};
|
||||
color: {TECH_BLUE['text']};
|
||||
}}
|
||||
@@ -1711,7 +1712,7 @@ class ShellWindow(QMainWindow):
|
||||
}}
|
||||
QPushButton#ShellNavButton[collapsed="true"] {{ padding: 0; }}
|
||||
QFrame#ShellAssistantCard {{
|
||||
background-color: {TECH_BLUE['surface']}; border-radius: 8px;
|
||||
background-color: {TECH_BLUE['surface']}; border-radius: {METRICS["r_lg"]};
|
||||
border: 1px solid {_RECEPTION_CHROME_LINE};
|
||||
}}
|
||||
QLabel#ShellAssistantName {{
|
||||
@@ -1723,7 +1724,7 @@ class ShellWindow(QMainWindow):
|
||||
QPushButton#ShellAssistantButton, QPushButton#ShellSettingsButton {{
|
||||
font-family: "{body_family()}"; font-weight: 400; font-size: {TYPE['fs_body']};
|
||||
}}
|
||||
QPushButton#ShellAssistantButton {{ border-radius: 6px; }}
|
||||
QPushButton#ShellAssistantButton {{ border-radius: {METRICS["r_md"]}; }}
|
||||
QPushButton#ShellSettingsButton {{
|
||||
min-height: 50px; max-height: 50px;
|
||||
text-align: left; padding: 0 32px;
|
||||
@@ -1738,7 +1739,7 @@ class ShellWindow(QMainWindow):
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid {_RECEPTION_CHROME_LINE};
|
||||
}}
|
||||
QFrame#ShellGlobalSearch {{ border: 1px solid {_RECEPTION_CHROME_LINE}; border-radius: 5px; }}
|
||||
QFrame#ShellGlobalSearch {{ background-color: {PALETTE["surface_alt"]}; border: 1px solid transparent; border-radius: {METRICS["r_md"]}; }}
|
||||
QFrame#ShellUserSeparator {{ background: {_RECEPTION_CHROME_LINE}; border: 0; }}
|
||||
QLabel#ShellShortcutHint {{
|
||||
font-family: "{number_family()}"; font-size: {TYPE['fs_small']};
|
||||
@@ -1767,7 +1768,7 @@ class ShellWindow(QMainWindow):
|
||||
if isinstance(widget, _PaintedIconButton):
|
||||
side = 50 if widget in (self.refresh_button, self.notification_button, self.settings_button) else 34
|
||||
stylesheet += f"QToolButton#ShellPaintedIconButton {{ min-width: {side - 2}px; max-width: {side - 2}px; min-height: {side - 2}px; max-height: {side - 2}px; }}"
|
||||
widget.setStyleSheet(stylesheet)
|
||||
widget.setStyleSheet(clinical_stylesheet(stylesheet))
|
||||
|
||||
self._set_reception_geometry(active)
|
||||
for widget in (
|
||||
|
||||
@@ -41,46 +41,46 @@ from doctor_workstation.resources import resource_path
|
||||
# ``motion`` deliberately imports nothing from this module, so this stays a
|
||||
# one-way dependency: tokens here, animation there.
|
||||
from . import motion
|
||||
from .design_system import PALETTE, RADII, SPACING, clinical_stylesheet
|
||||
|
||||
# Neutral reading colors follow the installed Codex light chrome defaults.
|
||||
# Shared reading colors keep native pages and dialog chrome consistent.
|
||||
# Brand accents are independent of reading ink; use opaque text for stable contrast.
|
||||
# The legacy teal/ink aliases remain for existing callers.
|
||||
COLORS = {
|
||||
"canvas": "#F4F6FA",
|
||||
"canvas_mid": "#FFFFFF",
|
||||
"canvas_glow": "#EEF1FA",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#F7F7F7",
|
||||
"raised": "#F7F7F7",
|
||||
"glass": "#FFFFFF",
|
||||
"glass_alt": "#F7F7F7",
|
||||
"line": "#EDEDEE",
|
||||
"line_soft": "#E4E4E5",
|
||||
"text": "#1A1C1F",
|
||||
"text_soft": "#606163",
|
||||
"muted": "#6A6B6D",
|
||||
"disabled_surface": "#F2F2F2",
|
||||
"disabled_text": "#8E8F90",
|
||||
"indigo": "#4F63D9",
|
||||
"indigo_hover": "#4156C4",
|
||||
"indigo_pressed": "#354BB4",
|
||||
"indigo_pale": "#EEF1FA",
|
||||
"focus": "#8B9AD9",
|
||||
"selection": "#EEF1FA",
|
||||
"success": "#287B65",
|
||||
"success_pale": "#EEF7F3",
|
||||
"warning": "#A9691D",
|
||||
"warning_pale": "#FCF5E9",
|
||||
"danger": "#BE4B58",
|
||||
"danger_pale": "#FCF0F2",
|
||||
"info": "#4F63D9",
|
||||
"info_pale": "#EEF1FA",
|
||||
# Backward-compatible names used by older UI code and integrations.
|
||||
"ink": "#1A1C1F",
|
||||
"ink_soft": "#606163",
|
||||
"teal": "#4F63D9",
|
||||
"teal_dark": "#354BB4",
|
||||
"teal_pale": "#EEF1FA",
|
||||
"canvas": PALETTE["canvas"],
|
||||
"canvas_mid": PALETTE["surface"],
|
||||
"canvas_glow": PALETTE["accent_soft"],
|
||||
"surface": PALETTE["surface"],
|
||||
"surface_alt": PALETTE["surface_alt"],
|
||||
"raised": PALETTE["surface_alt"],
|
||||
"glass": PALETTE["surface"],
|
||||
"glass_alt": PALETTE["surface_alt"],
|
||||
"line": PALETTE["line"],
|
||||
"line_soft": PALETTE["line_strong"],
|
||||
"text": PALETTE["heading"],
|
||||
"text_soft": PALETTE["text"],
|
||||
"muted": PALETTE["muted"],
|
||||
"disabled_surface": PALETTE["disabled_surface"],
|
||||
"disabled_text": PALETTE["disabled"],
|
||||
"indigo": PALETTE["accent"],
|
||||
"indigo_hover": PALETTE["accent_hover"],
|
||||
"indigo_pressed": PALETTE["accent_pressed"],
|
||||
"indigo_pale": PALETTE["accent_soft"],
|
||||
"focus": PALETTE["focus"],
|
||||
"selection": PALETTE["accent_soft"],
|
||||
"success": PALETTE["success"],
|
||||
"success_pale": PALETTE["success_soft"],
|
||||
"warning": PALETTE["warning"],
|
||||
"warning_pale": PALETTE["warning_soft"],
|
||||
"danger": PALETTE["danger"],
|
||||
"danger_pale": PALETTE["danger_soft"],
|
||||
"info": PALETTE["accent"],
|
||||
"info_pale": PALETTE["accent_soft"],
|
||||
"ink": PALETTE["heading"],
|
||||
"ink_soft": PALETTE["text"],
|
||||
"teal": PALETTE["accent"],
|
||||
"teal_dark": PALETTE["accent_pressed"],
|
||||
"teal_pale": PALETTE["accent_soft"],
|
||||
}
|
||||
|
||||
|
||||
@@ -122,11 +122,11 @@ METRICS = {
|
||||
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
|
||||
# element is far enough from the corner that its own radius is free.
|
||||
"r_xs": "4px", # chips, badges, tiny status pills
|
||||
"r_sm": "6px", # inline row actions, tags
|
||||
"r_md": "8px", # buttons, inputs, combo boxes
|
||||
"r_lg": "12px", # cards, filter bars, panels
|
||||
"r_xl": "16px", # the shell surfaces the cards sit on
|
||||
"pad_control": "12px", # horizontal padding inside default controls
|
||||
"r_sm": f"{RADII['small']}px", # inline row actions, tags
|
||||
"r_md": f"{RADII['control']}px", # buttons, inputs, combo boxes
|
||||
"r_lg": f"{RADII['panel']}px", # cards, filter bars, panels
|
||||
"r_xl": f"{RADII['window']}px", # the shell surfaces the cards sit on
|
||||
"pad_control": f"{SPACING['control']}px", # horizontal padding inside default controls
|
||||
"pad_compact": "9px",
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ QDialog[businessDialog="true"] QFrame#DialogSurface,
|
||||
QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
border-radius: 14px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QDialog[businessDialog="true"] QFrame#DialogHeader,
|
||||
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
|
||||
@@ -275,18 +275,18 @@ QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
|
||||
QFrame[glass="true"] {
|
||||
background-color: $glass;
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QFrame#FilterBar { background-color: $surface; }
|
||||
QFrame#SubtleCard {
|
||||
background-color: $glass_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QFrame#MetricCard {
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
border-radius: 11px;
|
||||
border-radius: $r_md;
|
||||
}
|
||||
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
||||
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
|
||||
@@ -297,7 +297,7 @@ QFrame#ReceptionAiCard {
|
||||
min-height: 132px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 13px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QLabel#ReceptionAiTitle {
|
||||
color: $indigo_pressed;
|
||||
@@ -313,7 +313,7 @@ QLabel#MetricGlyph {
|
||||
color: $indigo;
|
||||
background-color: $indigo_pale;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
border-radius: $r_md;
|
||||
font-size: $fs_section;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
@@ -329,7 +329,7 @@ QGroupBox {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
background-color: $glass;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
@@ -351,7 +351,7 @@ QPushButton {
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: $raised;
|
||||
border-color: $indigo_hover;
|
||||
border-color: $line_soft;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: $indigo_pale;
|
||||
@@ -401,7 +401,7 @@ QPushButton[variant="primary"]:disabled {
|
||||
QPushButton[variant="secondary"] {
|
||||
color: $indigo_hover;
|
||||
background-color: $indigo_pale;
|
||||
border-color: $line_soft;
|
||||
border-color: $indigo_pale;
|
||||
}
|
||||
QPushButton[variant="secondary"]:hover {
|
||||
color: $indigo_pressed;
|
||||
@@ -462,7 +462,7 @@ QToolButton#RowActionsMore::menu-indicator { width: 0; height: 0; image: none; }
|
||||
QFrame#NoteAttachmentTile {
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
border-radius: $r_md;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview {
|
||||
min-height: 132px;
|
||||
@@ -470,7 +470,7 @@ QPushButton#NoteAttachmentPreview {
|
||||
color: $muted;
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview:hover {
|
||||
@@ -558,7 +558,7 @@ QPushButton[variant="chip"] {
|
||||
color: $text_soft;
|
||||
background-color: $surface_alt;
|
||||
border-color: $line;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
}
|
||||
QPushButton[variant="chip"]:hover {
|
||||
color: $text;
|
||||
@@ -653,7 +653,7 @@ QComboBox QAbstractItemView {
|
||||
background-color: $raised;
|
||||
alternate-background-color: $surface_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
border-radius: $r_md;
|
||||
padding: 5px;
|
||||
outline: 0;
|
||||
selection-background-color: $indigo_pressed;
|
||||
@@ -692,7 +692,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
|
||||
background-color: $surface;
|
||||
alternate-background-color: $surface_alt;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
gridline-color: $line;
|
||||
selection-background-color: $selection;
|
||||
selection-color: $text;
|
||||
@@ -729,17 +729,17 @@ QListWidget::item:selected, QListView::item:selected,
|
||||
QTreeWidget::item:selected, QTreeView::item:selected {
|
||||
background-color: $indigo_pale;
|
||||
color: $indigo_pressed;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
}
|
||||
QListWidget::item:hover, QListView::item:hover,
|
||||
QTreeWidget::item:hover, QTreeView::item:hover {
|
||||
background-color: $surface_alt;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
}
|
||||
|
||||
QTabWidget::pane {
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
background-color: $glass;
|
||||
}
|
||||
QTabBar::tab {
|
||||
@@ -749,7 +749,7 @@ QTabBar::tab {
|
||||
color: $muted;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
border-radius: $r_md;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
|
||||
@@ -792,7 +792,7 @@ QMenu {
|
||||
color: $text;
|
||||
background-color: $raised;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 10px;
|
||||
border-radius: $r_md;
|
||||
padding: 6px;
|
||||
}
|
||||
QMenu::item {
|
||||
@@ -815,7 +815,7 @@ QCalendarWidget {
|
||||
color: $text;
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QCalendarWidget QWidget#qt_calendar_navigationbar {
|
||||
background-color: $raised;
|
||||
@@ -914,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
|
||||
color: #FFFFFF;
|
||||
background-color: $indigo;
|
||||
border: 1px solid $indigo;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
font-size: $fs_caption;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
@@ -937,7 +937,7 @@ QLabel#EmptyStateGlyph {
|
||||
|
||||
QFrame#MessageBanner {
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
border-radius: $r_md;
|
||||
color: $text_soft;
|
||||
}
|
||||
QFrame#MessageBanner QLabel { background-color: transparent; }
|
||||
@@ -955,7 +955,7 @@ QLabel#Toast {
|
||||
color: $text;
|
||||
background-color: $raised;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
border-radius: $r_md;
|
||||
padding: 11px 16px;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
@@ -995,13 +995,9 @@ QLabel#UserAvatar {
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
background-color: qlineargradient(
|
||||
x1: 0, y1: 0, x2: 1, y2: 1,
|
||||
stop: 0 $surface,
|
||||
stop: 1 $canvas_glow
|
||||
);
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 22px;
|
||||
border-radius: $r_lg;
|
||||
}
|
||||
QFrame#LoginCard {
|
||||
background-color: $glass;
|
||||
@@ -1043,6 +1039,7 @@ QToolTip {
|
||||
}
|
||||
"""
|
||||
).substitute(_QSS_TOKENS)
|
||||
GLOBAL_QSS = clinical_stylesheet(GLOBAL_QSS)
|
||||
|
||||
|
||||
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
|
||||
|
||||
@@ -44,6 +44,7 @@ from doctor_workstation.core.errors import (
|
||||
)
|
||||
|
||||
from . import icons, motion
|
||||
from .design_system import PALETTE
|
||||
|
||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
||||
@@ -798,7 +799,7 @@ class _BusyTrack(QWidget):
|
||||
chunk_width = max(36, int(rect.width() * 0.32))
|
||||
span = rect.width() + chunk_width
|
||||
x = int(self._phase * span) - chunk_width
|
||||
painter.setBrush(QColor("#4F63D9"))
|
||||
painter.setBrush(QColor(PALETTE["accent"]))
|
||||
painter.drawRoundedRect(x, 0, chunk_width, rect.height(), 3, 3)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Bounded, Qt-independent AI requests for one authenticated IM window.
|
||||
|
||||
Only server-archived clinical context is submitted. This module never sends IM
|
||||
messages and never shares the ordered call/recording lifecycle worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from threading import BoundedSemaphore, Event, RLock, Thread
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
MAX_RESULT_CHARS = 65_536
|
||||
MAX_SYNC_STEPS = 24
|
||||
SYNC_TIMEOUT_SECONDS = 45.0
|
||||
_WORKER_SLOTS = BoundedSemaphore(4)
|
||||
_CANCEL_SLOTS = BoundedSemaphore(4)
|
||||
_ACTIONS = frozenset({"summary", "followup", "reply"})
|
||||
|
||||
|
||||
def _integer(value: Any, *, minimum: int) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||
return None
|
||||
if isinstance(value, str) and re.fullmatch(r"0|[1-9][0-9]{0,18}", value) is None:
|
||||
return None
|
||||
number = int(value)
|
||||
return number if minimum <= number < 2**63 else None
|
||||
|
||||
|
||||
def _request_uuid(value: Any) -> bool:
|
||||
if not isinstance(value, str) or len(value) != 36:
|
||||
return False
|
||||
try:
|
||||
return str(UUID(value)) == value.lower()
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def task_prompt(action: str, doctor_account: str) -> tuple[str, str]:
|
||||
"""Keep instructions separate from clinical source text and under API limits."""
|
||||
|
||||
if action not in _ACTIONS or not re.fullmatch(r"doctor_[1-9][0-9]{0,18}", doctor_account):
|
||||
raise ValueError("AI 操作或当前医生账号无效")
|
||||
shared = (
|
||||
f"这是 IM 问诊辅助,本次医生 IM 账号为 {doctor_account}。"
|
||||
"优先依据该医生与本患者最近已归档对话,结合服务端授权病历;"
|
||||
"其他医生及历史记录仅作背景,不能当成本次已确认事实。"
|
||||
"若未找到本次对话请明确说明,不猜测最新诉求。"
|
||||
"区分已知、明确否认与未知,保留日期及不确定性;"
|
||||
"不补问脉象,不编造事实、不自动确诊、不新增或更改药物剂量。"
|
||||
)
|
||||
instructions = {
|
||||
"summary": "请用简短条目总结本次病情、病程、现用治疗与待核实事项,供医生交接复核。",
|
||||
"followup": (
|
||||
"请建议最多5条优先追问,直接给出可向患者询问的自然短句,"
|
||||
"每条可附简短目的;避免重复已回答的问题,不做通用问卷。"
|
||||
),
|
||||
"reply": (
|
||||
"请起草一段医生可审阅后发给患者的简短自然回复,围绕该对话最近的患者问题。"
|
||||
"不冒称已检查、已预约或已开处方,不承诺疗效,不让患者自行停换药。"
|
||||
"只给回复草稿,不写分析过程;任何缺失资料通过必要的问题核实。"
|
||||
),
|
||||
}
|
||||
prompt = shared + instructions[action] + "此内容须由医生复核,不能自动发送。"
|
||||
if len(prompt) > 500:
|
||||
raise ValueError("AI 提示词超过接口长度限制")
|
||||
return ("summary" if action == "summary" else "custom"), prompt
|
||||
|
||||
|
||||
def _warnings(value: Any) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
warnings = []
|
||||
for row in value[:20]:
|
||||
if isinstance(row, str):
|
||||
text = row
|
||||
elif isinstance(row, Mapping):
|
||||
text = row.get("message") or row.get("label")
|
||||
if not text and row.get("code"):
|
||||
text = (
|
||||
"请复核回答中的脉象判断;本流程不采集脉象。"
|
||||
if row["code"] == "PULSE_EXCLUDED_FROM_ASSESSMENT"
|
||||
else "请对照原始记录复核,回答中的事实、否认或未知状态可能不一致。"
|
||||
)
|
||||
else:
|
||||
continue
|
||||
if isinstance(text, str) and text.strip():
|
||||
rendered = text.strip()[:400]
|
||||
if rendered not in warnings:
|
||||
warnings.append(rendered)
|
||||
return warnings
|
||||
|
||||
|
||||
class _RequestError(Exception):
|
||||
"""A deliberately safe, user-facing failure message."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
identity: dict[str, Any]
|
||||
action: str
|
||||
cancelled: Event = field(default_factory=Event)
|
||||
completed: bool = False
|
||||
succeeded: bool = False
|
||||
stream_started: bool = False
|
||||
cancel_sent: bool = False
|
||||
|
||||
|
||||
class ImAiController:
|
||||
"""One live request, a coalesced mailbox, and bounded daemon workers.
|
||||
|
||||
Qt polls ``take_result`` every 60 ms. Worker threads only mutate plain data;
|
||||
they never touch a QObject. A cancelled worker must retire before a retry
|
||||
starts, so rapid clicks cannot create an unbounded collection of requests.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
appointment_id: Any,
|
||||
doctor_account: str,
|
||||
enabled: bool = False,
|
||||
clock: Callable[[], float] = monotonic,
|
||||
max_sync_steps: int = MAX_SYNC_STEPS,
|
||||
sync_timeout: float = SYNC_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.diagnosis_id = _integer(diagnosis_id, minimum=1)
|
||||
self.appointment_id = _integer(appointment_id, minimum=0)
|
||||
self.doctor_account = str(doctor_account)
|
||||
self.enabled = (
|
||||
enabled is True
|
||||
and repository is not None
|
||||
and self.diagnosis_id is not None
|
||||
and self.appointment_id is not None
|
||||
and re.fullmatch(r"doctor_[1-9][0-9]{0,18}", self.doctor_account) is not None
|
||||
)
|
||||
self.session_id = str(uuid4())
|
||||
self._clock = clock
|
||||
self._max_sync_steps = max(1, min(MAX_SYNC_STEPS, max_sync_steps))
|
||||
self._sync_timeout = max(0.01, min(SYNC_TIMEOUT_SECONDS, sync_timeout))
|
||||
self._lock = RLock()
|
||||
self._closed = False
|
||||
self._current: _Job | None = None
|
||||
self._running: _Job | None = None
|
||||
self._worker: Thread | None = None
|
||||
self._mailbox: dict[str, Any] | None = None
|
||||
self._seen: deque[str] = deque(maxlen=256)
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"enabled": self.enabled and not self._closed,
|
||||
"sessionId": self.session_id,
|
||||
**({} if self.enabled else {"reason": "当前账号或问诊暂不能使用 AI 辅助。"}),
|
||||
}
|
||||
|
||||
def _valid_identity(self, message: Mapping[str, Any]) -> bool:
|
||||
return (
|
||||
self.enabled
|
||||
and not self._closed
|
||||
and message.get("source") == "doctor-call"
|
||||
and message.get("sessionId") == self.session_id
|
||||
and _request_uuid(message.get("requestId"))
|
||||
and isinstance(message.get("contextKey"), str)
|
||||
and 0 < len(message["contextKey"]) <= 128
|
||||
and _integer(message.get("diagnosisId"), minimum=1) == self.diagnosis_id
|
||||
and _integer(message.get("appointmentId"), minimum=0) == self.appointment_id
|
||||
and isinstance(message.get("action"), str)
|
||||
and message["action"] in _ACTIONS
|
||||
)
|
||||
|
||||
def handle(self, message: Mapping[str, Any]) -> bool:
|
||||
with self._lock:
|
||||
if not self._valid_identity(message):
|
||||
return False
|
||||
event = message.get("event")
|
||||
identity = {key: message[key] for key in (
|
||||
"sessionId", "requestId", "contextKey", "diagnosisId", "appointmentId", "action"
|
||||
)}
|
||||
if event == "im-ai-cancel":
|
||||
if self._current is None or self._current.identity != identity:
|
||||
return False
|
||||
self._cancel(self._current)
|
||||
return True
|
||||
if event != "im-ai-request" or message["requestId"] in self._seen:
|
||||
return False
|
||||
self._seen.append(message["requestId"])
|
||||
job = _Job(identity, message["action"])
|
||||
if self._running is not None:
|
||||
self._cancel(self._running)
|
||||
self._current = job
|
||||
self._publish(job, "error", message="上次请求正在结束,请稍后重试。", terminal=True)
|
||||
return True
|
||||
self._current = job
|
||||
if not _WORKER_SLOTS.acquire(blocking=False):
|
||||
self._publish(job, "error", message="AI 正在处理其他请求,请稍后重试。", terminal=True)
|
||||
return True
|
||||
self._running = job
|
||||
self._publish(job, "loading", message="正在同步当前聊天记录…")
|
||||
self._worker = Thread(target=self._run, args=(job,), name="im-ai", daemon=True)
|
||||
try:
|
||||
self._worker.start()
|
||||
except RuntimeError:
|
||||
self._running = None
|
||||
_WORKER_SLOTS.release()
|
||||
self._publish(job, "error", message="AI 请求暂时无法启动,请稍后重试。", terminal=True)
|
||||
return True
|
||||
|
||||
def _publish(self, job: _Job, status: str, *, terminal: bool = False, **fields: Any) -> None:
|
||||
with self._lock:
|
||||
if self._closed or self._current is not job or job.cancelled.is_set() or job.completed:
|
||||
return
|
||||
if terminal:
|
||||
job.completed = True
|
||||
job.succeeded = status == "success"
|
||||
self._mailbox = {**job.identity, "status": status, **fields}
|
||||
|
||||
def take_result(self) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
result, self._mailbox = self._mailbox, None
|
||||
return None if self._closed else result
|
||||
|
||||
def _sync(self, job: _Job) -> bool:
|
||||
deadline = self._clock() + self._sync_timeout
|
||||
token = ""
|
||||
for _ in range(self._max_sync_steps):
|
||||
if job.cancelled.is_set():
|
||||
return False
|
||||
if self._clock() >= deadline:
|
||||
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
|
||||
result = self.repository.sync_im_chat_messages(
|
||||
self.diagnosis_id, scope="current", sync_token=token
|
||||
)
|
||||
if job.cancelled.is_set():
|
||||
return False
|
||||
if self._clock() >= deadline:
|
||||
raise _RequestError("聊天同步超时,尚未生成,请稍后重试。")
|
||||
if not isinstance(result, Mapping) or result.get("errors"):
|
||||
raise _RequestError("当前聊天记录未能完整同步,尚未生成,请稍后重试。")
|
||||
if result.get("completed") is True:
|
||||
return True
|
||||
token = result.get("sync_token", "")
|
||||
if not isinstance(token, str) or re.fullmatch(r"[a-f0-9]{48}", token) is None:
|
||||
raise _RequestError("聊天同步进度无效,请更新客户端或稍后重试。")
|
||||
raise _RequestError("聊天记录仍在同步,尚未生成,请稍后重试。")
|
||||
|
||||
def _run(self, job: _Job) -> None:
|
||||
try:
|
||||
if not self._sync(job):
|
||||
return
|
||||
task, prompt = task_prompt(job.action, self.doctor_account)
|
||||
with self._lock:
|
||||
if job.cancelled.is_set() or self._closed:
|
||||
return
|
||||
job.stream_started = True
|
||||
self._publish(job, "loading", message="聊天已同步,正在整理病历资料…")
|
||||
if job.cancelled.is_set():
|
||||
return
|
||||
text = ""
|
||||
events = self.repository.stream_diagnosis_ai(
|
||||
self.diagnosis_id, prompt, task=task,
|
||||
request_id=job.identity["requestId"], cancelled=job.cancelled.is_set,
|
||||
)
|
||||
try:
|
||||
for event in events:
|
||||
if job.cancelled.is_set():
|
||||
return
|
||||
if not isinstance(event, Mapping):
|
||||
continue
|
||||
kind = event.get("event")
|
||||
if kind == "delta":
|
||||
delta = event.get("text", "")
|
||||
if not isinstance(delta, str) or len(text) + len(delta) > MAX_RESULT_CHARS:
|
||||
raise _RequestError("AI 内容超过显示上限,已停止接收,请缩小问题后重试。")
|
||||
text += delta
|
||||
self._publish(job, "streaming", text=text, message="正在生成,内容尚未完成。")
|
||||
elif kind in {"start", "progress"}:
|
||||
message = str(event.get("message") or "正在整理资料并生成…")[:240]
|
||||
self._publish(job, "streaming" if text else "loading", text=text, message=message)
|
||||
elif kind == "done":
|
||||
result_id = event.get("diagnosis_id")
|
||||
if result_id is not None and _integer(result_id, minimum=1) != self.diagnosis_id:
|
||||
raise _RequestError("AI 返回的问诊上下文不匹配,结果未采用。")
|
||||
answer = event.get("answer", event.get("content", text))
|
||||
if not isinstance(answer, str) or not answer.strip():
|
||||
raise _RequestError("AI 未返回完整内容,请重试。")
|
||||
if len(answer) > MAX_RESULT_CHARS:
|
||||
raise _RequestError("AI 内容超过显示上限,结果未采用,请缩小问题后重试。")
|
||||
self._publish(
|
||||
job, "success", terminal=True, text=answer,
|
||||
message="已生成,请由医生复核后使用。",
|
||||
modelLabel=str(event.get("model_label") or "")[:100],
|
||||
warnings=_warnings(event.get("clinical_review_warnings")),
|
||||
)
|
||||
return
|
||||
elif kind == "error":
|
||||
raise _RequestError("AI 请求未完成,请检查权限或稍后重试。")
|
||||
if not job.cancelled.is_set():
|
||||
raise _RequestError("AI 连接提前结束,内容未完成,请重试。")
|
||||
finally:
|
||||
close = getattr(events, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
except Exception as error:
|
||||
message = str(error) if isinstance(error, _RequestError) else (
|
||||
"AI 请求失败,请检查权限或稍后重试。" if job.stream_started
|
||||
else "当前聊天同步失败,尚未生成,请检查权限或稍后重试。"
|
||||
)
|
||||
self._publish(job, "error", message=message, terminal=True)
|
||||
if job.stream_started and not job.cancelled.is_set() and not job.succeeded:
|
||||
self._request_cancel(job)
|
||||
finally:
|
||||
with self._lock:
|
||||
if self._running is job:
|
||||
self._running = None
|
||||
_WORKER_SLOTS.release()
|
||||
|
||||
def _cancel(self, job: _Job) -> None:
|
||||
if job.completed or job.cancelled.is_set():
|
||||
return
|
||||
job.cancelled.set()
|
||||
if self._current is job and not self._closed:
|
||||
self._mailbox = {
|
||||
**job.identity, "status": "cancelled",
|
||||
"message": ("已停止本次接收,上游是否结束尚未确认。" if job.stream_started
|
||||
else "已取消,尚未向 AI 提交请求。"),
|
||||
}
|
||||
if job.stream_started:
|
||||
self._request_cancel(job)
|
||||
|
||||
def _request_cancel(self, job: _Job) -> None:
|
||||
with self._lock:
|
||||
if job.cancel_sent:
|
||||
return
|
||||
job.cancel_sent = True
|
||||
if not _CANCEL_SLOTS.acquire(blocking=False):
|
||||
return
|
||||
|
||||
def send() -> None:
|
||||
try:
|
||||
result = self.repository.cancel_diagnosis_ai(self.diagnosis_id, job.identity["requestId"])
|
||||
if not isinstance(result, Mapping):
|
||||
return
|
||||
message = "已发送取消请求,上游是否结束尚未确认。"
|
||||
terminal = result.get("upstream_terminal_status")
|
||||
if result.get("upstream_terminal_confirmed") is True and terminal in {"stopped", "succeeded", "failed"}:
|
||||
message = "本次接收已停止,上游工作流已结束。"
|
||||
elif result.get("upstream_stop_confirmed") is True:
|
||||
message = "上游已接受停止请求,是否结束尚未确认。"
|
||||
with self._lock:
|
||||
if not self._closed and self._current is job and job.cancelled.is_set():
|
||||
self._mailbox = {**job.identity, "status": "cancelled", "message": message}
|
||||
except Exception:
|
||||
pass # Keep the visible unconfirmed cancellation; never expose transport details.
|
||||
finally:
|
||||
_CANCEL_SLOTS.release()
|
||||
|
||||
try:
|
||||
Thread(target=send, name="im-ai-cancel", daemon=True).start()
|
||||
except RuntimeError:
|
||||
_CANCEL_SLOTS.release()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._mailbox = None
|
||||
if self._running is not None:
|
||||
self._cancel(self._running)
|
||||
|
||||
def wait(self, timeout: float = 1.0) -> bool:
|
||||
"""Bounded diagnostic/test helper; never called by the Qt GUI."""
|
||||
|
||||
worker = self._worker
|
||||
if worker is not None:
|
||||
worker.join(max(0.0, min(timeout, 5.0)))
|
||||
return worker is None or not worker.is_alive()
|
||||
@@ -413,6 +413,7 @@ class VideoCallLauncher:
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
can_ai_assistant: bool = False,
|
||||
) -> Any:
|
||||
request = self.prepare(
|
||||
ticket,
|
||||
@@ -432,6 +433,7 @@ class VideoCallLauncher:
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=on_open_diagnosis,
|
||||
can_ai_assistant=can_ai_assistant is True,
|
||||
)
|
||||
|
||||
|
||||
@@ -450,6 +452,7 @@ def launch_video_call(
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
can_ai_assistant: bool = False,
|
||||
) -> Any:
|
||||
"""Normalize a ticket and open a call with the requested backend."""
|
||||
|
||||
@@ -468,4 +471,5 @@ def launch_video_call(
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=on_open_diagnosis,
|
||||
can_ai_assistant=can_ai_assistant is True,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||||
AiConsultDialog,
|
||||
@@ -2306,14 +2307,14 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
|
||||
assert "#FEF3F2" in risk_card
|
||||
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||||
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||||
assert "#BE4B58" in marker
|
||||
assert PALETTE["danger"] in marker
|
||||
|
||||
|
||||
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||||
qss = ai_consult_module.AI_CONSULT_QSS
|
||||
|
||||
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
|
||||
assert "color: #1a1c1f" in body.lower()
|
||||
assert f'color: {PALETTE["text"]}' in body
|
||||
assert "font-size: 14px" in body
|
||||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: 14px" in block
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtCore import QDate, QSize, Qt
|
||||
from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -565,8 +566,39 @@ def test_keyboard_focus_has_a_visible_state(
|
||||
assert focus_target.hasFocus()
|
||||
assert application.focusWidget() is focus_target
|
||||
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
|
||||
assert f"border-color: {PALETTE['focus']};" in APPOINTMENT_DRAWER_QSS
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("button_kind", ["date", "slot"])
|
||||
def test_appointment_hover_keeps_hit_target_stable_after_relayout(
|
||||
application: QApplication, button_kind: str,
|
||||
) -> None:
|
||||
"""Hovering must not move a time choice or restore a stale layout position."""
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF
|
||||
from PySide6.QtGui import QEnterEvent
|
||||
|
||||
host, drawer = _show_drawer(
|
||||
application, (1440, 900), repository=_VisualRepository(), autoload=True,
|
||||
)
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
button = drawer.date_buttons[tomorrow] if button_kind == "date" else drawer.slot_buttons["09:30-10:00"]
|
||||
original = button.geometry()
|
||||
try:
|
||||
for _ in range(3):
|
||||
application.sendEvent(button, QEnterEvent(QPointF(10, 10), QPointF(30, 34), QPointF(30, 34)))
|
||||
assert button.geometry() == original
|
||||
assert button.graphicsEffect() is None
|
||||
relocated = original.topLeft() + QPoint(12, 16)
|
||||
button.move(relocated) # A resize/reflow while the pointer stays inside.
|
||||
application.sendEvent(button, QEvent(QEvent.Type.Leave))
|
||||
assert button.pos() == relocated
|
||||
button.setGeometry(original)
|
||||
button.click()
|
||||
assert button.isChecked()
|
||||
finally:
|
||||
drawer.close()
|
||||
host.close()
|
||||
|
||||
@@ -22,6 +22,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.diagnosis_drawer import (
|
||||
DiagnosisChoiceButtons,
|
||||
DiagnosisComboBox,
|
||||
@@ -1012,14 +1013,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
|
||||
application.processEvents()
|
||||
# Sample the pad (not glyph center) so white text does not hide the fill.
|
||||
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert enabled_color.name().lower() == "#eef1fa"
|
||||
assert enabled_color.name().lower() == PALETTE["accent_soft"].lower()
|
||||
diet.setReadOnly(True)
|
||||
application.processEvents()
|
||||
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert selected.isChecked()
|
||||
assert selected.isEnabled()
|
||||
assert diet.isReadOnly()
|
||||
assert disabled_color.name().lower() == "#eef1fa"
|
||||
assert disabled_color.name().lower() == PALETTE["accent_soft"].lower()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -1039,7 +1040,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
|
||||
selected = next(button for button in diet._buttons if button.isChecked())
|
||||
application.processEvents()
|
||||
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert color.name().lower() == "#eef1fa"
|
||||
assert color.name().lower() == PALETTE["accent_soft"].lower()
|
||||
assert diet.isReadOnly()
|
||||
assert not dialog.save_button.isVisibleTo(dialog)
|
||||
dialog.close()
|
||||
|
||||
@@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DIAGNOSIS_INDEX_QSS,
|
||||
DiagnosisItemDelegate,
|
||||
@@ -424,8 +425,8 @@ def test_empty_loading_and_compact_footer_keep_the_table_shell(
|
||||
@pytest.mark.parametrize(
|
||||
("record", "stripe", "channel"),
|
||||
[
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), PALETTE["warning"].lower(), "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), PALETTE["accent"].lower(), "info"),
|
||||
],
|
||||
)
|
||||
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
|
||||
@@ -452,9 +453,9 @@ def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
|
||||
assert image.pixelColor(0, 20).name() == stripe
|
||||
assert image.pixelColor(1, 20).name() == stripe
|
||||
assert image.pixelColor(2, 20).name() == stripe
|
||||
assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
|
||||
assert image.pixelColor(6, 20).name() == "#f7f7f7"
|
||||
assert image.pixelColor(40, 20).name() == "#f7f7f7"
|
||||
assert image.pixelColor(3, 20).name() == PALETTE["surface_alt"].lower(), f"{channel} stripe widened"
|
||||
assert image.pixelColor(6, 20).name() == PALETTE["surface_alt"].lower()
|
||||
assert image.pixelColor(40, 20).name() == PALETTE["surface_alt"].lower()
|
||||
|
||||
|
||||
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
|
||||
@@ -9,10 +9,17 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtCore import QPoint, QRect, Qt
|
||||
from PySide6.QtGui import QFont, QImage, QPainter
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QStyleOptionViewItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DiagnosisTableHost,
|
||||
@@ -115,6 +122,79 @@ def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QA
|
||||
legacy.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"size,column_width,row_height",
|
||||
[((1024, 640), 84, 75), ((1440, 900), 126, 72)],
|
||||
)
|
||||
def test_followup_paint_keeps_time_whole_and_preserves_density(
|
||||
application: QApplication, size: tuple[int, int], column_width: int, row_height: int,
|
||||
) -> None:
|
||||
class RecordingPainter(QPainter):
|
||||
def __init__(self, image: QImage) -> None:
|
||||
super().__init__(image)
|
||||
self.lines: list[tuple[QRect, int, str]] = []
|
||||
|
||||
def drawText(self, rect: QRect, flags: int, text: str) -> None: # noqa: N802
|
||||
self.lines.append((QRect(rect), flags, text))
|
||||
super().drawText(rect, flags, text)
|
||||
|
||||
row = {
|
||||
"id": 501, "has_prescription": 1,
|
||||
"followup_time_text": "2026-08-17 09:00", "followup_doctor_name": "陈医生",
|
||||
}
|
||||
original = copy.deepcopy(row)
|
||||
host = DiagnosisTableHost(tech_blue=True)
|
||||
host.resize(*size)
|
||||
host.set_rows([row])
|
||||
host.show()
|
||||
_settle(application)
|
||||
try:
|
||||
assert host.main.columnWidth(6) == column_width
|
||||
assert host.main.rowHeight(0) == row_height
|
||||
assert host.fixed.rowHeight(0) == row_height
|
||||
index = host.model.index(0, 6)
|
||||
full_text = "2026-08-17 09:00\n陈医生"
|
||||
assert index.data() == full_text
|
||||
assert index.data(Qt.ItemDataRole.ToolTipRole) == full_text
|
||||
option = QStyleOptionViewItem()
|
||||
option.rect = QRect(0, 0, column_width, row_height)
|
||||
image = QImage(column_width, row_height, QImage.Format.Format_ARGB32)
|
||||
painter = RecordingPainter(image)
|
||||
host.main.itemDelegate().paint(painter, option, index)
|
||||
painter.end()
|
||||
expected = ["2026-08-17", "09:00", "陈医生"] if size[0] == 1024 else [
|
||||
"2026-08-17 09:00", "陈医生",
|
||||
]
|
||||
assert [line[2] for line in painter.lines] == expected
|
||||
content = option.rect.adjusted(8, 8, -8, -8)
|
||||
for rect, flags, _text in painter.lines:
|
||||
assert content.contains(rect)
|
||||
assert flags & Qt.TextFlag.TextSingleLine
|
||||
assert not flags & Qt.TextFlag.TextWrapAnywhere
|
||||
assert row == original
|
||||
finally:
|
||||
host.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_followup_long_metadata_has_complete_tooltip(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True)
|
||||
row = {
|
||||
"id": 502, "has_prescription": 1,
|
||||
"followup_time_text": "2026-08-17 09:00-09:30",
|
||||
"followup_doctor_name": "陈医生(内分泌科专家门诊)", "followup_rx_voided": True,
|
||||
}
|
||||
original = copy.deepcopy(row)
|
||||
host.set_rows([row])
|
||||
index = host.model.index(0, 6)
|
||||
assert index.data(Qt.ItemDataRole.ToolTipRole) == (
|
||||
"2026-08-17 09:00-09:30\n陈医生(内分泌科专家门诊)\n处方已作废"
|
||||
)
|
||||
assert index.data() == index.data(Qt.ItemDataRole.ToolTipRole)
|
||||
assert row == original
|
||||
host.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||
application: QApplication, tech_blue: bool,
|
||||
|
||||
@@ -8,6 +8,7 @@ from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
@@ -74,7 +75,7 @@ def test_checked_row_blue_and_unchecked_white(dialog, application):
|
||||
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
|
||||
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
|
||||
int(rect.center().y() * scale)).name())
|
||||
assert colors == ["#eaf2ff", "#ffffff"]
|
||||
assert colors == [PALETTE["accent_soft"].lower(), PALETTE["surface"].lower()]
|
||||
|
||||
|
||||
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Current-peer archive paging preserves old callers and request identities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def post(self, endpoint, payload, *, timeout=None):
|
||||
self.calls.append((endpoint, payload, timeout))
|
||||
return {"completed": False, "sync_token": "a" * 48, "errors": []}
|
||||
|
||||
|
||||
def test_sync_optional_parameters_and_timeout_preserve_exact_legacy_payload():
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
repository.sync_im_chat_messages(501)
|
||||
result = repository.sync_im_chat_messages(501, scope="current", sync_token="a" * 48)
|
||||
assert client.calls == [
|
||||
("tcm.diagnosis/triggerImChatSync", {"diagnosis_id": 501}, 15.0),
|
||||
("tcm.diagnosis/triggerImChatSync",
|
||||
{"diagnosis_id": 501, "scope": "current", "sync_token": "a" * 48}, 15.0),
|
||||
]
|
||||
assert result["completed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [{"scope": "other"}, {"sync_token": "bad"}, {"sync_token": None}])
|
||||
def test_sync_rejects_invalid_options_without_network(kwargs):
|
||||
client = Client()
|
||||
with pytest.raises(ValueError):
|
||||
RemoteDoctorRepository(client).sync_im_chat_messages(501, **kwargs)
|
||||
assert not client.calls
|
||||
|
||||
|
||||
def test_mock_sync_supports_paging_contract_and_legacy_receipt():
|
||||
repository = DemoDoctorRepository()
|
||||
assert repository.sync_im_chat_messages(501)["queued"] is True
|
||||
assert repository.sync_im_chat_messages(501, scope="current")["completed"] is True
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Offline tests for native IM AI authorization, synchronization and races."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from threading import BoundedSemaphore, Event
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.video import im_ai
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, pages=None, events=None):
|
||||
self.pages = deque(pages or [{"completed": True, "errors": []}])
|
||||
self.events = events or [{"event": "done", "diagnosis_id": 501, "answer": "合成回复"}]
|
||||
self.sync_calls = []
|
||||
self.stream_calls = []
|
||||
self.cancel_calls = []
|
||||
self.cancel_called = Event()
|
||||
|
||||
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
|
||||
self.sync_calls.append((diagnosis_id, kwargs))
|
||||
return self.pages.popleft()
|
||||
|
||||
def stream_diagnosis_ai(self, diagnosis_id, prompt, **kwargs):
|
||||
self.stream_calls.append((diagnosis_id, prompt, kwargs))
|
||||
yield from self.events
|
||||
|
||||
def cancel_diagnosis_ai(self, diagnosis_id, request_id):
|
||||
self.cancel_calls.append((diagnosis_id, request_id))
|
||||
self.cancel_called.set()
|
||||
return {"cancel_requested": True}
|
||||
|
||||
|
||||
def controller(repository=None, **kwargs):
|
||||
return im_ai.ImAiController(
|
||||
repository or Repository(), diagnosis_id=501, appointment_id=101,
|
||||
doctor_account="doctor_7", enabled=True, **kwargs,
|
||||
)
|
||||
|
||||
|
||||
def request(service, **changes):
|
||||
return {
|
||||
"source": "doctor-call", "event": "im-ai-request", "sessionId": service.session_id,
|
||||
"requestId": str(uuid4()), "contextKey": "view-1", "diagnosisId": "501",
|
||||
"appointmentId": 101, "action": "summary", **changes,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action,task", [("summary", "summary"), ("followup", "custom"), ("reply", "custom")])
|
||||
def test_current_peer_sync_completes_before_ai_and_keeps_identity(action, task):
|
||||
token = "a" * 48
|
||||
repository = Repository(pages=[
|
||||
{"completed": False, "phase": "checking_accounts", "sync_token": token, "errors": []},
|
||||
{"completed": False, "phase": "syncing", "sync_token": token, "errors": []},
|
||||
{"completed": True, "errors": []},
|
||||
], events=[
|
||||
{"event": "start"}, {"event": "delta", "text": "合成"},
|
||||
{"event": "done", "diagnosis_id": 501, "answer": "合成完整回复", "model_label": "模型",
|
||||
"clinical_review_warnings": [{"code": "PULSE_EXCLUDED_FROM_ASSESSMENT", "source_id": "report"}]},
|
||||
])
|
||||
service = controller(repository)
|
||||
message = request(service, action=action)
|
||||
assert service.handle(message)
|
||||
assert service.wait()
|
||||
result = service.take_result()
|
||||
assert result["status"] == "success"
|
||||
assert result["text"] == "合成完整回复"
|
||||
assert result["requestId"] == message["requestId"]
|
||||
assert result["modelLabel"] == "模型"
|
||||
assert "不采集脉象" in result["warnings"][0]
|
||||
assert len(repository.sync_calls) == 3
|
||||
assert repository.sync_calls[0] == (501, {"scope": "current", "sync_token": ""})
|
||||
assert repository.sync_calls[1][1]["sync_token"] == token
|
||||
assert len(repository.stream_calls) == 1
|
||||
diagnosis_id, prompt, options = repository.stream_calls[0]
|
||||
assert diagnosis_id == 501 and options["task"] == task
|
||||
assert options["request_id"] == message["requestId"]
|
||||
assert "doctor_7" in prompt and len(prompt) <= 500
|
||||
assert "不补问脉象" in prompt and "不自动确诊" in prompt
|
||||
assert not repository.cancel_calls
|
||||
assert service.take_result() is None
|
||||
assert not service.handle(message) # A replay cannot submit another billable request.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"sessionId": "other"}, {"diagnosisId": "502"}, {"diagnosisId": True},
|
||||
{"diagnosisId": "0501"}, {"appointmentId": 102}, {"requestId": "invalid"},
|
||||
{"contextKey": ""}, {"contextKey": "x" * 129}, {"action": "prescription"},
|
||||
{"action": {}}, {"source": "another-page"}, {"event": "send-message"},
|
||||
])
|
||||
def test_invalid_identity_or_action_cannot_read_patient_context(changes):
|
||||
repository = Repository()
|
||||
service = controller(repository)
|
||||
assert not service.handle(request(service, **changes))
|
||||
assert not repository.sync_calls and not repository.stream_calls
|
||||
|
||||
|
||||
def test_permission_default_denied_and_each_host_has_unique_session():
|
||||
repository = Repository()
|
||||
disabled = im_ai.ImAiController(
|
||||
repository, diagnosis_id=501, appointment_id=101, doctor_account="doctor_7"
|
||||
)
|
||||
assert disabled.config["enabled"] is False
|
||||
assert not disabled.handle(request(disabled))
|
||||
assert controller().session_id != controller().session_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", [
|
||||
{"queued": True}, {"completed": "true"}, {"completed": True, "errors": ["private detail"]},
|
||||
{"completed": False, "sync_token": "bad"}, None,
|
||||
])
|
||||
def test_incomplete_or_failed_sync_never_generates(page):
|
||||
repository = Repository(pages=[page])
|
||||
service = controller(repository)
|
||||
assert service.handle(request(service))
|
||||
assert service.wait()
|
||||
result = service.take_result()
|
||||
assert result["status"] == "error"
|
||||
assert "private detail" not in str(result)
|
||||
assert not repository.stream_calls and not repository.cancel_calls
|
||||
|
||||
|
||||
def test_sync_step_and_time_budget_are_bounded():
|
||||
page = {"completed": False, "sync_token": "b" * 48, "errors": []}
|
||||
repository = Repository(pages=[page] * 10)
|
||||
service = controller(repository, max_sync_steps=2)
|
||||
service.handle(request(service))
|
||||
assert service.wait()
|
||||
assert len(repository.sync_calls) == 2
|
||||
assert service.take_result()["status"] == "error"
|
||||
assert not repository.stream_calls
|
||||
|
||||
times = iter([0, 0, 46])
|
||||
repository = Repository()
|
||||
service = controller(repository, clock=lambda: next(times))
|
||||
service.handle(request(service))
|
||||
assert service.wait()
|
||||
assert "超时" in service.take_result()["message"]
|
||||
assert not repository.stream_calls
|
||||
|
||||
|
||||
def test_cancel_during_sync_stops_before_model_submission():
|
||||
entered, release = Event(), Event()
|
||||
|
||||
class SlowSync(Repository):
|
||||
def sync_im_chat_messages(self, diagnosis_id, **kwargs):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
return super().sync_im_chat_messages(diagnosis_id, **kwargs)
|
||||
|
||||
repository = SlowSync()
|
||||
service = controller(repository)
|
||||
message = request(service)
|
||||
service.handle(message)
|
||||
try:
|
||||
assert entered.wait(1)
|
||||
assert service.handle({**message, "event": "im-ai-cancel"})
|
||||
assert "尚未向 AI" in service.take_result()["message"]
|
||||
finally:
|
||||
release.set()
|
||||
assert service.wait()
|
||||
assert not repository.stream_calls and not repository.cancel_calls
|
||||
assert service.take_result() is None
|
||||
|
||||
|
||||
def test_cancel_is_independent_and_late_response_cannot_overwrite_next_request():
|
||||
entered, release, cancel_release = Event(), Event(), Event()
|
||||
|
||||
class SlowStream(Repository):
|
||||
def stream_diagnosis_ai(self, *args, **kwargs):
|
||||
self.stream_calls.append((args, kwargs))
|
||||
yield {"event": "delta", "text": "尚未完成"}
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
yield {"event": "done", "answer": "迟到的旧结果"}
|
||||
|
||||
def cancel_diagnosis_ai(self, *args):
|
||||
result = super().cancel_diagnosis_ai(*args)
|
||||
assert cancel_release.wait(2)
|
||||
return result
|
||||
|
||||
repository = SlowStream()
|
||||
service = controller(repository)
|
||||
first = request(service)
|
||||
service.handle(first)
|
||||
try:
|
||||
assert entered.wait(1)
|
||||
assert service.take_result()["text"] == "尚未完成"
|
||||
service.handle({**first, "event": "im-ai-cancel"})
|
||||
assert repository.cancel_called.wait(1) # Independent HTTP starts while stream is blocked.
|
||||
assert service.take_result()["status"] == "cancelled"
|
||||
second = request(service, contextKey="view-2")
|
||||
assert service.handle(second)
|
||||
result = service.take_result()
|
||||
assert result["requestId"] == second["requestId"] and result["status"] == "error"
|
||||
assert len(repository.stream_calls) == 1
|
||||
finally:
|
||||
release.set()
|
||||
cancel_release.set()
|
||||
assert service.wait()
|
||||
assert service.take_result() is None
|
||||
|
||||
|
||||
def test_close_invalidates_mailbox_and_cancels_without_waiting():
|
||||
entered, release = Event(), Event()
|
||||
|
||||
class SlowStream(Repository):
|
||||
def stream_diagnosis_ai(self, *args, **kwargs):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
yield {"event": "done", "answer": "旧患者回复"}
|
||||
|
||||
repository = SlowStream()
|
||||
service = controller(repository)
|
||||
message = request(service)
|
||||
service.handle(message)
|
||||
try:
|
||||
assert entered.wait(1)
|
||||
service.close()
|
||||
assert repository.cancel_called.wait(1)
|
||||
assert service.take_result() is None
|
||||
assert not service.handle(request(service))
|
||||
finally:
|
||||
release.set()
|
||||
assert service.wait()
|
||||
assert service.take_result() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("events", [
|
||||
[{"event": "delta", "text": "不完整"}],
|
||||
[{"event": "done", "answer": ""}],
|
||||
[{"event": "done", "diagnosis_id": 502, "answer": "其他患者"}],
|
||||
[{"event": "delta", "text": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
|
||||
[{"event": "done", "answer": "x" * (im_ai.MAX_RESULT_CHARS + 1)}],
|
||||
])
|
||||
def test_incomplete_mismatched_and_oversized_results_are_not_usable(events):
|
||||
repository = Repository(events=events)
|
||||
service = controller(repository)
|
||||
service.handle(request(service))
|
||||
assert service.wait()
|
||||
assert service.take_result()["status"] == "error"
|
||||
assert repository.cancel_called.wait(1)
|
||||
assert len(repository.stream_calls) == 1 # No transport/validation retry.
|
||||
|
||||
|
||||
def test_thousands_of_deltas_are_coalesced_into_one_mailbox_result():
|
||||
events = [{"event": "delta", "text": "字"}] * 4000 + [{"event": "done"}]
|
||||
service = controller(Repository(events=events))
|
||||
service.handle(request(service))
|
||||
assert service.wait()
|
||||
result = service.take_result()
|
||||
assert result["status"] == "success" and result["text"] == "字" * 4000
|
||||
assert service.take_result() is None
|
||||
|
||||
|
||||
def test_capacity_rejection_does_not_spawn_or_sync(monkeypatch):
|
||||
slots = BoundedSemaphore(1)
|
||||
slots.acquire()
|
||||
monkeypatch.setattr(im_ai, "_WORKER_SLOTS", slots)
|
||||
repository = Repository()
|
||||
service = controller(repository)
|
||||
try:
|
||||
service.handle(request(service))
|
||||
assert service.take_result()["status"] == "error"
|
||||
assert not repository.sync_calls
|
||||
finally:
|
||||
slots.release()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Exercise bridge trust and JSON delivery without starting WebEngine or HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.video import window
|
||||
from doctor_workstation.video.security import TrustedDocumentPolicy
|
||||
|
||||
pytestmark = pytest.mark.skipif(not window.WEBENGINE_AVAILABLE, reason="optional QtWebEngine")
|
||||
TRUSTED = "https://video.example.test/im/index.html"
|
||||
|
||||
|
||||
def host(**changes):
|
||||
item = SimpleNamespace(
|
||||
_closing=False, _released=False, _shutdown_requested=False, _injected=True,
|
||||
_policy=TrustedDocumentPolicy.from_url(TRUSTED, is_local=False),
|
||||
_page=Mock(), _im_ai=Mock(), _im_ai_timer=Mock(),
|
||||
)
|
||||
item._page.url.return_value.toString.return_value = TRUSTED
|
||||
item._im_ai_context_is_trusted = lambda: window._EmbeddedVideoWindow._im_ai_context_is_trusted(item)
|
||||
for key, value in changes.items():
|
||||
setattr(item, key, value)
|
||||
return item
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True}, {"_injected": False},
|
||||
])
|
||||
def test_inactive_or_closing_page_cannot_submit_or_receive_ai(changes):
|
||||
item = host(**changes)
|
||||
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
|
||||
window._EmbeddedVideoWindow._flush_im_ai(item)
|
||||
item._im_ai.handle.assert_not_called()
|
||||
item._im_ai.take_result.assert_not_called()
|
||||
item._page.runJavaScript.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["about:blank", "https://evil.example.test/im/index.html", TRUSTED + "?other=1"])
|
||||
def test_document_navigation_cannot_reuse_authenticated_ai_bridge(url):
|
||||
item = host()
|
||||
item._page.url.return_value.toString.return_value = url
|
||||
window._EmbeddedVideoWindow._handle_im_ai_message(item, {"event": "im-ai-request"})
|
||||
window._EmbeddedVideoWindow._flush_im_ai(item)
|
||||
item._im_ai.handle.assert_not_called()
|
||||
item._page.runJavaScript.assert_not_called()
|
||||
|
||||
|
||||
def test_trusted_bridge_routes_only_to_identity_checked_controller():
|
||||
item = host()
|
||||
message = {"source": "doctor-call", "event": "im-ai-request", "diagnosisId": 501}
|
||||
window._EmbeddedVideoWindow._handle_im_ai_message(item, message)
|
||||
item._im_ai.handle.assert_called_once_with(message)
|
||||
|
||||
|
||||
def test_result_content_is_json_encoded_and_no_javascript_is_executed():
|
||||
item = host()
|
||||
payload = {"status": "success", "text": '\");window.bad=true;//\n</script>\u2028', "warnings": ["合成提示"]}
|
||||
item._im_ai.take_result.return_value = payload
|
||||
window._EmbeddedVideoWindow._flush_im_ai(item)
|
||||
script = item._page.runJavaScript.call_args.args[0]
|
||||
prefix, suffix = "window.doctorConsultation?.imAiResult?.(", ");"
|
||||
assert script.startswith(prefix) and script.endswith(suffix)
|
||||
assert json.loads(script[len(prefix):-len(suffix)]) == payload
|
||||
assert "\\u2028" in script
|
||||
|
||||
|
||||
def test_host_acknowledges_initial_summary_only_after_request_gate_opens():
|
||||
item = host(_injected=False)
|
||||
item._im_ai.session_id = 'synthetic-session-\\"\u2028'
|
||||
|
||||
def verify_gate(script):
|
||||
assert item._im_ai_context_is_trusted() is True
|
||||
prefix, suffix = "window.doctorConsultation?.imAiReady?.(", ");"
|
||||
assert script.startswith(prefix) and script.endswith(suffix)
|
||||
assert json.loads(script[len(prefix):-len(suffix)]) == item._im_ai.session_id
|
||||
|
||||
item._page.runJavaScript.side_effect = verify_gate
|
||||
window._EmbeddedVideoWindow._after_injection(item, True)
|
||||
item._page.runJavaScript.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"_closing": True}, {"_released": True}, {"_shutdown_requested": True},
|
||||
])
|
||||
def test_late_injection_cannot_acknowledge_a_closed_ai_context(changes):
|
||||
item = host(_injected=False, **changes)
|
||||
window._EmbeddedVideoWindow._after_injection(item, True)
|
||||
item._page.runJavaScript.assert_not_called()
|
||||
|
||||
|
||||
def test_navigation_cannot_receive_ai_readiness_acknowledgement():
|
||||
item = host(_injected=False)
|
||||
item._page.url.return_value.toString.return_value = "https://evil.example.test/im/index.html"
|
||||
window._EmbeddedVideoWindow._after_injection(item, True)
|
||||
item._page.runJavaScript.assert_not_called()
|
||||
|
||||
|
||||
def test_failed_injection_closes_without_ai_readiness_acknowledgement():
|
||||
item = host(_injected=False, _request_companion_shutdown=Mock())
|
||||
window._EmbeddedVideoWindow._after_injection(item, False)
|
||||
item._request_companion_shutdown.assert_called_once_with("bridge-api-missing")
|
||||
item._page.runJavaScript.assert_not_called()
|
||||
|
||||
|
||||
def test_shutdown_cancels_ai_before_waiting_for_media_or_destroying_page():
|
||||
item = host(
|
||||
_start_requested=True, _call_cycle_closed=False, _companion_ended=False,
|
||||
_shutdown_timer=Mock(), close=Mock(),
|
||||
)
|
||||
window._EmbeddedVideoWindow._request_companion_shutdown(item, "window-closed")
|
||||
item._im_ai.close.assert_called_once()
|
||||
item._im_ai_timer.stop.assert_called_once()
|
||||
assert item._shutdown_requested is True
|
||||
item.close.assert_not_called() # Recording shutdown still gets its existing grace period.
|
||||
item._shutdown_timer.start.assert_called_once_with(190_000)
|
||||
|
||||
|
||||
def test_qt_notify_rejects_oversized_or_wrong_source_payloads():
|
||||
callback = Mock()
|
||||
bridge = window._QtVideoBridge(callback, Mock())
|
||||
bridge.notify("x" * 16_385)
|
||||
bridge.notify(json.dumps({"source": "evil", "event": "im-ai-request"}))
|
||||
bridge.notify("[]")
|
||||
callback.assert_not_called()
|
||||
message = {"source": "doctor-call", "event": "im-ai-cancel"}
|
||||
bridge.notify(json.dumps(message))
|
||||
callback.assert_called_once_with(message)
|
||||
|
||||
|
||||
def test_video_module_remains_importable_without_optional_qt():
|
||||
source = Path(__file__).resolve().parents[1] / "src"
|
||||
script = """
|
||||
import builtins
|
||||
original_import = builtins.__import__
|
||||
def without_qt(name, *args, **kwargs):
|
||||
if name == 'PySide6' or name.startswith('PySide6.'):
|
||||
raise ImportError('synthetic core-only installation')
|
||||
return original_import(name, *args, **kwargs)
|
||||
builtins.__import__ = without_qt
|
||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE, VideoCallWindow
|
||||
from doctor_workstation.video.im_ai import ImAiController
|
||||
assert WEBENGINE_AVAILABLE is False
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script], env={**os.environ, "PYTHONPATH": str(source)},
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -12,17 +12,16 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
# The module disables itself under the offscreen platform so widget grabs in the
|
||||
# other suites capture settled frames; these tests are about the animation, so
|
||||
# they opt back in.
|
||||
os.environ["DOCTOR_MOTION"] = "on"
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from PySide6.QtGui import QWheelEvent
|
||||
from PySide6.QtCore import QAbstractAnimation, QEvent, QPoint, QPointF, QPropertyAnimation, Qt
|
||||
from PySide6.QtGui import QKeyEvent, QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QGraphicsDropShadowEffect,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
@@ -32,6 +31,13 @@ from PySide6.QtWidgets import (
|
||||
from doctor_workstation.ui import motion
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_motion(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Opt in per test so collecting this module does not animate other suites'
|
||||
# screenshots or leak motion settings across the shared QApplication.
|
||||
monkeypatch.setenv("DOCTOR_MOTION", "on")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
@@ -122,15 +128,22 @@ def _scroll_area(application: QApplication) -> QScrollArea:
|
||||
return area
|
||||
|
||||
|
||||
def _wheel(delta: int) -> QWheelEvent:
|
||||
def _wheel(
|
||||
delta: int,
|
||||
*,
|
||||
horizontal: int = 0,
|
||||
pixels: int = 0,
|
||||
modifiers: Qt.KeyboardModifier = Qt.KeyboardModifier.NoModifier,
|
||||
phase: Qt.ScrollPhase = Qt.ScrollPhase.NoScrollPhase,
|
||||
) -> QWheelEvent:
|
||||
return QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, delta),
|
||||
QPoint(0, pixels),
|
||||
QPoint(horizontal, delta),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
modifiers,
|
||||
phase,
|
||||
False,
|
||||
)
|
||||
|
||||
@@ -226,3 +239,273 @@ def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||
|
||||
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||
area.close()
|
||||
|
||||
|
||||
def test_repeated_fade_continues_from_current_opacity(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
motion.fade_in(widget)
|
||||
first = widget._doctor_motion["fade"]
|
||||
first.setCurrentTime(motion.BASE // 2)
|
||||
opacity = widget.graphicsEffect().opacity()
|
||||
|
||||
motion.fade_in(widget)
|
||||
|
||||
assert widget.graphicsEffect().opacity() == pytest.approx(opacity)
|
||||
assert widget._doctor_motion["fade"].startValue() == pytest.approx(opacity)
|
||||
assert first.state() == QAbstractAnimation.State.Stopped
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_fade_out_starts_at_full_opacity(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
widget.show()
|
||||
|
||||
motion.fade_out(widget)
|
||||
|
||||
assert widget.graphicsEffect().opacity() == pytest.approx(1.0)
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_reversing_fade_does_not_run_cancelled_hide_callback(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
widget.show()
|
||||
completed = []
|
||||
motion.fade_out(widget, on_finished=lambda: completed.append(True))
|
||||
outgoing = widget._doctor_motion["fade"]
|
||||
outgoing.setCurrentTime(motion.FAST // 2)
|
||||
opacity = widget.graphicsEffect().opacity()
|
||||
|
||||
motion.fade_in(widget)
|
||||
incoming = widget._doctor_motion["fade"]
|
||||
|
||||
assert incoming.startValue() == pytest.approx(opacity)
|
||||
incoming.setCurrentTime(incoming.duration())
|
||||
application.processEvents()
|
||||
assert widget.isVisible()
|
||||
assert not completed
|
||||
assert widget.graphicsEffect() is None
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_previous_fade_cleanup_does_not_delete_reentrant_fade(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
widget.show()
|
||||
motion.fade_out(widget, on_finished=lambda: motion.fade_in(widget))
|
||||
outgoing = widget._doctor_motion["fade"]
|
||||
|
||||
outgoing.setCurrentTime(outgoing.duration())
|
||||
incoming = widget._doctor_motion["fade"]
|
||||
effect = widget.graphicsEffect()
|
||||
application.processEvents()
|
||||
|
||||
assert widget.graphicsEffect() is effect
|
||||
assert incoming.state() == QAbstractAnimation.State.Running
|
||||
incoming.setCurrentTime(incoming.duration())
|
||||
application.processEvents()
|
||||
assert widget.graphicsEffect() is None
|
||||
assert widget.isVisible()
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_replaced_and_completed_animations_are_released(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
destroyed = []
|
||||
for _ in range(10):
|
||||
motion.fade_in(widget)
|
||||
widget._doctor_motion["fade"].destroyed.connect(lambda: destroyed.append(True))
|
||||
fade = widget._doctor_motion["fade"]
|
||||
fade.setCurrentTime(fade.duration())
|
||||
application.processEvents()
|
||||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
assert not widget._doctor_motion
|
||||
assert not widget.findChildren(QPropertyAnimation)
|
||||
assert len(destroyed) == 10
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_fades_preserve_existing_graphics_effects(application: QApplication) -> None:
|
||||
widget = QWidget()
|
||||
shadow = QGraphicsDropShadowEffect(widget)
|
||||
widget.setGraphicsEffect(shadow)
|
||||
|
||||
motion.fade_in(widget)
|
||||
assert widget.graphicsEffect() is shadow
|
||||
motion.fade_out(widget)
|
||||
application.processEvents()
|
||||
|
||||
assert widget.graphicsEffect() is shadow
|
||||
assert widget.isHidden()
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_repeated_enter_does_not_accumulate_position_offset(application: QApplication) -> None:
|
||||
host = QWidget()
|
||||
widget = QWidget(host)
|
||||
origin = QPoint(10, 20)
|
||||
widget.move(origin)
|
||||
motion.enter(widget)
|
||||
widget._doctor_motion["enter"].setCurrentTime(motion.BASE // 3)
|
||||
position = widget.pos()
|
||||
|
||||
motion.enter(widget)
|
||||
|
||||
assert widget.pos() == position
|
||||
arrival = widget._doctor_motion["enter"]
|
||||
assert arrival.endValue() == origin
|
||||
arrival.setCurrentTime(arrival.duration())
|
||||
assert widget.pos() == origin
|
||||
host.close()
|
||||
|
||||
|
||||
def test_disabling_motion_settles_an_active_entry(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
widget = QWidget(host)
|
||||
origin = QPoint(10, 20)
|
||||
widget.move(origin)
|
||||
motion.enter(widget)
|
||||
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||
|
||||
motion.enter(widget)
|
||||
application.processEvents()
|
||||
|
||||
assert widget.pos() == origin
|
||||
assert widget.graphicsEffect() is None
|
||||
assert not widget._doctor_motion
|
||||
host.close()
|
||||
|
||||
|
||||
def test_wheel_reversal_discards_pending_travel(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
bar.setValue(500)
|
||||
for _ in range(4):
|
||||
scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
scroller._animation.setCurrentTime(30)
|
||||
position = bar.value()
|
||||
|
||||
scroller.eventFilter(area.viewport(), _wheel(120))
|
||||
|
||||
assert scroller._animation.startValue() == position
|
||||
assert scroller._animation.endValue() == position - motion.SCROLL_STEP
|
||||
scroller._animation.setCurrentTime(60)
|
||||
assert bar.value() < position
|
||||
area.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interaction", ["set_value", "keyboard", "slider_press", "range_change"])
|
||||
def test_direct_scroll_interaction_cancels_pending_wheel(
|
||||
application: QApplication, interaction: str
|
||||
) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
scroller._animation.setCurrentTime(30)
|
||||
|
||||
if interaction == "set_value":
|
||||
bar.setValue(500)
|
||||
elif interaction == "keyboard":
|
||||
application.sendEvent(
|
||||
area,
|
||||
QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Down, Qt.KeyboardModifier.NoModifier),
|
||||
)
|
||||
elif interaction == "slider_press":
|
||||
bar.setSliderDown(True)
|
||||
else:
|
||||
bar.setRange(0, 50)
|
||||
|
||||
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
|
||||
assert scroller._target == bar.value()
|
||||
if interaction == "set_value":
|
||||
scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
assert scroller._animation.endValue() == 500 + motion.SCROLL_STEP
|
||||
area.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wheel_options",
|
||||
[
|
||||
{"pixels": -8},
|
||||
{"phase": Qt.ScrollPhase.ScrollUpdate},
|
||||
{"phase": Qt.ScrollPhase.ScrollMomentum},
|
||||
{"modifiers": Qt.KeyboardModifier.ControlModifier},
|
||||
{"modifiers": Qt.KeyboardModifier.ShiftModifier},
|
||||
{"horizontal": -120},
|
||||
],
|
||||
)
|
||||
def test_native_gestures_cancel_pending_wheel(
|
||||
application: QApplication, wheel_options: dict
|
||||
) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
scroller._animation.setCurrentTime(30)
|
||||
delta = 0 if "horizontal" in wheel_options else -120
|
||||
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(delta, **wheel_options))
|
||||
assert scroller._animation.state() == QAbstractAnimation.State.Stopped
|
||||
assert scroller._target == area.verticalScrollBar().value()
|
||||
area.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["items", "text_blocks"])
|
||||
def test_non_pixel_scrollbars_keep_native_wheel_steps(application: QApplication, kind: str) -> None:
|
||||
if kind == "items":
|
||||
area = QListWidget()
|
||||
area.addItems([f"row {index}" for index in range(300)])
|
||||
area.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
|
||||
else:
|
||||
area = QPlainTextEdit("\n".join(f"line {index}" for index in range(300)))
|
||||
area.resize(300, 200)
|
||||
area.show()
|
||||
application.processEvents()
|
||||
motion.install_smooth_scroll(area)
|
||||
|
||||
assert not area._doctor_smooth_scroll.eventFilter(area.viewport(), _wheel(-120))
|
||||
application.sendEvent(area.viewport(), _wheel(-120))
|
||||
assert 0 < area.verticalScrollBar().value() < motion.SCROLL_STEP
|
||||
area.close()
|
||||
|
||||
|
||||
def test_pending_scroll_reaches_edge_before_handing_wheel_back(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
bar.setValue(bar.maximum() - 20)
|
||||
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
scroller._animation.setCurrentTime(scroller._animation.duration())
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
area.close()
|
||||
|
||||
|
||||
def test_horizontal_scroller_honors_horizontal_and_shift_wheel(application: QApplication) -> None:
|
||||
area = QScrollArea()
|
||||
content = QWidget()
|
||||
content.resize(2000, 1000)
|
||||
area.setWidget(content)
|
||||
area.resize(300, 200)
|
||||
motion.install_smooth_scroll(area, orientation=Qt.Orientation.Horizontal)
|
||||
area.show()
|
||||
application.processEvents()
|
||||
scroller = area._doctor_smooth_scroll
|
||||
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(0, horizontal=-120))
|
||||
scroller._animation.setCurrentTime(scroller._animation.duration())
|
||||
assert area.horizontalScrollBar().value() == motion.SCROLL_STEP
|
||||
assert area.verticalScrollBar().value() == 0
|
||||
assert scroller.eventFilter(
|
||||
area.viewport(), _wheel(-120, modifiers=Qt.KeyboardModifier.ShiftModifier)
|
||||
)
|
||||
assert scroller._animation.endValue() == motion.SCROLL_STEP * 2
|
||||
area.close()
|
||||
|
||||
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
@@ -222,7 +223,7 @@ def test_sorted_native_row_view_button_targets_visible_record(
|
||||
|
||||
|
||||
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
|
||||
kind, color, size = "pencil", "#1769E8", 19
|
||||
kind, color, size = "pencil", PALETTE["accent"], 19
|
||||
shared = icons.icon(kind, color, size)
|
||||
original_key = shared.cacheKey()
|
||||
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
|
||||
@@ -230,13 +231,19 @@ def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(applicatio
|
||||
|
||||
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
|
||||
|
||||
assert blue is not shared
|
||||
assert icons.icon(kind, color, size) is shared
|
||||
assert shared.cacheKey() == original_key
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, PALETTE["disabled"], size).toImage()
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
# Both surfaces share the disabled colour; changing the returned icon still
|
||||
# must not mutate the cached icon used by another page.
|
||||
blue.addPixmap(icons.pixmap(kind, PALETTE["danger"], size), QIcon.Mode.Disabled)
|
||||
assert shared.cacheKey() == original_key
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
|
||||
|
||||
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.design_system import PALETTE
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
@@ -43,12 +44,12 @@ def page(application, monkeypatch):
|
||||
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
|
||||
page.video_button.setEnabled(True)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
|
||||
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == PALETTE["accent"].lower()
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["surface"].lower()
|
||||
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == PALETTE["text"].lower()
|
||||
page.video_button.setEnabled(False)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == PALETTE["disabled"].lower()
|
||||
|
||||
|
||||
def test_typography_preserves_regular_body_and_distinct_values(page):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -295,6 +295,48 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_login_password_group_keeps_focus_when_reveal_is_keyboard_focused(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
window = LoginWindow(
|
||||
object(), settings=QSettings(str(tmp_path / "focus.ini"), QSettings.Format.IniFormat),
|
||||
)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
group = window.password_edit.parentWidget()
|
||||
geometry = group.geometry()
|
||||
try:
|
||||
for control in (window.password_edit, window.reveal_button):
|
||||
control.setFocus()
|
||||
application.processEvents()
|
||||
assert group.property("focused") is True
|
||||
assert group.geometry() == geometry
|
||||
window.login_button.setFocus()
|
||||
application.processEvents()
|
||||
assert group.property("focused") is False
|
||||
assert group.geometry() == geometry
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_compact_login_keeps_the_whole_form_visible(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
window = LoginWindow(
|
||||
object(), settings=QSettings(str(tmp_path / "compact.ini"), QSettings.Format.IniFormat),
|
||||
)
|
||||
window.resize(860, 640)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
assert not window.brand_panel.isVisible()
|
||||
assert window.login_scroll.verticalScrollBar().maximum() == 0
|
||||
assert window.card.height() >= window.card.minimumSizeHint().height()
|
||||
assert window.login_button.isVisibleTo(window)
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings_path = tmp_path / "remember-password.ini"
|
||||
|
||||
@@ -38,6 +38,8 @@ def page(application, monkeypatch):
|
||||
|
||||
|
||||
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
|
||||
# The responsive workspace exposes insights in its second tab.
|
||||
page.workbench.mode_tabs.setCurrentIndex(1)
|
||||
settle(application)
|
||||
for button in page.ai_prompt_buttons:
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
@@ -48,6 +50,8 @@ def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, applicati
|
||||
|
||||
|
||||
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
|
||||
# Force real overflow: a short case is allowed to fill the viewport.
|
||||
page.case_labels["present"].setText("Long clinical history\n" * 80)
|
||||
page.detail_tabs.setCurrentIndex(0)
|
||||
settle(application)
|
||||
consultation_height = page.clinical_pages.height()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)> <b> \'quote\'\n**<svg onload=alert(2)>**'
|
||||
const html = render(text)
|
||||
assert.equal(html, '<p><script>alert("x")</script><br><img src=x onerror=alert(1)> &lt;b&gt; 'quote'<br><strong><svg onload=alert(2)></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/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 <https://example.invalid></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><script>0.05 mg</script>\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><未结束></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 - ',
|
||||
'| <svg/onload=evil()> | \" onmouseover=evil() |\n| --- | --- |\n| <script> | <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 <svg onload="evil()"></code></pre><p><code>血压 130/80 mmHg</code></p><p><strong>${label('血糖')} ${value('2.00 mmol/L')}</strong></p><p><svg onload="evil()">${label('体温')} ${value('36.5℃')}</svg></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('<END></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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||