This commit is contained in:
Your Name
2026-08-14 14:37:30 +08:00
parent 21790e35f4
commit 18c15d1262
117 changed files with 28157 additions and 8080 deletions
+40
View File
@@ -780,6 +780,46 @@ export function prescriptionLibraryEditAiReport(params: {
)
}
/** 读取已保存的诊单 AI 报告;不会触发模型生成。 */
export function diagnosisAiReports(params: { id: number }) {
return request.get<PrescriptionAiReportsResponse>(
{
url: '/tcm.diagnosis/aiReports',
params,
timeout: 30000
},
{ ignoreCancelToken: true }
)
}
/** 重新生成诊单双模型 AI 报告;POST 不自动重试。 */
export function diagnosisGenerateAiReports(params: { id: number }) {
return request.post<PrescriptionAiReportsResponse>(
{
url: '/tcm.diagnosis/generateAiReports',
params,
timeout: 210000
},
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
/** 编辑一份已持久化的诊单 AI 报告。 */
export function diagnosisEditAiReport(params: {
id: number
report_id: number
content: string
}) {
return request.post<PrescriptionAiReportEditResponse>(
{
url: '/tcm.diagnosis/editAiReport',
params,
timeout: 30000
},
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
// ========== 诊单待办事项(T5 ==========
/** 待办列表(按 diagnosis_id */
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+408
View File
@@ -0,0 +1,408 @@
# APP 接诊台队列第二行 Python `dict` 泄漏审计
审计日期:2026-08-14
审计范围:`server` 列表 API → Python API client / repository / model normalize → `reception.py` 队列卡片
操作边界:只读诊断;未修改任何业务代码或测试代码,仅新增本报告。
## 0. Trellis 指令检查
仓库根目录 `D:\web\zyt` 下不存在 `.trellis/`,因此没有可读取的 `.trellis/workflow.md``.trellis/spec/` 或任务上下文。本审计已按根目录 `AGENTS.md` 的现有约束执行。
## 1. 结论
根因已经确定,不是 Qt 的渲染问题,也不是 JSON 解码问题,而是一个“对象字段被误当成文本别名”的类型边界错误:
1. 当前服务端 `doctor.appointment/lists` 使用 `->with('diagnosis')`,所以每条挂号记录的 `diagnosis` 字段实际是一个完整的关联诊单对象(JSON object / Python `dict`),缺失关联时则可能为 `null`;它不是诊断名称字符串。
2. `RemoteDoctorRepository.list_appointments()` 通过 `PageResult.from_payload(..., Appointment.from_dict)` 做 normalize。`Appointment.from_dict()` 没有诊断摘要字段,只把完整原始行保存在 `Appointment.raw`
3. `get_value()` 对 dataclass 上不存在的字段会回退到 `raw`,因此 `first_value(record, ..., "diagnosis")` 会取出那个 `dict`
4. `QueueRow` 把该值放进 `str(part).strip()`Python 按字典 `repr` 生成 `"{'id': ..., ...}"`,随后直接传给 `QLabel`。这正是用户看到的第二行文本。
触发点位于当前工作区尚未提交的接诊台视觉改造:旧版队列第二行只展示预约时间,不读取 `diagnosis`;当前改造在 `QueueRow` 中新增了 `"diagnosis"` 这个兜底别名,从而首次暴露服务端一直存在的关联对象。
**直接修复不能只是换成 `display_text()`。** `display_text()` 对容器同样执行 `str(value)`,仍会显示 Python 字典。也不应把整个嵌套 `diagnosis` 合并进 appointment,因为两层都有 `id``patient_id``status` 等不同语义字段,会污染挂号状态和三个 ID 的权威口径。
## 2. 完整数据链路
### 2.1 服务端列表实际返回嵌套对象
入口和响应封装:
| 文件 / 函数 | 当前行号 | 事实 |
|---|---:|---|
| `server/app/adminapi/controller/doctor/AppointmentController.php::lists()` | 62-65 | `doctor.appointment/lists` 交给 `AppointmentLists`。 |
| `server/app/common/service/JsonService.php::dataLists()` | 120-147 | HTTP envelope 的 `data``{lists, count, page_no, page_size, extend}`。 |
| `server/app/adminapi/lists/doctor/AppointmentLists.php::lists()` | 162-369 | 构造并序列化每一条挂号记录。 |
决定 `diagnosis` 类型的代码:
- `AppointmentLists.php:213-218`:查询从 `Appointment::alias('a')->with('diagnosis')` 开始;同时 join `tcm_diagnosis u`,只把患者、医生、医助、`diagnosis_id` 等少数字段平铺到顶层。
- `AppointmentLists.php:261-267`:模型 `select()->toArray()`;未限制字段的关联模型随主记录一起转成数组。
- `server/app/common/model/doctor/Appointment.php:99-102``diagnosis()``belongsTo(Diagnosis::class, 'patient_id', 'id')`。因此 appointment 表里的 `patient_id` 实际指向诊单 ID,而不是诊单对象中的真实患者 ID。
- `server/app/adminapi/logic/doctor/AppointmentLogic.php:623-642` 也明确记录:`appointment.patient_id == tcm_diagnosis.id`
- `server/app/common/model/tcm/Diagnosis.php:26-39`:关联对象对应 `tcm_diagnosis` 模型。
- `server/sql/tcm_diagnosis.sql:2-25`:基础 schema 中诊单至少包含 `id`、真实 `patient_id``patient_name``diagnosis_type``syndrome_type``symptoms``remark` 等字段。不同部署的后续列可能更多,但容器类型不变。
按照当前代码生成的响应形态如下(字段删减,仅表达类型和 ID 语义):
```json
{
"code": 1,
"data": {
"lists": [
{
"id": 101,
"patient_id": 501,
"diagnosis_id": 501,
"patient_name": "张三",
"appointment_time": "09:00",
"status": 1,
"diagnosis": {
"id": 501,
"patient_id": 301,
"patient_name": "张三",
"diagnosis_type": "follow_up",
"syndrome_type": "...",
"symptoms": "口干"
}
}
],
"count": 1,
"page_no": 1,
"page_size": 15,
"extend": {}
}
}
```
这里的关键合同是:
- 顶层 `diagnosis_id`:诊单 ID
- 顶层 `patient_id`:历史命名,当前也存诊单 ID
- `diagnosis.id`:诊单 ID
- `diagnosis.patient_id`:真实患者 ID
- `diagnosis`object 或 null,不应作为字符串渲染。
本次没有调用线上接口或读取生产数据库;“实际字段形态”依据当前 server 查询、关系定义、模型序列化和 schema 静态确认。容器类型由 `with('diagnosis')` 确定,不依赖具体数据内容。
### 2.2 API client 解 envelope,但不改变行字段
- `app/src/doctor_workstation/services/api_client.py::ApiClient._unwrap()`344-382:校验 envelope,在 `code == 1` 时直接返回 `envelope['data']`
- 所以 repository 收到的是 `{lists, count, ...}`,列表行里的嵌套 `diagnosis` 仍为 Python `dict`
### 2.3 Repository / model normalize 保留嵌套对象到 `raw`
- `app/src/doctor_workstation/services/repository.py::RemoteDoctorRepository.list_appointments()`879-909:请求 `doctor.appointment/lists`,再调用 `PageResult.from_payload(payload, Appointment.from_dict, ...)`
- `app/src/doctor_workstation/core/models.py::PageResult.from_payload()`804-865:在 840 行逐条调用 parser。
- `app/src/doctor_workstation/core/models.py::Appointment.from_dict()`213-257:只 normalize 顶层基本字段;没有 `clinical_diagnosis``diagnosis_name``disease_name``disease_course` dataclass 字段;256 行执行 `raw=dict(source)`,完整保留嵌套 relation。
- `app/src/doctor_workstation/ui/widgets.py::get_value()`,53-71:对象属性不存在时,66-67 行回退到对象的 `raw`
- `app/src/doctor_workstation/ui/widgets.py::first_value()`74-81:只排除 `None` 和空字符串,不排除 Mapping、Sequence 或其他不可展示容器。
因此 normalize 后的真实 Python 形态是:
```python
Appointment(
id=101,
patient_id=501,
diagnosis_id=501,
# 没有 canonical diagnosis summary 字段
raw={
# ...
"diagnosis": {"id": 501, "patient_id": 301, "symptoms": "口干"}
},
)
```
### 2.4 `QueueRow` 把 Mapping 转成 Python 文本
- `app/src/doctor_workstation/ui/pages/reception.py::ReceptionPage._apply_queue()`1473-1519`page_items()` 取出 `Appointment`,并为每条记录创建 `QueueRow(record)`
- `app/src/doctor_workstation/ui/pages/reception.py::QueueRow.__init__()`567-574:按 `clinical_diagnosis → diagnosis_name → disease_name → diagnosis` 取第一个非空值。
- 前三个字段在当前 server 顶层没有,`diagnosis` 则通过 `get_value()``raw` 回退命中关联 `dict`
- 同函数 586-590`str(part).strip()` 对该 dict 生成 Python repr。
- 591 行:repr 被送入 `QLabel`,没有任何类型检查。
- `app/src/doctor_workstation/ui/widgets.py::display_text()`,84-91:即使改用此函数,91 行仍是 `str(value)`,所以不是修复。
相邻的 `ReceptionPage._render_identity()``reception.py:1783-1809` 也有“候选值 → `str(part)`”模式。它当前处理的是详情响应中的 diagnosis mapping 内部字段,不会必然触发本问题,但建议复用同一个 scalar-only helper,避免未来某个详情别名变成 object/list 时再次泄漏容器 repr。
## 3. 可重复证据
使用项目现有虚拟环境、`-B` 禁止生成 bytecode,执行了一个无网络、无文件写入的最小复现:
```python
row = Appointment.from_dict({
"id": 1,
"patient_name": "张三",
"appointment_time": "09:00",
"diagnosis": {"id": 8, "patient_name": "张三", "symptoms": "口干"},
})
widget = QueueRow(row)
```
当前代码输出:
```text
normalized_type= Appointment raw_diagnosis_type= dict
fallback_value= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
rendered_subline= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
```
这同时证明:
- API row 到 `Appointment` 的 normalize 已发生;
- dict 并未来自 Qt
- 卡片最终文本和 Python dict repr 完全相同。
## 4. 为什么现有测试没有发现
1. `app/tests/test_reception_parity_ui.py::test_queue_status_badge_is_not_clipped_in_narrow_panel()`,103-128,只断言状态徽标尺寸和位置;fixture 不含 `diagnosis`,也没有读取 `ReceptionQueueSubline`
2. 同文件队列分页/筛选 fixtures247-386)只给 `id/patient_name/status` 等平铺字段,未模拟 server 的 `diagnosis: {...}` relation。
3. `app/tests/test_mock_repository.py::test_tolerant_page_parsing_accepts_aliases_and_bad_rows()`,298-324,只覆盖简单别名和坏行;没有嵌套关系字段。
4. `app/tests/test_repository_parity.py::test_page_result_preserves_outer_and_nested_extend()`155-174,覆盖的是分页 envelope 嵌套,不是 row 内 relation 嵌套。
5. `app/tests/test_repository_parity.py::test_remote_reception_is_forcibly_scoped_to_today()`227-244,只断言请求 endpoint/参数,不断言返回 DTO 字段类型。
6. Demo appointments 在 `app/src/doctor_workstation/services/mock_repository.py:3153-3283` 不包含 `diagnosis` relation,因此视觉截图只会走时间 fallback,无法暴露生产响应问题。
## 5. 兼容旧 / 新响应的稳健提取规则
### 5.1 必须先区分“容器”和“可展示标量”
建议定义一个只接受 JSON scalar 的 helper
- 接受:非空 `str`;必要时接受 `int/float` 并转换成字符串;
- 拒绝:`Mapping`、list/tuple/set、bool、`None`、空白字符串;
- 绝不对未知容器调用 `str()`
- 如果产品以后明确支持多选诊断,应单独定义“纯字符串列表 join”合同,不能把任意 list/dict 通用字符串化。
### 5.2 诊断摘要优先级
兼容三类已知/合理响应:
1. **新/平铺 canonical**:顶层 `clinical_diagnosis`
2. **平铺历史别名**:顶层 `diagnosis_name``disease_name`
3. **当前 server relation**:若顶层 `diagnosis` 是 Mapping,只从其内部的 `clinical_diagnosis``diagnosis_name``disease_name`、标量 `diagnosis` 中选;
4. **更老的标量别名**:只有当顶层 `diagnosis` 本身是 scalar 时,才把它作为最后兜底;
5. 都没有可展示文本时,返回空字符串,让 UI 回退到预约时间。
推荐顺序可写成:
```text
top.clinical_diagnosis
→ top.diagnosis_name
→ top.disease_name
→ diagnosis_object.clinical_diagnosis
→ diagnosis_object.diagnosis_name
→ diagnosis_object.disease_name
→ diagnosis_object.diagnosis(仅 scalar
→ top.diagnosis(仅 scalar
→ ""
```
不要把 `diagnosis_type` 直接当临床诊断:它在 server 中是初诊/复诊等类型 code;也不要直接显示未经翻译的 `syndrome_type` code。若产品明确希望第二行显示证型,应由 server 提供 `syndrome_type_text` 或由客户端字典翻译后作为另一个明确字段,不能把整个 relation 当成兜底。
### 5.3 病程摘要优先级
同样对顶层和 relation 内部执行 scalar-only 查找:
```text
top.disease_course_text
→ top.disease_course
→ top.course_text
→ top.course
→ diagnosis_object.disease_course_text
→ diagnosis_object.disease_course
→ diagnosis_object.course_text
→ diagnosis_object.course
→ ""
```
### 5.4 最终渲染规则
- `diagnosis``course` 都有文本:`诊断 · 病程`
- 只有一个:只显示该项;
- 两者都没有:显示预约时间;
- 时间也没有:显示“时间待确认”;
- 无论输入如何,最终字符串都不得包含由容器 repr 产生的 `{...}` / `[...]`
## 6. 建议补丁
### 6.1 首选:model 边界 canonicalize + UI 最后一道类型保护
#### A. `core/models.py`
在基础 helper 附近(当前 21-81 行)增加 scalar-only 提取器:
```python
def _first_scalar_text(*values: object) -> str:
for value in values:
if isinstance(value, str):
text = value.strip()
if text:
return text
elif isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
```
`Appointment`(当前 179-211 行)增加 canonical 字段:
```python
clinical_diagnosis: str = ""
disease_course: str = ""
```
`Appointment.from_dict()` 当前 217 行之后只选择性读取 relation**不要 merge 整个 nested mapping**
```python
source = _mapping(data)
diagnosis_value = source.get("diagnosis")
diagnosis = _mapping(diagnosis_value)
legacy_diagnosis = None if isinstance(diagnosis_value, Mapping) else diagnosis_value
clinical_diagnosis = _first_scalar_text(
source.get("clinical_diagnosis"),
source.get("diagnosis_name"),
source.get("disease_name"),
diagnosis.get("clinical_diagnosis"),
diagnosis.get("diagnosis_name"),
diagnosis.get("disease_name"),
diagnosis.get("diagnosis"),
legacy_diagnosis,
)
disease_course = _first_scalar_text(
source.get("disease_course_text"),
source.get("disease_course"),
source.get("course_text"),
source.get("course"),
diagnosis.get("disease_course_text"),
diagnosis.get("disease_course"),
diagnosis.get("course_text"),
diagnosis.get("course"),
)
```
随后赋给 dataclass 字段。可顺带在顶层 `diagnosis_id` 缺失时安全回退 `diagnosis.id`,但必须保持 appointment 的 `id/status/patient_id` 仍以顶层为权威。
#### B. `ui/pages/reception.py`
即使 model 已 canonicalize`QueueRow` 仍可能被测试仓库或其他 repository 直接传入 dict,因此 UI 应保留 scalar-only guard。最小安全改法不是简单删除 `"diagnosis"`,而是:
```python
def _display_scalar(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
```
然后在 `QueueRow.__init__()` 当前 567-591 行:
```python
diagnosis = _display_scalar(
first_value(
record,
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
default=None,
)
) or _display_scalar(get_value(record, "diagnosis", None))
course = _display_scalar(
first_value(
record,
"disease_course_text",
"disease_course",
"course_text",
"course",
default=None,
)
)
subline_parts = [part for part in (diagnosis, course) if part]
subline = QLabel(" · ".join(subline_parts) or display_text(time or "时间待确认"))
```
如果希望 `QueueRow` 本身也兼容未经 `Appointment.from_dict()` 的嵌套 raw dict,则把 5.2/5.3 的 relation 内部候选一起放进一个纯函数(例如 `_queue_summary_fields(record)`),并由 model/UI 共用或分别调用同一优先级。重点是 relation 容器永远不能进入 `QLabel`
建议同样把 `_render_identity()` 当前 1802-1805 行的 `str(part)` 改为这个 scalar-only helper,作为邻接防御。
### 6.2 不建议的修复
- **只改 `display_text(diagnosis)`**:仍会 `str(dict)`
- **只删掉 `"diagnosis"` 别名**:能止住当前服务端,但会丢掉历史 scalar `diagnosis` 兼容,也无法读取未来/其他部署的嵌套 canonical 文本。
- **`json.dumps(diagnosis)`**:只是把 Python repr 换成 JSON,仍然把内部对象和潜在隐私信息显示给用户。
- **把 relation 整体 merge 到 appointment**:会让 diagnosis 的 `id/patient_id/status` 覆盖挂号字段,破坏视频、完成接诊和选中一致性。
- **立即删除 server 的 `with('diagnosis')`**:可能影响已有管理端消费者;在没有完整 server contract 回归前不应作为 APP 热修。
### 6.3 可选的服务端长期收敛
长期可以让 `AppointmentLists` 明确返回队列所需的 scalar summary,例如 `clinical_diagnosis` / `disease_course_text` / 已翻译的 `syndrome_type_text`,并限制或移除列表里的完整 relation,以减少 payload 和 PII 面。但当前 schema 各部署并不完全一致,直接在 SQL field 中引用未必存在的列会造成查询失败,因此这应作为单独的 API contract 变更,不是本次桌面 APP 热修的前置条件。
## 7. 必需测试
### 7.1 Model / repository normalize 测试
建议放在 `app/tests/test_repository_parity.py`,直接通过 `PageResult.from_payload(..., Appointment.from_dict)` 覆盖真实路径:
1. relation object 内有 `clinical_diagnosis``disease_course`normalize 后得到 canonical 字符串,同时 `raw['diagnosis']` 仍保留原 dict。
2. 顶层 flattened canonical 字段优先于嵌套字段。
3. 顶层 legacy scalar `diagnosis` 可兼容。
4. `diagnosis` 只有无关 mapping 字段时,canonical 诊断为空,不出现 dict repr。
5. 顶层 `status=1/id=101/patient_id=501` 与 nested `status=0/id=501/patient_id=301` 同时存在时,appointment 权威字段不得被 nested 覆盖。
6. `diagnosis=null`、空 dict、字段为空白、错误的 list/dict 类型均不抛异常。
建议核心断言示例:
```python
assert appointment.clinical_diagnosis == "消渴"
assert appointment.disease_course == "2 年"
assert isinstance(appointment.raw["diagnosis"], dict)
assert appointment.id == 101
assert appointment.status == 1
assert appointment.patient_id == 501
```
### 7.2 QueueRow 渲染测试
建议放在 `app/tests/test_reception_parity_ui.py`,扩展当前 103 行附近的 `QueueRow` 测试;通过 `findChild(QLabel, 'ReceptionQueueSubline')` 直接断言:
| 输入 | 期望第二行 |
|---|---|
| flat `clinical_diagnosis='消渴'`, `disease_course_text='2 年'` | `消渴 · 2 年` |
| legacy scalar `diagnosis='消渴'`, `course='2 年'` | `消渴 · 2 年` |
| nested relation 内含 canonical 文本(经 `Appointment.from_dict` | `消渴 · 2 年` |
| `diagnosis={'id': 8, 'symptoms': '口干'}`,无摘要 | 回退预约时间 |
| `clinical_diagnosis={...}` / `course=[...]` | 回退预约时间,且不抛异常 |
| 所有字段缺失 | `时间待确认` |
每个 case 还应有通用安全断言:
```python
assert "{" not in subline.text()
assert "}" not in subline.text()
assert "[" not in subline.text()
assert "]" not in subline.text()
```
如果正常业务文本本身允许这些符号,则更精确地断言“不等于 `repr(payload['diagnosis'])`”并断言预期 fallback;不要只做脆弱的字符黑名单。
### 7.3 Server contract 测试(若修改 server
若后续调整 `AppointmentLists`PHP 侧应加入 endpoint/列表类 contract
- `diagnosis` 明确为 array|null,禁止在 contract 中宣称 string
- 新增的 queue summary 必须是 string|null
- count / scope / 日期过滤不受影响;
- relation 字段收窄或移除前,先盘点 admin 其他页面消费者。
## 8. 修复验收标准
1. 线上/current server 的 nested `diagnosis` response 不再把 `{...}` 显示在队列第二行。
2. 平铺 canonical、历史 scalar 和 nested canonical 三种形态均有确定输出。
3. 没有可展示诊断/病程时稳定回退预约时间,而不是空白或容器 repr。
4. `Appointment` 的挂号 `id/status/patient_id` 不被 nested diagnosis 覆盖。
5. 新增 model 与 QueueRow 测试通过;现有 same-day、分页、切换患者和状态徽标测试保持通过。
6. 不需要以修改 server 或数据库 schema 作为 APP 修复前提。
## 9. 最终判定
这是一个确定性的 P1 展示与数据边界缺陷:不会直接修改数据,但会把完整关联对象(其中可能包含手机号、身份证、病史等字段,取决于部署 schema)暴露在医生端 UI,并破坏卡片可读性。推荐用“repository/model selective normalize + UI scalar-only guard”双层修复;不要序列化对象,也不要 merge relation。
+203
View File
@@ -0,0 +1,203 @@
# 诊疗 / 病例 / 订单 / AI 报告子窗口蓝白参考审计
审计日期:2026-08-13
审计性质:只读;未修改业务源码、测试、脚本或既有 PNG。
审计边界:诊疗详情/编辑抽屉、独立病例只读页、诊断上下文中的业务订单详情、诊断 AI 报告。未审计或改动 `theme.py``widgets.py`、处方编辑/患者页/挂号页;AI 部分只看诊断报告模式,不扩展到处方业务。
## 1. 结论先行
1. **用户所指“蓝白参考”最接近的现有实图是 `artifacts/pixel_exact_v3/consultations.png`**1710×9202026-08-13 19:56)。它是当前最新、最完整的蓝白医生工作站视觉:浅蓝壳层、近白内容底、白色卡片、靛蓝主操作、冷灰蓝文字与边框。子窗口应从它取色,不应从旧 Diagnosis 深色图取色。
2. **子窗口结构不能统一成同一种 modal。** 现有正确结构分别是:诊疗编辑/viewOnly 为右侧 60% Drawer;独立病例为无 Tabs 的纵向滚动详情页;订单详情为右侧 80% readonly DrawerAI 报告为 920×760 的居中 Dialog。用户要求的“蓝白”应是视觉统一,不是破坏这些已经有测试与研究规格支撑的容器合同。
3. **`artifacts/diagnosis_visual/*.png` 的主诊疗图仍是深色旧产物,只能借布局,不能借色。** 例如 `diagnosis_edit_1440x900.png``diagnosis_viewonly_1440x900.png``diagnosis_readonly_1440x900.png``diagnosis_order_detail_drawer_1440x900.png` 均以 `#080B14/#101626/#151D31` 为主。它们与 19:56 的蓝白主壳不属于同一视觉版本。
4. **最接近当前浅色订单结构的实图是 `.pytest-tmp-ui-redesign-full/test_drawer_and_inline_player_0/order.png`**1100×7202026-08-13 17:53)。它准确显示了 20% 遮罩 + 80% 白色 Drawer、固定 Header/Body/Footer 和卡片层级,但色谱仍是上一轮灰白+青色(`#F5F7FB/#D8DEEA/#CFFAFE/#A5F3FC`),因此仅作订单布局参考。
5. **AI Dialog 已有一张新生成的 920×760 蓝白实图:`artifacts/subwindow_exact/prescription_ai_report_920x760.png`。** 它直接证明当前 AI 组件的 Header、snapshot、医疗警示、双模型 Tabs、两列报告、滚动区和 Footer 能按蓝白色板渲染;但画面是“AI 处方解释”模式,不是诊断 `DIAGNOSIS_AI_KIND` 的“AI 报告/完整病历”模式。诊断 AI 使用同一个 Dialog/QSS,故该图可作为外观强参考,仍不能替代诊断模式本身的验收图。`dialogs/prescription_ai.py` 当前在工作树中仍是未跟踪文件,故它是“当前工作树实现”,不是已有发布基线。
6. **当前工作树正在变化。** 审计期间 `dialogs/diagnosis.py` 的订单 QSS 已从灰白版本进一步改为蓝白版本(例如遮罩 `rgba(30,64,175,.18)`、面板 `#F6F9FE`、边框 `#DDE7FF`);随后 `diagnosis_drawer.py` 又加入 `_DIAGNOSIS_BLUE_REPLACEMENTS`,在不重写成熟选择器的前提下把旧青色/绿灰字面量映射到靛蓝/冷灰蓝。测试中的选中 chip 断言也已从 `#CFFAFE` 更新为 `#F0F2FF`。本报告以下约束以审计结束时的最新工作树为准,同时明确区分旧 PNG。
## 2. 证据优先级与可用方式
| 优先级 | 证据 | 用途 | 不可误用 |
|---|---|---|---|
| 1 | `artifacts/pixel_exact_v3/consultations.png` | 蓝白总色调、主/次文字、边框、内容底、主按钮 | 不是子窗口几何图,不能据此改变 Drawer 比例 |
| 2 | `artifacts/subwindow_exact/prescription_ai_report_920x760.png` | AI Dialog 蓝白实际渲染、阅读密度、滚动与 Footer | 是处方解释模式,不是诊断 AI 报告模式 |
| 3 | `src/doctor_workstation/ui/dialogs/prescription_ai.py``PRESCRIPTION_AI_QSS` | 已落地的蓝白 Dialog 色板、按钮、Tabs、阅读排版 | 文件未跟踪;不能当作已发布基线 |
| 4 | `.pytest-tmp-ui-redesign-full/test_drawer_and_inline_player_0/order.png` | 80% 订单 Drawer 的浅色结构和首屏信息密度 | 青色强调与黑色遮罩不是最终蓝白色值 |
| 5 | `artifacts/diagnosis_visual/diagnosis_edit_*.png``diagnosis_viewonly_*.png` | 60% 诊疗 Drawer、Header/Tabs/Body/Footer、滚动与窄宽布局 | 深色旧主题不能复用 |
| 6 | `artifacts/diagnosis_visual/diagnosis_readonly_*.png` | 独立病例纵向流、4/3 列病例密度、异常值层级 | 深色旧主题不能复用 |
| 7 | `artifacts/diagnosis_visual/diagnosis_order_detail_drawer_1440x900.png` 与两个 1024×640 状态图 | 80% 比例、金额五卡、处方/收款首屏、固定 Footer | 深色旧主题不能复用;文件名 `640x540` 实际为 1024×640 |
| 8 | `research/diagnosis_detail_visual_spec.md``diagnosis_final_visual_gate.md` | 后台结构事实、间距、字段顺序、状态、历史验收 | 旧门禁“PASS”只证明当时深色截图结构完整,不代表符合本轮蓝白参考 |
### 2.1 实图像素色谱
`pixel_exact_v3/consultations.png` 每 2 px 采样得到的主要实色:
| 角色 | 参考实色 | 说明 |
|---|---|---|
| 页面/内容底 | `#FCFDFE` | 最大面积;子窗口滚动内容的首选底色 |
| 主卡片/浮层 | `#FFFFFF` | 表格、表单、Header、Footer、信息卡 |
| 壳层浅蓝 | `#EEF3FD` | 适合 overlay 外的壳层或非常浅的背景层,不宜给所有内卡重复使用 |
| 次级填充 | `#F7F9FE``#F2F6FE` | 输入只读态、筛选块、提示块、轻卡底 |
| 主边框 | `#E2E7F4` | 参考图中卡片与分隔线的高频精确色 |
| 主色 | `#5265F6` | 参考图主按钮/选中态的高频精确色 |
| 主标题 | `#15224A` | 参考图高频深靛文字 |
| 正文 | `#3F4E75` | 正文/表格主内容 |
| 次文字 | `#7481A3` | 标签、说明、占位与元信息 |
| 危险 | `#F15B67`(实图) | 取消/危险语义;业务详情可继续用更稳的 `#C43E55` 文本 |
AI 报告现有色板与参考图极近:背景 `#F7F9FE`,主色 `#5761F4`hover `#6871F6`pressed/链接 `#4D57D8`,浅主色 `#F0F2FF`,边框 `#DCE3F2`,标题 `#17203F`,正文 `#37415E`,次文字 `#78849D`。两套主色只差 5 个 RGB 量级;**若追求实图像素统一,统一到 `#5265F6`;若追求最小改动,可把 AI 色板整套作为子窗口局部 token,但不得继续混入青色 `#0891B2/#0E7490/#CFFAFE/#A5F3FC`。**
## 3. 全部子窗口共同约束
### 3.1 色与表面
- 外层/滚动区:`#FCFDFE` 或需要轻微分层时 `#F7F9FE`
- Header、Footer、卡片、表格主体:`#FFFFFF`
- 一般边框/分隔:`1px #E2E7F4`;强调边框可用 `#DCE3F2``#DDE7FF`,但同一控件不要混用三种。
- 主操作、选中 Tab、focus`#5265F6`hover 可用 `#6871F6`pressed/深色链接 `#4D57D8`;浅背景 `#F0F2FF`,浅边框 `#D8DCFF`
- 标题/数据主值:`#15224A`(现有 AI 的 `#17203F` 可作为近似);正文 `#3F4E75`label/meta `#7481A3`
- 成功/提醒/危险仍保留业务语义色,不要全部染成蓝:成功 `#16876C``#16A34A`;提醒 `#9A6813`;危险 `#C43E55` 或异常指标 `#DC2626`
- 遮罩只负责层级,不变成黑墙:推荐订单当前工作树的 `rgba(30,64,175,.18)`;诊疗 Drawer 可略深但保持蓝灰透明。旧 `rgba(8,11,20,.78)` 明显不符合参考。
### 3.2 字体、圆角、密度
- 字体栈:`Microsoft YaHei UI`, `PingFang SC`, `Noto Sans CJK SC`, sans-serif。不要为普通正文引入另一套拉丁字体;病例编号/时间可使用等宽数字。
- 正文/控件 13 px;字段 label、提示与 meta 1112 px;卡片标题 1415 pxDrawer 标题 1819 pxAI 报告标题 20 px。
- 4 px 间距基线;常用 8/12/16/20/24 px。不要产生 5、13、17、21 等无依据的主布局间距。
- 控件/普通按钮高 34 px;诊疗 Footer 主按钮高 40 px;紧凑 close 为 32×32;诊疗 Tab 高 42 pxAI Tab 高 36 px。
- 内控件/按钮圆角 7–8 px;字段卡/分区 910 pxHero 12 px;独立只读大卡 14 px。999 px 只用于真正的状态 pill/选择 chip,不要给所有按钮胶囊化。
- 表格状态必须使用小型 Tag/pill,不得整格铺色。表头宜 `#F7F9FE/#F8FAFF`,主体白底,行/列分隔 `#E2E7F4`;金额右对齐,状态与操作位置保持现有合同。
- hover、pressed、disabled、focus 必须可辨;focus 使用主色边界/外环,不能因改蓝白而删除键盘焦点。
## 4. 诊疗编辑 / viewOnly Drawer
### 4.1 必须保持的几何结构
- `DiagnosisDialog` 外层仍覆盖 owner,右侧 panel RTL 贴边;桌面宽度为 owner 的 **60%**1024×640 时 614 px1440×900 时 864 px。
- 窗口宽 `<=768` 时 panel 全宽;当前 Dialog 最小 760×520、默认 1024×640。不要改成固定居中 880×680 modal。
- 三段分离:Header、可横向滚动 Tabs + 独立滚动 Body、固定 Footer。Footer 不得放到 ScrollArea 尾部。
- Header 内边距 `16px 13px`,横向 gap 10;标题行内部 gap 10,副标题与标题垂直 gap 4close 32×32。
- Tabs:单 Tab 最小高 42,水平 padding 14;横向 overflow 用 4 px 细滚动条,隐藏原生盒状左右箭头;激活条使用主色,建议 2–3 px。
- Basic Body`20px 16px 20px 20px`(左/上/右/下)内边距;分区纵向 gap 12;一行两字段时 gap 16;窄模式纵向 gap 12。
- Footer`20px 14px 20px 18px` 内边距,按钮 gap 12,白底、上边 `#E2E7F4`,主按钮 40 px 高。
### 4.2 应改成的视觉
- Panel/Header/Footer/Body 从旧深靛或当前青绿色调统一到 §3 色板:白色 Header/Footer、近白蓝 Body,主色 `#5265F6`
- `diagnosis_drawer.py` 的基础 QSS 字面量仍大量是青色 `#0891B2/#0E7490/#22D3EE/#CFFAFE/#A5F3FC` 与绿灰文字 `#134E4A/#2A6B64/#5B7A76`,但当前工作树已通过 `_DIAGNOSIS_BLUE_REPLACEMENTS` 在运行时成组映射为靛蓝/冷灰蓝。这个方向正确;最终检查重点应变为:映射是否覆盖所有 scoped QSS、QPainter 和 inline style,且没有选择器优先级让旧色漏出。
- 推荐映射:
| 当前 Diagnosis 色 | 蓝白目标 |
|---|---|
| `#0891B2`, `#0E7490` | `#5265F6` / pressed `#4D57D8` |
| `#22D3EE` | `#6871F6` 或 focus `#5265F6` |
| `#CFFAFE` | `#F0F2FF` |
| `#A5F3FC` | `#D8DCFF` |
| `#F5F8F7`, `#F6F6F6` | `#FCFDFE` / `#F7F9FE` |
| `#D5E5E2`, `#D9DEDA`, `#E2EBE8` | `#E2E7F4` / `#DCE3F2` |
| `#134E4A`, `#2A6B64` | `#15224A` / `#3F4E75` |
| `#5B7A76`, `#66736D` | `#7481A3` |
- mode badge、锁定 warning、成功/失败保存状态保留语义色;不要把 warning 也涂成主蓝。
- 当前表单代码为中宽两列、字段 label 固定 100 px`content_w < 520` 才堆叠。研究规格中的后台 label 160 px 与当前 614 px 两列桌面实现存在冲突;**本轮蓝白适配不应贸然把 100 改成 160**,否则 1024×640 会失去已测试的两列无裁切合同。若未来要追后台 160 px,需单独重做栅格,不属于纯视觉换肤。
## 5. 独立病例 / 病历只读页
本节“病例”按当前 `DiagnosisDialog` 的 standalone readonly + `CaseGrid` 理解;不进入处方历史详情实现。
### 5.1 必须保持的布局
- 独立只读页是纵向 ScrollArea,**没有 Tabs**;页面内容四边 16 px、卡片间 gap 16。
- 顶部 Hero 左右可换行;宽度 `<900` 时右侧患者摘要落到下一行。Hero 内边距 `16px 12px`,内部 gap 12,圆角 12。
- 患者信息大卡内边距 18、纵向 gap 14;内部患者 Hero 内边距 `18px 16px`、纵向 gap 3。
- 病例卡 `CaseGrid` 内边距 18、纵向 gap 14;分组之间 dashed 分隔;网格横向 16、纵向 8。
- 宽屏病例分组保持既有列数:基本信息/生命体征/主诉 4 列,现病史与其他病史 3 列,既往史与补充意见整行。异常高压/低压/血糖继续用红色、700 字重和上箭头。
- 病例 label/value 的视觉尺度保持 1212.5 pxlabel `#7481A3`value `#15224A/#1F2937`,空值使用更浅的灰蓝。
### 5.2 蓝白外观
- 页面底 `#FCFDFE`;Hero 可使用研究规格已有的浅蓝渐变 `#F5F8FF → #EEF3FF`,边框 `#DDE7FF`;不要使用深色整页。
- 通用只读卡白底、`1px #E6EBF2`、14 px 圆角;卡片标题 15/700,左侧 3×16 px 主蓝标记。
- 当前工作树在患者信息卡标题行新增了“AI 报告”按钮。位置应固定在标题行右侧;使用 secondary 样式(字 `#4D57D8`、底 `#F0F2FF`、边 `#D8DCFF`、34 px 高、7 px 圆角),避免与“保存/生成”级主动作争抢。
- `CaseGrid` 已有少量蓝灰 inline 色(subtitle `#7886AA`、divider `#D8DEEE`),方向正确,但应收敛到统一的 `#7481A3/#E2E7F4`,避免同一页出现多套近似边框。
## 6. 业务订单详情 Drawer
### 6.1 必须保持的结构与尺寸
- `OrderDetailDrawer` 覆盖 owner,右侧 panel 固定 **80%**1024 owner 为约 819 px1440 owner 为 1152 px;左侧 20% 为 scrim。
- Header/Body/Footer 三段独立;Body 单独纵向滚动,Footer 始终可见。
- Header 当前内边距 `20px 15px 18px 15px`gap 12;标题栈 gap 4;标题 19 pxmeta 12 px;右侧依次是只读 badge、状态 Tag、关闭。
- Body 当前内边距 `18px 16px 18px 22px`,区块 gap 14。区块内边距 `15px 14px 15px 16px`,gap 11;字段/金额卡网格 gap 8。
- 金额概览首行 5 等分卡;值 18/700。信息字段为 3 列,物流元信息 2 列。收款表最小高 145,按行数增长但最大 280。
- 内容顺序必须保持:金额概览 → 处方详情 → 收款记录 → 履约与收货 → 物流轨迹 → 操作日志。readonly 隐藏收款方式等敏感/可编辑内容,不能为了“清爽”删掉已规定的只读信息层级。
- Footer 当前内边距 `18px 10px`;左侧数据来源说明,右侧关闭。
### 6.2 应匹配的蓝白细节
- 当前工作树 `_ORDER_DETAIL_QSS` 已基本走在正确方向:scrim `rgba(30,64,175,.18)`、Drawer/Scroll `#F6F9FE`、Header/Footer 白、强边 `#DDE7FF`、section `#FFFFFF/#E6EBF2`、字段底 `#F8FAFF`、空态 dashed `#C9D8F2`。这套可保留。
- 仍需确保从 `DIAGNOSIS_QSS` 继承的通用按钮/Tag/表格不会把订单局部重新染成青色。订单局部关闭按钮使用 secondary 蓝白;状态 Tag 保留绿/黄/红业务语义。
- 时间轴左线 `#93B4F4` 是合理的浅主蓝;标题 `#1F2937`、meta `#64748B` 可保留,若做全局像素统一再收敛到 `#15224A/#7481A3`
- 旧订单 PNG 中黑色 20% scrim 和深色主画面不能作为目标;浅色测试图的 80% 几何和卡片层级才是目标。
## 7. AI 报告 Dialog
### 7.1 当前实现已接近目标
- 居中 Dialog,默认 920×760,最小 720×560;不要改成右侧 Drawer,除非产品另行决定窗口范式。
- Root 内边距 `22px 18px`,纵向 gap 12。
- 标题 20/700,副标题 13;右侧可有状态 badge。
- 完整病历 snapshot:白底、`1px #DCE3F2`、10 px 圆角,内边距 `14px 12px`gap 12;左 label 固定 72 px。
- 医疗提示使用淡黄语义卡 `#FFF9EE/#F3DFB5`、9 px 圆角;不要为“全蓝白”抹掉警示语义。
- 两模型 Tabs 高 36、水平 padding 18selected 字 `#4D57D8`、底 `#F0F2FF`、2 px 主色下划线;Tab 内容白底、10 px 圆角。
- 报告 ScrollArea 白底;host 内边距 `4px 4px 12px 8px`gap 12。
- 核心判断 summary`#F0F2FF`、左 3 px `#5761F4`,内容内边距 `18px 16px`;结构化报告四格为 2 列,列 gap 28;编辑框最小高 280。
- Footer 右对齐;按钮 34 px 高、水平 padding 16、7 px 圆角;生成/保存为 primary,关闭/取消为普通或 secondary。
### 7.2 已有实图与仍缺的证据
- `artifacts/subwindow_exact/prescription_ai_report_920x760.png` 已显示一张质量足够的蓝白 AI Dialog:标题区、药材 snapshot、淡黄医疗警示、两模型状态 Tabs、核心判断浅蓝强调、2×2 报告栅格、垂直滚动和底部“关闭/重新生成”均完整,无深色或青色残留。它能证明共享 Dialog 外观已成立。
- 该图标题是“AI 处方解释”、snapshot 为“药材组合”,并非诊断模式的“AI 报告/完整病历”。诊断模式虽然复用同一个类和 QSS,仍需自己的实图验证文案高度、完整病历摘要换行和按钮标签。
- `tests/test_prescription_ai_ui.py` 只验证权限、文字、双模型数据、生成与编辑,没有截图、尺寸、主色、滚动和窄窗断言。
- 当前 `scripts/render_subwindow_exact.py` 的审计结束版本不再包含 AI render 分支,现有 AI PNG 的可重复生成链路不清晰;因此不能仅凭文件存在判可持续门禁。
- 后续视觉门禁至少需要:920×760 有报告态、720×560 空态/加载态、编辑态(含 280 px editor)、生成失败/旧报告回退态各一张;同时验证 Tabs、snapshot、warning、Footer 和纵向滚动无裁切。
## 8. 现有 tests / scripts / research 能保护什么
### 8.1 已有强结构约束
- `tests/test_diagnosis_drawer_visual.py`
- 诊疗 Drawer 60%、右贴边、全高、Footer 固定;1024/1440 两档。
- 独立 readonly 无 Tabs、内容 gap 16、无横向滚动。
- Tabs 横向滚动条 4 px,原生工具按钮不可见。
- 订单 Tab、病例/Notes/Daily 等真实组件可达,窗口 resize/reopen 与 owner 同步。
- `tests/test_diagnosis_order_video_visual.py`
- 订单 Drawer 为 80%,覆盖 ownerreadonly 属性与各信息区存在。
- 空字段必须显示明确空态,不能伪造 0;操作日志受权限保护。
- 生成的 order image 只要求 1100×720 且文件大于 10 KB。
- `research/diagnosis_detail_visual_spec.md`
- 明确三种诊疗视图的结构、4 px 间距基线、Header/Tabs/Footer、病例 4/3 列、订单 80% Drawer 和业务字段顺序。
- `research/diagnosis_final_visual_gate.md`
- 证明旧版 60%/80% 几何、fixed Footer、窄窗滚动和所有详情区曾完整入镜。
### 8.2 当前视觉门禁缺口
1. Diagnosis tests 原先明确断言选中 chip 为青色 `#CFFAFE`;审计结束时当前工作树已把三处断言同步为 `#F0F2FF`。此项已在改动层关闭,但仍需最终测试运行证明未回归。
2. 订单的截图测试只看尺寸和文件体积,不校验 80% 边界位置、Header/Footer 色、主色、scrim 或卡片色;可能在视觉回退时继续通过。
3. AI 报告完全没有 PNG/像素门禁。
4. `scripts/render_diagnosis_detail_visual.py``render_diagnosis_order_video_visual.py` 能重建诊疗与订单实图;当前工作树还新增了未跟踪的 `scripts/render_subwindow_exact.py`,聚焦生成诊疗 Drawer、订单 Drawer 和日常记录编辑器。`artifacts/subwindow_exact/prescription_ai_report_920x760.png` 虽已存在,但当前脚本版本不再覆盖它,且该图是处方模式;诊断 AI 的可重复 render 门禁仍缺失。`artifacts/diagnosis_visual` 仍是深色旧产物,蓝白实现完成后必须以新图验收,不可用旧图宣布通过。
5. 深色 replacement 表仍留在 `diagnosis_drawer.py` 作为未来暗色材料。当前注释说明默认 light 不执行它;后续实现不应删除未来暗色能力,也不能误把该 replacement 再无条件应用到默认模式。
## 9. 建议的实现优先级(仅供父任务使用)
1. **先完成并验证 Diagnosis scoped QSS 色板统一**:当前 replacement 表已经落地,下一步应验证青色/绿灰全部映射为蓝白 token,同时保持 60%/80%/窗口结构不动。
2. **再清理 inline style 漏点**:病例 subtitle/divider、banner 文本、订单 toolbar/meta 等应使用 objectName + 同一色板,避免局部仍冒出旧深色或青色。
3. **保留订单当前工作树的蓝白局部 QSS**,检查它与通用 `DIAGNOSIS_QSS` 的选择器优先级,避免按钮和表格被旧青色覆盖。
4. **以 AI 报告色板为一致性校准**,必要时把其主色从 `#5761F4` 微调到参考精确色 `#5265F6`;医疗 warning、成功、错误保留语义色。
5. **最后重渲并逐窗比较**:必须同时看 1024×640 与 1440×900 的诊疗/病例/订单,AI 看 920×760 与 720×560。旧深色 PNG 不再作为通过证据。
## 10. 最终可验收口径
- 诊疗:60% 右 Drawer,白 Header/Footer、近白蓝 Body、靛蓝 active/focus/primary,无青色残留;Tabs 与 Footer 不裁切。
- 病例:无 Tabs 的纵向白卡流,浅蓝 Hero,4/3 列病例仍可读;AI 报告 secondary 按钮位于患者信息卡标题行右侧。
- 订单:20% 淡蓝 scrim + 80% 白/浅蓝 Drawer;五金额卡、处方、收款、履约、物流、日志顺序完整;Footer 固定。
- AI 报告:920×760/720×560 都能完整显示标题、snapshot、医疗警示、双模型 Tabs、滚动报告与 Footer;主色与 `#5265F6` 同色系,无青色。
- 四窗共同:`#FCFDFE/#FFFFFF/#E2E7F4/#5265F6/#15224A/#7481A3` 形成稳定层级;34/40 px 控件节奏、7/10/12/14 px 圆角层级和 4 px 间距基线一致;语义色不被“全蓝化”。
@@ -12,20 +12,65 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
from doctor_workstation.ui.dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
)
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
class VisualRepository:
def list_medicines(self, **_filters: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
report = {
"summary": "益气健脾为主,兼顾养阴安神,适合脾气不足、气阴两虚方向的复核参考。",
"possible_symptoms": ["乏力气短", "食少便溏", "睡眠不安"],
"main_indications": "气阴两虚、脾气不足所见的倦怠与纳差。",
"efficacy": ["益气健脾", "养阴生津", "宁心安神"],
"suitable_people": ["辨证属气阴两虚者", "需由医师结合四诊确认"],
"compatibility_analysis": "黄芪、党参、白术与茯苓协同补气健脾,麦冬、五味子兼顾养阴敛津,酸枣仁与远志用于宁心安神。",
"cautions": ["仍需核对过敏史与现用药", "症状变化时及时复诊"],
"disclaimer": "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。",
}
return {
"prescription_id": template_id,
"can_refresh": True,
"can_edit": True,
"reports": [
{
"report_id": 21,
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-13 10:32:00",
"report": report,
"is_stale": False,
"is_edited": True,
},
{
"report_id": 22,
"model_key": "openai",
"model_label": "OpenAI",
"model_name": "gpt-5.6-sol",
"generated_at": "2026-08-13 10:32:00",
"report": report,
"is_stale": False,
"is_edited": False,
},
],
}
def _seed() -> dict[str, Any]:
return {
"diagnosis_id": 501,
"id": 2501,
"appointment_id": 2501,
"prescription_no": "RX202608130021",
"patient_name": "林晓岚",
"phone": "13800138000",
"gender": 0,
@@ -57,6 +102,8 @@ def _seed() -> dict[str, Any]:
"prescription_name": "宁心辅方",
},
"doctor_name": "陈医生",
"audit_status": 1,
"create_time": "2026-08-13 10:26:00",
"herbs": [
{"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"},
@@ -78,11 +125,6 @@ def _make_editor(host: QWidget) -> PrescriptionEditorDialog:
current_user=SimpleNamespace(id=1, name="陈医生"),
parent=host,
)
# Diagnosis-detail callers apply their scoped sheet after construction. The
# prescription surface owns its own scoped rules, so this also verifies that
# real entry path rather than a renderer-only appearance.
editor.setObjectName("DiagnosisPrescriptionEditor")
editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
return editor
@@ -101,43 +143,59 @@ def render() -> list[Path]:
if families:
app.setFont(QFont(families[0], 9))
output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual"
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True)
rendered: list[Path] = []
for host_width, height in ((1440, 900), (1024, 768), (920, 780)):
host = QWidget()
host.resize(host_width, height)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
editor.body_scroll.verticalScrollBar().setValue(0)
_settle(app, 3)
width = min(PrescriptionEditorDialog.DRAWER_WIDTH, host_width)
path = output / f"diagnosis_state_prescription_editor_{width}x{height}.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
_settle(app, 2)
host = QWidget()
host.resize(1440, 900)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
for index, state in ((1, "herbs"), (2, "usage"), (3, "signature")):
editor.tabs.setCurrentIndex(index)
_settle(app, 4)
path = output / f"diagnosis_state_prescription_editor_{state}_1200x900.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.body_scroll.verticalScrollBar().setValue(0)
_settle(app, 4)
path = output / "prescription_editor_860x900.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
detail = PrescriptionDetailDialog(
_seed(),
can_open_diagnosis=True,
can_open_orders=True,
)
detail.show()
_settle(app, 10)
path = output / "prescription_detail_920x780.png"
if not detail.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
detail.close()
_settle(app, 2)
ai_dialog = PrescriptionAiReportDialog(
VisualRepository(),
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
)
ai_dialog.open_for(
{
"id": 2501,
"prescription_name": "益气养阴安神方",
"formula_type": "主方",
"herbs": _seed()["herbs"],
}
)
ai_dialog.show()
_settle(app, 16)
path = output / "prescription_ai_report_920x760.png"
if not ai_dialog.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
ai_dialog.close()
_settle(app, 2)
return rendered
+78
View File
@@ -0,0 +1,78 @@
"""Render the 1710×920 reception AI acceptance artifact offscreen."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
app = QApplication.instance() or QApplication([])
apply_theme(app)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(4):
app.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
app.processEvents()
output = Path(__file__).resolve().parents[1] / "artifacts" / "reception_ai_exact" / "reception.png"
output.parent.mkdir(parents=True, exist_ok=True)
pixmap = window.grab()
if pixmap.size().width() != 1710 or pixmap.size().height() != 920:
raise RuntimeError(f"unexpected render size: {pixmap.size().width()}x{pixmap.size().height()}")
if not pixmap.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
page = window.pages.get("reception")
if page is not None:
left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry()
left_global = page.ai_analysis_card.mapTo(window, QPoint(0, 0))
right_global = page.ai_assistant_card.mapTo(window, QPoint(0, 0))
print(
"AI_CARDS",
left.x(),
left.y(),
left.width(),
left.height(),
right.x(),
right.y(),
right.width(),
right.height(),
page._ai_analysis_state,
)
print(
"AI_CARDS_GLOBAL",
left_global.x(),
left_global.y(),
right_global.x(),
right_global.y(),
)
print(output)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,93 @@
"""Render the structured reception medication/case card for visual review."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
app = QApplication.instance() or QApplication([])
apply_theme(app)
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(4):
app.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
page = window.pages.get("reception")
if page is None:
raise RuntimeError("reception page was not created")
page.detail_stack.setCurrentIndex(1)
medication_index = next(
index
for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "用药记录"
)
page.detail_tabs.setCurrentIndex(medication_index)
page._render_case(
{"has_prescription": True},
{"age": 56, "gender": 1},
{
"diagnosis_date": "2026-08-14",
"diagnosis_type_text": "复诊",
"systolic": 148,
"diastolic": 92,
"blood_pressure_status": "偏高",
"fasting_blood_sugar": "6.8",
"fasting_blood_sugar_status": "偏高",
"clinical_diagnosis": "2 型糖尿病(血糖控制未达标),合并高血压与轻度脂肪肝",
"current_medications": [
"二甲双胍缓释片 0.5 g,早晚餐后各一次",
"格列吡嗪片 5 mg,早餐前一次",
],
"allergy_history_text": "青霉素过敏",
"chief_complaint": "口干口苦、多饮多汗,近期睡眠欠佳",
"present_illness": (
"近两周空腹血糖波动,伴头昏、腰膝酸软及夜间多尿;"
"未发生明确低血糖,服药依从性一般。"
),
"tongue": "舌红,苔黄腻",
"pulse": "脉弦滑",
"treatment_principle": "益气养阴、清热利湿,兼顾健脾化痰",
"diabetes_history_text": "10 年",
"past_history_text": "高血压 6 年、高脂血症 3 年",
"family_history_text": "父亲患 2 型糖尿病",
"sleep_condition_text": "多梦、入睡困难",
"diet_condition_text": "主食量偏多,晚餐较晚",
"remark": "重点核对降糖药剂量,复查肝肾功能,并关注夜间低血糖风险。",
},
)
for _index in range(6):
app.processEvents()
output = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "reception_medication_case"
/ "reception_medication_case_1710x920.png"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not window.grab().save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
print(output)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+121
View File
@@ -0,0 +1,121 @@
"""Render focused blue-white diagnosis subwindow acceptance references."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from PySide6.QtGui import QFont, QFontDatabase # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from render_diagnosis_detail_visual import ( # noqa: E402
ScreenshotRepository,
_run_immediately,
)
from render_diagnosis_order_video_visual import order_detail # noqa: E402
from doctor_workstation.core import PermissionSet # noqa: E402
from doctor_workstation.services import DemoDoctorRepository # noqa: E402
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog # noqa: E402
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module # noqa: E402
from doctor_workstation.ui.dialogs import prescription_ai as ai_module # noqa: E402
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog # noqa: E402
from doctor_workstation.ui.dialogs.prescription_ai import ( # noqa: E402
DIAGNOSIS_AI_KIND,
PrescriptionAiReportDialog,
)
def _save(widget: object, path: Path, app: QApplication) -> None:
widget.show()
for _ in range(8):
app.processEvents()
if not widget.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
def main() -> int:
output = ROOT / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True)
app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
diagnosis_module.run_async = _run_immediately
ai_module.run_async = _run_immediately
host = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
host.resize(1280, 800)
host.open_for(501, editable=True)
_save(host, output / "diagnosis_edit_drawer_1280x800.png", app)
order = host._build_order_detail_dialog(order_detail(), 801)
_save(order, output / "diagnosis_order_detail_1280x800.png", app)
order.close()
host.close()
app.processEvents()
daily = DailyRecordEditorDialog(
"blood",
{
"id": 6101,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_time": "08:20",
"fasting_blood_sugar": 8.2,
"postprandial_blood_sugar": 12.4,
"systolic_pressure": 146,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
"remark": "继续观察餐后波动。",
},
)
daily.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
daily.resize(680, 660)
_save(daily, output / "diagnosis_daily_editor_680x660.png", app)
daily.close()
app.processEvents()
ai_report = PrescriptionAiReportDialog(
DemoDoctorRepository(),
permissions=PermissionSet(["*"]),
kind=DIAGNOSIS_AI_KIND,
)
ai_report.open_for(
{
"id": 501,
"diagnosis_id": 501,
"patient_name": "林晓岚",
"diagnosis_date": "2026-08-10",
"diagnosis_type": "复诊",
"syndrome_type": "气阴两虚",
"chief_complaint": "口干乏力,餐后血糖波动",
"present_illness": "近两周睡眠改善,仍需记录空腹与餐后血糖。",
"clinical_diagnosis": "2 型糖尿病",
"treatment_principle": "益气养阴,兼顾饮食与运动管理",
"doctor_advice": "连续记录七日血糖并按时复诊",
}
)
ai_report.resize(920, 760)
_save(ai_report, output / "diagnosis_ai_report_920x760.png", app)
ai_report.close()
app.processEvents()
for path in sorted(output.glob("*.png")):
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -16
View File
@@ -138,17 +138,20 @@ class DemoVideoDialog(QDialog):
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#0B1210;}"
"QLabel{color:#EAF2EE;}"
"QFrame#RemoteStage{background:#14211E;border:1px solid #2C403A;border-radius:18px;}"
"QFrame#LocalStage{background:#20312C;border:1px solid #3C554D;border-radius:14px;}"
"QPushButton{min-width:96px;min-height:42px;border-radius:21px;background:#253A34;"
"color:#F4F8F6;border:1px solid #3C554D;}"
"QPushButton:hover{background:#304A42;}"
"QPushButton#Hangup{background:#B94B44;border-color:#CF625B;}"
)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
@@ -159,7 +162,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#91B9AC;font-size:12px;")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
@@ -174,8 +177,8 @@ class DemoVideoDialog(QDialog):
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
@@ -184,7 +187,7 @@ class DemoVideoDialog(QDialog):
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#80948D;font-size:12px;")
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
@@ -194,7 +197,7 @@ class DemoVideoDialog(QDialog):
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
@@ -4,6 +4,7 @@ from .api_client import ApiClient
from .factory import build_repository
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
from .repository import (
DIAGNOSIS_AI_PERMISSIONS,
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
@@ -17,6 +18,7 @@ __all__ = [
"AuditAction",
"DEMO_PERMISSIONS",
"DemoDoctorRepository",
"DIAGNOSIS_AI_PERMISSIONS",
"DoctorRepository",
"KeyringLike",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
@@ -111,10 +111,13 @@ class ApiClient:
params: Mapping[str, Any] | None = None,
*,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue a GET request and return the unwrapped envelope data."""
return self.request("GET", endpoint, params=params, headers=headers)
return self.request(
"GET", endpoint, params=params, headers=headers, timeout=timeout
)
def post(
self,
@@ -123,13 +126,16 @@ class ApiClient:
*,
json: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue a non-retried JSON POST and return the unwrapped data."""
if payload is not None and json is not None:
raise ValueError("pass either payload or json, not both")
body = json if json is not None else payload
return self.request("POST", endpoint, json=body or {}, headers=headers)
return self.request(
"POST", endpoint, json=body or {}, headers=headers, timeout=timeout
)
def post_multipart(
self,
@@ -226,6 +232,7 @@ class ApiClient:
data: Mapping[str, Any] | None = None,
files: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue one API request with structured transport/envelope errors."""
@@ -245,6 +252,7 @@ class ApiClient:
)
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
for attempt in range(attempts):
try:
response = self._client.request(
@@ -255,7 +263,7 @@ class ApiClient:
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=self.timeout,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
"""Compatibility module exporting the production doctor repository."""
from .repository import (
DIAGNOSIS_AI_PERMISSIONS,
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
@@ -11,6 +12,7 @@ from .repository import (
__all__ = [
"AuditAction",
"DoctorRepository",
"DIAGNOSIS_AI_PERMISSIONS",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
+450 -176
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -41,9 +41,21 @@ PRESCRIPTION_LIBRARY_PERMISSIONS: Final[dict[str, str]] = {
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
"ai_reports": "tcm.prescriptionLibrary/aiReports",
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
}
"""Canonical permissions used by the routed prescription-library view."""
DIAGNOSIS_AI_PERMISSIONS: Final[dict[str, str]] = {
"ai_reports": "tcm.diagnosis/aiReports",
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
"edit_ai_report": "tcm.diagnosis/editAiReport",
"analysis": "tcm.diagnosis/aiAnalysis",
"assistant": "tcm.diagnosis/aiAssistant",
}
"""Canonical permissions used by reception and patient-profile AI reports."""
PRESCRIPTION_PERMISSIONS: Final[dict[str, str]] = {
"create": "cf.prescription/add",
"read": "cf.prescription/read",
@@ -140,6 +152,64 @@ class DoctorRepository(Protocol):
def delete_prescription_template(self, template_id: int) -> Any:
"""Delete a prescription-library record."""
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Return saved AI interpretation reports for one library template."""
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate every model report for one library template."""
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one library template."""
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Return saved AI reports for one diagnosis / patient profile."""
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate every model report for one diagnosis."""
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one diagnosis."""
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Ask the first-party diagnosis assistant; the server selects the model."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured reception analysis for a diagnosis."""
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every persisted AI diagnosis snapshot for one patient."""
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one model-specific AI diagnosis snapshot for one patient."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
@@ -619,38 +689,38 @@ class DoctorRepository(Protocol):
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`."""
@@ -1165,7 +1235,191 @@ class RemoteDoctorRepository:
return self.client.post("tcm.prescriptionLibrary/delete", {"id": template_id})
def list_medicines(
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Read persisted AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.prescriptionLibrary/aiReports",
{"id": template_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/aiReports"))
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/generateAiReports",
{"id": template_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/generateAiReports"))
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved model report."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/editAiReport",
{"id": template_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/editAiReport"))
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Read persisted diagnosis AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/aiReports",
{"id": diagnosis_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiReports"))
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generateAiReports",
{"id": diagnosis_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/generateAiReports"))
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved diagnosis report."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/editAiReport",
{"id": diagnosis_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/editAiReport"))
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Submit a diagnosis question to the first-party server assistant."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAssistant",
{"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task},
# The upstream is allowed 90 seconds by server configuration. Keep a
# small transport buffer so the desktop can receive the server's own
# timeout response instead of racing it.
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured analysis via the first-party API."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAnalysis",
{"id": diagnosis_id, "model": clean_model},
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAnalysis"))
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Read patient-level snapshots without triggering model generation."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/patientAiReports",
{"patient_id": patient_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/patientAiReports"))
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one patient snapshot; the desktop never sends provider secrets."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generatePatientAiReport",
{"patient_id": patient_id, "model": clean_model},
timeout=105.0,
)
return dict(
_require_mapping(payload, "tcm.diagnosis/generatePatientAiReport")
)
def list_medicines(
self,
*,
name: str = "",
@@ -2201,157 +2455,157 @@ class RemoteDoctorRepository:
ticket.diagnosis_id = diagnosis_id
return ticket
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
payload = self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
payload = self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
# Compatibility aliases keep UI naming independent from endpoint history.
def my_self(self) -> Session:
@@ -2402,6 +2656,26 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
def _material_kind(
material_type: str,
) -> Literal["image", "video", "file"]:
@@ -53,28 +53,28 @@ from .widgets import (
)
APPOINTMENT_DRAWER_QSS = r"""
QDialog#AppointmentDrawerOverlay {
background-color: transparent;
color: #134E4A;
font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
}
QDialog#AppointmentDrawerOverlay {
background-color: transparent;
color: #111F46;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
background-color: #FFFFFF;
border-left: 1px solid #DCDFE6;
border-left: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E2EBE8;
border-bottom: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
color: #134E4A;
font-size: 18px;
font-weight: 600;
color: #111F46;
font-size: 18px;
font-weight: 700;
}
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
@@ -86,14 +86,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
border: 0;
border-radius: 4px;
background-color: transparent;
color: #5B7A76;
color: #7886AA;
font-size: 22px;
font-weight: 400;
}
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
@@ -109,13 +109,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
}
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
color: #5B7A76;
color: #3F4E75;
font-size: 14px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
color: #5B7A76;
color: #7886AA;
}
QDialog#AppointmentDrawerOverlay QComboBox,
@@ -123,12 +123,12 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
min-height: 30px;
padding: 0 11px;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #E6EAF5;
border-radius: 9px;
background-color: #FFFFFF;
color: #134E4A;
selection-background-color: #A0CFFF;
selection-color: #134E4A;
color: #111F46;
selection-background-color: #5761F4;
selection-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
@@ -138,13 +138,13 @@ QDialog#AppointmentDrawerOverlay QPlainTextEdit {
QDialog#AppointmentDrawerOverlay QComboBox:hover,
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
border-color: #C0C4CC;
border-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QComboBox:focus,
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
border: 2px solid #79BBFF;
border: 2px solid #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
@@ -154,77 +154,79 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
background-color: #FFFFFF;
color: #134E4A;
border: 1px solid #D5E5E2;
selection-background-color: #ECFEFF;
selection-color: #0891B2;
color: #111F46;
border: 1px solid #E6EAF5;
selection-background-color: #5761F4;
selection-color: #FFFFFF;
outline: 0;
}
QDialog#AppointmentDrawerOverlay QRadioButton {
min-height: 24px;
spacing: 8px;
color: #134E4A;
color: #3F4E75;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
width: 12px;
height: 12px;
border-radius: 7px;
border: 1px solid #DCDFE6;
border: 1px solid #E6EAF5;
background-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
border-color: #0891B2;
border-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
width: 4px;
height: 4px;
border: 5px solid #0891B2;
border: 5px solid #5761F4;
border-radius: 7px;
background-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
color: #0891B2;
color: #4451E2;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
padding: 0;
border: 1px solid #DCDFE6;
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
min-height: 38px;
max-height: 38px;
padding: 0;
border: 1px solid #E6EAF5;
border-radius: 8px;
background-color: #FFFFFF;
color: #5B7A76;
color: #3F4E75;
font-size: 14px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
color: #0891B2;
border-color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
color: #FFFFFF;
border-color: #0891B2;
background-color: #0891B2;
border-color: #5761F4;
background-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
background-color: #F8F9FA;
background-color: #F7F9FE;
border: 0;
border-radius: 8px;
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
color: #134E4A;
color: #111F46;
font-size: 15px;
font-weight: 600;
}
@@ -236,27 +238,27 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
border: 0;
border-radius: 4px;
background-color: transparent;
color: #0891B2;
color: #4451E2;
font-size: 13px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
background-color: #ECFEFF;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
min-width: 110px;
min-height: 70px;
padding: 0 8px;
border: 2px solid #D5E5E2;
border: 2px solid #E6EAF5;
border-radius: 8px;
background-color: #FFFFFF;
color: #134E4A;
color: #111F46;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
color: #134E4A;
color: #111F46;
font-size: 15px;
font-weight: 600;
}
@@ -265,33 +267,33 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#Appo
padding: 0 8px;
border-radius: 4px;
background-color: #F4F4F5;
color: #5B7A76;
color: #7886AA;
font-size: 12px;
font-weight: 400;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
color: #67C23A;
background-color: #F0F9FF;
color: #17A77D;
background-color: #EAF9F3;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
color: #0891B2;
border-color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
color: #FFFFFF;
border-color: #0891B2;
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 #0891B2,
stop:1 #66B1FF
border-color: #5761F4;
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 #5761F4,
stop:1 #7769F7
);
}
@@ -305,18 +307,18 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLa
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
color: #C0C4CC;
border-color: #D5E5E2;
background-color: #F5F7FA;
color: #A4ADC3;
border-color: #E6EAF5;
background-color: #F0F2F8;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
color: #C0C4CC;
color: #A4ADC3;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
background-color: #F5F7FA;
background-color: #F0F2F8;
}
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
@@ -324,94 +326,94 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
color: #5B7A76;
color: #7886AA;
font-size: 14px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
background-color: #ECFEFF;
border: 1px solid #D9ECFF;
border-radius: 4px;
background-color: #F0F4FF;
border: 1px solid #DDE5FF;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] {
background-color: #FDF6EC;
border: 1px solid #FAECD8;
border-radius: 4px;
background-color: #FFF5E6;
border: 1px solid #F6E3C4;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] {
background-color: #FEF0F0;
border: 1px solid #FDE2E2;
border-radius: 4px;
background-color: #FFF1F3;
border: 1px solid #F7D7DC;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
background-color: #F0F9EB;
border: 1px solid #E1F3D8;
border-radius: 4px;
background-color: #EAF9F3;
border: 1px solid #D4F0E5;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
color: #0891B2;
color: #4D69ED;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
color: #E6A23C;
color: #D38625;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
color: #F56C6C;
color: #F15B67;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
color: #67C23A;
color: #17A77D;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
background-color: #FFFFFF;
border: 0;
border-top: 1px solid #E2EBE8;
border-top: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
min-height: 30px;
max-height: 30px;
padding: 0 15px;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #E6EAF5;
border-radius: 9px;
background-color: #FFFFFF;
color: #5B7A76;
font-size: 14px;
font-weight: 500;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
color: #0891B2;
border-color: #A5F3FC;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
color: #FFFFFF;
border-color: #0891B2;
background-color: #0891B2;
border-color: #5761F4;
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
color: #FFFFFF;
border-color: #66B1FF;
background-color: #66B1FF;
border-color: #4C57E9;
background-color: #4C57E9;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
color: #FFFFFF;
border-color: #A0CFFF;
background-color: #A0CFFF;
border-color: #E6EAF5;
background-color: #A4ADC3;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
@@ -420,7 +422,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
color: #5B7A76;
color: #7886AA;
font-size: 14px;
}
@@ -434,11 +436,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
min-height: 30px;
border-radius: 3px;
background-color: #DCDFE6;
background-color: #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
background-color: #C0C4CC;
background-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
+204 -154
View File
@@ -1,9 +1,9 @@
"""Scoped visual primitives for diagnosis readonly pages and edit drawers.
Diagnosis chrome uses the workstation clinical teal palette (not Element blue)
so the drawer feels native to the desktop app while remaining visually isolated
from other pages via object-name selectors.
"""
"""Scoped visual primitives for diagnosis readonly pages and edit drawers.
The diagnosis workspace uses the same cool white, indigo and blue-gray visual
language as the desktop shell. Every rule remains isolated behind diagnosis
object names and dynamic properties so adjacent pages keep their own styling.
"""
from __future__ import annotations
@@ -40,7 +40,6 @@ from PySide6.QtWidgets import (
QLineEdit,
QPlainTextEdit,
QPushButton,
QRadioButton,
QScrollBar,
QSizePolicy,
QTableWidget,
@@ -384,9 +383,11 @@ QLabel#DiagnosisUnitLabel {
}
QLineEdit[diagnosisField="true"],
QComboBox[diagnosisField="true"],
QPlainTextEdit[diagnosisField="true"],
QDateEdit[diagnosisField="true"],
QDoubleSpinBox[diagnosisField="true"] {
QPlainTextEdit[diagnosisField="true"],
QDateEdit[diagnosisField="true"],
QDoubleSpinBox[diagnosisField="true"],
QSpinBox[diagnosisField="true"],
QTimeEdit[diagnosisField="true"] {
color: #134E4A;
background-color: #FFFFFF;
border: 1px solid #D5E5E2;
@@ -405,9 +406,11 @@ QPlainTextEdit[diagnosisField="true"] {
}
QLineEdit[diagnosisField="true"]:focus,
QComboBox[diagnosisField="true"]:focus,
QPlainTextEdit[diagnosisField="true"]:focus,
QDateEdit[diagnosisField="true"]:focus,
QDoubleSpinBox[diagnosisField="true"]:focus {
QPlainTextEdit[diagnosisField="true"]:focus,
QDateEdit[diagnosisField="true"]:focus,
QDoubleSpinBox[diagnosisField="true"]:focus,
QSpinBox[diagnosisField="true"]:focus,
QTimeEdit[diagnosisField="true"]:focus {
border: 1px solid #0891B2;
background-color: #F0FDFA;
}
@@ -421,9 +424,11 @@ QLineEdit[diagnosisField="true"]:read-only,
QPlainTextEdit[diagnosisField="true"]:read-only,
QLineEdit[diagnosisField="true"]:disabled,
QComboBox[diagnosisField="true"]:disabled,
QPlainTextEdit[diagnosisField="true"]:disabled,
QDateEdit[diagnosisField="true"]:disabled,
QDoubleSpinBox[diagnosisField="true"]:disabled {
QPlainTextEdit[diagnosisField="true"]:disabled,
QDateEdit[diagnosisField="true"]:disabled,
QDoubleSpinBox[diagnosisField="true"]:disabled,
QSpinBox[diagnosisField="true"]:disabled,
QTimeEdit[diagnosisField="true"]:disabled {
color: #5B7A76;
background-color: #F0F2EF;
border-color: #D9DEDA;
@@ -460,11 +465,40 @@ QLabel#DiagnosisReadonlyTitle {
font-size: 16px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyPatientName {
color: #0F172A;
font-size: 18px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyPatientName {
color: #0F172A;
font-size: 18px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyHeroMeta,
QLabel#DiagnosisPrivacyText,
QLabel#DiagnosisOrderOffsetPreview,
QLabel#DiagnosisOrdersSummary {
color: #64748B;
font-size: 12px;
}
QLabel#DiagnosisOrderOffsetLabel {
color: #1F2937;
font-size: 13px;
font-weight: 600;
}
QPushButton#DiagnosisOrderOffsetHelp {
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
padding: 0;
color: #0E7490;
background-color: #CFFAFE;
border: 1px solid #A5F3FC;
border-radius: 11px;
font-weight: 700;
}
QPushButton#DiagnosisOrderOffsetHelp:hover,
QPushButton#DiagnosisOrderOffsetHelp:focus {
background-color: #A5F3FC;
border-color: #22D3EE;
}
QLabel#DiagnosisReadonlyStatus[severity="neutral"] {
color: #9AA39E;
background-color: rgba(255, 255, 255, 190);
@@ -775,11 +809,35 @@ QLabel#DiagnosisUnsupportedState {
padding: 22px;
font-size: 13px;
}
QDialog#DiagnosisDailyEditor,
QDialog#DiagnosisOrderDetailDialog,
QDialog#DiagnosisRecordingPlayer {
background-color: #FFFFFF;
}
QDialog#DiagnosisDailyEditor,
QDialog#DiagnosisOrderDetailDialog,
QDialog#DiagnosisRecordingPlayer {
background-color: #FFFFFF;
}
QDialog#DiagnosisDailyEditor {
background-color: #F7F9FE;
}
QFrame#DiagnosisEditorHeader,
QFrame#DiagnosisEditorFooter {
background-color: #FFFFFF;
border: 0;
}
QFrame#DiagnosisEditorHeader {
border-bottom: 1px solid #E6EAF5;
}
QFrame#DiagnosisEditorFooter {
border-top: 1px solid #E6EAF5;
}
QWidget#DiagnosisEditorContent {
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 12px;
}
QWidget#DiagnosisEditorContent QLabel {
color: #3F4E75;
font-size: 12px;
font-weight: 600;
}
QLabel#DiagnosisDialogHeading {
color: #0F172A;
font-size: 18px;
@@ -797,12 +855,17 @@ QLabel#DiagnosisEditorError {
border-radius: 7px;
padding: 8px 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisOrderDetailScroll {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisOrderDetailScroll {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisEditorScroll > QWidget > QWidget {
background-color: #F7F9FE;
border: 0;
}
QFrame#DiagnosisOrderOffsetBar,
QFrame#DiagnosisOrderDetailHero {
background-color: #F8FAFC;
@@ -875,89 +938,76 @@ QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox:focus {
}
"""
# Keep the component-scoped rules authoritative while applying one restrained
# dark indigo visual system across every nested editor, table, state and card.
_DIAGNOSIS_DARK_REPLACEMENTS = (
("color: #FFFFFF", "color: #EEF2FF"),
("color:#FFFFFF", "color:#EEF2FF"),
("selection-color: #134E4A", "selection-color: #EEF2FF"),
("selection-background-color: #CFFAFE", "selection-background-color: #29334F"),
("selection-background-color: #A5F3FC", "selection-background-color: #6675F5"),
("background-color: #0F172A", "background-color: #080B14"),
("color: #CBD5E1", "color: #9AA7C0"),
("rgba(15, 23, 42, 112)", "rgba(8, 11, 20, 196)"),
("rgba(255, 255, 255, 218)", "rgba(8, 11, 20, 224)"),
("rgba(255, 255, 255, 210)", "rgba(8, 11, 20, 216)"),
("rgba(255, 255, 255, 190)", "rgba(21, 29, 49, 230)"),
("#FFFFFF", "#101626"),
("#F5F8F7", "#080B14"),
("#F6F6F6", "#080B14"),
("#F8FAFC", "#151D31"),
("#F1F5F9", "#151D31"),
("#F0F2EF", "#151D31"),
("#F0F5F3", "#151D31"),
("#F3F4F6", "#151D31"),
("#FAFAFA", "#151D31"),
("#ECFEFF", "#151D31"),
("#CFFAFE", "#151D31"),
("#FFFBEB", "#151D31"),
("#F0FDF4", "#151D31"),
("#FEF2F2", "#151D31"),
("#FEE2E2", "#1B2440"),
("#ECFDF5", "#151D31"),
("#FFF7F7", "#151D31"),
("#FFF7ED", "#151D31"),
("#A5F3FC", "#1B2440"),
("#67E8F9", "#1B2440"),
("#E2E8F0", "#29334F"),
("#DCE3EC", "#29334F"),
("#CBD5E1", "#29334F"),
("#FCD34D", "#29334F"),
("#BBF7D0", "#29334F"),
("#FECACA", "#29334F"),
("#D5E5E2", "#29334F"),
("#E2EBE8", "#29334F"),
("#D9DEDA", "#29334F"),
("#CBD3CE", "#29334F"),
("#E5E7EB", "#29334F"),
("#FED7AA", "#29334F"),
("#D9ECFF", "#29334F"),
("#E6EBF2", "#29334F"),
("#0F172A", "#EEF2FF"),
("#134E4A", "#EEF2FF"),
("#1F2937", "#EEF2FF"),
("#333333", "#EEF2FF"),
("#64748B", "#9AA7C0"),
("#5B7A76", "#9AA7C0"),
("#475569", "#9AA7C0"),
("#2A6B64", "#9AA7C0"),
("#66736D", "#9AA7C0"),
("#6B7280", "#9AA7C0"),
("#999999", "#9AA7C0"),
("#9AA39E", "#9AA7C0"),
("#94A8A4", "#9AA7C0"),
("#C0C4CC", "#9AA7C0"),
("#0891B2", "#6675F5"),
("#0E7490", "#6675F5"),
("#22D3EE", "#78A7FF"),
("#16A34A", "#49C6A5"),
("#15803D", "#49C6A5"),
("#B45309", "#E4B967"),
("#EA580C", "#E4B967"),
("#F97316", "#E4B967"),
("#DC2626", "#F07886"),
("#B91C1C", "#F07886"),
("#F56C6C", "#F07886"),
("#FCA5A5", "#F07886"),
("#F0FDFA", "#1B2440"),
("#F8F8F8", "#151D31"),
# Run last so foreground white remains readable while former white
# surfaces stay in the dark elevation system.
("background-color: #EEF2FF", "background-color: #101626"),
("background: #EEF2FF", "background: #101626"),
)
# 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.
# Normalize the legacy component palette in one place. Keeping the selectors
# untouched protects the mature drawer behavior while aligning every nested
# editor, table and state with the shell's current blue-white theme.
_DIAGNOSIS_BLUE_REPLACEMENTS = (
("#0891B2", "#5265F6"),
("#0E7490", "#4D57D8"),
("#22D3EE", "#6871F6"),
("#67E8F9", "#C9CEFF"),
("#A5F3FC", "#D8DCFF"),
("#CFFAFE", "#F0F2FF"),
("#ECFEFF", "#F5F8FF"),
("#D9ECFF", "#DDE7FF"),
("#134E4A", "#15224A"),
("#2A6B64", "#3F4E75"),
("#5B7A76", "#7481A3"),
("#66736D", "#7481A3"),
("#94A8A4", "#A4ADC3"),
("#67B8C9", "#6871F6"),
("#D5E5E2", "#E2E7F4"),
("#E2EBE8", "#E2E7F4"),
("#D9DEDA", "#E2E7F4"),
("#CBD3CE", "#DCE3F2"),
("#F5F8F7", "#FCFDFE"),
("#F0F2EF", "#F2F6FE"),
("#F0F5F3", "#F7F9FE"),
("#F0FDFA", "#F7F9FE"),
("#0F172A", "#15224A"),
("#1F2937", "#15224A"),
("#333333", "#15224A"),
("#475569", "#3F4E75"),
("#64748B", "#7481A3"),
("#6B7280", "#7481A3"),
("#999999", "#A4ADC3"),
("#9AA39E", "#A4ADC3"),
("#C0C4CC", "#A4ADC3"),
("#F1F5F9", "#F7F9FE"),
("#E2E8F0", "#E2E7F4"),
("#DCE3EC", "#E2E7F4"),
("#CBD5E1", "#DCE3F2"),
("#E5E7EB", "#E2E7F4"),
("#E6EBF2", "#E2E7F4"),
("#F8FAFC", "#FCFDFE"),
("#F6F6F6", "#FCFDFE"),
("#FAFAFA", "#FCFDFE"),
("#F8F8F8", "#F7F9FE"),
("#16A34A", "#17A77D"),
("#15803D", "#17A77D"),
("#F0FDF4", "#EAF9F3"),
("#ECFDF5", "#EAF9F3"),
("#BBF7D0", "#BFE9DC"),
("#A7F3D0", "#BFE9DC"),
("#B45309", "#D38625"),
("#EA580C", "#D38625"),
("#F97316", "#D38625"),
("#FFFBEB", "#FFF5E6"),
("#FFF7ED", "#FFF5E6"),
("#FCD34D", "#F3D6AC"),
("#FDE68A", "#F3D6AC"),
("#FED7AA", "#F3D6AC"),
("#DC2626", "#F15B67"),
("#B91C1C", "#D94856"),
("#F56C6C", "#F15B67"),
("#FEF2F2", "#FFF1F3"),
("#FFF7F7", "#FFF1F3"),
("#FEE2E2", "#FFE4E8"),
("#FECACA", "#F7C8CD"),
("#FCA5A5", "#F19BA4"),
)
for _source_color, _theme_color in _DIAGNOSIS_BLUE_REPLACEMENTS:
DIAGNOSIS_QSS = DIAGNOSIS_QSS.replace(_source_color, _theme_color)
def _text(value: Any, default: str = "") -> str:
@@ -1037,7 +1087,7 @@ 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 "#D8DEEA"))
painter.setBrush(QColor("#5761F4" if checked else "#D8DEEE"))
painter.drawRoundedRect(track, track.height() / 2, track.height() / 2)
diameter = track.height() - 4
x = track.right() - diameter - 2 if checked else track.left() + 2
@@ -1081,9 +1131,9 @@ class SaveStateButton(QPushButton):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
enabled = self.isEnabled()
color = {
"success": "#16876C",
"error": "#C43E55",
}.get(self._state, "#4F63D9" if enabled else "#98A2B3")
"success": "#17A77D",
"error": "#F15B67",
}.get(self._state, "#5761F4" if enabled else "#A4ADC3")
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 10, 10)
@@ -1602,18 +1652,18 @@ class MessageStrip(QFrame):
self.glyph = QLabel("i")
self.glyph.setFixedWidth(18)
self.glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.glyph.setStyleSheet("font-weight:700; color:#2F6EDB;")
self.glyph.setStyleSheet("font-weight:700; color:#4D69ED;")
self.label = QLabel()
self.label.setWordWrap(True)
self.label.setStyleSheet("color:#667085; font-size:12px;")
self.label.setStyleSheet("color:#3F4E75; font-size:12px;")
layout.addWidget(self.glyph)
layout.addWidget(self.label, 1)
self.action_button = QPushButton()
self.action_button.setObjectName("DiagnosisMessageAction")
self.action_button.setStyleSheet(
"QPushButton{min-height:28px;padding:0 10px;color:#3446AF;background:#E9EDFF;"
"border:1px solid #C8D1FF;border-radius:7px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{background:#DCE3FF;border-color:#4F63D9;}"
"QPushButton{min-height:28px;padding:0 10px;color:#4451E2;background:#F0F2FF;"
"border:1px solid #D3D8FF;border-radius:7px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{background:#E4E7FF;border-color:#5761F4;}"
)
self.action_button.clicked.connect(self.action_requested)
self.action_button.hide()
@@ -1772,15 +1822,15 @@ class RecordTable(QTableWidget):
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
if column_index in danger_columns:
item.setForeground(QColor("#F07886"))
item.setForeground(QColor("#D94856"))
kind = semantic.get(column_index)
if kind:
palette = {
"success": ("#49C6A5", "#1B2440"),
"warning": ("#E4B967", "#1B2440"),
"danger": ("#F07886", "#1B2440"),
"info": ("#78A7FF", "#1B2440"),
"neutral": ("#9AA7C0", "#1B2440"),
"success": ("#137A61", "#EAF9F3"),
"warning": ("#A86616", "#FFF5E6"),
"danger": ("#D94856", "#FFF1F3"),
"info": ("#4059D8", "#F0F4FF"),
"neutral": ("#64739A", "#F5F7FC"),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -1820,20 +1870,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("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "当前时间范围暂无血糖数据")
painter.end()
return
upper = max(12.0, max(values) * 1.15)
painter.setPen(QPen(QColor("#29334F"), 1))
painter.setPen(QPen(QColor("#E6EAF5"), 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("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(
4, y - 8, 40, 16, Qt.AlignmentFlag.AlignRight, f"{upper * step / 4:.0f}"
)
painter.setPen(QPen(QColor("#29334F"), 1))
painter.setPen(QPen(QColor("#E6EAF5"), 1))
count = max(1, len(self._dates) - 1)
def point(index: int, value: float) -> QPointF:
@@ -1843,8 +1893,8 @@ class BloodTrendChart(QWidget):
)
for series, color in (
(self._fasting, QColor("#6675F5")),
(self._postprandial, QColor("#E4B967")),
(self._fasting, QColor("#5761F4")),
(self._postprandial, QColor("#D38625")),
):
previous: QPointF | None = None
painter.setPen(QPen(color, 2.5))
@@ -1858,12 +1908,12 @@ class BloodTrendChart(QWidget):
painter.drawLine(previous, current)
painter.drawEllipse(current, 3.2, 3.2)
previous = current
painter.setPen(QColor("#6675F5"))
painter.setPen(QColor("#5761F4"))
painter.drawText(plot.left(), 4, 94, 18, Qt.AlignmentFlag.AlignLeft, "● 空腹血糖")
painter.setPen(QColor("#E4B967"))
painter.setPen(QColor("#D38625"))
painter.drawText(plot.left() + 100, 4, 110, 18, Qt.AlignmentFlag.AlignLeft, "● 餐后血糖")
if self._dates:
painter.setPen(QColor("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(
plot.left(),
plot.bottom() + 8,
@@ -2061,7 +2111,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:#9AA7C0; font-size:12px;")
self.todo_summary.setStyleSheet("color:#7886AA; font-size:12px;")
todo_layout.addWidget(self.todo_summary, 0, Qt.AlignmentFlag.AlignRight)
root.addWidget(todo_card)
self.clear()
@@ -2215,8 +2265,8 @@ class DailyRecordPanel(QFrame):
has_records = False
for row_index, (metric, label) in enumerate(self.METRICS):
label_item = QTableWidgetItem(label)
label_item.setForeground(QColor("#9AA7C0"))
label_item.setBackground(QColor("#151D31"))
label_item.setForeground(QColor("#64739A"))
label_item.setBackground(QColor("#F5F7FC"))
self.matrix.setItem(row_index, 0, label_item)
for date_index, date in enumerate(dates, 1):
b = blood.get(date)
@@ -2269,11 +2319,11 @@ class DailyRecordPanel(QFrame):
item = QTableWidgetItem(value)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if high:
item.setForeground(QColor("#F07886"))
item.setBackground(QColor("#1B2440"))
item.setForeground(QColor("#D94856"))
item.setBackground(QColor("#FFF1F3"))
elif value.endswith("· 自录"):
item.setForeground(QColor("#78A7FF"))
item.setBackground(QColor("#1B2440"))
item.setForeground(QColor("#4059D8"))
item.setBackground(QColor("#F0F4FF"))
edit_kind = ""
edit_source: Any = None
if metric in {"fasting", "postprandial", "other", "bp", "western", "insulin"}:
@@ -2764,7 +2814,7 @@ class ChatPanel(QWidget):
toolbar_layout.setContentsMargins(0, 0, 0, 0)
toolbar_layout.setSpacing(8)
archive = QLabel("仅展示服务端已归档消息(only_archived=1")
archive.setStyleSheet("color:#9AA7C0;font-size:12px;")
archive.setStyleSheet("color:#7886AA;font-size:12px;")
toolbar_layout.addWidget(archive)
toolbar_layout.addStretch(1)
self.sync_button = QPushButton("同步最新(后台异步)")
@@ -2992,7 +3042,7 @@ class CaseGrid(QFrame):
title_row.addWidget(self.title_label)
self.subtitle = QLabel("病例 · 诊断日期 —")
self.subtitle.setStyleSheet(
'color:#9AA7C0;font-size:12px;font-family:"IBM Plex Mono",Consolas,monospace;'
'color:#7886AA;font-size:12px;font-family:"IBM Plex Mono",Consolas,monospace;'
)
title_row.addWidget(self.subtitle)
title_row.addStretch(1)
@@ -3002,7 +3052,7 @@ class CaseGrid(QFrame):
if group_index:
divider = QFrame()
divider.setFrameShape(QFrame.Shape.HLine)
divider.setStyleSheet("border:0; border-top:1px dashed #29334F;")
divider.setStyleSheet("border:0; border-top:1px dashed #D8DEEE;")
self.root.addWidget(divider)
group_title = QLabel(f"{group_name}")
group_title.setProperty("diagnosisCaseGroup", True)
@@ -3155,11 +3205,11 @@ def readonly_card(title: str, object_name: str, body: QWidget) -> QFrame:
def set_tag_item(item: QTableWidgetItem, kind: str) -> None:
palette = {
"success": ("#49C6A5", "#1B2440"),
"warning": ("#E4B967", "#1B2440"),
"danger": ("#F07886", "#1B2440"),
"info": ("#78A7FF", "#1B2440"),
"neutral": ("#9AA7C0", "#1B2440"),
"success": ("#137A61", "#EAF9F3"),
"warning": ("#A86616", "#FFF5E6"),
"danger": ("#D94856", "#FFF1F3"),
"info": ("#4059D8", "#F0F4FF"),
"neutral": ("#64739A", "#F5F7FC"),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QDialog,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QLayout,
@@ -161,27 +162,45 @@ class DailyRecordEditorDialog(QDialog):
self.resize(650, 620 if kind == "blood" else 590)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(14)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
header = QFrame()
header.setObjectName("DiagnosisEditorHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(22, 18, 22, 16)
header_layout.setSpacing(5)
heading = QLabel(self.windowTitle())
heading.setObjectName("DiagnosisDialogHeading")
root.addWidget(heading)
header_layout.addWidget(heading)
guidance = QLabel("保存后将重新加载当前日期范围;带 * 的字段为必填项。")
guidance.setObjectName("DiagnosisDialogGuidance")
guidance.setWordWrap(True)
root.addWidget(guidance)
header_layout.addWidget(guidance)
root.addWidget(header)
scroll = QScrollArea()
scroll.setObjectName("DiagnosisEditorScroll")
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
content.setObjectName("DiagnosisEditorContent")
self.form = QFormLayout(content)
self.form.setContentsMargins(8, 8, 12, 8)
self.form.setHorizontalSpacing(18)
self.form.setVerticalSpacing(11)
self.form.setContentsMargins(22, 20, 22, 22)
self.form.setHorizontalSpacing(20)
self.form.setVerticalSpacing(12)
self.form.setLabelAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
scroll.setWidget(content)
root.addWidget(scroll, 1)
footer = QFrame()
footer.setObjectName("DiagnosisEditorFooter")
footer_layout = QVBoxLayout(footer)
footer_layout.setContentsMargins(22, 12, 22, 14)
footer_layout.setSpacing(10)
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
@@ -202,8 +221,10 @@ class DailyRecordEditorDialog(QDialog):
self.error_label.setObjectName("DiagnosisEditorError")
self.error_label.setWordWrap(True)
self.error_label.hide()
root.addWidget(self.error_label)
footer_layout.addWidget(self.error_label)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(8)
actions.addStretch(1)
cancel = QPushButton("取消")
cancel.setProperty("variant", "ghost")
@@ -215,7 +236,8 @@ class DailyRecordEditorDialog(QDialog):
save.setDefault(True)
save.clicked.connect(self.accept)
actions.addWidget(save)
root.addLayout(actions)
footer_layout.addLayout(actions)
root.addWidget(footer)
@staticmethod
def _field(widget: QWidget) -> QWidget:
@@ -63,10 +63,10 @@ from PySide6.QtWidgets import (
from .widgets import display_text, first_value, gender_text, get_value
PRIMARY = QColor("#4F63D9")
TEXT = QColor("#172033")
SECONDARY = QColor("#667085")
PLACEHOLDER = QColor("#98A2B3")
PRIMARY = QColor("#5265F6")
TEXT = QColor("#15224A")
SECONDARY = QColor("#7481A3")
PLACEHOLDER = QColor("#A4ADC3")
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 340)
@@ -79,7 +79,7 @@ def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
color = QColor("#C43E55" if danger else "#667085")
color = QColor("#C43E55" if danger else "#667085")
pen = QPen(color, 1.6)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
@@ -146,16 +146,14 @@ class _DiagnosisActionMenu(QMenu):
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.fillRect(rect, QColor("#FFFFFF"))
painter.fillRect(rect, QColor("#FFFFFF"))
item_rect = rect
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(
QColor("#EEF2F8") if self.activeAction() is action else QColor("#FFFFFF")
)
painter.setBrush(QColor("#EEF2F8") if self.activeAction() is action else QColor("#FFFFFF"))
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("#C43E55" if action.isEnabled() else "#98A2B3"))
painter.setPen(QColor("#C43E55" if action.isEnabled() else "#98A2B3"))
painter.drawText(
item_rect.adjusted(34, 0, -10, 0),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
@@ -684,7 +682,7 @@ class DiagnosisHeader(QHeaderView):
center_y = rect.center().y()
painter.save()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(PRIMARY if direction == "asc" else QColor("#667085"))
painter.setBrush(PRIMARY if direction == "asc" else QColor("#667085"))
painter.drawPolygon(
QPolygon(
(
@@ -694,7 +692,7 @@ class DiagnosisHeader(QHeaderView):
)
)
)
painter.setBrush(PRIMARY if direction == "desc" else QColor("#667085"))
painter.setBrush(PRIMARY if direction == "desc" else QColor("#667085"))
painter.drawPolygon(
QPolygon(
(
@@ -742,15 +740,15 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
return
record = model.record(index.row())
painter.save()
self._paint_row_background(
painter,
option.rect,
record,
index.row(),
index.column(),
model,
selected=bool(option.state & QStyle.StateFlag.State_Selected),
)
self._paint_row_background(
painter,
option.rect,
record,
index.row(),
index.column(),
model,
selected=bool(option.state & QStyle.StateFlag.State_Selected),
)
if index.column() == 0:
self._paint_checkbox(painter, option, index)
elif index.column() in {1, 2}:
@@ -762,7 +760,7 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
option.rect,
"已确认" if _confirmed(record) else "未确认",
QColor("#16876C") if _confirmed(record) else QColor("#9A6813"),
QColor("#16876C") if _confirmed(record) else QColor("#9A6813"),
bold=not _confirmed(record),
)
elif index.column() == 6:
@@ -821,22 +819,22 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
return super().editorEvent(event, model, option, index)
@staticmethod
def _paint_row_background(
def _paint_row_background(
painter: QPainter,
rect: QRect,
record: Any,
row: int,
column: int,
model: DiagnosisTableModel,
*,
selected: bool = False,
) -> None:
hovered = model.hover_row == row
base = (
QColor("#DCE3FF")
if selected
else QColor("#EEF2F8" if hovered else "#F7F8FC" if row % 2 else "#FFFFFF")
)
column: int,
model: DiagnosisTableModel,
*,
selected: bool = False,
) -> None:
hovered = model.hover_row == row
base = (
QColor("#DCE3FF")
if selected
else QColor("#EEF2F8" if hovered else "#F7F8FC" if row % 2 else "#FFFFFF")
)
special = not _confirmed(record) or not _has_appointment(record)
if special:
global_left = sum(_TABLE_COLUMN_WIDTHS[:column])
@@ -848,19 +846,19 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
rect.top(),
)
if not _confirmed(record):
gradient.setColorAt(0, QColor(228, 185, 103, 48 if hovered or selected else 30))
gradient.setColorAt(0, QColor(228, 185, 103, 48 if hovered or selected else 30))
gradient.setColorAt(1, base)
stripe = QColor("#9A6813")
stripe = QColor("#9A6813")
else:
gradient.setColorAt(0, QColor(120, 167, 255, 42 if hovered or selected else 24))
gradient.setColorAt(0, QColor(120, 167, 255, 42 if hovered or selected else 24))
gradient.setColorAt(1, base)
stripe = QColor("#2F6EDB")
stripe = QColor("#2F6EDB")
painter.fillRect(rect, gradient)
if column == 0:
painter.fillRect(QRect(rect.left(), rect.top(), 3, rect.height()), stripe)
else:
painter.fillRect(rect, base)
painter.setPen(QPen(QColor("#D8DEEA"), 1))
painter.setPen(QPen(QColor("#D8DEEA"), 1))
painter.drawLine(QLine(rect.bottomLeft(), rect.bottomRight()))
@staticmethod
@@ -886,7 +884,7 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
"",
)
content = rect.adjusted(8, 4, -6, -4)
self._draw_text(painter, content, text, TEXT, 14, bold=True)
self._draw_text(painter, content, text, TEXT, 14, bold=True)
if (
column == 1
and get_value(record, "assign_read_at", "sentinel") is None
@@ -898,8 +896,8 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(left, content.center().y() - 10, 31, 20),
"NEW",
QColor("#C43E55"),
QColor("#FFFFFF"),
QColor("#C43E55"),
QColor("#FFFFFF"),
10,
)
@@ -911,17 +909,17 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
rows = _appointments(record)
statuses = {_as_int(first_value(row, "status", "appointment_status"), -1) for row in rows}
if 1 in statuses:
border = QColor(102, 117, 245, 112)
start = QColor(102, 117, 245, 58)
end = QColor(102, 117, 245, 24)
border = QColor(102, 117, 245, 112)
start = QColor(102, 117, 245, 58)
end = QColor(102, 117, 245, 24)
elif 4 in statuses:
border = QColor(228, 185, 103, 112)
start = QColor(228, 185, 103, 54)
end = QColor(228, 185, 103, 22)
border = QColor(228, 185, 103, 112)
start = QColor(228, 185, 103, 54)
end = QColor(228, 185, 103, 22)
else:
border = QColor(154, 167, 192, 92)
start = QColor(154, 167, 192, 42)
end = QColor(154, 167, 192, 18)
border = QColor(154, 167, 192, 92)
start = QColor(154, 167, 192, 42)
end = QColor(154, 167, 192, 18)
gradient = QLinearGradient(content.topLeft(), content.bottomRight())
gradient.setColorAt(0, start)
gradient.setColorAt(1, end)
@@ -932,10 +930,10 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
for position, appointment in enumerate(rows or [{}]):
status = _as_int(first_value(appointment, "status", "appointment_status"), -1)
label, fg, bg = {
1: ("已预约", QColor("#2F6EDB"), QColor("#EAF2FF")),
3: ("已完成", QColor("#16876C"), QColor("#E8F6F1")),
4: ("已过号", QColor("#9A6813"), QColor("#FFF4D8")),
}.get(status, ("未知", SECONDARY, QColor("#F7F8FC")))
1: ("已预约", QColor("#2F6EDB"), QColor("#EAF2FF")),
3: ("已完成", QColor("#16876C"), QColor("#E8F6F1")),
4: ("已过号", QColor("#9A6813"), QColor("#FFF4D8")),
}.get(status, ("未知", SECONDARY, QColor("#F7F8FC")))
self._draw_tag(painter, QRect(content.left() + 6, y, 46, 18), label, bg, fg, 11)
doctor = display_text(first_value(appointment, "doctor_name"), "-")
time_text = display_text(first_value(appointment, "time_text", "appointment_time"), "-")
@@ -951,12 +949,12 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(content.left() + 6, y + 19, max(20, content.width() - 12), 17),
time_text,
PRIMARY if status == 1 else (QColor("#9A6813") if status == 4 else SECONDARY),
PRIMARY if status == 1 else (QColor("#9A6813") if status == 4 else SECONDARY),
12,
)
y += 42
if position < len(rows) - 1:
painter.setPen(QPen(QColor("#D8DEEA"), 1, Qt.PenStyle.DashLine))
painter.setPen(QPen(QColor("#D8DEEA"), 1, Qt.PenStyle.DashLine))
painter.drawLine(content.left() + 6, y - 2, content.right() - 6, y - 2)
channel = first_value(
record,
@@ -999,7 +997,7 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
painter,
QRect(content.left(), content.top() + 38, 72, 18),
"处方已作废",
QColor("#F7F8FC"),
QColor("#F7F8FC"),
SECONDARY,
11,
outlined=True,
@@ -1012,11 +1010,11 @@ class DiagnosisItemDelegate(QStyledItemDelegate):
else:
days = _as_int(value)
color = (
QColor("#16876C")
QColor("#16876C")
if days <= 2
else QColor("#9A6813")
else QColor("#9A6813")
if days <= 6
else QColor("#C43E55")
else QColor("#C43E55")
)
text, bold = str(days), True
self._draw_text(
@@ -1216,9 +1214,9 @@ class _FixedColumnShadow(QWidget):
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter(self)
gradient = QLinearGradient(self.rect().left(), 0, self.rect().right(), 0)
gradient.setColorAt(0, QColor(8, 11, 20, 150))
gradient.setColorAt(0.45, QColor(8, 11, 20, 72))
gradient.setColorAt(1, QColor(8, 11, 20, 0))
gradient.setColorAt(0, QColor(8, 11, 20, 150))
gradient.setColorAt(0.45, QColor(8, 11, 20, 72))
gradient.setColorAt(1, QColor(8, 11, 20, 0))
painter.fillRect(self.rect(), gradient)
super().paintEvent(event)
@@ -1493,13 +1491,13 @@ class DiagnosisTableHost(QFrame):
more.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = _DiagnosisActionMenu(more)
menu.setStyleSheet(
"QMenu{background:#FFFFFF;color:#172033;border:1px solid #D8DEEA;"
"QMenu{background:#FFFFFF;color:#172033;border:1px solid #D8DEEA;"
"border-radius:4px;padding:6px;font-size:13px;}"
"QMenu::item{min-width:118px;min-height:30px;padding:0 12px 0 10px;"
"border-radius:3px;}"
"QMenu::item:selected{color:#3446AF;background:#E9EDFF;}"
"QMenu::item:disabled{color:#98A2B3;}"
"QMenu::separator{height:1px;background:#D8DEEA;margin:5px 8px;}"
"QMenu::item:selected{color:#3446AF;background:#E9EDFF;}"
"QMenu::item:disabled{color:#98A2B3;}"
"QMenu::separator{height:1px;background:#D8DEEA;margin:5px 8px;}"
)
if self.action_policy.get("assign", False):
_add_menu_action(
@@ -1769,7 +1767,7 @@ class DiagnosisLoadingOverlay(QWidget):
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
visible = self.visibleRegion().boundingRect()
center = (
visible.center()
@@ -1793,16 +1791,16 @@ class DiagnosisLoadingOverlay(QWidget):
DIAGNOSIS_INDEX_QSS = """
#DiagnosisIndex {
background: #080B14;
color: #EEF2FF;
#DiagnosisIndex {
background: #080B14;
color: #EEF2FF;
font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard,
#DiagnosisIndex QFrame#DiagnosisListCard {
background: #101626;
border: 0;
background: #101626;
border: 0;
border-radius: 4px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
@@ -1810,77 +1808,77 @@ DIAGNOSIS_INDEX_QSS = """
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: #151D31;
color: #9AA7C0;
background: #151D31;
color: #9AA7C0;
font-size: 14px;
font-weight: 500;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover {
background: #1B2440;
background: #1B2440;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:focus {
border-color: #6675F5;
border-color: #6675F5;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="info"] {
color: #78A7FF;
background: #151D31;
color: #78A7FF;
background: #151D31;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="info"]:hover {
background: #1B2440;
background: #1B2440;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="success"] {
color: #49C6A5;
background: #151D31;
color: #49C6A5;
background: #151D31;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="success"]:hover {
background: #1B2440;
background: #1B2440;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"] {
color: #E4B967;
background: #151D31;
color: #E4B967;
background: #151D31;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]:hover {
background: #1B2440;
background: #1B2440;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"] {
color: #EEF2FF;
background: #6675F5;
color: #EEF2FF;
background: #6675F5;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"] {
color: #EEF2FF;
background: #78A7FF;
color: #EEF2FF;
background: #78A7FF;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="success"] {
color: #080B14;
background: #49C6A5;
color: #080B14;
background: #49C6A5;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="warning"] {
color: #080B14;
background: #E4B967;
color: #080B14;
background: #E4B967;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"] {
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
color: #9AA7C0;
color: #9AA7C0;
background: transparent;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"]:hover {
color: #EEF2FF;
background: #1B2440;
color: #EEF2FF;
background: #1B2440;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"]:checked {
color: #EEF2FF;
background: #1B2440;
color: #EEF2FF;
background: #1B2440;
}
#DiagnosisIndex QLabel[filterGroup="true"] {
color: #9AA7C0;
color: #9AA7C0;
font-size: 13px;
}
#DiagnosisIndex QFrame#DiagnosisQuickFilters,
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
border: 0;
border-top: 1px solid #29334F;
border-top: 1px solid #29334F;
background: transparent;
}
#DiagnosisIndex QFrame#DiagnosisFilterDivider {
@@ -1889,15 +1887,15 @@ DIAGNOSIS_INDEX_QSS = """
min-height: 14px;
max-height: 14px;
margin: 0 4px;
background: #29334F;
background: #29334F;
border: 0;
}
#DiagnosisIndex QFrame#DiagnosisDateRange {
min-height: 30px;
max-height: 30px;
border: 1px solid #29334F;
border: 1px solid #29334F;
border-radius: 4px;
background: #151D31;
background: #151D31;
}
#DiagnosisIndex QFrame#DiagnosisDateRange QDateEdit[diagnosisRangePart="true"] {
min-height: 28px;
@@ -1906,98 +1904,98 @@ DIAGNOSIS_INDEX_QSS = """
padding: 0 5px;
}
#DiagnosisIndex QLabel[diagnosisRangeSeparator="true"] {
color: #9AA7C0;
color: #9AA7C0;
}
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
border-top: 1px dashed #29334F;
border-top: 1px dashed #29334F;
}
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
border: 0;
background: transparent;
color: #78A7FF;
color: #78A7FF;
padding: 4px 6px;
font-size: 13px;
}
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox {
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox {
min-height: 30px;
border: 1px solid #29334F;
border: 1px solid #29334F;
border-radius: 4px;
padding: 0 8px;
background: #151D31;
color: #EEF2FF;
selection-background-color: #6675F5;
background: #151D31;
color: #EEF2FF;
selection-background-color: #6675F5;
}
#DiagnosisIndex QLineEdit:focus,
#DiagnosisIndex QComboBox:focus,
#DiagnosisIndex QDateEdit:focus,
#DiagnosisIndex QSpinBox:focus {
border-color: #6675F5;
border-color: #6675F5;
}
#DiagnosisIndex QComboBox[smallControl="true"],
#DiagnosisIndex QPushButton[smallControl="true"] {
min-height: 22px;
max-height: 22px;
}
#DiagnosisIndex QPushButton {
#DiagnosisIndex QPushButton {
min-height: 30px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #29334F;
background: #151D31;
color: #EEF2FF;
}
#DiagnosisIndex QPushButton:hover,
#DiagnosisIndex QPushButton:focus {
border-color: #6675F5;
background: #1B2440;
}
#DiagnosisIndex QPushButton:disabled {
color: #9AA7C0;
background: #101626;
}
#DiagnosisIndex QPushButton[variant="primary"] {
border-color: #6675F5;
background: #6675F5;
color: #EEF2FF;
}
#DiagnosisIndex QPushButton[variant="success"] {
border-color: #49C6A5;
background: #49C6A5;
color: #080B14;
}
#DiagnosisIndex QPushButton[variant="warning"] {
border-color: #E4B967;
color: #E4B967;
}
#DiagnosisIndex QPushButton[variant="danger"] {
border-color: #F07886;
color: #F07886;
}
border: 1px solid #29334F;
background: #151D31;
color: #EEF2FF;
}
#DiagnosisIndex QPushButton:hover,
#DiagnosisIndex QPushButton:focus {
border-color: #6675F5;
background: #1B2440;
}
#DiagnosisIndex QPushButton:disabled {
color: #9AA7C0;
background: #101626;
}
#DiagnosisIndex QPushButton[variant="primary"] {
border-color: #6675F5;
background: #6675F5;
color: #EEF2FF;
}
#DiagnosisIndex QPushButton[variant="success"] {
border-color: #49C6A5;
background: #49C6A5;
color: #080B14;
}
#DiagnosisIndex QPushButton[variant="warning"] {
border-color: #E4B967;
color: #E4B967;
}
#DiagnosisIndex QPushButton[variant="danger"] {
border-color: #F07886;
color: #F07886;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar {
border: 0;
border-bottom: 1px solid #29334F;
background: #101626;
}
#DiagnosisIndex QTableView {
border: 0;
background: #101626;
border-bottom: 1px solid #29334F;
background: #101626;
}
#DiagnosisIndex QTableView {
border: 0;
background: #101626;
selection-background-color: transparent;
outline: 0;
}
#DiagnosisIndex QTableView:focus {
border: 1px solid #6675F5;
border: 1px solid #6675F5;
}
#DiagnosisIndex QTableView#DiagnosisFixedTable {
border-left: 1px solid #29334F;
border-left: 1px solid #29334F;
}
#DiagnosisIndex QHeaderView::section {
background: #151D31;
color: #9AA7C0;
background: #151D31;
color: #9AA7C0;
border: 0;
border-bottom: 1px solid #29334F;
border-bottom: 1px solid #29334F;
padding: 0 8px;
font-size: 13px;
font-weight: 600;
@@ -2013,43 +2011,43 @@ DIAGNOSIS_INDEX_QSS = """
}
#DiagnosisIndex QToolButton[rowLink]:hover,
#DiagnosisIndex QToolButton[rowLink]:focus {
background: #1B2440;
background: #1B2440;
border-radius: 3px;
}
#DiagnosisIndex QToolButton[rowLink]:disabled {
color: #9AA7C0;
color: #9AA7C0;
background: transparent;
}
#DiagnosisIndex QToolButton[rowLink="primary"] { color: #78A7FF; }
#DiagnosisIndex QToolButton[rowLink="success"] { color: #49C6A5; }
#DiagnosisIndex QToolButton[rowLink="warning"] { color: #E4B967; }
#DiagnosisIndex QToolButton[rowLink="muted"] { color: #9AA7C0; }
#DiagnosisIndex QToolButton[rowLink="primary"] { color: #78A7FF; }
#DiagnosisIndex QToolButton[rowLink="success"] { color: #49C6A5; }
#DiagnosisIndex QToolButton[rowLink="warning"] { color: #E4B967; }
#DiagnosisIndex QToolButton[rowLink="muted"] { color: #9AA7C0; }
#DiagnosisIndex QToolButton[appointmentCancel="true"] {
border: 0;
border-radius: 3px;
padding: 0 3px;
background: #1B2440;
color: #F07886;
background: #1B2440;
color: #F07886;
font-size: 11px;
}
#DiagnosisIndex QToolButton[appointmentCancel="true"]:hover,
#DiagnosisIndex QToolButton[appointmentCancel="true"]:focus {
background: #29334F;
color: #F07886;
background: #29334F;
color: #F07886;
}
#DiagnosisIndex QLabel[fixedMuted="true"],
#DiagnosisIndex QLabel[pagerMuted="true"] {
color: #9AA7C0;
color: #9AA7C0;
font-size: 13px;
}
#DiagnosisIndex QLabel#DiagnosisTableEmpty {
color: #9AA7C0;
background: rgba(16, 22, 38, 0.96);
color: #9AA7C0;
background: rgba(16, 22, 38, 0.96);
font-size: 14px;
}
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] {
color: #F07886;
background: rgba(16, 22, 38, 0.98);
color: #F07886;
background: rgba(16, 22, 38, 0.98);
}
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 32px;
@@ -2058,15 +2056,15 @@ DIAGNOSIS_INDEX_QSS = """
border: 0;
border-radius: 2px;
background: transparent;
color: #EEF2FF;
color: #EEF2FF;
font-size: 14px;
}
#DiagnosisIndex QToolButton[pagerButton="true"]:hover,
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] {
color: #78A7FF;
color: #78A7FF;
font-weight: 600;
}
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #9AA7C0; }
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #9AA7C0; }
#DiagnosisIndex QComboBox#DiagnosisPageSize,
#DiagnosisIndex QSpinBox#DiagnosisPageJumper {
min-height: 30px;
@@ -2074,47 +2072,64 @@ DIAGNOSIS_INDEX_QSS = """
}
#DiagnosisIndex QScrollBar:horizontal {
height: 12px;
background: #101626;
background: #101626;
}
#DiagnosisIndex QScrollBar::handle:horizontal {
min-width: 32px;
border-radius: 5px;
background: #29334F;
}
"""
_DIAGNOSIS_INDEX_LIGHT_REPLACEMENTS = (
("#080B14", "#F5F7FB"),
("#101626", "#FFFFFF"),
("#151D31", "#F7F8FC"),
("#1B2440", "#EEF2F8"),
("#29334F", "#D8DEEA"),
("#EEF2FF", "#172033"),
("#9AA7C0", "#667085"),
("#6675F5", "#4F63D9"),
("#78A7FF", "#2F6EDB"),
("#49C6A5", "#16876C"),
("#E4B967", "#9A6813"),
("#F07886", "#C43E55"),
("rgba(16, 22, 38, 0.96)", "rgba(255, 255, 255, 0.96)"),
("rgba(16, 22, 38, 0.98)", "rgba(255, 255, 255, 0.98)"),
)
for _source, _target in _DIAGNOSIS_INDEX_LIGHT_REPLACEMENTS:
DIAGNOSIS_INDEX_QSS = DIAGNOSIS_INDEX_QSS.replace(_source, _target)
del _source, _target
# Filled actions remain white-on-accent after the general foreground rewrite.
DIAGNOSIS_INDEX_QSS += """
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"],
#DiagnosisIndex QPushButton[variant="primary"],
#DiagnosisIndex QPushButton[variant="success"] {
color: #FFFFFF;
}
"""
__all__ = [
#DiagnosisIndex QScrollBar::handle:horizontal {
min-width: 32px;
border-radius: 5px;
background: #29334F;
}
"""
_DIAGNOSIS_INDEX_LIGHT_REPLACEMENTS = (
("#080B14", "#F8FAFF"),
("#101626", "#FFFFFF"),
("#151D31", "#F8FAFF"),
("#1B2440", "#F0F3FC"),
("#29334F", "#E2E7F4"),
("#EEF2FF", "#15224A"),
("#9AA7C0", "#7481A3"),
("#6675F5", "#5265F6"),
("#78A7FF", "#4776EE"),
("#49C6A5", "#159C79"),
("#E4B967", "#C17A16"),
("#F07886", "#EC5266"),
("rgba(16, 22, 38, 0.96)", "rgba(255, 255, 255, 0.96)"),
("rgba(16, 22, 38, 0.98)", "rgba(255, 255, 255, 0.98)"),
)
for _source, _target in _DIAGNOSIS_INDEX_LIGHT_REPLACEMENTS:
DIAGNOSIS_INDEX_QSS = DIAGNOSIS_INDEX_QSS.replace(_source, _target)
del _source, _target
# Filled actions remain white-on-accent after the general foreground rewrite.
DIAGNOSIS_INDEX_QSS += """
#DiagnosisIndex QFrame#DiagnosisFilterCard,
#DiagnosisIndex QFrame#DiagnosisListCard {
border: 1px solid #E2E7F4;
border-radius: 13px;
}
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox,
#DiagnosisIndex QPushButton {
border-radius: 8px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"] { border-radius: 8px; }
#DiagnosisIndex QFrame#DiagnosisListToolbar {
border-top-left-radius: 13px;
border-top-right-radius: 13px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"],
#DiagnosisIndex QPushButton[variant="primary"],
#DiagnosisIndex QPushButton[variant="success"] {
color: #FFFFFF;
}
"""
__all__ = [
"DIAGNOSIS_INDEX_QSS",
"DiagnosisChip",
"DiagnosisLoadingOverlay",
@@ -128,48 +128,64 @@ def open_safe_http_url(target: str) -> bool:
_INLINE_PLAYER_QSS = """
QWidget#DiagnosisInlineRecordingPlayer {
background: #101626;
border: 1px solid #29334F;
border-radius: 6px;
background: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 9px;
}
QFrame#DiagnosisInlineRecordingSurface {
background: #080B14;
background: #11182E;
border: 0;
border-radius: 5px 5px 0 0;
border-radius: 8px 8px 0 0;
}
QLabel#DiagnosisInlineRecordingPlaceholder {
color: #9AA7C0;
color: #C7D0E8;
font-size: 12px;
line-height: 1.4;
}
QLabel#DiagnosisInlineRecordingTime {
color: #9AA7C0;
color: #64739A;
font-size: 11px;
}
QPushButton[recordingControl="true"] {
min-height: 24px;
max-height: 24px;
padding: 0 8px;
color: #EEF2FF;
background: #151D31;
border: 1px solid #29334F;
border-radius: 4px;
color: #3F4E75;
background: #FAFBFE;
border: 1px solid #D8DEEE;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
QPushButton[recordingControl="true"]:hover,
QPushButton[recordingControl="true"]:focus {
color: #EEF2FF;
background: #6675F5;
border-color: #6675F5;
color: #4451E2;
background: #F0F2FF;
border-color: #8D9BFF;
}
QPushButton[recordingControl="true"]:disabled { color: #9AA7C0; background:#101626; }
QSlider::groove:horizontal { height: 3px; background: #29334F; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #6675F5; border-radius: 1px; }
QPushButton#DiagnosisInlineRecordingPlay {
color: #FFFFFF;
background: #5761F4;
border-color: #5761F4;
}
QPushButton#DiagnosisInlineRecordingPlay:hover,
QPushButton#DiagnosisInlineRecordingPlay:focus {
color: #FFFFFF;
background: #4C57E9;
border-color: #4C57E9;
}
QPushButton[recordingControl="true"]:disabled {
color: #A4ADC3;
background: #F0F2F8;
border-color: #E6EAF5;
}
QSlider::groove:horizontal { height: 3px; background: #D8DEEE; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #5761F4; border-radius: 1px; }
QSlider::handle:horizontal {
width: 10px;
margin: -4px 0;
background: #EEF2FF;
border: 1px solid #9AA7C0;
background: #FFFFFF;
border: 1px solid #5761F4;
border-radius: 5px;
}
"""
@@ -445,11 +461,11 @@ class RecordingPlaybackCell(QWidget):
separator = QFrame()
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("color:#29334F;")
separator.setStyleSheet("color:#E6EAF5;")
layout.addWidget(separator)
label = QLabel("备用地址")
label.setObjectName("DiagnosisRecordingAlternateLabel")
label.setStyleSheet("color:#9AA7C0; font-size:12px;")
label.setStyleSheet("color:#7886AA; font-size:12px;")
layout.addWidget(label)
links = QHBoxLayout()
links.setContentsMargins(0, 0, 0, 0)
@@ -469,7 +485,7 @@ class RecordingPlaybackCell(QWidget):
layout.addLayout(links)
self.link_status = QLabel("")
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
self.link_status.setStyleSheet("color:#F07886; font-size:11px;")
self.link_status.setStyleSheet("color:#D94856; font-size:11px;")
self.link_status.setWordWrap(True)
self.link_status.hide()
layout.addWidget(self.link_status)
@@ -69,6 +69,7 @@ from ..widgets import (
page_total,
run_async,
)
from .prescription_ai import can_open_diagnosis_ai_report, present_diagnosis_ai_report
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
_PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders"
@@ -186,38 +187,38 @@ _ORDER_OFFSET_HELP = (
_ORDER_DETAIL_QSS = """
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
QFrame#DiagnosisOrderDetailScrim { background: rgba(8, 11, 20, 0.78); border: 0; }
QFrame#DiagnosisOrderDetailScrim { background: rgba(30, 64, 175, 0.18); border: 0; }
QFrame#DiagnosisOrderDetailDrawer {
background: #F5F7FB;
background: #F7F9FE;
border: 0;
border-left: 1px solid #D8DEEA;
border-left: 1px solid #DDE7FF;
}
QFrame#DiagnosisOrderDetailHeader {
background: #FFFFFF;
border: 0;
border-bottom: 1px solid #D8DEEA;
border-bottom: 1px solid #DDE7FF;
}
QLabel#DiagnosisOrderDetailTitle { color: #172033; font-size: 19px; font-weight: 650; }
QLabel#DiagnosisOrderDetailMeta { color: #667085; font-size: 12px; }
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; }
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; }
QLabel#DiagnosisOrderReadonlyBadge {
color: #667085;
background: #F7F8FC;
border: 1px solid #D8DEEA;
color: #3F4E75;
background: #F7F9FE;
border: 1px solid #E2E7F4;
border-radius: 4px;
padding: 3px 7px;
font-size: 11px;
font-weight: 600;
}
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F5F7FB; }
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F5F7FB; }
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; }
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; }
QFrame[orderAmountCard="true"] {
background: #FFFFFF;
border: 1px solid #D8DEEA;
border-radius: 7px;
border: 1px solid #DDE7FF;
border-radius: 9px;
}
QLabel[orderAmountTitle="true"] { color: #667085; font-size: 11px; font-weight: 550; }
QLabel[orderAmountTitle="true"] { color: #7481A3; font-size: 11px; font-weight: 550; }
QLabel[orderAmountValue="true"] {
color: #172033;
color: #15224A;
font-size: 18px;
font-weight: 700;
}
@@ -226,34 +227,34 @@ QLabel[orderAmountTone="success"] { color: #16876C; }
QLabel[orderAmountTone="warning"] { color: #9A6813; }
QFrame[orderDetailSection="true"] {
background: #FFFFFF;
border: 1px solid #D8DEEA;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; }
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; }
QFrame[orderField="true"] {
background: #F2F6FE;
border: 1px solid #E2E7F4;
border-radius: 7px;
}
QLabel[orderSectionTitle="true"] { color: #172033; font-size: 15px; font-weight: 650; }
QLabel[orderSectionHint="true"] { color: #667085; font-size: 11px; }
QFrame[orderField="true"] {
background: #F7F8FC;
border: 1px solid #D8DEEA;
border-radius: 5px;
}
QLabel[orderFieldLabel="true"] { color: #667085; font-size: 11px; }
QLabel[orderFieldValue="true"] { color: #172033; font-size: 13px; }
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; }
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; }
QLabel[orderEmptyState="true"] {
color: #667085;
background: #F7F8FC;
border: 1px dashed #D8DEEA;
color: #7481A3;
background: #F2F6FE;
border: 1px dashed #C9D8F2;
border-radius: 5px;
padding: 18px 12px;
font-size: 12px;
}
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #D8DEEA; }
QLabel#DiagnosisOrderTimelineTime { color: #667085; font-size: 11px; }
QLabel#DiagnosisOrderTimelineTitle { color: #172033; font-size: 12px; font-weight: 600; }
QLabel#DiagnosisOrderTimelineBody { color: #667085; font-size: 12px; }
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; }
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; }
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; }
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; }
QFrame#DiagnosisOrderDetailFooter {
background: #FFFFFF;
border: 0;
border-top: 1px solid #D8DEEA;
border-top: 1px solid #DDE7FF;
}
"""
@@ -973,8 +974,8 @@ class DiagnosisDialog(QDialog):
back.setCursor(Qt.CursorShape.PointingHandCursor)
back.setStyleSheet(
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
"color:#78A7FF;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#1B2440;border-radius:6px;}"
"color:#5265F6;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
)
back.clicked.connect(self.reject)
left_layout.addWidget(back)
@@ -990,7 +991,7 @@ class DiagnosisDialog(QDialog):
self.readonly_hero_name.setObjectName("DiagnosisReadonlyPatientName")
right_layout.addWidget(self.readonly_hero_name)
self.readonly_hero_meta = QLabel("")
self.readonly_hero_meta.setStyleSheet("color:#9AA7C0; font-size:13px;")
self.readonly_hero_meta.setObjectName("DiagnosisReadonlyHeroMeta")
right_layout.addWidget(self.readonly_hero_meta)
self.readonly_status = QLabel("从未打卡")
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
@@ -1008,9 +1009,18 @@ class DiagnosisDialog(QDialog):
layout = QVBoxLayout(card)
layout.setContentsMargins(18, 18, 18, 18)
layout.setSpacing(14)
heading_row = QHBoxLayout()
heading_row.setContentsMargins(0, 0, 0, 0)
heading_row.setSpacing(12)
heading = QLabel("患者信息")
heading.setObjectName("DiagnosisReadonlyCardTitle")
layout.addWidget(heading)
heading_row.addWidget(heading, 1)
self.readonly_ai_button = QPushButton("AI 报告", card)
self.readonly_ai_button.setProperty("variant", "secondary")
self.readonly_ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_ai_button.clicked.connect(self._open_ai_report)
heading_row.addWidget(self.readonly_ai_button, 0)
layout.addLayout(heading_row)
patient_hero = QFrame()
patient_hero.setObjectName("DiagnosisReadonlyPatientHero")
hero_layout = QVBoxLayout(patient_hero)
@@ -1186,7 +1196,7 @@ class DiagnosisDialog(QDialog):
privacy_layout.setContentsMargins(12, 9, 12, 9)
self.privacy_label = QLabel("存在未完成的业务订单,患者基本信息不可修改")
self.privacy_label.setWordWrap(True)
self.privacy_label.setStyleSheet("color:#E4B967; font-size:12px;")
self.privacy_label.setObjectName("DiagnosisPrivacyText")
privacy_layout.addWidget(self.privacy_label)
self.privacy_banner.hide()
layout.addWidget(self.privacy_banner)
@@ -1445,7 +1455,6 @@ class DiagnosisDialog(QDialog):
toolbar_layout.setContentsMargins(14, 10, 14, 10)
label = QLabel("复诊统计起始偏移")
label.setObjectName("DiagnosisOrderOffsetLabel")
label.setStyleSheet("color:#9AA7C0; font-size:12px; font-weight:500;")
label.setToolTip(_ORDER_OFFSET_HELP)
toolbar_layout.addWidget(label)
help_button = QPushButton("?")
@@ -1454,12 +1463,6 @@ class DiagnosisDialog(QDialog):
help_button.setToolTip(_ORDER_OFFSET_HELP)
help_button.setFixedSize(22, 22)
help_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
help_button.setStyleSheet(
"QPushButton{color:#9AA7C0;border:1px solid #29334F;border-radius:11px;"
"background:#151D31;padding:0;font-size:12px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{color:#EEF2FF;border-color:#6675F5;"
"background:#1B2440;}"
)
toolbar_layout.addWidget(help_button)
self.order_offset_help = help_button
self.order_offset = QSpinBox()
@@ -1473,7 +1476,6 @@ class DiagnosisDialog(QDialog):
self.order_offset_preview = QLabel("第 1 笔实单计为一诊")
self.order_offset_preview.setObjectName("DiagnosisOrderOffsetPreview")
self.order_offset_preview.setToolTip(_ORDER_OFFSET_HELP)
self.order_offset_preview.setStyleSheet("color:#9AA7C0; font-size:12px;")
toolbar_layout.addWidget(self.order_offset_preview)
offset_save = QPushButton("保存")
offset_save.setProperty("variant", "primary")
@@ -1486,7 +1488,7 @@ class DiagnosisDialog(QDialog):
layout.addWidget(self.orders_table, 1)
footer = QHBoxLayout()
self.orders_summary = QLabel("共 0 条")
self.orders_summary.setStyleSheet("color:#9AA7C0; font-size:12px;")
self.orders_summary.setObjectName("DiagnosisOrdersSummary")
footer.addWidget(self.orders_summary)
footer.addStretch(1)
self.orders_previous = QPushButton("上一页")
@@ -1655,6 +1657,9 @@ class DiagnosisDialog(QDialog):
self._can_note_delete = has_permission(
self.permissions, _NOTE_DELETE_PERMISSION, default=False
) and callable(getattr(self.repository, "delete_doctor_note_image", None))
if hasattr(self, "readonly_ai_button"):
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
self.tabs.clear()
for key, label, codes in _TAB_DEFINITIONS:
@@ -1862,6 +1867,30 @@ class DiagnosisDialog(QDialog):
self._owner_filter_installed = False
super().done(result)
def _open_ai_report(self) -> None:
if not can_open_diagnosis_ai_report(self.permissions):
return
if self._diagnosis_id <= 0:
return
detail = self._detail if isinstance(self._detail, Mapping) else {}
diagnosis = get_value(detail, "diagnosis", None)
row = dict(diagnosis) if isinstance(diagnosis, Mapping) else dict(detail)
row.update(
{
"id": self._diagnosis_id,
"diagnosis_id": self._diagnosis_id,
"patient_name": first_value(
row,
"patient_name",
"name",
default=self.readonly_patient_title.text()
if hasattr(self, "readonly_patient_title")
else "患者",
),
}
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
@@ -2373,7 +2402,9 @@ class DiagnosisDialog(QDialog):
if key == "appetite" and raw in (None, "", [], ()):
raw = first_value(diagnosis, "oral_condition", "mouth_condition", default="")
if key == "diagnosis_type":
raw = _diagnosis_type_value(raw)
raw = _diagnosis_type_value(raw) or _diagnosis_type_value(
first_value(diagnosis, "consultation_type", default="")
)
if key == "gender":
gender_token = str(raw).strip().lower() if raw not in (None, "") else ""
if gender_token in {"2", "f", "female", ""}:
@@ -105,28 +105,28 @@ from ..widgets import (
PRESCRIPTION_DRAWER_QSS = r"""
QFrame#PrescriptionDrawerSurface {
color: #134E4A;
color: #17203F;
background-color: #FFFFFF;
border-left: 1px solid #D9E0E7;
border-left: 1px solid #DCE3F2;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QFrame#PrescriptionDrawerHeader {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5E7EB;
border-bottom: 1px solid #E7ECF5;
}
QLabel#PrescriptionDrawerTitle {
color: #303133;
font-size: 16px;
font-weight: 600;
color: #17203F;
font-size: 18px;
font-weight: 700;
}
QLabel#PrescriptionFormLabel {
color: #606266;
font-size: 14px;
color: #56617A;
font-size: 13px;
}
QLabel#PrescriptionFormHint {
color: #909399;
color: #8A94AA;
font-size: 12px;
}
QLabel#PrescriptionLockTag {
@@ -138,31 +138,31 @@ QLabel#PrescriptionLockTag {
font-size: 12px;
}
QWidget#PrescriptionHerbTableHeader {
background-color: #F5F7FA;
border: 1px solid #EBEEF5;
background-color: #F5F7FC;
border: 1px solid #E2E8F4;
border-bottom: 0;
}
QLabel#PrescriptionHerbTableHead {
color: #909399;
color: #7D879E;
font-size: 12px;
font-weight: 600;
}
QFrame#PrescriptionHerbTableRow {
background-color: #FFFFFF;
border: 1px solid #EBEEF5;
border: 1px solid #E2E8F4;
border-top: 0;
}
QFrame#PrescriptionHerbTableRow[duplicate="true"] {
background-color: #FDF6EC;
}
QLabel#PrescriptionDrawerSubtitle {
color: #5B7A76;
color: #78849D;
font-size: 12px;
}
QLabel#PrescriptionDrawerMode {
color: #1677A3;
background-color: #EAF6FB;
border: 1px solid #B9DEEC;
color: #4D57D8;
background-color: #F0F2FF;
border: 1px solid #D8DCFF;
border-radius: 5px;
padding: 4px 9px;
font-size: 12px;
@@ -174,7 +174,7 @@ QPushButton#PrescriptionDrawerClose {
min-height: 34px;
max-height: 34px;
padding: 0;
color: #5B7A76;
color: #78849D;
background-color: transparent;
border: 0;
border-radius: 7px;
@@ -182,8 +182,8 @@ QPushButton#PrescriptionDrawerClose {
font-weight: 400;
}
QPushButton#PrescriptionDrawerClose:hover {
color: #134E4A;
background-color: #F2F4F7;
color: #5761F4;
background-color: #F0F2FF;
}
QScrollArea#PrescriptionDrawerBody,
QScrollArea#PrescriptionDrawerBody > QWidget > QWidget {
@@ -193,30 +193,30 @@ QScrollArea#PrescriptionDrawerBody > QWidget > QWidget {
QWidget#PrescriptionDrawerContent {
background-color: #FFFFFF;
}
QFrame#PrescriptionSection {
background-color: #F5F7FA;
border: 0;
border-radius: 7px;
QFrame#PrescriptionFormSection {
background-color: #FAFBFE;
border: 1px solid #E3E8F4;
border-radius: 10px;
}
QLabel#PrescriptionSectionTitle {
color: #134E4A;
color: #17203F;
font-size: 15px;
font-weight: 600;
}
QLabel#PrescriptionSectionHint {
color: #5B7A76;
color: #8A94AA;
font-size: 12px;
}
QWidget#PrescriptionField {
background-color: transparent;
}
QLabel#PrescriptionFieldLabel {
color: #5B7A76;
color: #56617A;
font-size: 12px;
font-weight: 500;
}
QLabel#PrescriptionUsageMain {
color: #409EFF;
color: #5761F4;
font-size: 13px;
font-weight: 600;
padding: 8px 0 4px 0;
@@ -229,21 +229,21 @@ QLabel#PrescriptionUsageAux {
}
QFrame#PrescriptionRpToolbar {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border: 1px solid #DCE3F2;
border-radius: 10px;
}
QFrame#PrescriptionRpMarker {
background-color: #0891B2;
background-color: #5761F4;
border: 0;
border-radius: 2px;
}
QLabel#PrescriptionRpHeading {
color: #134E4A;
color: #17203F;
font-size: 16px;
font-weight: 600;
}
QLabel#PrescriptionRpMeta {
color: #7A8492;
color: #8A94AA;
font-size: 12px;
}
QLabel#PrescriptionRpLock {
@@ -261,9 +261,9 @@ QLabel#PrescriptionFormulaTag {
font-weight: 600;
}
QLabel#PrescriptionFormulaTag[formula="main"] {
color: #0E7490;
background-color: #ECFEFF;
border: 1px solid #A0CFFF;
color: #4D57D8;
background-color: #F0F2FF;
border: 1px solid #D8DCFF;
}
QLabel#PrescriptionFormulaTag[formula="aux"] {
color: #B88230;
@@ -271,24 +271,24 @@ QLabel#PrescriptionFormulaTag[formula="aux"] {
border: 1px solid #F3D19E;
}
QLabel#PrescriptionFormulaEmpty {
color: #909399;
color: #8A94AA;
background-color: #FFFFFF;
border: 1px solid #EBEEF5;
border: 1px solid #E2E8F4;
border-top: 0;
padding: 16px 8px;
font-size: 13px;
}
QFrame#PrescriptionHerbCard {
background-color: #FFFFFF;
border: 1px solid #D5E5E2;
border-radius: 7px;
border: 1px solid #DCE3F2;
border-radius: 8px;
}
QFrame#PrescriptionHerbCard[duplicate="true"] {
background-color: #FDF6EC;
border: 1px solid #E6A23C;
}
QLabel#PrescriptionHerbCardTitle {
color: #5B7A76;
color: #78849D;
font-size: 12px;
}
QPushButton#PrescriptionHerbDelete {
@@ -306,29 +306,29 @@ QPushButton#PrescriptionHerbDelete:hover {
QFrame#PrescriptionDrawerFooter {
background-color: #FFFFFF;
border: 0;
border-top: 1px solid #E5E7EB;
border-top: 1px solid #E7ECF5;
}
QFrame#PrescriptionDrawerSurface QLineEdit,
QFrame#PrescriptionDrawerSurface QTextEdit,
QFrame#PrescriptionDrawerSurface QSpinBox,
QFrame#PrescriptionDrawerSurface QDoubleSpinBox {
min-height: 32px;
color: #303133;
color: #17203F;
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #DCE3F2;
border-radius: 7px;
padding: 0 11px;
selection-background-color: #D9ECFF;
selection-background-color: #E3E6FF;
}
QFrame#PrescriptionDrawerSurface QComboBox {
min-height: 32px;
color: #303133;
color: #17203F;
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #DCE3F2;
border-radius: 7px;
padding-left: 11px;
padding-right: 28px;
selection-background-color: #D9ECFF;
selection-background-color: #E3E6FF;
}
QFrame#PrescriptionDrawerSurface QTextEdit {
padding: 7px 9px;
@@ -338,28 +338,28 @@ QFrame#PrescriptionDrawerSurface QTextEdit:focus,
QFrame#PrescriptionDrawerSurface QComboBox:focus,
QFrame#PrescriptionDrawerSurface QSpinBox:focus,
QFrame#PrescriptionDrawerSurface QDoubleSpinBox:focus {
border: 1px solid #409EFF;
border: 1px solid #5761F4;
}
QFrame#PrescriptionDrawerSurface QLineEdit:read-only {
color: #5B7A76;
background-color: #F0F2F5;
color: #78849D;
background-color: #F5F7FC;
}
QFrame#PrescriptionDatePicker {
min-height: 32px;
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #DCE3F2;
border-radius: 7px;
}
QFrame#PrescriptionDatePicker:hover {
border-color: #C0C4CC;
}
QFrame#PrescriptionDatePicker[active="true"] {
border: 1px solid #409EFF;
border: 1px solid #5761F4;
}
QFrame#PrescriptionDrawerSurface QFrame#PrescriptionDatePicker QLineEdit,
QFrame#PrescriptionDrawerSurface QFrame#PrescriptionDatePicker QLineEdit:read-only {
min-height: 30px;
color: #303133;
color: #17203F;
background-color: transparent;
border: 0;
padding: 0 4px 0 11px;
@@ -370,14 +370,14 @@ QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton {
min-height: 30px;
max-height: 30px;
padding: 0;
color: #909399;
color: #8A94AA;
background-color: transparent;
border: 0;
border-radius: 0;
}
QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton:hover,
QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDateButton:pressed {
color: #409EFF;
color: #5761F4;
background-color: transparent;
border: 0;
}
@@ -398,30 +398,30 @@ QFrame#PrescriptionDrawerSurface QComboBox:hover {
}
QFrame#PrescriptionDrawerSurface QComboBox QAbstractItemView {
outline: 0;
color: #303133;
color: #17203F;
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
selection-background-color: #ECF5FF;
selection-color: #409EFF;
border: 1px solid #DCE3F2;
selection-background-color: #F0F2FF;
selection-color: #4D57D8;
}
QFrame#PrescriptionDrawerSurface QRadioButton {
min-height: 32px;
spacing: 6px;
color: #606266;
color: #56617A;
}
QFrame#PrescriptionDrawerSurface QPushButton {
min-height: 32px;
padding: 0 15px;
color: #606266;
color: #4F5B75;
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #DCE3F2;
border-radius: 7px;
font-weight: 500;
}
QFrame#PrescriptionDrawerSurface QPushButton:hover {
color: #409EFF;
background-color: #ECF5FF;
border-color: #C6E2FF;
color: #4D57D8;
background-color: #F0F2FF;
border-color: #D8DCFF;
}
QFrame#PrescriptionDrawerSurface QPushButton[size="small"] {
min-height: 24px;
@@ -434,7 +434,7 @@ QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose {
min-height: 34px;
max-height: 34px;
padding: 0;
color: #5B7A76;
color: #78849D;
background-color: transparent;
border: 0;
border-radius: 7px;
@@ -442,27 +442,27 @@ QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose {
font-weight: 400;
}
QFrame#PrescriptionDrawerSurface QPushButton#PrescriptionDrawerClose:hover {
color: #134E4A;
background-color: #F2F4F7;
color: #5761F4;
background-color: #F0F2FF;
border: 0;
}
QFrame#PrescriptionDrawerSurface QPushButton:pressed {
background-color: #0E7490;
border-color: #0E7490;
background-color: #4D57D8;
border-color: #4D57D8;
}
QFrame#PrescriptionDrawerSurface QPushButton:disabled {
color: #A8ABB2;
background-color: #F5F7FA;
border-color: #D5E5E2;
color: #A1A9BA;
background-color: #F5F7FC;
border-color: #E2E8F4;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #409EFF;
border-color: #409EFF;
background-color: #5761F4;
border-color: #5761F4;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="primary"]:hover {
background-color: #66B1FF;
border-color: #66B1FF;
background-color: #6871F6;
border-color: #6871F6;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="success"] {
color: #FFFFFF;
@@ -484,23 +484,23 @@ QFrame#PrescriptionDrawerSurface QPushButton[variant="warning"]:hover {
border-color: #E6A23C;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"] {
color: #409EFF;
color: #4D57D8;
background-color: #FFFFFF;
border-color: #B3D8FF;
border-color: #D8DCFF;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="secondary"]:hover {
color: #FFFFFF;
background-color: #409EFF;
border-color: #409EFF;
background-color: #5761F4;
border-color: #5761F4;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"] {
color: #5B7A76;
color: #78849D;
background-color: transparent;
border-color: transparent;
}
QFrame#PrescriptionDrawerSurface QPushButton[variant="ghost"]:hover {
color: #134E4A;
background-color: #F2F4F7;
color: #17203F;
background-color: #F0F2FF;
}
QFrame#PrescriptionDrawerSurface QScrollBar:vertical {
width: 10px;
@@ -639,31 +639,219 @@ def _prescription_calendar_qss() -> str:
return """
QCalendarWidget {
background-color: #FFFFFF;
border: 1px solid #DCDFE6;
border: 1px solid #DCE3F2;
}
QCalendarWidget QWidget#qt_calendar_navigationbar {
background-color: #FFFFFF;
border: 0;
}
QCalendarWidget QToolButton {
color: #303133;
color: #17203F;
background-color: transparent;
border: 0;
padding: 4px 8px;
font-size: 13px;
}
QCalendarWidget QToolButton:hover {
color: #409EFF;
color: #5761F4;
}
QCalendarWidget QAbstractItemView:enabled {
color: #303133;
selection-background-color: #409EFF;
color: #17203F;
selection-background-color: #5761F4;
selection-color: #FFFFFF;
outline: 0;
}
"""
PRESCRIPTION_DIALOG_QSS = """
QDialog#PrescriptionDialogSurface {
color: #17203F;
background-color: #F7F9FE;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QDialog#PrescriptionDialogSurface QLabel {
color: #4F5B75;
}
QDialog#PrescriptionDialogSurface QLabel[role="pageTitle"] {
color: #17203F;
font-size: 20px;
font-weight: 700;
}
QDialog#PrescriptionDialogSurface QLabel[role="muted"] {
color: #8A94AA;
}
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="neutral"] {
color: #78849D;
background-color: #F5F7FC;
border-color: #E2E8F4;
}
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="success"] {
color: #319F84;
background-color: #ECFBF6;
border-color: #BDEBDD;
}
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="warning"] {
color: #B48739;
background-color: #FFF5E6;
border-color: #F3DFB5;
}
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="danger"] {
color: #D84E5B;
background-color: #FFF1F3;
border-color: #F7C9CF;
}
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="info"],
QDialog#PrescriptionDialogSurface QLabel#StatusBadge[kind="accent"] {
color: #4D57D8;
background-color: #F0F2FF;
border-color: #D8DCFF;
}
QDialog#PrescriptionDialogSurface QFrame#MessageBanner[kind="info"] QLabel {
color: #4D57D8;
}
QDialog#PrescriptionDialogSurface QFrame#MessageBanner[kind="success"] QLabel {
color: #319F84;
}
QDialog#PrescriptionDialogSurface QFrame#MessageBanner[kind="warning"] QLabel {
color: #B48739;
}
QDialog#PrescriptionDialogSurface QFrame#MessageBanner[kind="danger"] QLabel {
color: #D84E5B;
}
QDialog#PrescriptionDialogSurface QLineEdit,
QDialog#PrescriptionDialogSurface QTextEdit,
QDialog#PrescriptionDialogSurface QComboBox,
QDialog#PrescriptionDialogSurface QSpinBox,
QDialog#PrescriptionDialogSurface QDoubleSpinBox,
QDialog#PrescriptionDialogSurface QListWidget,
QDialog#PrescriptionDialogSurface QTextBrowser {
color: #17203F;
background-color: #FFFFFF;
border: 1px solid #DCE3F2;
border-radius: 7px;
selection-background-color: #E3E6FF;
selection-color: #17203F;
}
QDialog#PrescriptionDialogSurface QLineEdit,
QDialog#PrescriptionDialogSurface QComboBox,
QDialog#PrescriptionDialogSurface QSpinBox,
QDialog#PrescriptionDialogSurface QDoubleSpinBox {
min-height: 34px;
padding: 0 10px;
}
QDialog#PrescriptionDialogSurface QTextEdit,
QDialog#PrescriptionDialogSurface QTextBrowser {
padding: 8px;
}
QDialog#PrescriptionDialogSurface QLineEdit:focus,
QDialog#PrescriptionDialogSurface QTextEdit:focus,
QDialog#PrescriptionDialogSurface QComboBox:focus,
QDialog#PrescriptionDialogSurface QSpinBox:focus,
QDialog#PrescriptionDialogSurface QDoubleSpinBox:focus {
border: 1px solid #5761F4;
}
QDialog#PrescriptionDialogSurface QPushButton {
min-height: 34px;
padding: 0 15px;
color: #4F5B75;
background-color: #FFFFFF;
border: 1px solid #DCE3F2;
border-radius: 7px;
font-weight: 500;
}
QDialog#PrescriptionDialogSurface QPushButton:hover {
color: #4D57D8;
background-color: #F0F2FF;
border-color: #D8DCFF;
}
QDialog#PrescriptionDialogSurface QPushButton:pressed {
color: #FFFFFF;
background-color: #4D57D8;
border-color: #4D57D8;
}
QDialog#PrescriptionDialogSurface QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #5761F4;
border-color: #5761F4;
}
QDialog#PrescriptionDialogSurface QPushButton[variant="primary"]:hover {
background-color: #6871F6;
border-color: #6871F6;
}
QDialog#PrescriptionDialogSurface QPushButton:disabled {
color: #A1A9BA;
background-color: #F0F2F8;
border-color: #E2E8F4;
}
QDialog#PrescriptionDialogSurface QTabWidget::pane {
background-color: #FFFFFF;
border: 1px solid #DCE3F2;
border-radius: 9px;
top: -1px;
}
QDialog#PrescriptionDialogSurface QTabBar::tab {
min-height: 34px;
padding: 0 16px;
color: #78849D;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
QDialog#PrescriptionDialogSurface QTabBar::tab:hover {
color: #4D57D8;
background-color: #F5F7FC;
}
QDialog#PrescriptionDialogSurface QTabBar::tab:selected {
color: #4D57D8;
background-color: #F0F2FF;
border-bottom: 2px solid #5761F4;
font-weight: 600;
}
QDialog#PrescriptionDialogSurface QTableWidget {
color: #17203F;
background-color: #FFFFFF;
alternate-background-color: #FAFBFE;
border: 1px solid #DCE3F2;
border-radius: 8px;
gridline-color: #E7ECF5;
selection-background-color: #F0F2FF;
selection-color: #17203F;
}
QDialog#PrescriptionDialogSurface QHeaderView::section {
min-height: 36px;
color: #78849D;
background-color: #F5F7FC;
border: 0;
border-bottom: 1px solid #E2E8F4;
padding: 0 9px;
font-weight: 600;
}
QDialog#PrescriptionDialogSurface QScrollBar:vertical {
width: 10px;
margin: 2px;
background-color: transparent;
}
QDialog#PrescriptionDialogSurface QScrollBar::handle:vertical {
min-height: 32px;
background-color: #C8D0E0;
border-radius: 4px;
}
QDialog#PrescriptionDialogSurface QScrollBar::add-line:vertical,
QDialog#PrescriptionDialogSurface QScrollBar::sub-line:vertical {
height: 0;
}
"""
def _apply_prescription_dialog_style(dialog: QDialog) -> None:
"""Keep prescription-only windows aligned with the blue-white desktop shell."""
dialog.setObjectName("PrescriptionDialogSurface")
dialog.setStyleSheet(PRESCRIPTION_DIALOG_QSS)
_PRESCRIPTION_DARK_REPLACEMENTS = (
("color: #FFFFFF", "color: #EEF2FF"),
("selection-background-color: #D9ECFF", "selection-background-color: #6675F5"),
@@ -1968,6 +2156,7 @@ class PrescriptionTemplateDialog(QDialog):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.repository = repository
self.template = template
self.mode = mode
@@ -2097,6 +2286,7 @@ class TemplateImportDialog(QDialog):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.repository = repository
self.prescribing_creator_id = prescribing_creator_id
self._page = 1
@@ -2333,6 +2523,7 @@ class PasteHerbsDialog(QDialog):
def __init__(self, repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.repository = repository
self.resolved: list[dict[str, Any]] = []
self.skipped: list[str] = []
@@ -2608,7 +2799,11 @@ class PrescriptionEditorDialog(QDialog):
layout.setSpacing(12)
title = QLabel("新增处方" if self.mode == "add" else "编辑处方")
title.setObjectName("PrescriptionDrawerTitle")
layout.addWidget(title, 1)
layout.addWidget(title)
mode = QLabel("新增" if self.mode == "add" else "编辑")
mode.setObjectName("PrescriptionDrawerMode")
layout.addWidget(mode)
layout.addStretch(1)
close_button = QPushButton("×", header)
close_button.setObjectName("PrescriptionDrawerClose")
close_button.setToolTip("关闭")
@@ -2621,8 +2816,8 @@ class PrescriptionEditorDialog(QDialog):
section = QFrame()
section.setObjectName("PrescriptionFormSection")
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(11)
if title:
heading_row = QHBoxLayout()
heading_row.setContentsMargins(0, 0, 0, 0)
@@ -2652,7 +2847,7 @@ class PrescriptionEditorDialog(QDialog):
caption.setText(f'<span style="color:#F56C6C">*</span> {label}')
else:
caption.setText(label)
caption.setFixedWidth(12 if blank_label else 120)
caption.setFixedWidth(12 if blank_label else 92)
caption.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
tall = isinstance(widget, (QTextEdit, SignaturePad))
layout.addWidget(
@@ -2716,7 +2911,7 @@ class PrescriptionEditorDialog(QDialog):
diagnosis_id = _int(self._source.get("diagnosis_id"), 0)
diagnosis_row = QWidget(section)
diagnosis_layout = QHBoxLayout(diagnosis_row)
diagnosis_layout.setContentsMargins(120, 0, 0, 0)
diagnosis_layout.setContentsMargins(92, 0, 0, 0)
diagnosis_layout.setSpacing(8)
self.diagnosis_button = QPushButton("查看患者诊单详情", diagnosis_row)
self.diagnosis_button.setProperty("variant", "primary")
@@ -3640,6 +3835,7 @@ class PatchPatientDialog(QDialog):
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.prescription_id = _int(first_value(prescription, "id", "prescription_id"))
self.setWindowTitle("修正姓名、性别与手机号")
self.resize(460, 300)
@@ -3693,6 +3889,7 @@ class AuditPrescriptionDialog(QDialog):
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.prescription_id = _int(first_value(prescription, "id", "prescription_id"))
self.action = ""
self.setWindowTitle("处方审核")
@@ -4709,6 +4906,7 @@ class PrescriptionDetailDialog(QDialog):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.prescription = prescription
self.document = QTextDocument(self)
self.document.setDocumentMargin(0)
@@ -4808,6 +5006,7 @@ class PrescriptionDetailDialog(QDialog):
self.tabs.setCurrentIndex(case_index)
root.addWidget(self.tabs, 1)
close = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
close.button(QDialogButtonBox.StandardButton.Close).setText("关闭")
close.rejected.connect(self.reject)
root.addWidget(close)
@@ -5022,7 +5221,7 @@ class DiagnosisDetailDialog(QDialog):
return
order_id = _int(first_value(order, "id", "order_id"), 0)
if order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)):
try:
try: # noqa: SIM105 - retain the embedded row if detail lookup fails
order = self.repository.get_prescription_order(order_id)
except Exception: # noqa: BLE001 - fall back to embedded row
pass
@@ -5058,6 +5257,7 @@ class PrescriptionOrderDialog(QDialog):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.repository = repository
self.prescription = prescription
self.can_select_ship_mode = can_select_ship_mode
@@ -5400,6 +5600,7 @@ class PrescriptionOrderListDialog(QDialog):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
_apply_prescription_dialog_style(self)
self.repository = repository
self.prescription_id = prescription_id
self.patient_id = patient_id
@@ -5445,6 +5646,7 @@ class PrescriptionOrderListDialog(QDialog):
self.next = next_button
root.addLayout(footer)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭")
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
QTimer.singleShot(0, self.load)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+422 -86
View File
@@ -7,8 +7,8 @@ from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, Qt, QTime, QTimer, Signal
from PySide6.QtGui import QAction, QBrush, QColor
from PySide6.QtCore import QDate, QSize, Qt, QTime, QTimer, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
@@ -39,6 +39,7 @@ from PySide6.QtWidgets import (
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog, present_order_detail
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..theme import mark_business_dialog
from ..widgets import (
EmptyState,
MessageBanner,
@@ -88,21 +89,171 @@ FULFILLMENT_TEXT = {
}
_SEMANTIC_COLORS = {
"primary": "#4F63D9",
"success": "#16876C",
"warning": "#9A6813",
"danger": "#C43E55",
"info": "#2F6EDB",
"muted": "#667085",
"primary": "#5265F6",
"success": "#159C79",
"warning": "#C17A16",
"danger": "#EC5266",
"info": "#4776EE",
"muted": "#7481A3",
}
PATIENTS_LIGHT_QSS = """
#PatientsPage QPushButton[variant="ghost"]:checked {
color: #3446AF;
background-color: #E9EDFF;
border: 1px solid #4F63D9;
#PatientsPage QWidget#PageHeader { min-height: 88px; max-height: 88px; }
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #10204A;
font-size: 20px;
font-weight: 700;
}
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #DCE3FF; }
#PatientsPage QWidget#PageHeader QLabel[role="muted"] {
color: #6F7FA5;
font-size: 12px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 34px;
max-height: 34px;
color: #FFFFFF;
background-color: #5265F6;
border: 1px solid #5265F6;
border-radius: 7px;
font-weight: 600;
}
#PatientsPage QPushButton[patientSearchAction="true"]:hover {
background-color: #4557E7;
border-color: #4557E7;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 112px;
max-height: 112px;
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 11px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 34px;
max-height: 34px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 7px;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 13px;
color: #405074;
background-color: #F8F9FD;
border: 0;
border-radius: 5px;
}
#PatientsPage QPushButton[patientStatusChip="true"]:hover {
color: #5265F6;
background-color: #F0F2FF;
}
#PatientsPage QPushButton[patientStatusChip="true"]:checked {
color: #5265F6;
background-color: #F0F1FF;
border: 1px solid #9EA8FF;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
color: #29365C;
background-color: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
#PatientsPage QPushButton[patientQuickDate="true"]:hover {
color: #5265F6;
background-color: #F7F8FF;
}
#PatientsPage QPushButton[patientQuickDate="true"]:checked {
color: #5265F6;
background-color: #F8F8FF;
border-color: #7C85FF;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
border: 0;
border-top: 1px solid #E6EAF5;
background-color: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
min-width: 86px;
min-height: 40px;
padding: 0 6px;
margin-right: 8px;
color: #59698E;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-weight: 600;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #5265F6;
border-bottom-color: #5265F6;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 54px;
max-height: 54px;
padding: 0 13px;
color: #5265F6;
background-color: #F7F8FF;
border: 1px solid #E1E5FF;
border-radius: 9px;
text-align: left;
font-weight: 600;
}
#PatientsPage QPushButton[summaryCard="true"]:hover {
color: #3C4FD9;
background-color: #EEF1FF;
border-color: #AEB9FF;
}
#PatientsPage QPushButton[rowAction="true"] {
min-height: 26px;
max-height: 26px;
padding: 0 7px;
color: #5265F6;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 6px;
font-size: 10px;
}
#PatientsPage QPushButton[rowAction="true"]:hover {
background-color: #EEF1FF;
border-color: #AEB9FF;
}
#PatientsPage QPushButton[rowAction="true"][variant="danger"] {
color: #EC5266;
background-color: #FFF7F8;
border-color: #F4C4CC;
}
#PatientsPage QTableWidget#PatientTable QHeaderView::section {
min-height: 34px;
background-color: #F8FAFF;
}
#PatientsPage QTableWidget#PatientTable::item { padding: 5px 7px; }
#PatientsPage QTableWidget#PatientTable {
gridline-color: #E8ECF5;
selection-background-color: #F7F8FF;
selection-color: #15224A;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator {
width: 13px;
height: 13px;
background-color: #FFFFFF;
border: 1px solid #CBD3E7;
border-radius: 2px;
}
#PatientsPage QCheckBox[patientSelector="true"]::indicator:checked {
background-color: #5265F6;
border-color: #5265F6;
}
#PatientsPage QPushButton[variant="ghost"]:checked {
color: #3C4FD9;
background-color: #EEF1FF;
border: 1px solid #5265F6;
}
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #E8ECFF; }
#PatientsPage QPushButton[statusKind="info"]:checked {
color: #2F6EDB;
border-color: #2F6EDB;
@@ -119,35 +270,57 @@ PATIENTS_LIGHT_QSS = """
color: #C43E55;
border-color: #C43E55;
}
#PatientsPage QLabel[metricKind="primary"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="info"] { color: #2F6EDB; }
#PatientsPage QLabel[metricKind="success"] { color: #16876C; }
#PatientsPage QLabel[metricKind="warning"] { color: #9A6813; }
#PatientsPage QLabel[metricKind="danger"] { color: #C43E55; }
#PatientsPage QLabel[metricKind="primary"] { color: #5265F6; }
#PatientsPage QLabel[metricKind="info"] { color: #4776EE; }
#PatientsPage QLabel[metricKind="success"] { color: #159C79; }
#PatientsPage QLabel[metricKind="warning"] { color: #C17A16; }
#PatientsPage QLabel[metricKind="danger"] { color: #EC5266; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
background-color: transparent;
border: 1px solid #D8DEEA;
border-radius: 10px;
border: 0;
top: -1px;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
color: #667085;
color: #7481A3;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:hover {
color: #172033;
background-color: #F7F8FC;
color: #15224A;
background-color: #F8FAFF;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #3446AF;
background-color: #F7F8FC;
border-bottom-color: #4F63D9;
color: #3C4FD9;
background-color: transparent;
border-bottom-color: #5265F6;
}
"""
def _summary_calendar_icon() -> QIcon:
"""Paint the compact calendar tile from the reference without a font glyph."""
pixmap = QPixmap(34, 34)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EFEEFF"))
painter.drawRoundedRect(0, 0, 34, 34, 9, 9)
pen = QPen(QColor("#5B59F7"), 1.7)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(9, 10, 16, 15, 3, 3)
painter.drawLine(9, 15, 25, 15)
painter.drawLine(13, 8, 13, 12)
painter.drawLine(21, 8, 21, 12)
painter.end()
return QIcon(pixmap)
def _style_table_cell(table: SortableTable, row: int, column: int, kind: str) -> None:
item = table.item(row, column)
color = _SEMANTIC_COLORS.get(kind, _SEMANTIC_COLORS["muted"])
@@ -311,15 +484,15 @@ class _LegacyAppointmentDialog(QDialog):
self.setModal(True)
self.resize(540, 560)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
patient = QLabel(display_text(first_value(row, "patient_name", "name", default="患者")))
patient.setProperty("role", "pageTitle")
patient.setProperty("dialogRole", "title")
root.addWidget(patient)
identity = QLabel(
f"诊单 #{display_text(self.diagnosis_id)} · 患者号仅用于视频,不参与本次预约"
)
identity.setProperty("role", "muted")
identity.setProperty("dialogRole", "subtitle")
root.addWidget(identity)
form = QFormLayout()
form.setVerticalSpacing(10)
@@ -362,9 +535,11 @@ class _LegacyAppointmentDialog(QDialog):
self.ok_button.setText("确认预约")
self.ok_button.setProperty("variant", "primary")
self.ok_button.setEnabled(False)
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "LegacyAppointmentDialog")
self._load_initial_options()
@staticmethod
@@ -737,15 +912,21 @@ class _PaymentDialog(QDialog):
def __init__(self, row: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("补齐支付单")
self.resize(500, 430)
self.resize(520, 450)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
title = QLabel("补齐支付单")
title.setProperty("dialogRole", "title")
root.addWidget(title)
remaining = max(
0.0,
_as_float(first_value(row, "amount"))
- _as_float(first_value(row, "linked_pay_paid_total")),
)
root.addWidget(QLabel(f"待补齐参考金额:{_money(remaining)}"))
amount_hint = QLabel(f"待补齐参考金额:{_money(remaining)}")
amount_hint.setProperty("dialogRole", "subtitle")
root.addWidget(amount_hint)
form = QFormLayout()
self.order_type = QComboBox()
self.order_type.addItem("药品", 3)
@@ -771,10 +952,14 @@ class _PaymentDialog(QDialog):
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确认新增")
primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
primary_button.setText("确认新增")
primary_button.setProperty("variant", "primary")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "PatientPaymentDialog")
def payload(self) -> dict[str, Any]:
return {
@@ -790,9 +975,13 @@ class _RefundDialog(QDialog):
def __init__(self, row: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("订单退款")
self.resize(500, 340)
self.resize(520, 380)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
title = QLabel("订单退款")
title.setProperty("dialogRole", "title")
root.addWidget(title)
form = QFormLayout()
self.reason = QPlainTextEdit()
self.reason.setMaximumHeight(100)
@@ -813,10 +1002,14 @@ class _RefundDialog(QDialog):
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确认退款")
danger_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
danger_button.setText("确认退款")
danger_button.setProperty("variant", "danger")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "PatientRefundDialog")
def _toggle_amount(self, checked: bool) -> None:
self.refund_amount.setEnabled(checked)
@@ -841,12 +1034,12 @@ class _AssignDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("指派医助")
self.setModal(True)
self.resize(430, 230)
self.resize(460, 260)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
title = QLabel(display_text(first_value(row, "patient_name", default="患者")))
title.setProperty("role", "sectionTitle")
title.setProperty("dialogRole", "title")
root.addWidget(title)
form = QFormLayout()
self.assistant_combo = QComboBox()
@@ -871,10 +1064,14 @@ class _AssignDialog(QDialog):
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确认指派")
primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
primary_button.setText("确认指派")
primary_button.setProperty("variant", "primary")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "PatientAssignDialog")
@property
def assistant_id(self) -> int:
@@ -888,10 +1085,10 @@ class _OrderDetailDialog(QDialog):
self.setModal(True)
self.resize(620, 520)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
title = QLabel(f"订单 {display_text(first_value(detail, 'order_no', 'id'))}")
title.setProperty("role", "pageTitle")
title.setProperty("dialogRole", "title")
root.addWidget(title)
card = QFrame()
card.setObjectName("SubtleCard")
@@ -921,8 +1118,10 @@ class _OrderDetailDialog(QDialog):
form.addRow(caption, label)
root.addWidget(card, 1)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.button(QDialogButtonBox.StandardButton.Close).setText("关闭")
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "PatientOrderDetailDialog")
class _OrderEditDialog(QDialog):
@@ -933,12 +1132,12 @@ class _OrderEditDialog(QDialog):
self.detail = detail
self.setWindowTitle("编辑订单")
self.setModal(True)
self.resize(580, 560)
self.resize(600, 580)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(12)
root.setContentsMargins(24, 22, 24, 18)
root.setSpacing(14)
title = QLabel(f"订单 {display_text(first_value(detail, 'order_no', 'id'))}")
title.setProperty("role", "pageTitle")
title.setProperty("dialogRole", "title")
root.addWidget(title)
self.error_label = QLabel()
self.error_label.setProperty("role", "danger")
@@ -1007,10 +1206,14 @@ class _OrderEditDialog(QDialog):
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("保存订单")
primary_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
primary_button.setText("保存订单")
primary_button.setProperty("variant", "primary")
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
root.addWidget(buttons)
mark_business_dialog(self, "PatientOrderEditDialog")
def accept(self) -> None:
if not all(
@@ -1088,8 +1291,8 @@ class PatientListWorkspace(QWidget):
self._setting_dates = False
root = QVBoxLayout(self)
root.setContentsMargins(0, 10, 0, 0)
root.setSpacing(12)
root.setContentsMargins(0, 2, 0, 0)
root.setSpacing(10)
root.addWidget(self._build_filters())
root.addLayout(self._build_summary())
self.banner = MessageBanner()
@@ -1103,15 +1306,22 @@ class PatientListWorkspace(QWidget):
def _build_filters(self) -> QWidget:
card = QFrame()
card.setObjectName("FilterBar")
grid = QGridLayout(card)
grid.setContentsMargins(14, 12, 14, 12)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(8)
card.setProperty("patientListFilter", True)
panel = QVBoxLayout(card)
panel.setContentsMargins(18, 18, 18, 12)
panel.setSpacing(10)
top_row = QHBoxLayout()
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
self.keyword_edit = QLineEdit()
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.setMinimumWidth(300)
self.keyword_edit.setMaximumWidth(605)
self.keyword_edit.returnPressed.connect(self.search)
grid.addWidget(self.keyword_edit, 0, 0, 1, 3)
top_row.addWidget(self.keyword_edit, 1)
top_row.addSpacing(21)
# Compatibility-only control: it is intentionally hidden and never
# inserted into a layout, so it needs an explicit parent to avoid
# becoming a transient top-level Windows HWND during page creation.
@@ -1123,9 +1333,10 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
status_host = QWidget()
status_host.setFixedWidth(396)
status_row = QHBoxLayout(status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(6)
status_row.setSpacing(0)
self.status_group = QButtonGroup(self)
self.status_group.setExclusive(True)
self.status_buttons: dict[str, QPushButton] = {}
@@ -1140,6 +1351,7 @@ class PatientListWorkspace(QWidget):
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setProperty("variant", "ghost")
button.setProperty("patientStatusChip", True)
button.setProperty(
"statusKind",
{
@@ -1154,22 +1366,32 @@ class PatientListWorkspace(QWidget):
)
self.status_group.addButton(button)
self.status_buttons[value] = button
status_row.addWidget(button)
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
grid.addWidget(status_host, 0, 3)
top_row.addWidget(status_host)
top_row.addStretch(1)
search = QPushButton("查询")
search.setProperty("variant", "secondary")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.setFixedWidth(72)
search.clicked.connect(self.search)
grid.addWidget(search, 0, 4)
top_row.addWidget(search)
top_row.addSpacing(12)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.setFixedWidth(72)
reset.clicked.connect(self.reset_filters)
grid.addWidget(reset, 0, 5)
top_row.addWidget(reset)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(0)
quick_host = QWidget()
quick_host.setFixedWidth(730)
quick = QHBoxLayout(quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(4)
quick.setSpacing(0)
self.quick_group = QButtonGroup(self)
self.quick_group.setExclusive(True)
self.quick_buttons: dict[str, QPushButton] = {}
@@ -1184,30 +1406,47 @@ class PatientListWorkspace(QWidget):
button = QPushButton(label)
button.setCheckable(True)
button.setProperty("variant", "ghost")
button.setProperty("patientQuickDate", True)
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
self.quick_group.addButton(button)
self.quick_buttons[mode] = button
quick.addWidget(button)
quick.addWidget(button, 1)
self.quick_buttons["all"].setChecked(True)
grid.addWidget(quick_host, 1, 0, 1, 3)
bottom_row.addWidget(quick_host)
bottom_row.addSpacing(20)
date_host = QWidget()
date_host.setMinimumWidth(390)
date_host.setMaximumWidth(476)
dates = QHBoxLayout(date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(10)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
self.start_date.setEnabled(False)
self.start_date.editingFinished.connect(self._custom_date_changed)
grid.addWidget(self.start_date, 1, 3)
dates.addWidget(self.start_date, 1)
separator = QLabel("~")
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
separator.setProperty("role", "muted")
separator.setFixedWidth(24)
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setCalendarPopup(True)
self.end_date.setDisplayFormat("yyyy-MM-dd")
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
grid.addWidget(self.end_date, 1, 4)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(date_host, 1)
bottom_row.addSpacing(20)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.setFixedWidth(100)
custom.clicked.connect(lambda: self.set_date_mode("custom"))
grid.addWidget(custom, 1, 5)
grid.setColumnStretch(0, 1)
bottom_row.addWidget(custom)
bottom_row.addStretch(1)
panel.addLayout(bottom_row)
return card
def _build_summary(self) -> QHBoxLayout:
@@ -1220,8 +1459,10 @@ class PatientListWorkspace(QWidget):
("day_after", "后天预约", "day_after"),
):
button = QPushButton(f"{label}\n0 人")
button.setProperty("variant", "secondary")
button.setMinimumHeight(58)
button.setProperty("summaryCard", True)
button.setFixedHeight(56)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
self.summary_buttons[key] = button
layout.addWidget(button, 1)
@@ -1236,6 +1477,7 @@ class PatientListWorkspace(QWidget):
heading = QHBoxLayout()
title = QLabel("患者列表")
title.setProperty("role", "sectionTitle")
title.setMinimumHeight(20)
heading.addWidget(title)
heading.addStretch(1)
self.scope_label = QLabel(self._scope)
@@ -1249,39 +1491,45 @@ class PatientListWorkspace(QWidget):
host_layout.setSpacing(8)
self.table = SortableTable(
[
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn(
"patient_name",
"患者",
160,
"患者信息",
180,
lambda _value, row: (
f"{display_text(first_value(row, 'patient_name', 'name'))} · "
f"{gender_text(first_value(row, 'gender_desc', 'gender'))} · "
f"{display_text(first_value(row, 'age'))}"
f"{display_text(first_value(row, 'age'))}\n"
f"{display_text(first_value(row, 'phone_masked', 'phone'), '')}"
),
),
TableColumn("assistant_name", "归属助理", 105),
TableColumn("assistant_name", "归属助理", 84),
TableColumn(
"appointment_doctor_name",
"预约医生",
125,
108,
lambda _value, row: (
f"{display_text(first_value(row, 'appointment_doctor_name'), '未预约')} / "
f"{_patient_status(row)[0]}"
),
),
TableColumn("appointment_time_text", "预约时间", 145),
TableColumn("appointment_time_text", "预约时间", 116),
TableColumn(
"revisit_count",
"复诊",
65,
72,
lambda value, _row: f"{display_text(value, '0')}",
Qt.AlignmentFlag.AlignCenter,
),
TableColumn("confirmation_text", "确认信息", 95),
TableColumn("diagnosis_date_text", "诊单日期", 105),
TableColumn("phone_masked", "手机", 118),
TableColumn("confirmation_text", "确认信息", 88),
TableColumn("diagnosis_date_text", "诊单日期", 96),
TableColumn("phone_masked", "手机", 116),
TableColumn("_actions", "操作", 430),
]
)
self.table.setObjectName("PatientTable")
self.table.horizontalHeader().setFixedHeight(38)
self.table.verticalHeader().setDefaultSectionSize(40)
self.table.itemSelectionChanged.connect(self._update_actions)
self.table.itemDoubleClicked.connect(lambda _item: self._open_selected_diagnosis())
host_layout.addWidget(self.table, 1)
@@ -1294,6 +1542,93 @@ class PatientListWorkspace(QWidget):
layout.addWidget(self.content_stack, 1)
return card
def _install_row_actions(self) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
can_book = _canonical_allowed(self.permissions, "tcm.diagnosis/guahao")
can_assign = _canonical_allowed(self.permissions, "tcm.diagnosis/assign")
can_orders = _canonical_allowed(self.permissions, "tcm.prescriptionOrder/lists")
action_column = self.table.columnCount() - 1
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
row = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
selector_host = QWidget(self.table)
selector_layout = QHBoxLayout(selector_host)
selector_layout.setContentsMargins(0, 0, 0, 0)
selector_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
selector = QCheckBox(selector_host)
selector.setProperty("patientSelector", True)
selector.setToolTip("选择患者")
selector.clicked.connect(
lambda checked=False, index=row_index: self.table.selectRow(index) if checked else None
)
selector_layout.addWidget(selector)
self.table.setCellWidget(row_index, 0, selector_host)
selector_item = self.table.item(row_index, 0)
if selector_item is not None:
selector_item.setText("")
host = QWidget(self.table)
layout = QHBoxLayout(host)
layout.setContentsMargins(5, 3, 5, 3)
layout.setSpacing(4)
def add_action(
label: str,
callback: Any,
*,
danger: bool = False,
_host: QWidget = host,
_layout: QHBoxLayout = layout,
) -> None:
button = QPushButton(label, _host)
button.setProperty("rowAction", True)
if danger:
button.setProperty("variant", "danger")
button.clicked.connect(callback)
_layout.addWidget(button)
if editable or readable:
add_action(
"诊单" if editable else "查看",
lambda _checked=False, value=row, can_edit=editable: self.diagnosis_requested.emit(
value, can_edit
),
)
if can_book:
add_action(
"预约",
lambda _checked=False, value=row: self.appointment_requested.emit(value),
)
if can_assign:
add_action(
"重新指派" if _as_int(first_value(row, "assistant_id")) > 0 else "指派医助",
lambda _checked=False, value=row: self.assign_requested.emit(value),
)
if editable and not _as_bool(first_value(row, "has_id_card", default=False)):
add_action(
"补全身份证",
lambda _checked=False, value=row: self.fill_id_requested.emit(value),
)
if can_orders:
add_action(
"关联订单",
lambda _checked=False, value=row: self.orders_requested.emit(value),
)
status = _as_int(first_value(row, "appointment_status"), -1)
appointment_id = _as_int(first_value(row, "appointment_id"))
if can_book and appointment_id > 0 and status in {1, 4}:
add_action(
"取消",
lambda _checked=False, value=row: self.cancel_requested.emit(value),
danger=True,
)
layout.addStretch(1)
self.table.setCellWidget(row_index, action_column, host)
action_item = self.table.item(row_index, action_column)
if action_item is not None:
action_item.setText("")
self.table.setRowHeight(row_index, 40)
def _build_actions(self) -> QHBoxLayout:
layout = QHBoxLayout()
layout.setSpacing(6)
@@ -1465,10 +1800,11 @@ class PatientListWorkspace(QWidget):
return
rows = page_items(result)
self.table.set_rows(rows)
self._install_row_actions()
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
_style_table_cell(self.table, row_index, 2, _patient_status(source)[1])
_style_table_cell(self.table, row_index, 3, _patient_status(source)[1])
self.pager.update_state(self._page, page_total(result, len(rows)))
self.content_stack.setCurrentIndex(0 if rows else 1)
extend = _page_extend(result)
@@ -2270,13 +2606,13 @@ class PatientsPage(QWidget):
self._assistant_generation = 0
root = QVBoxLayout(self)
root.setContentsMargins(24, 18, 24, 20)
root.setSpacing(10)
root.setContentsMargins(25, 4, 30, 14)
root.setSpacing(0)
header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
self.scope_badge = StatusBadge("按权限加载", "neutral")
header.add_action(self.scope_badge)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "secondary")
refresh.setProperty("variant", "primary")
refresh.clicked.connect(self.refresh)
header.add_action(refresh)
root.addWidget(header)
@@ -2,9 +2,13 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QFrame,
@@ -20,11 +24,12 @@ from PySide6.QtWidgets import (
)
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..widgets import (
EmptyState,
MessageBanner,
MetricCard,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
@@ -38,6 +43,129 @@ from ..widgets import (
run_async,
show_toast,
)
from .prescriptions import (
BusinessPager,
_cell_host,
_painted_icon,
_row_action_button,
_style_row_host,
_tag_label,
)
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 80px; max-height: 80px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 22px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="info"] {
border-color: #E5DFFF; background: #F2EEFF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="success"] {
border-color: #CDEFE3; background: #E8F8F2;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="warning"] {
border-color: #F8DFC2; background: #FFF3E5;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar {
background: #FFFFFF; border: 0; border-bottom: 1px solid #E7EBF5;
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 36px; max-height: 36px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #5265F6; background: transparent; border-bottom-color: #5265F6;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 36px; max-height: 36px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton {
min-height: 32px; max-height: 32px; padding: 0 13px;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 27px; max-width: 27px; min-height: 27px; max-height: 27px;
padding: 0; border-radius: 7px; background: #FFFFFF;
border: 1px solid #DCE3F5;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover {
background: #F3F5FF; border-color: #AAB7FF;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"] {
background: #FFF9FA; border-color: #FFD9DE;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover {
background: #FFF1F3; border-color: #FFACB8;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; border-radius: 0; background: #FFFFFF;
alternate-background-color: #FBFCFF; font-size: 12px;
}
#PrescriptionLibraryPage QTableWidget::item {
padding: 4px 8px; border-bottom: 1px solid #EDF0F7;
}
#PrescriptionLibraryPage QTableWidget::item:selected {
color: #26365F; background: #FCFDFF;
}
#PrescriptionLibraryPage QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border: 0; border-bottom: 1px solid #E7EBF5;
font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QWidget#Pager { min-height: 42px; }
#PrescriptionLibraryPage QWidget#Pager QPushButton {
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
}
#PrescriptionLibraryPage QWidget#Pager QLabel#PagerActive {
min-width: 48px; min-height: 30px; border-radius: 7px;
background: #5265F6; color: #FFFFFF; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
}
"""
def _formula_text(value: Any, _row: Any = None) -> str:
@@ -70,6 +198,40 @@ def _herbs_detail(_value: Any, row: Any) -> str:
return "".join(pieces) if pieces else "暂无药材"
def _efficacy_text(_value: Any, row: Any) -> str:
return display_text(
first_value(
row,
"efficacy",
"efficacy_text",
"effect",
"effect_text",
"indications",
default="",
)
)
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(80)
card.layout().setContentsMargins(18, 12, 16, 12)
icon = QLabel(card)
icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(42, 42)
colors = {
"accent": "#5365F5",
"info": "#8268E8",
"success": "#23A77D",
"warning": "#E6932C",
}
icon.setPixmap(_painted_icon("document", colors.get(kind, "#5365F5"), 18).pixmap(18, 18))
card.layout().addWidget(icon)
return card
class PrescriptionLibraryPage(QWidget):
"""Filter, inspect, and manage reusable prescriptions."""
@@ -81,6 +243,8 @@ class PrescriptionLibraryPage(QWidget):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("PrescriptionLibraryPage")
self.setStyleSheet(PRESCRIPTION_LIBRARY_PAGE_QSS)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
@@ -91,83 +255,155 @@ class PrescriptionLibraryPage(QWidget):
self._page_size = 15
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(15)
root.setContentsMargins(24, 19, 24, 14)
root.setSpacing(12)
header = PageHeader(
"我的处方库",
"管理可复用药材组合;公开模板可被其他医生导入,禁用修改仅作用于导入后的处方。",
"处方库",
"管理常用处方模板,支持 AI 解析辅助开方。",
)
self.new_button = QPushButton(" 新增处方", header)
header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header)
self.new_button.setMinimumWidth(124)
self.new_button.setProperty("variant", "primary")
self.new_button.setIcon(_painted_icon("plus", "#FFFFFF", 15))
self.new_button.setIconSize(QSize(15, 15))
self.new_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
self.new_button.clicked.connect(self._new_template)
header.add_action(self.new_button)
root.addWidget(header)
metrics = QHBoxLayout()
metrics.setContentsMargins(0, 0, 0, 5)
metrics.setSpacing(24)
self.metric_cards = {
"total": _metric_card("全部处方"),
"private": _metric_card("仅自己", "info"),
"public": _metric_card("公开处方", "success"),
"month": _metric_card("本月新增", "warning"),
}
for metric in self.metric_cards.values():
metrics.addWidget(metric)
root.addLayout(metrics)
self.hint_banner = MessageBanner(parent=self)
self.hint_banner.show_message(
"AI 仅依据药材组合推测可能证候与主治方向,不能替代四诊、辨证和处方审核。",
"warning",
)
self.hint_banner.hide()
filters = QFrame()
filters.setObjectName("FilterBar")
filters.setObjectName("PrescriptionLibraryFilterBar")
grid = QGridLayout(filters)
grid.setContentsMargins(16, 13, 16, 13)
grid.setHorizontalSpacing(10)
grid.setContentsMargins(16, 11, 16, 11)
grid.setHorizontalSpacing(20)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("处方名称")
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True)
self.name_filter.returnPressed.connect(self._search)
grid.addWidget(self.name_filter, 0, 0, 1, 2)
grid.addWidget(self.name_filter, 0, 0)
self.formula_filter = QComboBox()
self.formula_filter.addItem("全部类型", "")
self.formula_filter.addItem("主方", "主方")
self.formula_filter.addItem("辅方", "辅方")
grid.addWidget(self.formula_filter, 0, 2)
grid.addWidget(self.formula_filter, 0, 1)
self.visibility_filter = QComboBox()
self.visibility_filter.addItem("全部公开范围", "")
self.visibility_filter.addItem("仅自己可见", 0)
self.visibility_filter.addItem("所有人可见", 1)
grid.addWidget(self.visibility_filter, 0, 3)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 0, 4)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 0, 5)
grid.addWidget(self.visibility_filter, 0, 2)
self.effect_filter = QComboBox()
self.effect_filter.addItem("全部功效类型", "")
self.effect_filter.addItem("益气养阴", "益气养阴")
self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(500)
self.formula_filter.setFixedWidth(190)
self.visibility_filter.setFixedWidth(174)
self.effect_filter.setFixedWidth(190)
self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66)
self.query_button.setProperty("variant", "secondary")
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.query_button.clicked.connect(self._search)
grid.addWidget(self.query_button, 0, 4)
self.reset_button = QPushButton("重置")
self.reset_button.setFixedWidth(66)
self.reset_button.setProperty("variant", "ghost")
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters)
grid.addWidget(self.reset_button, 0, 5)
grid.setColumnStretch(0, 1)
root.addWidget(filters)
self.banner = MessageBanner()
root.addWidget(self.banner)
card = QFrame()
card.setObjectName("Card")
card.setObjectName("PrescriptionLibraryTableCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(16, 14, 16, 14)
card_layout.setSpacing(10)
toolbar = QHBoxLayout()
title = QLabel("处方模板")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
card_layout.setContentsMargins(0, 0, 0, 0)
card_layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 10, 16, 9)
toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True)
self.all_tab.setCheckable(True)
self.all_tab.setChecked(True)
self.all_tab.clicked.connect(lambda: self._set_library_scope(False))
toolbar.addWidget(self.all_tab)
self.favorite_tab = QPushButton("我的收藏", toolbar_host)
self.favorite_tab.setProperty("toolbarTab", True)
self.favorite_tab.setCheckable(True)
self.favorite_tab.clicked.connect(lambda: self._set_library_scope(True))
toolbar.addWidget(self.favorite_tab)
toolbar.addStretch(1)
self.view_button = QPushButton("查看", card)
self.view_button.setIcon(_painted_icon("eye", "#5265F6", 15))
self.view_button.setIconSize(QSize(15, 15))
self.view_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
self.view_button.setEnabled(False)
self.view_button.clicked.connect(self._view_selected)
toolbar.addWidget(self.view_button)
self.ai_button = QPushButton("AI解释", card)
self.ai_button.setProperty("variant", "secondary")
self.ai_button.setIcon(_painted_icon("spark", "#5265F6", 15))
self.ai_button.setIconSize(QSize(15, 15))
self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_button.setVisible(can_open_ai_explain(permissions))
self.ai_button.setEnabled(False)
self.ai_button.clicked.connect(self._explain_selected)
toolbar.addWidget(self.ai_button)
self.edit_button = QPushButton("编辑", card)
self.edit_button.setIcon(_painted_icon("pencil", "#5265F6", 15))
self.edit_button.setIconSize(QSize(15, 15))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
self.edit_button.setEnabled(False)
self.edit_button.clicked.connect(self._edit_selected)
toolbar.addWidget(self.edit_button)
self.delete_button = QPushButton("删除", card)
self.delete_button.setProperty("variant", "danger")
self.delete_button.setIcon(_painted_icon("trash", "#F34E64", 15))
self.delete_button.setIconSize(QSize(15, 15))
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
self.delete_button.setEnabled(False)
self.delete_button.clicked.connect(self._delete_selected)
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.setIcon(_painted_icon("refresh", "#5D6E96", 15))
refresh.setIconSize(QSize(15, 15))
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
card_layout.addLayout(toolbar)
card_layout.addWidget(toolbar_host)
self.stack = QStackedWidget()
table_host = QWidget()
@@ -175,21 +411,26 @@ class PrescriptionLibraryPage(QWidget):
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("id", "ID", 60),
TableColumn("prescription_name", "处方名称", 180),
TableColumn("formula_type", "处方类型", 90, _formula_text),
TableColumn("herbs", "药材数量", 90, _herb_count),
TableColumn("herbs", "药材明细", 300, _herbs_detail),
TableColumn("is_public", "是否公开", 110, _visibility_text),
TableColumn("disable_edit", "禁用修改", 95, _disable_edit_text),
TableColumn("id", "ID", 62),
TableColumn("prescription_name", "处方名称", 162),
TableColumn("formula_type", "处方类型", 88, _formula_text),
TableColumn("herbs", "药材数量", 84, _herb_count),
TableColumn("herbs", "药材组成(部分)", 296, _herbs_detail),
TableColumn("efficacy", "功效主治", 170, _efficacy_text),
TableColumn("is_public", "公开范围", 135, _visibility_text),
TableColumn("creator_name", "创建人", 100),
TableColumn("create_time", "创建时间", 150),
TableColumn("create_time", "创建时间", 178),
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
]
)
self.table.verticalHeader().setDefaultSectionSize(37)
self.table.horizontalHeader().setFixedHeight(41)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager = BusinessPager(self._page_size)
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
@@ -208,10 +449,23 @@ class PrescriptionLibraryPage(QWidget):
self._page = 1
self.refresh()
def _set_library_scope(self, favorites_only: bool) -> None:
"""Switch the visual library tab while retaining server-backed rows.
The current repository contract has no favorites discriminator, so the
tab is a presentation-compatible scope until that capability is exposed.
"""
self.all_tab.setChecked(not favorites_only)
self.favorite_tab.setChecked(favorites_only)
self._page = 1
self.refresh()
def _reset_filters(self) -> None:
self.name_filter.clear()
self.formula_filter.setCurrentIndex(0)
self.visibility_filter.setCurrentIndex(0)
self.effect_filter.setCurrentIndex(0)
self._search()
def _change_page(self, page: int) -> None:
@@ -250,12 +504,165 @@ class PrescriptionLibraryPage(QWidget):
if generation != self._generation:
return
rows = page_items(result)
effect = str(self.effect_filter.currentData() or "").strip()
if effect:
rows = [
row
for row in rows
if effect
in str(
first_value(
row,
"efficacy",
"efficacy_text",
"effect",
"effect_text",
"indications",
default="",
)
)
]
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self._decorate_rows(rows)
total = page_total(result, len(rows))
self.pager.update_state(requested_page, total)
public_count = sum(
1 for row in rows if _truthy(first_value(row, "is_public", default=False))
)
month_prefix = datetime.now().strftime("%Y-%m")
month_count = sum(
1
for row in rows
if str(first_value(row, "create_time", "created_at", default="")).startswith(
month_prefix
)
)
self.metric_cards["total"].set_value(total)
self.metric_cards["private"].set_value(max(0, len(rows) - public_count))
self.metric_cards["public"].set_value(public_count)
self.metric_cards["month"].set_value(month_count)
self.stack.setCurrentIndex(0 if rows else 1)
self.banner.clear()
self._selection_changed()
def _decorate_rows(self, rows: list[Any]) -> None:
"""Install compact tags and permission-aware row actions from the comp."""
for row_index, row in enumerate(rows):
id_item = self.table.item(row_index, 0)
if id_item is not None:
id_item.setTextAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
name_item = self.table.item(row_index, 1)
if name_item is not None:
name_item.setForeground(QColor("#24355F"))
font = name_item.font()
font.setWeight(QFont.Weight.DemiBold)
name_item.setFont(font)
formula = _formula_text(first_value(row, "formula_type", default="主方"))
formula_kind = "success" if formula == "主方" else "accent"
self.table.setCellWidget(
row_index,
2,
_style_row_host(
_cell_host(
_tag_label(formula, formula_kind, self.table.viewport())
),
row_index,
),
)
visibility_host = QWidget(self.table.viewport())
_style_row_host(visibility_host, row_index)
visibility_layout = QHBoxLayout(visibility_host)
visibility_layout.setContentsMargins(8, 0, 6, 0)
visibility_layout.setSpacing(6)
visibility_icon = QLabel(visibility_host)
visibility_icon.setFixedSize(15, 15)
visibility_icon.setPixmap(_painted_icon("lock", "#60709A", 14).pixmap(14, 14))
visibility_layout.addWidget(visibility_icon)
visibility_label = QLabel(
_visibility_text(first_value(row, "is_public", default=0)),
visibility_host,
)
visibility_label.setStyleSheet("color:#3F4F76;background:transparent;border:0;")
visibility_layout.addWidget(visibility_label)
visibility_layout.addStretch(1)
self.table.setCellWidget(row_index, 6, visibility_host)
actions_host = QWidget(self.table.viewport())
_style_row_host(actions_host, row_index)
actions = QHBoxLayout(actions_host)
actions.setContentsMargins(6, 0, 6, 0)
actions.setSpacing(5)
actions.addStretch(1)
if has_permission(self.permissions, "wcf.prescription/read"):
actions.addWidget(
_row_action_button(
"eye",
"查看处方模板",
lambda _checked=False, target=row: self._run_row_action(
target, self._view_selected
),
actions_host,
)
)
if can_open_ai_explain(self.permissions):
actions.addWidget(
_row_action_button(
"spark",
"AI 解释",
lambda _checked=False, target=row: self._run_row_action(
target, self._explain_selected
),
actions_host,
)
)
manageable = self._can_manage_row(row)
if has_permission(self.permissions, "wcf.prescription/edit"):
actions.addWidget(
_row_action_button(
"pencil",
"编辑处方模板",
lambda _checked=False, target=row: self._run_row_action(
target, self._edit_selected
),
actions_host,
enabled=manageable,
)
)
if has_permission(self.permissions, "wcf.prescription/delete"):
actions.addWidget(
_row_action_button(
"trash",
"删除处方模板",
lambda _checked=False, target=row: self._run_row_action(
target, self._delete_selected
),
actions_host,
danger=True,
enabled=manageable,
)
)
actions.addStretch(1)
self.table.setCellWidget(row_index, 9, actions_host)
def _run_row_action(self, row: Any, callback: Any) -> None:
target_id = first_value(row, "id", "template_id", default=None)
for row_index in range(self.table.rowCount()):
item = self.table.item(row_index, 0)
candidate = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
candidate_id = first_value(candidate, "id", "template_id", default=None)
if candidate is row or (
target_id is not None and str(candidate_id) == str(target_id)
):
self.table.selectRow(row_index)
break
callback()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
@@ -270,6 +677,7 @@ class PrescriptionLibraryPage(QWidget):
def _selection_changed(self) -> None:
row = self.table.current_data()
self.view_button.setEnabled(row is not None)
self.ai_button.setEnabled(row is not None)
manageable = self._can_manage_row(row)
self.edit_button.setEnabled(row is not None and manageable)
self.delete_button.setEnabled(row is not None and manageable)
@@ -310,6 +718,14 @@ class PrescriptionLibraryPage(QWidget):
parent=self,
).exec()
def _explain_selected(self) -> None:
row = self.table.current_data()
if row is None or not can_open_ai_explain(self.permissions):
return
dialog = PrescriptionAiReportDialog(self.repository, self.permissions, parent=self)
dialog.open_for(row)
dialog.exec()
def _edit_selected(self) -> None:
row = self.table.current_data()
if (
@@ -400,6 +816,7 @@ class PrescriptionLibraryPage(QWidget):
self.new_button.setEnabled(enabled)
if not enabled:
self.view_button.setEnabled(False)
self.ai_button.setEnabled(False)
self.edit_button.setEnabled(False)
self.delete_button.setEnabled(False)
else:
@@ -6,8 +6,10 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateTimeEdit,
QDialog,
@@ -39,7 +41,6 @@ from ..widgets import (
EmptyState,
MessageBanner,
PageHeader,
Pager,
SortableTable,
TableColumn,
display_text,
@@ -54,6 +55,308 @@ from ..widgets import (
show_toast,
)
PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage { background: #F8FAFF; }
#PrescriptionsPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar,
#PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 13px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar {
background: #FFFFFF;
border: 0;
border-bottom: 1px solid #E7EBF5;
border-top-left-radius: 13px;
border-top-right-radius: 13px;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
min-height: 34px; max-height: 34px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionsPage QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton {
min-height: 32px; max-height: 32px; padding: 0 13px;
}
#PrescriptionsPage QLabel#PrescriptionCountBadge {
min-height: 22px; max-height: 22px; padding: 0 9px;
color: #5265F6; background: #F0F2FF; border-radius: 11px;
font-size: 11px; font-weight: 600;
}
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border-radius: 7px; background: #FFFFFF;
border: 1px solid #DCE3F5;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover {
background: #F3F5FF; border-color: #AAB7FF;
}
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"] {
background: #FFF9FA; border-color: #FFD9DE;
}
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover {
background: #FFF1F3; border-color: #FFACB8;
}
#PrescriptionsPage QTableWidget {
border: 0; border-radius: 0; background: #FFFFFF;
alternate-background-color: #FBFCFF; font-size: 12px;
}
#PrescriptionsPage QTableWidget::item {
padding: 4px 8px; border-bottom: 1px solid #EDF0F7;
}
#PrescriptionsPage QTableWidget::item:selected {
color: #26365F; background: #FCFDFF;
}
#PrescriptionsPage QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border: 0; border-bottom: 1px solid #E7EBF5;
font-size: 12px; font-weight: 600;
}
#PrescriptionsPage QWidget#Pager { min-height: 42px; }
#PrescriptionsPage QWidget#Pager QPushButton {
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
}
#PrescriptionsPage QWidget#Pager QLabel#PagerActive {
min-width: 48px; min-height: 30px; border-radius: 7px;
background: #5265F6; color: #FFFFFF; font-weight: 700;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionsPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
}
"""
def _painted_icon(kind: str, color: str = "#5265F6", size: int = 16) -> QIcon:
"""Return a crisp page-local icon without relying on emoji or icon fonts."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(QColor(color), max(1.25, size / 11.5))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
scale = size / 16.0
if kind == "eye":
painter.drawEllipse(QRectF(6.0 * scale, 6.0 * scale, 4.0 * scale, 4.0 * scale))
painter.drawArc(QRectF(1.8 * scale, 3.3 * scale, 12.4 * scale, 9.4 * scale), 18 * 16, 144 * 16)
painter.drawArc(QRectF(1.8 * scale, 3.3 * scale, 12.4 * scale, 9.4 * scale), 198 * 16, 144 * 16)
elif kind == "pencil":
painter.drawLine(4.0 * scale, 12.0 * scale, 11.8 * scale, 4.2 * scale)
painter.drawLine(5.7 * scale, 13.0 * scale, 13.0 * scale, 5.7 * scale)
painter.drawLine(4.0 * scale, 12.0 * scale, 3.2 * scale, 14.0 * scale)
painter.drawLine(3.2 * scale, 14.0 * scale, 5.7 * scale, 13.0 * scale)
painter.drawLine(11.8 * scale, 4.2 * scale, 13.0 * scale, 5.7 * scale)
elif kind == "trash":
painter.drawRoundedRect(QRectF(4.4 * scale, 5.2 * scale, 7.2 * scale, 8.2 * scale), 1, 1)
painter.drawLine(3.3 * scale, 4.1 * scale, 12.7 * scale, 4.1 * scale)
painter.drawLine(6.2 * scale, 2.4 * scale, 9.8 * scale, 2.4 * scale)
painter.drawLine(6.7 * scale, 7.2 * scale, 6.7 * scale, 11.2 * scale)
painter.drawLine(9.3 * scale, 7.2 * scale, 9.3 * scale, 11.2 * scale)
elif kind == "refresh":
painter.drawArc(QRectF(2.5 * scale, 2.5 * scale, 11.0 * scale, 11.0 * scale), 35 * 16, 276 * 16)
painter.drawLine(11.5 * scale, 2.7 * scale, 13.5 * scale, 3.0 * scale)
painter.drawLine(11.5 * scale, 2.7 * scale, 12.1 * scale, 4.8 * scale)
elif kind == "spark":
painter.drawLine(8.0 * scale, 1.8 * scale, 8.0 * scale, 5.0 * scale)
painter.drawLine(8.0 * scale, 11.0 * scale, 8.0 * scale, 14.2 * scale)
painter.drawLine(1.8 * scale, 8.0 * scale, 5.0 * scale, 8.0 * scale)
painter.drawLine(11.0 * scale, 8.0 * scale, 14.2 * scale, 8.0 * scale)
painter.drawLine(4.0 * scale, 4.0 * scale, 6.0 * scale, 6.0 * scale)
painter.drawLine(10.0 * scale, 10.0 * scale, 12.0 * scale, 12.0 * scale)
painter.drawLine(12.0 * scale, 4.0 * scale, 10.0 * scale, 6.0 * scale)
painter.drawLine(6.0 * scale, 10.0 * scale, 4.0 * scale, 12.0 * scale)
painter.drawEllipse(QRectF(6.2 * scale, 6.2 * scale, 3.6 * scale, 3.6 * scale))
elif kind == "plus":
painter.drawLine(8.0 * scale, 3.2 * scale, 8.0 * scale, 12.8 * scale)
painter.drawLine(3.2 * scale, 8.0 * scale, 12.8 * scale, 8.0 * scale)
elif kind == "lock":
painter.drawRoundedRect(QRectF(3.4 * scale, 7.0 * scale, 9.2 * scale, 7.0 * scale), 1.4, 1.4)
painter.drawArc(QRectF(5.0 * scale, 2.4 * scale, 6.0 * scale, 8.0 * scale), 0, 180 * 16)
elif kind == "checkbox":
painter.drawRoundedRect(QRectF(3.2 * scale, 3.2 * scale, 9.6 * scale, 9.6 * scale), 1.6, 1.6)
elif kind == "document":
painter.drawRoundedRect(QRectF(3.4 * scale, 2.0 * scale, 9.2 * scale, 12.0 * scale), 1.2, 1.2)
painter.drawLine(5.4 * scale, 6.0 * scale, 10.6 * scale, 6.0 * scale)
painter.drawLine(5.4 * scale, 8.5 * scale, 10.6 * scale, 8.5 * scale)
painter.drawLine(5.4 * scale, 11.0 * scale, 8.8 * scale, 11.0 * scale)
painter.end()
return QIcon(pixmap)
def _tag_label(text: str, kind: str, parent: QWidget) -> QLabel:
palettes = {
"accent": ("#315CF4", "#EDF2FF"),
"success": ("#16966F", "#E7F8F1"),
"warning": ("#D98A16", "#FFF5DF"),
"danger": ("#E84B5F", "#FFF0F2"),
"neutral": ("#657397", "#F1F3F8"),
}
foreground, background = palettes.get(kind, palettes["neutral"])
label = QLabel(text, parent)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setStyleSheet(
f"color:{foreground};background:{background};border:0;border-radius:4px;"
"padding:2px 7px;font-size:11px;font-weight:600;"
)
label.setSizePolicy(label.sizePolicy().horizontalPolicy(), label.sizePolicy().verticalPolicy())
return label
def _cell_host(widget: QWidget, *, alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft) -> QWidget:
host = QWidget(widget.parentWidget())
layout = QHBoxLayout(host)
layout.setContentsMargins(8, 0, 8, 0)
layout.setSpacing(6)
if alignment & Qt.AlignmentFlag.AlignHCenter:
layout.addStretch(1)
layout.addWidget(widget)
layout.addStretch(1)
return host
def _style_row_host(host: QWidget, row_index: int) -> QWidget:
host.setObjectName("PrescriptionTableCellHost")
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
host.setStyleSheet(
"QWidget#PrescriptionTableCellHost {"
f"background:{'#FFFFFF' if row_index % 2 == 0 else '#FBFCFF'};border:0;"
"}"
)
return host
def _row_action_button(
kind: str,
tooltip: str,
callback: Callable[[], None],
parent: QWidget,
*,
danger: bool = False,
enabled: bool = True,
) -> QPushButton:
button = QPushButton(parent)
button.setProperty("rowAction", True)
button.setProperty("danger", danger)
button.setAccessibleName(tooltip)
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setIcon(_painted_icon(kind, "#F34E64" if danger else "#4965F5", 15))
button.setIconSize(QSize(15, 15))
button.setEnabled(enabled)
button.clicked.connect(callback)
return button
class BusinessPager(QWidget):
"""Reference-style numbered pager while retaining the page's fixed size contract."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("", self)
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("", self)
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.size_combo = QComboBox(self)
self.size_combo.addItem(f"{page_size} 条/页", page_size)
layout.addWidget(self.size_combo)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"{self.total}")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def _int(value: Any, default: int = 0) -> int:
try:
@@ -77,11 +380,7 @@ def _row_mapping(value: Any) -> dict[str, Any]:
if isinstance(raw, Mapping) and raw:
return dict(raw)
fields = getattr(value, "__dataclass_fields__", {})
return {
name: getattr(value, name, None)
for name in fields
if name != "raw"
}
return {name: getattr(value, name, None) for name in fields if name != "raw"}
def merge_prescription_detail(row: Any, detail: Any) -> dict[str, Any]:
@@ -166,18 +465,14 @@ def _order_warnings(row: Any) -> list[str]:
def _sn_cell(_value: Any, row: Any) -> str:
sn = first_value(row, "sn", "prescription_no", "id", default="")
prescription_id = first_value(row, "id", "prescription_id", default="")
warnings = _order_warnings(row)
suffix = "\n" + "\n".join(warnings) if warnings else ""
return f"{sn}\nID: {prescription_id}{suffix}"
return str(first_value(row, "sn", "prescription_no", "id", default=""))
def _patient_cell(_value: Any, row: Any) -> str:
gender = first_value(row, "gender", default=None)
gender_text = "" if gender in (1, "1") else "" if gender in (0, "0") else "未知"
age = first_value(row, "age", default="")
return f"{first_value(row, 'patient_name', default='')}\n{gender_text} · {age}"
return f"{first_value(row, 'patient_name', default='')} · {gender_text} · {age}"
def _source_cell(_value: Any, row: Any) -> str:
@@ -208,10 +503,7 @@ def _void_cell(_value: Any, row: Any) -> str:
def _doctor_cell(_value: Any, row: Any) -> str:
return (
f"{first_value(row, 'doctor_name', 'creator_name', default='')}\n"
f"{first_value(row, 'prescription_date', default='')}"
)
return str(first_value(row, "doctor_name", "creator_name", default=""))
class DoctorMultiSelect(QWidget):
@@ -292,6 +584,8 @@ class PrescriptionsPage(QWidget):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("PrescriptionsPage")
self.setStyleSheet(PRESCRIPTIONS_PAGE_QSS)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
@@ -307,18 +601,27 @@ class PrescriptionsPage(QWidget):
self._mutation_pending = False
root = QVBoxLayout(self)
root.setContentsMargins(24, 20, 24, 24)
root.setSpacing(14)
# Reference business screens place the heading 32 px below the 70 px
# shell bar; keep that baseline shared with the library screen.
root.setContentsMargins(24, 19, 24, 14)
root.setSpacing(12)
header = PageHeader(
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
header.actions.setSpacing(16)
self.orders_button = QPushButton("业务订单", header)
self.orders_button.setMinimumWidth(86)
self.orders_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
self.orders_button.clicked.connect(lambda: self._open_orders())
header.add_action(self.orders_button)
self.add_button = QPushButton(" 新增处方", header)
self.add_button = QPushButton("新增处方", header)
self.add_button.setMinimumWidth(122)
self.add_button.setProperty("variant", "primary")
self.add_button.setIcon(_painted_icon("plus", "#FFFFFF", 15))
self.add_button.setIconSize(QSize(15, 15))
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
self.add_button.clicked.connect(self._add_prescription)
header.add_action(self.add_button)
@@ -331,11 +634,11 @@ class PrescriptionsPage(QWidget):
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("FilterBar")
frame.setObjectName("PrescriptionFilterBar")
grid = QGridLayout(frame)
grid.setContentsMargins(14, 12, 14, 12)
grid.setHorizontalSpacing(9)
grid.setVerticalSpacing(8)
grid.setContentsMargins(14, 17, 14, 17)
grid.setHorizontalSpacing(18)
grid.setVerticalSpacing(15)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
@@ -366,7 +669,7 @@ class PrescriptionsPage(QWidget):
self.source_filter.addItem("全部来源", "all")
self.source_filter.addItem("手工", "manual")
self.source_filter.addItem("空白处方", "system")
grid.addWidget(self.source_filter, 0, 4)
grid.addWidget(self.source_filter, 0, 4, 1, 2)
self.sn_filter = QLineEdit()
self.sn_filter.setPlaceholderText("处方编号")
self.sn_filter.setClearButtonEnabled(True)
@@ -382,34 +685,47 @@ class PrescriptionsPage(QWidget):
current_name = str(first_value(self.current_user, "name", "real_name", default="")).strip()
if current_id and current_name:
self.doctor_filter.update_options([{"id": current_id, "name": current_name}])
grid.addWidget(self.doctor_filter, 1, 2)
query = QPushButton("查询")
query.setProperty("variant", "secondary")
query.clicked.connect(self._search)
grid.addWidget(query, 1, 3)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters)
grid.addWidget(reset, 1, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
grid.addWidget(self.doctor_filter, 1, 2, 1, 2)
self.query_button = QPushButton("查询")
self.query_button.setProperty("variant", "secondary")
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.query_button.clicked.connect(self._search)
grid.addWidget(self.query_button, 1, 4)
self.reset_button = QPushButton("重置")
self.reset_button.setProperty("variant", "ghost")
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters)
grid.addWidget(self.reset_button, 1, 5)
grid.setColumnMinimumWidth(0, 218)
grid.setColumnMinimumWidth(4, 92)
grid.setColumnMinimumWidth(5, 92)
grid.setColumnStretch(1, 383)
grid.setColumnStretch(2, 320)
grid.setColumnStretch(3, 207)
return frame
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("Card")
card.setObjectName("PrescriptionTableCard")
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 13, 14, 13)
layout.setSpacing(9)
toolbar = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionToolbar")
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 11, 16, 11)
toolbar.setSpacing(8)
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
toolbar.addWidget(title)
self.count_badge = QLabel("共 0 条", toolbar_host)
self.count_badge.setObjectName("PrescriptionCountBadge")
toolbar.addWidget(self.count_badge)
toolbar.addStretch(1)
self.view_button = self._action_button("查看", "cf.prescription/read", self._view_selected)
toolbar.addWidget(self.view_button)
self.patch_button = self._action_button(
"患者", "tcm.prescription/patchPatient", self._patch_selected
"患者", "tcm.prescription/patchPatient", self._patch_selected
)
toolbar.addWidget(self.patch_button)
self.create_order_button = self._action_button(
@@ -428,30 +744,42 @@ class PrescriptionsPage(QWidget):
toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost")
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.setIcon(_painted_icon("refresh", "#5D6E96", 15))
refresh.setIconSize(QSize(15, 15))
refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh)
layout.addLayout(toolbar)
layout.addWidget(toolbar_host)
self.stack = QStackedWidget()
table_host = QWidget()
table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable(
[
TableColumn("sn", "处方编号", 190, _sn_cell),
TableColumn("prescription_type", "处方类型", 95),
TableColumn("is_system_auto", "来源", 90, _source_cell),
TableColumn("patient_name", "患者信息", 120, _patient_cell),
TableColumn("audit_status", "审核状态", 220, _audit_cell),
TableColumn("void_status", "作废", 70, _void_cell),
TableColumn("doctor_name", "医生信息", 130, _doctor_cell),
TableColumn("assistant_name", "", 90),
TableColumn("create_time", "创建时间", 145),
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
TableColumn("sn", "处方编号", 174, _sn_cell),
TableColumn("prescription_type", "处方类型", 96),
TableColumn("is_system_auto", "来源", 88, _source_cell),
TableColumn("patient_name", "患者信息", 190, _patient_cell),
TableColumn("audit_status", "审核状态", 110, _audit_cell),
TableColumn("void_status", "作废", 72, _void_cell),
TableColumn("doctor_name", "生信息", 180, _doctor_cell),
TableColumn("assistant_name", "医助", 125),
TableColumn("create_time", "创建时间", 180),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
]
)
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 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())
table_layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager = BusinessPager(self._page_size)
self.pager.summary.hide()
self.pager.page_changed.connect(self._change_page)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host)
@@ -472,6 +800,17 @@ class PrescriptionsPage(QWidget):
# Keep permission-driven initial visibility inside the page hierarchy;
# otherwise Windows briefly exposes the button as its own window.
button = QPushButton(text, self)
button.setCursor(Qt.CursorShape.PointingHandCursor)
icon_kind = {
"查看": "eye",
"编辑": "pencil",
"删除": "trash",
}.get(text)
if icon_kind:
button.setIcon(
_painted_icon(icon_kind, "#F34E64" if danger else "#5265F6", 15)
)
button.setIconSize(QSize(15, 15))
if danger:
button.setProperty("variant", "danger")
button.setVisible(has_permission(self.permissions, permission))
@@ -578,7 +917,10 @@ class PrescriptionsPage(QWidget):
return
rows = page_items(result)
self.table.set_rows(rows)
self.pager.update_state(requested_page, page_total(result, len(rows)))
self._decorate_rows(rows)
total = page_total(result, len(rows))
self.pager.update_state(requested_page, total)
self.count_badge.setText(f"{total}")
self.stack.setCurrentIndex(0 if rows else 1)
doctor_rows = []
for row in rows:
@@ -599,6 +941,130 @@ class PrescriptionsPage(QWidget):
self.table.selectRow(0)
self._selection_changed()
def _decorate_rows(self, rows: list[Any]) -> None:
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
for row_index, row in enumerate(rows):
selector = self.table.item(row_index, 0)
if selector is not None:
selector.setFlags(
selector.flags()
| Qt.ItemFlag.ItemIsUserCheckable
| Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
)
selector.setCheckState(Qt.CheckState.Unchecked)
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
sn_item = self.table.item(row_index, 1)
if sn_item is not None:
sn_item.setForeground(QColor("#315CF4"))
font = sn_item.font()
font.setWeight(QFont.Weight.DemiBold)
sn_item.setFont(font)
warnings = _order_warnings(row)
if warnings:
sn_item.setToolTip("\n".join(warnings))
prescription_type = display_text(
first_value(row, "prescription_type", default="")
)
self.table.setCellWidget(
row_index,
2,
_style_row_host(
_cell_host(_tag_label(prescription_type, "accent", self.table.viewport())),
row_index,
),
)
audit_text, audit_kind = prescription_status(row)
audit = _tag_label(audit_text, audit_kind, self.table.viewport())
audit_item = self.table.item(row_index, 5)
if audit_item is not None and "\n" in audit_item.text():
audit.setToolTip(audit_item.text())
self.table.setCellWidget(
row_index, 5, _style_row_host(_cell_host(audit), row_index)
)
doctor_name = _doctor_cell(None, row)
doctor_host = QWidget(self.table.viewport())
_style_row_host(doctor_host, row_index)
doctor_layout = QHBoxLayout(doctor_host)
doctor_layout.setContentsMargins(8, 0, 6, 0)
doctor_layout.setSpacing(7)
avatar = QLabel(doctor_name[:1] if doctor_name != "" else "", doctor_host)
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(20, 20)
avatar.setStyleSheet(
"color:#5365D9;background:#EEF1FF;border:1px solid #DBE1FF;"
"border-radius:10px;font-size:10px;font-weight:700;"
)
doctor_layout.addWidget(avatar)
doctor_label = QLabel(doctor_name, doctor_host)
doctor_label.setStyleSheet("color:#31416A;background:transparent;border:0;")
doctor_layout.addWidget(doctor_label)
doctor_layout.addStretch(1)
self.table.setCellWidget(row_index, 7, doctor_host)
actions_host = QWidget(self.table.viewport())
_style_row_host(actions_host, row_index)
actions = QHBoxLayout(actions_host)
actions.setContentsMargins(7, 0, 7, 0)
actions.setSpacing(7)
actions.addStretch(1)
if has_permission(self.permissions, "cf.prescription/read"):
actions.addWidget(
_row_action_button(
"eye",
"查看处方",
lambda _checked=False, target=row: self._run_row_action(
target, self._view_selected
),
actions_host,
)
)
if has_permission(self.permissions, "cf.prescription/edit"):
actions.addWidget(
_row_action_button(
"pencil",
"编辑处方",
lambda _checked=False, target=row: self._run_row_action(
target, self._edit_selected
),
actions_host,
enabled=can_edit_or_delete(row),
)
)
if has_permission(self.permissions, "cf.prescription/del"):
actions.addWidget(
_row_action_button(
"trash",
"删除处方",
lambda _checked=False, target=row: self._run_row_action(
target, self._delete_selected
),
actions_host,
danger=True,
enabled=can_edit_or_delete(row),
)
)
actions.addStretch(1)
self.table.setCellWidget(row_index, 10, actions_host)
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = first_value(row, "id", "prescription_id", default=None)
for row_index in range(self.table.rowCount()):
item = self.table.item(row_index, 0)
candidate = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
candidate_id = first_value(candidate, "id", "prescription_id", default=None)
if candidate is row or (
target_id is not None and str(candidate_id) == str(target_id)
):
self.table.selectRow(row_index)
break
callback()
def _load_error(self, error: Exception, generation: int) -> None:
if generation == self._generation:
self.banner.show_message(friendly_error(error), "danger")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+363 -53
View File
@@ -1,55 +1,68 @@
"""Application-wide light desktop theme.
"""Application-wide visual system for the AI consultation workstation.
The default workstation palette uses white data surfaces, cool neutral canvas
tones and a restrained indigo accent. Dense medical content stays opaque and
high-contrast while borders and focus states preserve the desktop hierarchy.
The palette and density follow the supplied product references: a quiet blue
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from string import Template
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QEvent, QObject, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QFileDialog,
QInputDialog,
QMessageBox,
)
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
# callers while the stylesheet itself is generated from this mapping.
COLORS = {
"canvas": "#F5F7FB",
"canvas_mid": "#F8F9FC",
"canvas_glow": "#EEF2FF",
# Sampled from the supplied 1710 x 920 product comps. The window edge is
# the only blue-tinted surface; the application workspace itself is an
# almost-white #FCFDFE field.
"canvas": "#EEF3FD",
"canvas_mid": "#F7F9FE",
"canvas_glow": "#E5ECFD",
"surface": "#FFFFFF",
"surface_alt": "#F7F8FC",
"raised": "#EEF2F8",
"glass": "rgba(255, 255, 255, 248)",
"glass_alt": "rgba(247, 248, 252, 250)",
"line": "#D8DEEA",
"line_soft": "rgba(79, 99, 217, 52)",
"text": "#172033",
"text_soft": "#34415A",
"muted": "#667085",
"disabled_surface": "#ECEFF5",
"disabled_text": "#98A2B3",
"indigo": "#4F63D9",
"indigo_hover": "#4053C7",
"indigo_pressed": "#3446AF",
"indigo_pale": "#E9EDFF",
"focus": "#8795F5",
"selection": "#DCE3FF",
"success": "#16876C",
"success_pale": "#E8F6F1",
"warning": "#9A6813",
"warning_pale": "#FFF4D8",
"danger": "#C43E55",
"danger_pale": "#FDECEF",
"info": "#2F6EDB",
"info_pale": "#EAF2FF",
"surface_alt": "#FAFBFE",
"raised": "#F5F7FC",
"glass": "rgba(255, 255, 255, 252)",
"glass_alt": "rgba(248, 250, 255, 252)",
"line": "#E6EAF5",
"line_soft": "rgba(82, 97, 246, 40)",
"text": "#111F46",
"text_soft": "#3F4E75",
"muted": "#7886AA",
"disabled_surface": "#F0F2F8",
"disabled_text": "#A4ADC3",
"indigo": "#5761F4",
"indigo_hover": "#4C57E9",
"indigo_pressed": "#4451E2",
"indigo_pale": "#F0F2FF",
"focus": "#8D9BFF",
"selection": "#EDF0FF",
"success": "#17A77D",
"success_pale": "#EAF9F3",
"warning": "#D38625",
"warning_pale": "#FFF5E6",
"danger": "#F15B67",
"danger_pale": "#FFF1F3",
"info": "#4D69ED",
"info_pale": "#F0F4FF",
# Backward-compatible names used by older UI code and integrations.
"ink": "#172033",
"ink_soft": "#34415A",
"teal": "#4F63D9",
"teal_dark": "#3446AF",
"teal_pale": "#E9EDFF",
"ink": "#111F46",
"ink_soft": "#3F4E75",
"teal": "#5761F4",
"teal_dark": "#4451E2",
"teal_pale": "#F0F2FF",
}
@@ -65,17 +78,86 @@ QWidget {
QMainWindow, QDialog, QWidget#LoginCanvas {
background-color: $canvas;
}
QWidget#AppCanvas, QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
QDialog[businessDialog="true"] {
background-color: $canvas_mid;
}
QDialog[businessDialog="true"] QFrame#DialogSurface,
QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
background-color: $surface;
border: 1px solid $line;
border-radius: 14px;
}
QDialog[businessDialog="true"] QFrame#DialogHeader,
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
min-height: 58px;
background-color: $surface;
border: 0;
border-bottom: 1px solid $line;
}
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
color: $text;
font-size: 18px;
font-weight: 700;
}
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
color: $muted;
font-size: 12px;
}
QDialog[businessDialog="true"] QDialogButtonBox,
QMessageBox QDialogButtonBox,
QInputDialog QDialogButtonBox {
min-height: 58px;
background-color: $surface;
border: 0;
border-top: 1px solid $line;
}
QDialog[businessDialog="true"] QDialogButtonBox QPushButton,
QMessageBox QDialogButtonBox QPushButton,
QInputDialog QDialogButtonBox QPushButton {
min-width: 88px;
min-height: 38px;
}
QMessageBox[businessDialog="true"],
QInputDialog[businessDialog="true"] {
background-color: $canvas_mid;
}
QMessageBox QLabel#qt_msgbox_label {
min-width: 300px;
padding: 8px 2px;
color: $text_soft;
font-size: 14px;
}
QMessageBox QLabel#qt_msgboxex_icon_label {
min-width: 44px;
min-height: 44px;
margin: 4px 10px 4px 0;
}
QMessageBox[messageKind="warning"] QLabel#qt_msgbox_label,
QMessageBox[messageKind="critical"] QLabel#qt_msgbox_label {
color: $text;
}
QInputDialog QLabel {
color: $text_soft;
font-size: 14px;
}
QWidget#AppCanvas {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $canvas,
stop: 0.58 $canvas_mid,
stop: 1 $canvas_glow
);
border-radius: 18px;
}
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
background-color: #FCFDFE;
}
QLabel[role="muted"] { color: $muted; }
QLabel[role="danger"] { color: $danger; }
QLabel[role="breadcrumb"] { color: $muted; font-size: 12px; }
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: 14px; }
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: 12px; font-weight: 600; }
QLabel[role="eyebrow"] {
color: $indigo_hover;
font-size: 11px;
@@ -84,7 +166,7 @@ QLabel[role="eyebrow"] {
}
QLabel[role="pageTitle"] {
color: $text;
font-size: 24px;
font-size: 22px;
font-weight: 700;
}
QLabel[role="sectionTitle"] {
@@ -106,14 +188,52 @@ QLabel[role="metric"] {
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
QFrame[glass="true"] {
background-color: $glass;
border: 1px solid $line_soft;
border-radius: 14px;
border: 1px solid $line;
border-radius: 12px;
}
QFrame#FilterBar { background-color: $surface; }
QFrame#SubtleCard {
background-color: $glass_alt;
border: 1px solid $line;
border-radius: 12px;
}
QFrame#MetricCard {
background-color: $surface;
border: 1px solid $line;
border-radius: 11px;
}
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: 12px; }
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: 21px; font-weight: 700; }
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: 11px; }
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
QFrame#ReceptionAiCard {
min-height: 132px;
background-color: #F8FAFF;
border: 1px solid $line_soft;
border-radius: 13px;
}
QLabel#ReceptionAiTitle {
color: $indigo_pressed;
font-size: 14px;
font-weight: 700;
}
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
min-height: 30px;
padding: 0 9px;
font-size: 11px;
}
QLabel#MetricGlyph {
color: $indigo;
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 11px;
font-size: 16px;
font-weight: 700;
}
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
QLabel#MetricGlyph[kind="info"] { color: $info; background-color: $info_pale; }
QFrame#Divider {
background-color: $line;
min-height: 1px;
@@ -138,8 +258,8 @@ QPushButton {
min-height: 36px;
padding: 0 16px;
border: 1px solid $line;
border-radius: 10px;
background-color: $surface_alt;
border-radius: 9px;
background-color: $surface;
color: $text;
font-weight: 600;
}
@@ -168,7 +288,10 @@ QPushButton:disabled {
QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: $indigo;
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 $indigo, stop: 1 #7769F7
);
border-color: $indigo;
}
QPushButton[variant="primary"]:hover,
@@ -363,7 +486,7 @@ QTimeEdit, QSpinBox, QDoubleSpinBox, QKeySequenceEdit {
min-height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 10px;
border-radius: 9px;
background-color: $surface;
color: $text;
selection-background-color: $indigo;
@@ -436,7 +559,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
color: $text;
background-color: $surface;
alternate-background-color: $surface_alt;
alternate-background-color: #FBFCFF;
border: 0;
border-radius: 12px;
gridline-color: $line;
@@ -446,7 +569,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
}
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
QTableWidget::item, QTableView::item {
padding: 9px 8px;
padding: 8px 8px;
border-bottom: 1px solid $line;
}
QTableWidget::item:hover, QTableView::item:hover { background-color: $surface_alt; }
@@ -455,12 +578,12 @@ QTableWidget::item:selected, QTableView::item:selected {
background-color: $selection;
}
QHeaderView::section {
background-color: $surface_alt;
background-color: #F7F9FE;
color: $muted;
border: 0;
border-right: 1px solid $line;
border-bottom: 1px solid $line;
padding: 10px 8px;
padding: 9px 8px;
font-size: 12px;
font-weight: 700;
}
@@ -506,6 +629,26 @@ QTabBar::tab:selected {
border-color: $line_soft;
}
QTabBar::tab:disabled { color: $disabled_text; }
QTabBar#ReceptionDetailTabs {
background-color: $surface;
border-bottom: 1px solid $line;
}
QTabBar#ReceptionDetailTabs::tab {
min-height: 38px;
padding: 0 14px;
margin: 0 4px 0 0;
color: $muted;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
}
QTabBar#ReceptionDetailTabs::tab:hover { color: $text; background-color: $surface_alt; }
QTabBar#ReceptionDetailTabs::tab:selected {
color: $indigo_pressed;
background-color: transparent;
border-bottom: 2px solid $indigo;
}
QMenuBar {
color: $text_soft;
@@ -634,6 +777,23 @@ QLabel#StatusBadge[kind="danger"] { color: $danger; background-color: $danger_pa
QLabel#StatusBadge[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 72); }
QLabel#StatusBadge[kind="accent"] { color: $indigo_hover; background-color: $indigo_pale; border-color: $line_soft; }
QWidget#Pager QLabel#PagerActive {
min-width: 42px;
min-height: 30px;
color: #FFFFFF;
background-color: $indigo;
border: 1px solid $indigo;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
QWidget#Pager QPushButton {
min-height: 30px;
padding: 0 10px;
border: 1px solid $line;
background-color: $surface;
}
QWidget#EmptyState { background-color: transparent; }
QLabel#EmptyStateGlyph {
color: $indigo_hover;
@@ -684,7 +844,8 @@ QFrame#BusyOverlay QProgressBar { background-color: $line; }
QWidget#Sidebar {
background-color: rgba(255, 255, 255, 250);
border-right: 1px solid $line;
border: 1px solid $line;
border-radius: 17px;
}
QFrame#TopBar, QFrame#MultipleTabs {
background-color: rgba(255, 255, 255, 248);
@@ -739,10 +900,158 @@ def _apply_group(
palette.setColor(group, role, QColor(value))
def _register_preferred_cjk_fonts() -> str:
"""Make the bundled/offscreen Windows runtime aware of its CJK fonts.
Qt's offscreen platform does not always enumerate the Windows font
collection. Registering the already-installed YaHei collection only
when it is missing prevents Chinese text from degrading to tofu boxes in
packaged captures and headless visual checks. Other platforms continue
to use their native PingFang/Noto fallback.
"""
platform_families = {
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
"darwin": ("PingFang SC", "Hiragino Sans GB"),
}
preferred = platform_families.get(sys.platform, ("Noto Sans CJK SC", "Source Han Sans SC"))
available = set(QFontDatabase.families())
if not any(family in available for family in preferred) and sys.platform == "win32":
windows_root = Path(
os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or r"C:\Windows"
)
font_dir = windows_root / "Fonts"
for filename in ("msyh.ttc", "msyhbd.ttc", "msyhl.ttc"):
font_path = font_dir / filename
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0 and QFontDatabase.applicationFontFamilies(font_id):
available.update(QFontDatabase.applicationFontFamilies(font_id))
for family in (*preferred, "Arial Unicode MS"):
if family in available:
return family
return QApplication.font().family()
_PRIMARY_STANDARD_BUTTONS = frozenset(
{
QDialogButtonBox.StandardButton.Ok,
QDialogButtonBox.StandardButton.Save,
QDialogButtonBox.StandardButton.SaveAll,
QDialogButtonBox.StandardButton.Open,
QDialogButtonBox.StandardButton.Apply,
QDialogButtonBox.StandardButton.Yes,
QDialogButtonBox.StandardButton.YesToAll,
QDialogButtonBox.StandardButton.Retry,
}
)
_DANGER_STANDARD_BUTTONS = frozenset(
{
QDialogButtonBox.StandardButton.Abort,
QDialogButtonBox.StandardButton.Discard,
}
)
def _refresh_widget_style(widget: object) -> None:
style = getattr(widget, "style", lambda: None)()
if style is None:
return
style.unpolish(widget)
style.polish(widget)
def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
"""Assign semantic variants to Qt standard buttons without changing behavior."""
for button in button_box.buttons():
if button.property("variant"):
continue
standard = button_box.standardButton(button)
if standard in _PRIMARY_STANDARD_BUTTONS:
variant = "primary"
elif standard in _DANGER_STANDARD_BUTTONS:
variant = "danger"
else:
variant = "secondary"
button.setProperty("variant", variant)
_refresh_widget_style(button)
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
"""Opt a business subwindow into the shared visual contract.
The helper intentionally does not change modality, ownership or result
handling. It only supplies stable styling metadata and semantic button
roles, so existing workflows keep their original behavior.
"""
if object_name and not dialog.objectName():
dialog.setObjectName(object_name)
dialog.setProperty("businessDialog", True)
for button_box in dialog.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
_refresh_widget_style(dialog)
class _BusinessDialogStyleFilter(QObject):
"""Style dynamically-created Qt dialogs just before they become visible."""
@staticmethod
def _is_native_or_overlay(dialog: QDialog) -> bool:
return (
isinstance(dialog, QFileDialog)
or dialog.metaObject().className() == "QPrintDialog"
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
)
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
event_type = event.type()
if isinstance(watched, QDialogButtonBox) and event_type in {
QEvent.Type.Polish,
QEvent.Type.Show,
}:
_polish_dialog_buttons(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
for button_box in watched.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
if not self._is_native_or_overlay(watched):
mark_business_dialog(watched)
if isinstance(watched, QMessageBox):
icon_kinds = {
QMessageBox.Icon.Information: "info",
QMessageBox.Icon.Warning: "warning",
QMessageBox.Icon.Critical: "critical",
QMessageBox.Icon.Question: "question",
}
watched.setProperty("messageKind", icon_kinds.get(watched.icon(), "plain"))
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
elif isinstance(watched, QInputDialog):
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
return False
def _install_business_dialog_styling(app: QApplication) -> None:
existing = getattr(app, "_doctor_business_dialog_style_filter", None)
if existing is not None:
return
style_filter = _BusinessDialogStyleFilter(app)
app.installEventFilter(style_filter)
app._doctor_business_dialog_style_filter = style_filter
def apply_theme(app: QApplication) -> None:
"""Apply the global Fusion palette and stylesheet to ``app``."""
app.setStyle("Fusion")
cjk_family = _register_preferred_cjk_fonts()
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback
# list can resolve to Qt's generic sans face in offscreen/native title-bar
# captures, which changes glyph width and can even yield tofu boxes.
application_font = QFont(app.font())
application_font.setFamily(cjk_family)
app.setFont(application_font)
palette = QPalette()
active = {
QPalette.ColorRole.WindowText: COLORS["text"],
@@ -795,6 +1104,7 @@ def apply_theme(app: QApplication) -> None:
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS)
_install_business_dialog_styling(app)
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme"]
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme", "mark_business_dialog"]
+80 -6
View File
@@ -378,7 +378,7 @@ def friendly_error(error: Any) -> str:
class PageHeader(QWidget):
"""Consistent title, subtitle, and action area for business pages."""
"""Reference-design page heading with breadcrumb, copy and actions."""
def __init__(
self,
@@ -387,9 +387,29 @@ class PageHeader(QWidget):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
self.setObjectName("PageHeader")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
layout.setSpacing(8)
breadcrumb = QHBoxLayout()
breadcrumb.setContentsMargins(0, 0, 0, 0)
breadcrumb.setSpacing(7)
home_label = QLabel("首页", self)
home_label.setProperty("role", "breadcrumb")
breadcrumb.addWidget(home_label)
separator = QLabel("", self)
separator.setProperty("role", "breadcrumbSeparator")
breadcrumb.addWidget(separator)
self.breadcrumb_label = QLabel(title, self)
self.breadcrumb_label.setProperty("role", "breadcrumbCurrent")
breadcrumb.addWidget(self.breadcrumb_label)
breadcrumb.addStretch(1)
layout.addLayout(breadcrumb)
heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(16)
text_layout = QVBoxLayout()
text_layout.setSpacing(3)
self.title_label = QLabel(title, self)
@@ -400,10 +420,11 @@ class PageHeader(QWidget):
self.subtitle_label.setWordWrap(True)
self.subtitle_label.setVisible(bool(subtitle))
text_layout.addWidget(self.subtitle_label)
layout.addLayout(text_layout, 1)
heading.addLayout(text_layout, 1)
self.actions = QHBoxLayout()
self.actions.setSpacing(8)
layout.addLayout(self.actions)
heading.addLayout(self.actions)
layout.addLayout(heading)
def add_action(self, widget: QWidget) -> QWidget:
self.actions.addWidget(widget)
@@ -414,6 +435,56 @@ class PageHeader(QWidget):
self.subtitle_label.setVisible(bool(text))
class MetricCard(QFrame):
"""Compact dashboard metric used above dense business tables."""
def __init__(
self,
title: str,
value: str = "0",
*,
hint: str = "",
kind: str = "accent",
glyph: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("MetricCard")
self.setProperty("kind", kind)
self.setMinimumHeight(82)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 14, 12)
layout.setSpacing(12)
copy = QVBoxLayout()
copy.setSpacing(3)
title_label = QLabel(title, self)
title_label.setProperty("role", "metricTitle")
copy.addWidget(title_label)
self.value_label = QLabel(str(value), self)
self.value_label.setProperty("role", "metricValue")
copy.addWidget(self.value_label)
self.hint_label = QLabel(hint, self)
self.hint_label.setProperty("role", "metricHint")
self.hint_label.setVisible(bool(hint))
copy.addWidget(self.hint_label)
layout.addLayout(copy, 1)
self.glyph_label = QLabel(glyph, self)
self.glyph_label.setObjectName("MetricGlyph")
self.glyph_label.setProperty("kind", kind)
self.glyph_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.glyph_label.setFixedSize(38, 38)
self.glyph_label.setVisible(bool(glyph))
layout.addWidget(self.glyph_label)
def set_value(self, value: Any) -> None:
self.value_label.setText(display_text(value, "0"))
def set_hint(self, text: str) -> None:
self.hint_label.setText(text)
self.hint_label.setVisible(bool(text))
class StatusBadge(QLabel):
def __init__(
self, text: str = "", kind: str = "neutral", parent: QWidget | None = None
@@ -688,20 +759,22 @@ class Pager(QWidget):
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("Pager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("上一页")
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.page_label = QLabel("1 / 1")
self.page_label.setObjectName("PagerActive")
self.page_label.setMinimumWidth(58)
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_label)
@@ -763,6 +836,7 @@ __all__ = [
"BusyOverlay",
"EmptyState",
"MessageBanner",
"MetricCard",
"OverlayHost",
"PageHeader",
"Pager",
+1 -1
View File
@@ -559,7 +559,7 @@ 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: #0891B2;" in APPOINTMENT_DRAWER_QSS
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
+125 -7
View File
@@ -9,7 +9,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QLabel
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -348,14 +348,14 @@ def test_appointment_multiline_cells_receive_enough_row_height(
False,
)
patient_text = page.table.item(0, 1).text()
appointment_text = page.table.item(0, 3).text()
assert patient_text.count("\n") == 2
assert appointment_text == "2026-08-11\n14:30"
patient_text = page.table.item(0, 2).text()
appointment_text = page.table.item(0, 4).text()
assert patient_text == "张玉英"
assert appointment_text.count("\n") == 2
assert "2026-08-11 14:30" in appointment_text
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert page.table.rowHeight(0) >= required
assert page.table.item(0, 1).toolTip() == patient_text
assert page.table.item(0, 3).toolTip() == appointment_text
assert page.table.item(0, 4).toolTip() == appointment_text
page.close()
@@ -379,11 +379,68 @@ def test_video_qr_dialog_renders_downloaded_image_inside_app(
assert image is not None
assert image.pixmap() is not None and not image.pixmap().isNull()
assert dialog.findChild(QLabel, "VideoQrImage").text() == ""
assert dialog.objectName() == "VideoQrDialog"
assert dialog.property("businessDialog") is True
buttons = dialog.findChild(QDialogButtonBox)
assert next(button for button in buttons.buttons() if button.text() == "浏览器打开").property(
"variant"
) == "primary"
dialog.close()
page.close()
application.processEvents()
def test_page_owned_appointment_dialogs_have_stable_visual_contract(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(
["doctor.appointment/complete", "doctor.appointment/addDoctorNote"]
),
current_user={"id": 1001, "role_id": 1},
)
captured: list[QDialog] = []
def reject_dialog(dialog: QDialog) -> QDialog.DialogCode:
captured.append(dialog)
return QDialog.DialogCode.Rejected
monkeypatch.setattr(QDialog, "exec", reject_dialog)
page._open_custom_date()
page._show_detail({"id": 101, "patient_name": "鹿立核"}, page._action_generation)
page.table.set_rows(
[{"id": 101, "diagnosis_id": 501, "patient_id": 301, "status": 1}]
)
page.table.selectRow(0)
page._complete_selected()
assert [dialog.objectName() for dialog in captured] == [
"AppointmentCustomDateDialog",
"AppointmentDetailDialog",
"AppointmentCompleteDialog",
]
assert all(dialog.property("businessDialog") is True for dialog in captured)
assert all(
any(label.property("dialogRole") == "title" for label in dialog.findChildren(QLabel))
for dialog in captured
)
custom_buttons = captured[0].findChild(QDialogButtonBox)
complete_buttons = captured[2].findChild(QDialogButtonBox)
assert custom_buttons.button(QDialogButtonBox.StandardButton.Ok).property(
"variant"
) == "primary"
assert complete_buttons.button(QDialogButtonBox.StandardButton.Ok).property(
"variant"
) == "primary"
for dialog in captured:
dialog.close()
page.close()
application.processEvents()
def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -425,3 +482,64 @@ def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
assert opened and opened[0][1]["id"] == 501
page.close()
application.processEvents()
def test_appointment_ai_report_button_visible_with_reception_permission(
application: QApplication,
) -> None:
hidden = AppointmentsPage(
SimpleNamespace(),
permissions=PermissionSet(["tcm.diagnosis/kaifang"]),
)
assert hidden.ai_button.isHidden()
hidden.close()
page = AppointmentsPage(
SimpleNamespace(),
permissions=PermissionSet(["doctor.appointment/reception"]),
)
assert not page.ai_button.isHidden()
assert not page.ai_button.isEnabled()
page.close()
application.processEvents()
def test_appointments_reference_split_layout_and_video_list(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page._loaded(
{
"lists": [
{
"id": 101,
"diagnosis_id": 501,
"patient_name": "赵俊霞",
"gender": 0,
"age": 53,
"assistant_name": "自媒体4",
"appointment_date": "2026-08-13",
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
}
],
"count": 1,
},
page._generation,
False,
)
application.processEvents()
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert page.video_list.parentWidget().width() == 420
assert page.table.objectName() == "AppointmentTable"
assert page.date_buttons["today"].isChecked()
page.close()
application.processEvents()
+63 -13
View File
@@ -9,10 +9,12 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Signal
from PySide6.QtWidgets import (
QApplication,
QDialog,
QInputDialog,
QMessageBox,
QApplication,
QDialog,
QDialogButtonBox,
QInputDialog,
QLabel,
QMessageBox,
QToolButton,
QWidget,
)
@@ -20,9 +22,14 @@ from PySide6.QtWidgets import (
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_video_payload,
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_AppointmentLogDialog,
_DiagnosisCreateDialog,
_DiagnosisOrderDialog,
_DiagnosisOrderQrDialog,
_QrResultDialog,
_video_payload,
appointment_rows,
is_diagnosis_confirmed,
is_valid_id_card,
@@ -81,7 +88,7 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
@@ -105,8 +112,50 @@ def _row(**changes: Any) -> dict[str, Any]:
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_prescription": 0,
}
row.update(changes)
return row
row.update(changes)
return row
def test_consultation_business_dialogs_expose_shared_visual_contract(
application: QApplication,
) -> None:
record = _row(patient_name="鹿立核", patient_id=301)
dialogs = (
_DiagnosisCreateDialog(),
_DiagnosisOrderDialog(record),
_AppointmentLogDialog(record, []),
_QrResultDialog("视频二维码", record, "https://example.invalid/video.png"),
_DiagnosisOrderQrDialog(record, "ORDER-301"),
)
assert [dialog.objectName() for dialog in dialogs] == [
"DiagnosisCreateDialog",
"DiagnosisOrderDialog",
"AppointmentLogDialog",
"DiagnosisQrResultDialog",
"DiagnosisOrderQrDialog",
]
assert all(dialog.property("businessDialog") is True for dialog in dialogs)
assert all(
any(label.property("dialogRole") == "title" for label in dialog.findChildren(QLabel))
for dialog in dialogs
)
assert dialogs[0].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Save
).property("variant") == "primary"
assert dialogs[1].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "primary"
assert next(
button
for button in dialogs[3].findChild(QDialogButtonBox).buttons()
if button.text() == "打开二维码"
).property("variant") == "primary"
assert dialogs[4].open_button.property("variant") == "primary"
for dialog in dialogs:
dialog.close()
application.processEvents()
def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
@@ -165,9 +214,10 @@ def test_default_query_matches_admin_today_and_page_size_contract(
assert len(calls) == 1
query = calls[0]
assert query["page_no"] == 1
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert "status" not in query
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert query["appointment_status"] == "1"
assert "status" not in query
assert query["has_appointment"] == ""
assert query["diagnosis_confirmed"] == ""
assert {
+4 -4
View File
@@ -775,7 +775,7 @@ def test_hpi_choice_chips_are_visible_after_dictionary_load(
def test_choice_chips_keep_visible_checked_style_when_readonly(
application: QApplication,
) -> None:
"""Selected chips must stay cyan even when the form is view-only locked."""
"""Selected chips keep the blue-tinted fill when the form is view-only locked."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
@@ -785,14 +785,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() == "#cffafe"
assert enabled_color.name().lower() == "#f0f2ff"
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() == "#cffafe"
assert disabled_color.name().lower() == "#f0f2ff"
dialog.close()
application.processEvents()
@@ -812,7 +812,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() == "#cffafe"
assert color.name().lower() == "#f0f2ff"
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
"""Patient-level AI report contracts and reception history behaviour."""
from __future__ import annotations
import os
from datetime import date
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
AI_MEDICAL_DISCLAIMER,
ReceptionPage,
_generated_patient_report,
_patient_report_rows,
_ReceptionAiAnalysisDialog,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(reception_module, "run_async", run_immediately)
def _detail(appointment_id: int, patient_id: int, diagnosis_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": patient_id,
"patient_name": "快照患者",
"status": 1,
"appointment_date": date.today().isoformat(),
},
"patient": {"id": patient_id, "patient_name": "快照患者", "age": 48},
"diagnosis": {
"id": diagnosis_id,
"patient_id": patient_id,
"clinical_diagnosis": "气阴两虚证",
},
}
def _snapshot(model: str, version: int, stamp: str) -> dict[str, Any]:
label = "OpenAI" if model == "openai" else "千问"
return {
"id": version * 10 + (2 if model == "openai" else 1),
"patient_id": 301,
"model_key": model,
"model_label": label,
"model_name": "gpt-demo" if model == "openai" else "qwen-demo",
"version": version,
"generated_at": stamp,
"report": {
"diagnosis": f"{label}{version} 版诊断建议",
"risk_assessment": [{"label": "随访风险", "level": "low"}],
"treatment_advice": f"{label}{version} 版治疗建议",
"disclaimer": "服务端免责声明",
},
}
def test_remote_patient_report_contract_sends_only_patient_and_model() -> None:
class Client:
token = "token"
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any], **_kwargs: Any) -> Any:
self.calls.append(("get", endpoint, dict(params)))
return {"patient_id": params["patient_id"], "reports": []}
def post(self, endpoint: str, body: dict[str, Any], **_kwargs: Any) -> Any:
self.calls.append(("post", endpoint, dict(body)))
return {"patient_id": body["patient_id"], "reports": []}
client = Client()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.list_patient_ai_reports(301)
repository.generate_patient_ai_report(301, model="qwen")
assert client.calls == [
("get", "tcm.diagnosis/patientAiReports", {"patient_id": 301}),
(
"post",
"tcm.diagnosis/generatePatientAiReport",
{"patient_id": 301, "model": "qwen"},
),
]
assert not ({"key", "api_key", "base_url", "provider"} & client.calls[-1][2].keys())
def test_demo_patient_history_has_two_versions_and_generation_appends() -> None:
repository = DemoDoctorRepository()
before = repository.list_patient_ai_reports(301)
assert len(before["reports"]) == 4
assert [row["version"] for row in before["reports"] if row["model_key"] == "qwen"] == [2, 1]
generated = repository.generate_patient_ai_report(301, model="qwen")
assert "reports" not in generated
assert "latest_by_model" not in generated
assert generated["generated_report"]["version"] == 3
assert generated["generated_report"] == generated["report"]
assert generated["disclaimer"] == AI_MEDICAL_DISCLAIMER
assert generated["generated_report"]["disclaimer"] == AI_MEDICAL_DISCLAIMER
assert isinstance(generated["source_summary"], dict)
assert generated["source_summary"] == generated["generated_report"]["source_summary"]
assert len(repository.list_patient_ai_reports(301)["reports"]) == 5
assert repository.list_patient_ai_reports(301)["disclaimer"] == AI_MEDICAL_DISCLAIMER
def test_saved_history_is_rendered_without_automatic_generation(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(101, 301, 501)
reports = [
_snapshot("qwen", 2, "2026-08-13 15:42:00"),
_snapshot("openai", 2, "2026-08-13 15:43:00"),
_snapshot("qwen", 1, "2026-08-12 09:18:00"),
_snapshot("openai", 1, "2026-08-12 09:19:00"),
]
class Repository:
list_calls: list[int] = []
generate_calls: list[tuple[int, str]] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 101
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": reports}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
raise AssertionError("saved history must not auto-generate")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
application.processEvents()
assert repository.list_calls == [301]
assert repository.generate_calls == []
assert page.ai_summary_label.text() == "千问第 2 版诊断建议"
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
assert "第 2 版" in page.ai_analysis_snapshot_meta.text()
assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER
assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton"
assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton"
dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen")
assert dialog.history_selector.count() == 2
assert dialog.disclaimer_label.text() == AI_MEDICAL_DISCLAIMER
dialog.history_selector.setCurrentIndex(1)
assert "第 1 版诊断建议" in dialog.diagnosis_label.text()
dialog.close()
page.close()
def test_empty_database_and_manual_refresh_append_qwen_then_openai(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(102, 302, 502)
class Repository:
def __init__(self) -> None:
self.reports: list[dict[str, Any]] = []
self.calls: list[tuple[str, Any]] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.calls.append(("list", patient_id))
return {"patient_id": patient_id, "reports": list(self.reports)}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
self.calls.append(("generate", model))
version = 1 + sum(row["model_key"] == model for row in self.reports)
row = _snapshot(model, version, f"2026-08-14 10:0{len(self.reports)}:00")
row["patient_id"] = patient_id
self.reports.append(row)
return {
"patient_id": patient_id,
"generated_report": row,
"report": row,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
application.processEvents()
assert repository.calls == [("list", 302), ("generate", "qwen"), ("generate", "openai")]
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [1, 1]
page.ai_analysis_regenerate_button.click()
application.processEvents()
assert repository.calls[-2:] == [("generate", "qwen"), ("generate", "openai")]
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
page.close()
def test_openai_failure_keeps_new_qwen_snapshot(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(103, 303, 503)
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
if model == "openai":
raise RuntimeError("OpenAI 暂时不可用")
row = _snapshot("qwen", 1, "2026-08-14 10:30:00")
row["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": row,
"report": row,
}
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
application.processEvents()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
assert len(page._ai_analysis_histories["qwen"]) == 1
assert page._ai_analysis_model_states["openai"] == "error"
assert "千问新快照已保留" in page.ai_analysis_secondary_status.text()
page.close()
def test_late_patient_history_response_is_discarded_after_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(104, 304, 504)
second = _detail(105, 305, 505)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return first if appointment_id == 104 else second
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
row = _snapshot("qwen", 1, f"2026-08-14 10:{patient_id - 300:02d}:00")
row["patient_id"] = patient_id
row["report"]["diagnosis"] = f"患者 {patient_id} 的报告"
return {"patient_id": patient_id, "reports": [row]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
raise AssertionError("history exists")
def finish(job: dict[str, Any]) -> None:
result = job["function"](*job.get("args", ()))
if job.get("on_success"):
job["on_success"](result)
if job.get("on_finished"):
job["on_finished"]()
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
finish(jobs[0])
first_history_job = jobs[1]
page._select_record(second["appointment"])
finish(jobs[2])
second_history_job = jobs[3]
finish(second_history_job)
assert page.ai_summary_label.text() == "患者 305 的报告"
finish(first_history_job)
assert page._ai_analysis_patient_id == 305
assert page.ai_summary_label.text() == "患者 305 的报告"
page.close()
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
valid = {"patient_id": 301, "reports": [row]}
rows = _patient_report_rows(valid, expected_patient_id=301)
assert rows is not None and len(rows) == 1
invalid_top_level_ids: tuple[Any, ...] = (None, 0, -1, True, "301", 302)
for patient_id in invalid_top_level_ids:
assert (
_patient_report_rows(
{"patient_id": patient_id, "reports": [row]},
expected_patient_id=301,
)
is None
)
wrong_row = dict(row, patient_id=302)
assert (
_patient_report_rows(
{"patient_id": 301, "reports": [wrong_row]},
expected_patient_id=301,
)
is None
)
assert (
_patient_report_rows(
{
"patient_id": 302,
"data": {"patient_id": 301, "reports": [row]},
},
expected_patient_id=301,
)
is None
)
def test_post_accepts_only_the_current_persisted_snapshot() -> None:
valid = _snapshot("qwen", 3, "2026-08-14 11:05:00")
accepted = _generated_patient_report(
{"patient_id": 301, "generated_report": valid},
expected_patient_id=301,
expected_model="qwen",
)
assert accepted is not None and accepted["id"] == valid["id"]
invalid_payloads = (
{"patient_id": 301, "reports": [valid], "report": valid},
{"patient_id": 301, "generated_report": {}, "reports": [valid]},
{"patient_id": 302, "generated_report": valid},
{
"patient_id": 301,
"generated_report": dict(valid, patient_id=302),
},
{"patient_id": 301, "generated_report": dict(valid, id=0)},
{"patient_id": 301, "generated_report": dict(valid, id="31")},
{
"patient_id": 301,
"generated_report": dict(valid, model_key="openai"),
},
)
for payload in invalid_payloads:
assert (
_generated_patient_report(
payload,
expected_patient_id=301,
expected_model="qwen",
)
is None
)
def test_stale_post_history_cannot_fake_generation_success(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(106, 306, 506)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[str] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append(model)
old = _snapshot("qwen", 9, "2026-08-13 08:00:00")
old["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": None,
"reports": [old],
"report": old,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
application.processEvents()
assert repository.generate_calls == ["qwen"]
assert not any(page._ai_analysis_histories.values())
assert page._ai_analysis_model_states["qwen"] == "error"
page.close()
def test_patient_report_generation_requires_read_and_generate_permissions(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(107, 307, 507)
row = _snapshot("qwen", 1, "2026-08-14 11:10:00")
row["patient_id"] = 307
class Repository:
def __init__(self) -> None:
self.list_calls = 0
self.generate_calls = 0
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls += 1
return {"patient_id": patient_id, "reports": [row]}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls += 1
return {"patient_id": patient_id, "generated_report": row}
cases = (
([], False, 0),
(["tcm.diagnosis/patientAiReports"], False, 1),
(["tcm.diagnosis/generatePatientAiReport"], False, 0),
(["tcm.diagnosis/aiAnalysis"], False, 0),
(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
],
True,
1,
),
)
for permissions, expected_enabled, expected_list_calls in cases:
repository = Repository()
page = ReceptionPage(repository, PermissionSet(permissions))
page._select_record(detail["appointment"])
application.processEvents()
assert page.ai_analysis_regenerate_button.isEnabled() is expected_enabled
assert repository.list_calls == expected_list_calls
assert repository.generate_calls == 0
page.close()
def test_ui_never_displays_internal_prompt_version(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(108, 308, 508)
reports = [
_snapshot("qwen", 2, "2026-08-14 11:20:00"),
_snapshot("qwen", 1, "2026-08-13 11:20:00"),
]
for row in reports:
row["patient_id"] = 308
row.pop("version")
row["prompt_version"] = "patient-longitudinal-report-internal-v99"
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return detail
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": reports}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError("saved history must not auto-generate")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
application.processEvents()
assert page.ai_analysis_snapshot_meta.text().startswith("第 2 版")
assert "internal-v99" not in page.ai_analysis_snapshot_meta.text()
assert "internal-v99" not in page.ai_analysis_snapshot_meta.toolTip()
dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen")
assert dialog.history_selector.itemText(0).startswith("第 2 版")
assert dialog.history_selector.itemText(1).startswith("第 1 版")
assert "internal-v99" not in dialog.meta_label.text()
dialog.close()
page.close()
def test_patient_ai_disclaimer_remains_the_unified_text() -> None:
assert AI_MEDICAL_DISCLAIMER == (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
+95 -10
View File
@@ -8,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QInputDialog
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
@@ -21,6 +21,9 @@ from doctor_workstation.ui.pages.patients import (
PatientProgressWorkspace,
PatientsPage,
_AppointmentDialog,
_AssignDialog,
_OrderDetailDialog,
_OrderEditDialog,
_PaymentDialog,
_RefundDialog,
)
@@ -102,7 +105,7 @@ def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions(
resolved = _resolve_navigation(menu, permissions, demo_mode=False)
assert [(item.key, title) for item, title in resolved] == [
("patients", "患者工作区"),
("patients", "接诊台"),
("consultations", "问诊工作区"),
]
assert _resolve_navigation([], permissions, demo_mode=False) == []
@@ -187,7 +190,7 @@ def test_patient_refresh_generation_ignores_late_results(
application.processEvents()
assert workspace.table.rowCount() == 1
assert workspace.table.item(0, 0).text().startswith("新结果")
assert workspace.table.item(0, 1).text().startswith("新结果")
workspace.close()
@@ -371,6 +374,59 @@ def test_payment_and_refund_forms_expose_full_contract(
application.processEvents()
def test_patient_business_dialogs_expose_shared_visual_contract(
application: QApplication,
) -> None:
detail = {
"id": 81,
"order_no": "ORDER-81",
"patient_name": "鹿立核",
"recipient_name": "鹿立核",
"recipient_phone": "13800138000",
"shipping_address": "北京市朝阳区测试路 8 号",
"amount": 368,
}
dialogs = (
_PaymentDialog(detail),
_RefundDialog(detail),
_AssignDialog(
detail,
[{"id": 7, "name": "陈医助", "department_name": "中医门诊"}],
),
_OrderDetailDialog(detail),
_OrderEditDialog(detail),
)
assert [dialog.objectName() for dialog in dialogs] == [
"PatientPaymentDialog",
"PatientRefundDialog",
"PatientAssignDialog",
"PatientOrderDetailDialog",
"PatientOrderEditDialog",
]
assert all(dialog.property("businessDialog") is True for dialog in dialogs)
assert all(
any(label.property("dialogRole") == "title" for label in dialog.findChildren(QLabel))
for dialog in dialogs
)
assert dialogs[0].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "primary"
assert dialogs[1].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "danger"
assert dialogs[2].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "primary"
assert dialogs[4].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "primary"
for dialog in dialogs:
dialog.close()
application.processEvents()
def test_out_of_order_mutation_successes_each_trigger_reconciliation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -525,12 +581,12 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
patient_menu = next(
row for row in session.menu if row.get("perms") == "firstvisit.myPatient/lists"
reception_menu = next(
row for row in session.menu if row.get("perms") == "doctor.appointment/lists"
)
patient_menu["name"] = "患者中心"
patient_menu["sort"] = 99
session.menu = [patient_menu]
reception_menu["name"] = "接诊台"
reception_menu["sort"] = 99
session.menu = [reception_menu]
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
@@ -540,8 +596,8 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
shell.show()
application.processEvents()
assert list(shell.pages) == ["patients"]
assert shell.nav_buttons["patients"].text().endswith("患者中心")
assert list(shell.pages) == ["reception"]
assert shell.nav_buttons["reception"].text().endswith("接诊台")
assert shell.minimumWidth() == 1024
assert shell.minimumHeight() == 640
assert shell.size().width() == 1024
@@ -556,3 +612,32 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
}
shell.close()
application.processEvents()
def test_patient_list_reference_geometry_and_row_actions(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
page.resize(1460, 820)
page.show()
application.processEvents()
page.patient_workspace.refresh()
application.processEvents()
workspace = page.patient_workspace
assert all(
button.minimumHeight() == 56 and button.maximumHeight() == 56
for button in workspace.summary_buttons.values()
)
assert workspace.table.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
if workspace.table.rowCount():
assert workspace.table.rowHeight(0) == 40
assert workspace.table.cellWidget(0, 0) is not None
assert workspace.table.cellWidget(0, 9) is not None
page.close()
application.processEvents()
+478
View File
@@ -0,0 +1,478 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.core.errors import ApiTimeoutError
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
from doctor_workstation.ui.dialogs.prescription_ai import (
DIAGNOSIS_AI_KIND,
DiagnosisAiAssistantDialog,
PrescriptionAiReportDialog,
can_open_ai_explain,
can_open_diagnosis_ai_report,
can_use_diagnosis_ai_assistant,
diagnosis_ai_task,
preferred_ai_model,
structured_report_to_text,
structured_text_to_report,
)
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(ai_module, "run_async", run_immediately)
def test_structured_report_round_trips_eight_chinese_sections() -> None:
parsed = structured_text_to_report(
structured_report_to_text(
{
"summary": "肝郁脾虚",
"possible_symptoms": ["胁胀", "纳差"],
"main_indications": "疏肝健脾",
"efficacy": ["疏肝"],
"suitable_people": ["情志不畅者"],
"compatibility_analysis": "柴胡配白芍",
"cautions": ["需辨证"],
"disclaimer": "仅供审方",
}
)
)
assert parsed["ok"] is True
assert parsed["report"]["summary"] == "肝郁脾虚"
assert parsed["report"]["possible_symptoms"] == ["胁胀", "纳差"]
assert "缺少章节标题" in structured_text_to_report("核心判断\n有内容").get("error", "")
def test_ai_explain_permission_matches_admin_or_guard() -> None:
assert can_open_ai_explain(PermissionSet(["wcf.prescription/read"]))
assert can_open_ai_explain(PermissionSet(["tcm.prescriptionLibrary/aiReports"]))
assert not can_open_ai_explain(PermissionSet(["wcf.prescription/edit"]))
def test_ai_entry_content_selects_the_initial_server_report() -> None:
assert preferred_ai_model(entry="prescription") == "qwen"
assert preferred_ai_model("请给出中药用药调整建议", entry="reception_assistant") == "qwen"
assert preferred_ai_model("下一步并发症筛查", entry="reception_assistant") == "openai"
assert diagnosis_ai_task("下一步检查建议") == "exam_review"
assert diagnosis_ai_task("用药调整建议") == "medication_review"
assert diagnosis_ai_task("并发症筛查") == "complication_risk"
assert diagnosis_ai_task("请核对最新版指南") == "guideline_review"
assert diagnosis_ai_task("概括当前病情") == "summary"
assert diagnosis_ai_task("评估当前用药风险") == "medication_review"
assert diagnosis_ai_task("患者教育要点") == "custom"
def test_library_hides_ai_explain_without_permission(application: QApplication) -> None:
page = PrescriptionLibraryPage(
SimpleNamespace(),
PermissionSet(["wcf.prescription/edit"]),
SimpleNamespace(id=1),
)
assert page.ai_button.isHidden()
page.close()
application.processEvents()
def test_dialog_renders_saved_structured_report(
application: QApplication,
immediate_async: None,
) -> None:
dialog = PrescriptionAiReportDialog(
DemoDoctorRepository(),
PermissionSet(
[
"tcm.prescriptionLibrary/aiReports",
"tcm.prescriptionLibrary/generateAiReports",
"tcm.prescriptionLibrary/editAiReport",
"wcf.prescription/read",
]
),
)
dialog.open_for(
{
"id": 701,
"prescription_name": "疏肝健脾基础方",
"formula_type": "主方",
"herbs": [{"name": "柴胡", "dosage": "10g"}],
}
)
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
assert dialog.windowTitle() == "AI 处方解释"
assert dialog.subtitle_label.text() == "疏肝健脾基础方"
assert "柴胡 10g" in dialog.snapshot_body.text()
assert any("核心判断" in text for text in labels)
assert any("疏肝健脾" in text for text in labels)
assert dialog.can_refresh is True
assert not dialog.generate_button.isHidden()
dialog.close()
application.processEvents()
def test_dialog_generate_creates_missing_model_reports(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
dialog = PrescriptionAiReportDialog(
repository,
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
)
dialog.open_for(
{
"id": 702,
"prescription_name": "安神助眠加减方",
"formula_type": "辅方",
"herbs": [{"name": "酸枣仁", "dosage": "20g"}],
}
)
assert dialog._state("qwen").data is None
dialog._generate()
assert dialog._state("qwen").data is not None
assert dialog._state("openai").data is not None
assert "安神助眠" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
dialog.close()
application.processEvents()
def test_dialog_edit_saves_structured_json_payload(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
return {
"prescription_id": template_id,
"can_refresh": True,
"can_edit": True,
"reports": [
{
"report_id": 11,
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-13 10:00:00",
"report": {
"summary": "原判断",
"possible_symptoms": ["乏力"],
"main_indications": "健脾",
"efficacy": ["益气"],
"suitable_people": ["脾虚者"],
"compatibility_analysis": "黄芪为君",
"cautions": ["需辨证"],
"disclaimer": "仅供审方",
},
"content": "",
"is_stale": False,
"is_edited": False,
}
],
}
def edit_prescription_template_ai_report(
self, template_id: int, *, report_id: int, content: str
) -> dict[str, Any]:
calls.append(
{"template_id": template_id, "report_id": report_id, "content": content}
)
return {
"can_edit": True,
"can_refresh": True,
"report": {
"report_id": report_id,
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-13 10:00:00",
"report": {
"summary": "修订判断",
"possible_symptoms": ["乏力"],
"main_indications": "健脾",
"efficacy": ["益气"],
"suitable_people": ["脾虚者"],
"compatibility_analysis": "黄芪为君",
"cautions": ["需辨证"],
"disclaimer": "仅供审方",
},
"is_edited": True,
},
}
dialog = PrescriptionAiReportDialog(
Repository(),
PermissionSet(["tcm.prescriptionLibrary/editAiReport"]),
)
dialog.open_for({"id": 88, "prescription_name": "测试方", "formula_type": "主方", "herbs": []})
dialog._begin_edit()
assert dialog._state("qwen").editing is True
dialog._state("qwen").draft = structured_report_to_text(
{
"summary": "修订判断",
"possible_symptoms": ["乏力"],
"main_indications": "健脾",
"efficacy": ["益气"],
"suitable_people": ["脾虚者"],
"compatibility_analysis": "黄芪为君",
"cautions": ["需辨证"],
"disclaimer": "仅供审方",
}
)
dialog._save_edit()
assert calls[0]["template_id"] == 88
assert calls[0]["report_id"] == 11
assert '"summary": "修订判断"' in calls[0]["content"]
assert dialog._state("qwen").editing is False
dialog.close()
application.processEvents()
def test_diagnosis_ai_permission_matches_reception_or_guard() -> None:
assert can_open_diagnosis_ai_report(PermissionSet(["doctor.appointment/reception"]))
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/aiReports"]))
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/readonlyDetail"]))
assert not can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/edit"]))
assert can_use_diagnosis_ai_assistant(PermissionSet(["tcm.diagnosis/aiAssistant"]))
assert not can_use_diagnosis_ai_assistant(PermissionSet(["doctor.appointment/reception"]))
def test_diagnosis_dialog_renders_saved_case_report(
application: QApplication,
immediate_async: None,
) -> None:
dialog = PrescriptionAiReportDialog(
DemoDoctorRepository(),
PermissionSet(
[
"tcm.diagnosis/aiReports",
"tcm.diagnosis/generateAiReports",
"tcm.diagnosis/editAiReport",
]
),
kind=DIAGNOSIS_AI_KIND,
)
dialog.open_for(
{
"id": 501,
"diagnosis_id": 501,
"patient_name": "林晓岚",
"consultation_type": "复诊",
"clinical_diagnosis": "肝郁脾虚证",
"tongue": "舌淡红,苔薄白",
"pulse": "弦细",
}
)
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
assert dialog.windowTitle() == "AI 报告"
assert dialog.subtitle_label.text() == "林晓岚"
assert dialog.snapshot_caption.text() == "完整病历"
assert "肝郁脾虚证" in dialog.snapshot_body.text()
assert any("核心判断" in text for text in labels)
assert dialog.can_refresh is True
assert not dialog.generate_button.isHidden()
dialog.close()
application.processEvents()
def test_dialog_preferred_model_and_capabilities_respect_local_permission(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
assert diagnosis_id == 501
return {
"can_refresh": True,
"can_edit": True,
"reports": [
{"report_id": 1, "model_key": "qwen", "content": "千问报告"},
{"report_id": 2, "model_key": "openai", "content": "OpenAI 报告"},
],
}
dialog = PrescriptionAiReportDialog(
Repository(),
PermissionSet(["tcm.diagnosis/aiReports"]),
kind=DIAGNOSIS_AI_KIND,
)
dialog.open_for(
{"id": 501, "patient_name": "林晓岚"},
preferred_model="openai",
)
assert dialog.active_profile == "openai"
assert dialog.tabs.currentIndex() == 1
assert dialog.can_refresh is False
assert dialog.can_edit is False
assert dialog.generate_button.isHidden()
dialog.close()
application.processEvents()
def test_dialog_shows_friendly_timeout_and_reenables_loading_state(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
raise ApiTimeoutError(f"diagnosis {diagnosis_id} timed out")
dialog = PrescriptionAiReportDialog(
Repository(),
PermissionSet(["tcm.diagnosis/aiReports"]),
kind=DIAGNOSIS_AI_KIND,
)
dialog.open_for({"id": 501, "patient_name": "超时患者"})
assert dialog.load_loading is False
assert dialog.load_error == "连接服务器超时,请检查网络后重试。"
dialog.close()
application.processEvents()
def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str,
) -> dict[str, Any]:
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
dialog = DiagnosisAiAssistantDialog(Repository())
dialog.open_for(501, "并发症筛查", task="complication_risk")
assert calls == [
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
]
assert dialog.answer_label.text() == "建议复核肾功能与眼底。"
assert "openai" in dialog.model_label.text()
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
assert dialog.loading is False
assert dialog.retry_button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str,
) -> dict[str, Any]:
raise ApiTimeoutError(
f"diagnosis {diagnosis_id} {task} {prompt} timed out"
)
dialog = DiagnosisAiAssistantDialog(Repository())
dialog.open_for(501, "下一步检查建议", task="exam_review")
assert dialog.status_banner.label.text() == "连接服务器超时,请检查网络后重试。"
assert dialog.loading is False
assert dialog.retry_button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_assistant_exposes_loading_state(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pending: dict[str, Any] = {}
def hold_request(function: Any, **callbacks: Any) -> object:
pending.update({"function": function, **callbacks})
return object()
monkeypatch.setattr(ai_module, "run_async", hold_request)
dialog = DiagnosisAiAssistantDialog(SimpleNamespace())
dialog.open_for(501, "下一步检查建议", task="exam_review")
assert dialog.loading is True
assert not dialog.retry_button.isEnabled()
assert "正在" in dialog.status_banner.label.text()
pending["on_success"]({"answer": "检查建议", "model_key": "openai"})
assert dialog.loading is False
assert dialog.retry_button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_dialog_generate_creates_missing_model_reports(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
dialog = PrescriptionAiReportDialog(
repository,
PermissionSet(["*", "tcm.diagnosis/editAiReport"]),
kind=DIAGNOSIS_AI_KIND,
)
dialog.open_for(
{
"id": 502,
"patient_name": "赵明远",
"clinical_diagnosis": "痰湿中阻证",
}
)
assert dialog._state("qwen").data is None
dialog._generate()
assert dialog._state("qwen").data is not None
assert dialog._state("openai").data is not None
assert "赵明远" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
dialog.close()
application.processEvents()
+33 -6
View File
@@ -221,11 +221,35 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 9
assert page.table.columnCount() == 10
assert [
page.table.horizontalHeaderItem(index).text()
for index in range(page.table.columnCount())
] == [
"ID",
"处方名称",
"处方类型",
"药材数量",
"药材组成(部分)",
"功效主治",
"公开范围",
"创建人",
"创建时间",
"操作",
]
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 6) is not None
assert page.table.cellWidget(0, 9) is not None
assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
assert page.ai_button.text() == "AI解释"
assert not page.edit_button.isHidden() and page.edit_button.isEnabled()
assert not page.delete_button.isHidden() and page.delete_button.isEnabled()
assert page.pager.page_size == 15
assert page.metric_cards["total"].value_label.text() == "1"
assert page.metric_cards["private"].value_label.text() == "1"
assert page.metric_cards["public"].value_label.text() == "0"
assert page.metric_cards["month"].value_label.text() == "1"
page.close()
application.processEvents()
@@ -278,6 +302,12 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 11
assert page.table.horizontalHeaderItem(10).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 5) is not None
assert page.table.cellWidget(0, 10) is not None
assert calls == [
{
"page_no": 1,
@@ -615,16 +645,13 @@ def test_editor_matches_admin_empty_rows_dosage_choices_and_detail_merge(
assert editor.herbs.rows == []
editor.herbs.add_row(formula_type="主方")
assert editor.payload()["herbs"] == [
{"name": "", "dosage": 0.0, "formula_type": "主方"}
]
assert editor.payload()["herbs"] == [{"name": "", "dosage": 0.0, "formula_type": "主方"}]
editor.need_decoction.setChecked(True)
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("饮片"))
assert editor.need_decoction.isChecked()
assert [
editor.dosage_amount.itemData(index)
for index in range(editor.dosage_amount.count())
editor.dosage_amount.itemData(index) for index in range(editor.dosage_amount.count())
] == [50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0]
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("颗粒"))
File diff suppressed because it is too large Load Diff
+299
View File
@@ -11,6 +11,7 @@ from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import (
DIAGNOSIS_AI_PERMISSIONS,
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
RemoteDoctorRepository,
@@ -64,6 +65,30 @@ class RecordingClient:
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试患者"}
if endpoint == "tcm.prescription/getByAppointment":
return {}
if endpoint == "tcm.prescriptionLibrary/aiReports":
return {
"prescription_id": int((params or {}).get("id", 0)),
"prescription_name": "疏肝健脾基础方",
"formula_type": "主方",
"reports": [],
"missing_model_keys": ["qwen", "openai"],
"can_view": True,
"can_refresh": True,
"can_edit": True,
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
}
if endpoint == "tcm.diagnosis/aiReports":
return {
"diagnosis_id": int((params or {}).get("id", 0)),
"patient_name": "林晓岚",
"case_summary": "临床诊断:肝郁脾虚证",
"reports": [],
"missing_model_keys": ["qwen", "openai"],
"can_view": True,
"can_refresh": True,
"can_edit": True,
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
}
if endpoint == "doctor.appointment/availableSlots":
return {"slots": [{"time": "09:00", "available": True}]}
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
@@ -79,6 +104,78 @@ class RecordingClient:
return {"id": 88}
if endpoint == "tcm.diagnosis/startCall":
return {"call_record_id": 901}
if endpoint == "tcm.prescriptionLibrary/generateAiReports":
return {
"prescription_id": int(body.get("id", 0)),
"reports": [],
"can_refresh": True,
"can_edit": True,
"status": "success",
}
if endpoint == "tcm.prescriptionLibrary/editAiReport":
return {
"prescription_id": int(body.get("id", 0)),
"report": {
"report_id": int(body.get("report_id", 0)),
"model_key": "qwen",
"content": body.get("content", ""),
},
"can_edit": True,
"can_refresh": True,
}
if endpoint == "tcm.diagnosis/generateAiReports":
return {
"diagnosis_id": int(body.get("id", 0)),
"reports": [],
"can_refresh": True,
"can_edit": True,
"status": "success",
}
if endpoint == "tcm.diagnosis/editAiReport":
return {
"diagnosis_id": int(body.get("id", 0)),
"report": {
"report_id": int(body.get("report_id", 0)),
"model_key": "qwen",
"content": body.get("content", ""),
},
"can_edit": True,
"can_refresh": True,
}
if endpoint == "tcm.diagnosis/aiAssistant":
return {
"diagnosis_id": int(body.get("id", 0)),
"answer": "服务端分析结果",
"model_key": "qwen",
"task": body.get("task"),
}
if endpoint == "tcm.diagnosis/aiAnalysis":
model = str(body.get("model") or "")
if model == "openai":
return {
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
"risk_assessment": [
{"label": "用药安全风险", "level": "medium"},
{"label": "肾功能风险", "level": "low"},
],
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
"model_key": "openai",
"model_label": "OpenAI",
"model_name": "gpt-5.2",
"generated_at": "2026-08-14 10:31:00",
}
return {
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
"risk_assessment": [
{"label": "高血糖风险", "level": "high"},
{"label": "心血管风险", "level": "medium"},
],
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-14 10:30:00",
}
return {"ok": True}
@@ -395,9 +492,211 @@ def test_canonical_prescription_permissions_match_routed_views() -> None:
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
"ai_reports": "tcm.prescriptionLibrary/aiReports",
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
}
assert PRESCRIPTION_PERMISSIONS["delete"] == "cf.prescription/del"
assert PRESCRIPTION_PERMISSIONS["patch_patient"] == "tcm.prescription/patchPatient"
assert DIAGNOSIS_AI_PERMISSIONS == {
"ai_reports": "tcm.diagnosis/aiReports",
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
"edit_ai_report": "tcm.diagnosis/editAiReport",
"analysis": "tcm.diagnosis/aiAnalysis",
"assistant": "tcm.diagnosis/aiAssistant",
}
def test_remote_prescription_library_ai_report_endpoints() -> None:
"""AI interpretation uses the same adminapi contract as the Vue library page."""
client = RecordingClient()
repository = RemoteDoctorRepository(client)
listed = repository.list_prescription_template_ai_reports(701)
generated = repository.generate_prescription_template_ai_reports(701)
edited = repository.edit_prescription_template_ai_report(
701, report_id=9, content='{"summary":"演示"}'
)
assert listed["prescription_id"] == 701
assert generated["status"] == "success"
assert edited["report"]["report_id"] == 9
assert client.get_calls[-1] == (
"tcm.prescriptionLibrary/aiReports",
{"id": 701},
)
assert (
"tcm.prescriptionLibrary/generateAiReports",
{"id": 701},
) in client.post_calls
assert (
"tcm.prescriptionLibrary/editAiReport",
{"id": 701, "report_id": 9, "content": '{"summary":"演示"}'},
) in client.post_calls
def test_remote_diagnosis_ai_report_endpoints() -> None:
"""Patient-profile AI reports use the diagnosis adminapi contract."""
client = RecordingClient()
repository = RemoteDoctorRepository(client)
listed = repository.list_diagnosis_ai_reports(501)
generated = repository.generate_diagnosis_ai_reports(501)
edited = repository.edit_diagnosis_ai_report(
501, report_id=9, content='{"summary":"演示"}'
)
assert listed["diagnosis_id"] == 501
assert generated["status"] == "success"
assert edited["report"]["report_id"] == 9
assert client.get_calls[-1] == (
"tcm.diagnosis/aiReports",
{"id": 501},
)
assert (
"tcm.diagnosis/generateAiReports",
{"id": 501},
) in client.post_calls
assert (
"tcm.diagnosis/editAiReport",
{"id": 501, "report_id": 9, "content": '{"summary":"演示"}'},
) in client.post_calls
def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
class TimeoutRecordingClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.timeouts: list[float | None] = []
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
self.timeouts.append(timeout)
return super().post(endpoint, payload)
client = TimeoutRecordingClient()
repository = RemoteDoctorRepository(client)
result = repository.analyze_diagnosis_ai(
501,
"请给出用药调整建议",
task="medication_review",
)
assert result["answer"] == "服务端分析结果"
assert client.post_calls == [
(
"tcm.diagnosis/aiAssistant",
{"id": 501, "prompt": "请给出用药调整建议", "task": "medication_review"},
)
]
body = client.post_calls[0][1]
assert not ({"key", "api_key", "base_url", "provider", "model"} & body.keys())
assert client.timeouts == [105.0]
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
"""The legacy default is qwen, followed by an explicit OpenAI request."""
class TimeoutRecordingClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.timeouts: list[float | None] = []
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
self.timeouts.append(timeout)
return super().post(endpoint, payload)
client = TimeoutRecordingClient()
repository = RemoteDoctorRepository(client)
qwen_result = repository.get_diagnosis_ai_analysis(501)
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
assert client.post_calls == [
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "qwen"}),
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "openai"}),
]
assert client.timeouts == [105.0, 105.0]
assert qwen_result == {
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
"risk_assessment": [
{"label": "高血糖风险", "level": "high"},
{"label": "心血管风险", "level": "medium"},
],
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-14 10:30:00",
}
assert openai_result == {
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
"risk_assessment": [
{"label": "用药安全风险", "level": "medium"},
{"label": "肾功能风险", "level": "low"},
],
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
"model_key": "openai",
"model_label": "OpenAI",
"model_name": "gpt-5.2",
"generated_at": "2026-08-14 10:31:00",
}
calls_before_validation = list(client.post_calls)
timeouts_before_validation = list(client.timeouts)
with pytest.raises(ValueError, match="qwen or openai"):
repository.get_diagnosis_ai_analysis(501, model="invalid") # type: ignore[arg-type]
with pytest.raises(ValueError, match="positive"):
repository.get_diagnosis_ai_analysis(0)
assert client.post_calls == calls_before_validation
assert client.timeouts == timeouts_before_validation
def test_demo_diagnosis_ai_analysis_matches_structured_contract() -> None:
repository = DemoDoctorRepository(today=date(2026, 8, 14))
qwen_result = repository.get_diagnosis_ai_analysis(501)
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
expected_keys = {
"diagnosis_advice",
"risk_assessment",
"treatment_advice",
"model_key",
"model_label",
"model_name",
"generated_at",
}
for result in (qwen_result, openai_result):
assert set(result) == expected_keys
assert "肝郁脾虚证" in result["diagnosis_advice"]
assert result["treatment_advice"]
assert result["risk_assessment"]
assert all(
set(item) == {"label", "level"}
and item["level"] in {"high", "medium", "low"}
for item in result["risk_assessment"]
)
assert (qwen_result["model_key"], qwen_result["model_label"]) == ("qwen", "千问")
assert (openai_result["model_key"], openai_result["model_label"]) == (
"openai",
"OpenAI",
)
assert qwen_result["model_name"] != openai_result["model_name"]
assert qwen_result["diagnosis_advice"] != openai_result["diagnosis_advice"]
assert qwen_result["treatment_advice"] != openai_result["treatment_advice"]
def test_demo_mutates_prescriptions_orders_and_patient_workspaces() -> None:
+73 -18
View File
@@ -6,6 +6,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.ui import shell as shell_module
@@ -35,6 +36,22 @@ def application() -> QApplication:
return QApplication.instance() or QApplication([])
def test_patients_navigation_ignores_the_legacy_server_menu_title() -> None:
resolved = shell_module._resolve_navigation(
[
{
"name": "我的患者",
"component": "first_visit/my_patients",
"perms": "firstvisit.myPatient/lists",
}
],
{"firstvisit.myPatient/lists"},
demo_mode=False,
)
assert [(item.key, title) for item, title in resolved] == [("patients", "接诊台")]
@pytest.fixture
def shell_window(
application: QApplication,
@@ -45,16 +62,23 @@ def shell_window(
for key, title, glyph, permission in (
("reception", "接诊台", "", "doctor.appointment/lists"),
("appointments", "挂号列表", "", "doctor.appointment/lists"),
("prescription_library", "我的处方库", "", "tcm.prescriptionLibrary/lists"),
(
"prescription_library",
"我的处方库",
"",
"tcm.prescriptionLibrary/lists",
),
("prescriptions", "已开处方", "", "tcm.prescription/lists"),
("patients", "我的患者", "", "firstvisit.myPatient/lists"),
("patients", "接诊台", "", "firstvisit.myPatient/lists"),
("consultations", "问诊列表", "", "tcm.diagnosis/lists"),
)
]
monkeypatch.setattr(
shell_module,
"_resolve_navigation",
lambda _menu, _permissions, *, demo_mode: [(item, item.title) for item in navigation],
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
window = ShellWindow(
object(),
@@ -71,7 +95,7 @@ def shell_window(
application.processEvents()
def test_shell_matches_admin_geometry_at_both_acceptance_sizes(
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
application: QApplication,
shell_window: ShellWindow,
) -> None:
@@ -79,19 +103,24 @@ def test_shell_matches_admin_geometry_at_both_acceptance_sizes(
shell_window.resize(width, height)
application.processEvents()
assert shell_window.sidebar.width() == 208
assert shell_window.topbar.height() == 58
assert shell_window.tabs_host.height() == 42
assert shell_window.workspace.width() == width - 208
assert shell_window.stack.width() == width - 208
assert shell_window.stack.height() == height - 100
assert shell_window.sidebar.width() == 199
assert shell_window.topbar.height() == 62
assert shell_window.tabs_host.height() == 0
assert shell_window.workspace.width() == width - 26 - 199
assert shell_window.stack.width() == width - 26 - 199
assert shell_window.stack.height() == height - 26 - 62
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(20, 300).name().lower() == "#ffffff"
assert image.pixelColor(500, 10).name().lower() == "#ffffff"
assert image.pixelColor(220, 110).name().lower() == "#f5f7fb"
assert image.pixelColor(20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
@@ -100,6 +129,25 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
assert not page.isWindow()
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
shell_window: ShellWindow,
) -> None:
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
assert (
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "服务端自动匹配" in shell_window.model_label.text()
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""
shell_window.set_connection_state(False)
assert "离线" in shell_window.assistant_status.text()
shell_window.set_connection_state(True)
assert "在线" in shell_window.assistant_status.text()
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
shell_window: ShellWindow,
) -> None:
@@ -146,10 +194,15 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
assert not shell_window.close_tab("reception")
assert shell_window.close_tab("patients")
assert "patients" not in shell_window.visited_tab_keys()
assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "consultations"
assert (
shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex())
== "consultations"
)
assert shell_window.close_current_tab()
assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "reception"
assert (
shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "reception"
)
assert shell_window.stack.currentWidget() is shell_window.pages["reception"]
assert shell_window.nav_buttons["reception"].isChecked()
@@ -158,16 +211,18 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
assert shell_window.stack.currentWidget() is shell_window.pages["reception"]
def test_sidebar_collapse_preserves_active_navigation(shell_window: ShellWindow) -> None:
def test_sidebar_collapse_preserves_active_navigation(
shell_window: ShellWindow,
) -> None:
assert shell_window.navigate("consultations")
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 72
assert shell_window.sidebar.width() == 68
assert shell_window.nav_buttons["consultations"].text() == ""
assert shell_window.nav_buttons["consultations"].isChecked()
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 208
assert shell_window.sidebar.width() == 195
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
assert shell_window.nav_buttons["consultations"].isChecked()
+36 -2
View File
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QCoreApplication, QSettings
from PySide6.QtGui import QPageSize
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
from PySide6.QtGui import QPageSize, QRawFont
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
@@ -17,6 +17,7 @@ from doctor_workstation.ui import widgets as widget_module
from doctor_workstation.ui.login import LoginWindow
from doctor_workstation.ui.pages.consultations import _video_payload
from doctor_workstation.ui.shell import NAVIGATION
from doctor_workstation.ui.theme import apply_theme
from doctor_workstation.ui.widgets import (
friendly_error,
gender_text,
@@ -47,6 +48,39 @@ def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
assert gender_text("未知标签") == "未知标签"
def test_theme_resolves_real_chinese_glyphs() -> None:
"""Guard packaged/offscreen builds against rendering every CJK glyph as tofu."""
application = QApplication.instance() or QApplication([])
apply_theme(application)
raw_font = QRawFont.fromFont(application.font())
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
assert glyphs
assert all(glyph > 0 for glyph in glyphs)
assert len(set(glyphs)) > 1
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
dialog = QDialog()
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
parent=dialog,
)
QVBoxLayout(dialog).addWidget(buttons)
dialog.show()
application.processEvents()
assert dialog.property("businessDialog") is True
assert buttons.button(QDialogButtonBox.StandardButton.Save).property("variant") == "primary"
assert buttons.button(QDialogButtonBox.StandardButton.Cancel).property("variant") == "secondary"
dialog.close()
application.processEvents()
def test_navigation_requires_each_pages_actual_list_capability() -> None:
assert {item.key: item.permissions for item in NAVIGATION} == {
"reception": ("doctor.appointment/lists",),
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-R5GzqA8s.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CED5X2W4.css">
<script type="module" crossorigin src="./assets/index-DXNYj41g.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-sts1UBl3.css">
</head>
<body>
<div id="app"></div>
+90
View File
@@ -341,3 +341,93 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.message-list { padding-inline: 18px; }
.message-bubble { max-width: 82%; }
}
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
the dark stage; every application-owned chrome surface follows the shell. */
:root { color: #111f46; background: #eef3fd; }
.consultation-shell { background: #eef3fd; }
.chat-panel { background: #f7f9fe; }
.chat-header,
.composer { border-color: #e6eaf5; background: rgba(255, 255, 255, .98); }
.patient-avatar { background: linear-gradient(135deg, #5761f4, #7769f7); }
.chat-heading p,
.empty-chat,
.message-meta,
.composer-toolbar { color: #7886aa; }
.connection-dot--online {
background: #17a77d;
box-shadow: 0 0 0 3px rgba(23, 167, 125, .12);
}
.primary-action,
.send-button,
.capture-button {
border-color: #5761f4;
background: linear-gradient(90deg, #5761f4, #7769f7);
}
.primary-action:hover,
.send-button:hover,
.capture-button:hover { background: #4c57e9; }
.secondary-action {
border-color: #e6eaf5;
color: #3f4e75;
}
.secondary-action:hover {
border-color: #5761f4;
color: #4451e2;
background: #f0f2ff;
}
.message-list {
background:
radial-gradient(circle at 12% 16%, rgba(87, 97, 244, .055), transparent 25%),
#f7f9fe;
}
.load-more,
.empty-chat__icon,
.message-bubble { border-color: #e6eaf5; }
.load-more { color: #7886aa; }
.empty-chat__icon { color: #5761f4; }
.empty-chat h2 { color: #111f46; }
.message-bubble { box-shadow: 0 4px 14px rgba(17, 31, 70, .05); }
.message-row--mine .message-bubble { border-color: #5761f4; background: #5761f4; }
.composer-toolbar button { color: #4451e2; background: #f0f2ff; }
.composer textarea {
border-color: #e6eaf5;
color: #111f46;
background: #fafbfe;
}
.composer textarea:focus {
border-color: #8d9bff;
box-shadow: 0 0 0 3px rgba(87, 97, 244, .11);
}
.video-actions button { border-radius: 9px; }
.recording-status {
border-color: rgba(230, 234, 245, .88);
border-radius: 9px;
color: #3f4e75;
background: rgba(255, 255, 255, .95);
}
.recording-status--active,
.recording-status--error {
border-color: rgba(241, 91, 103, .45);
color: #b63849;
background: rgba(255, 241, 243, .96);
}
.recording-indicator {
background: #f15b67;
box-shadow: 0 0 0 4px rgba(241, 91, 103, .16);
}
@keyframes recording-pulse {
50% { box-shadow: 0 0 0 8px rgba(241, 91, 103, .04); opacity: .72; }
}
.hangup-button { border-color: #f15b67; background: #f15b67; }
.hangup-button:hover { background: #d94857; }
.video-notice {
border-color: rgba(23, 167, 125, .35);
color: #12765b;
background: rgba(234, 249, 243, .95);
}
.video-notice--error {
border-color: rgba(241, 91, 103, .4);
color: #b63849;
background: rgba(255, 241, 243, .96);
}
+3 -3
View File
@@ -1,6 +1,6 @@
[prescription_ai]
ENABLE = true
BASE_URL = "http://chat2.zhenyangtang.com.cn:8088/v1"
QWEN_API_KEY = "app-your-qwen-dify-key"
OPENAI_API_KEY = "app-your-openai-dify-key"
BASE_URL = "https://ai.example.com/v1"
QWEN_API_KEY = "replace-on-server"
OPENAI_API_KEY = "replace-on-server"
TIMEOUT = 90
@@ -17,8 +17,10 @@ namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\logic\order\OrderActionLogLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -222,7 +224,7 @@ class DiagnosisController extends BaseAdminController
return $this->data(DiagnosisLogic::guahaoLogList((int) $params['id']));
}
/**
* @notes 诊单详情(患者端)
* @return \think\response\Json
@@ -230,22 +232,22 @@ class DiagnosisController extends BaseAdminController
public function diagnosisDetail()
{
$params = $this->request->get();
if (empty($params['id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['user_id'])) {
return $this->fail('用户ID不能为空');
}
$result = DiagnosisLogic::diagnosisDetail($params);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 检查手机号是否重复
* @return \think\response\Json
@@ -256,7 +258,7 @@ class DiagnosisController extends BaseAdminController
$result = DiagnosisLogic::checkPhone($params);
return $this->data($result);
}
/**
* @notes 检查身份证号是否重复
* @return \think\response\Json
@@ -281,7 +283,7 @@ class DiagnosisController extends BaseAdminController
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 指派医助
* @return \think\response\Json
@@ -289,12 +291,12 @@ class DiagnosisController extends BaseAdminController
public function assign()
{
$params = $this->request->post();
// 验证参数
if (empty($params['id'])) {
return $this->fail('诊单ID不能为空');
}
if (!array_key_exists('assistant_id', $params)) {
return $this->fail('请选择医助或取消指派');
}
@@ -327,25 +329,25 @@ class DiagnosisController extends BaseAdminController
public function getCallSignature()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
if (empty($params['patient_id'])) {
return $this->fail('患者ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::getCallSignature($params);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 发起通话
* @return \think\response\Json
@@ -353,57 +355,57 @@ class DiagnosisController extends BaseAdminController
public function startCall()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
if ($result !== false) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
public function startCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::startCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 幂等写入当前通话的已完成转写分段 */
public function upsertCallTranscriptSegments()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 完成当前通话转写并固化对话文字 */
public function finishCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::finishCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
if ($result !== false) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
public function startCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::startCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 幂等写入当前通话的已完成转写分段 */
public function upsertCallTranscriptSegments()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 完成当前通话转写并固化对话文字 */
public function finishCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::finishCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 结束通话
* @return \think\response\Json
@@ -411,21 +413,21 @@ class DiagnosisController extends BaseAdminController
public function endCall()
{
$params = $this->request->post();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::endCall($params);
if ($result) {
return $this->success('', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 获取通话记录
* @return \think\response\Json
@@ -433,11 +435,11 @@ class DiagnosisController extends BaseAdminController
public function getCallRecords()
{
$params = $this->request->get();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
}
$result = DiagnosisLogic::getCallRecords($params);
return $this->data($result);
}
@@ -603,11 +605,11 @@ class DiagnosisController extends BaseAdminController
public function getDoctorSignature()
{
$params = $this->request->get();
if (empty($params['patient_id'])) {
return $this->fail('医助理ID不能为空');
}
$result = DiagnosisLogic::getDoctorSignature((int)$params['patient_id']);
if ($result) {
return $this->data($result);
@@ -621,18 +623,18 @@ class DiagnosisController extends BaseAdminController
public function getPatientSignature()
{
$params = $this->request->get();
if (empty($params['patient_id'])) {
return $this->fail('患者ID不能为空');
}
$result = DiagnosisLogic::getPatientSignature((int)$params['patient_id']);
if ($result) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 获取医助列表
* @return \think\response\Json
@@ -642,7 +644,7 @@ class DiagnosisController extends BaseAdminController
$result = DiagnosisLogic::getAssistants((int) $this->adminId, $this->adminInfo);
return $this->data($result);
}
/**
* @notes 获取医生列表
* @return \think\response\Json
@@ -652,7 +654,7 @@ class DiagnosisController extends BaseAdminController
$result = DiagnosisLogic::getDoctors();
return $this->data($result);
}
/**
* @notes 生成小程序码
* @return \think\response\Json
@@ -824,7 +826,7 @@ class DiagnosisController extends BaseAdminController
}
$offset = ($page_no - 1) * $page_size;
$lists = \app\common\model\tcm\Diagnosis::where('patient_name|phone|id_card', 'like', '%' . $keyword . '%')
->field(['id', 'patient_name', 'phone', 'id_card', 'gender', 'age'])
->limit($offset, $page_size)
@@ -842,4 +844,129 @@ class DiagnosisController extends BaseAdminController
'page_size' => $page_size
]);
}
/**
* @notes 读取已保存的双模型诊单 AI 报告,不触发上游调用
*/
public function aiReports()
{
$params = (new DiagnosisValidate())->get()->goCheck('aiReports');
$reports = DiagnosisAiLogic::getSavedReports(
(int) $params['id'],
$this->adminId,
$this->adminInfo
);
if ($reports === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($reports);
}
/**
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
*/
public function aiAssistant()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$result = DiagnosisAiLogic::assistant(
(int) $params['id'],
(string) $params['task'],
(string) ($params['prompt'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($result);
}
/**
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
*/
public function aiAnalysis()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAnalysis');
$result = DiagnosisAiLogic::analysis(
(int) $params['id'],
$this->adminId,
$this->adminInfo,
(string) ($params['model'] ?? 'qwen')
);
if ($result === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($result);
}
/**
* @notes 查询患者级 AI 诊断报告全部历史及各模型最新版本,不触发模型调用
*/
public function patientAiReports()
{
$params = (new DiagnosisValidate())->get()->goCheck('patientAiReports');
$result = PatientAiReportLogic::reports(
(int) $params['patient_id'],
$this->adminId,
$this->adminInfo
);
if ($result === null) {
return $this->fail(PatientAiReportLogic::getError());
}
return $this->data($result);
}
/**
* @notes 聚合当前数据域内患者纵向资料,调用指定固定模型并新增一份不可变报告快照
*/
public function generatePatientAiReport()
{
$params = (new DiagnosisValidate())->post()->goCheck('generatePatientAiReport');
$result = PatientAiReportLogic::generate(
(int) $params['patient_id'],
(string) $params['model'],
$this->adminId,
$this->adminInfo
);
if ($result === null) {
return $this->fail(PatientAiReportLogic::getError());
}
return $this->data($result);
}
/**
* @notes 整份重新生成两个固定模型的诊单 AI 报告并保存成功项
*/
public function generateAiReports()
{
$params = (new DiagnosisValidate())->post()->goCheck('generateAiReports');
$reports = DiagnosisAiLogic::generateAll(
(int) $params['id'],
$this->adminId,
$this->adminInfo
);
if ($reports === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($reports);
}
/**
* @notes 编辑一份已保存的诊单 AI 报告
*/
public function editAiReport()
{
$params = (new DiagnosisValidate())->post()->goCheck('editAiReport');
$report = DiagnosisAiLogic::editReport(
(int) $params['id'],
(int) $params['report_id'],
$params['content'] ?? null,
$this->adminId,
$this->adminInfo
);
if ($report === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($report);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -198,7 +198,7 @@ class PrescriptionLibraryAiLogic extends BaseLogic
'prescription_id' => $id,
'model_key' => $modelKey,
'admin_id' => $adminId,
'error' => $e->getMessage(),
'exception_class' => get_class($e),
]);
$result = [
'ok' => false,
@@ -246,7 +246,7 @@ class PrescriptionLibraryAiLogic extends BaseLogic
'prescription_id' => $id,
'model_key' => $modelKey,
'admin_id' => $adminId,
'error' => $e->getMessage(),
'exception_class' => get_class($e),
]);
$failureCount++;
$results[] = array_merge($resultBase, [
@@ -45,6 +45,12 @@ class DiagnosisValidate extends BaseValidate
'diagnosis_id' => 'require|integer|checkDiagnosisId',
'tracking_content' => 'require|length:1,1000',
'revisit_slot_start_offset' => 'integer|between:0,20',
'report_id' => 'number|gt:0',
'content' => 'max:12000',
'task' => 'require|in:summary,tcm_pattern,prescription_review,medication_review,exam_review,complication_risk,guideline_review,custom',
'prompt' => 'max:500',
'model' => 'in:qwen,openai',
'patient_id' => 'integer|gt:0',
];
protected $message = [
@@ -72,6 +78,18 @@ class DiagnosisValidate extends BaseValidate
'diagnosis_id.require' => '诊单ID不能为空',
'tracking_content.require' => '跟踪备注内容不能为空',
'tracking_content.length' => '跟踪备注最多1000个字符',
'report_id.require' => '报告ID不能为空',
'report_id.number' => '报告ID必须为数字',
'report_id.gt' => '报告ID必须大于0',
'content.require' => '报告内容不能为空',
'content.max' => '报告内容最多12000个字符',
'task.require' => '请选择 AI 助手任务',
'task.in' => 'AI 助手任务不受支持',
'prompt.max' => '问题最多500个字符',
'model.in' => 'AI模型仅支持qwen或openai',
'patient_id.require' => '患者ID不能为空',
'patient_id.integer' => '患者ID必须为整数',
'patient_id.gt' => '患者ID必须大于0',
];
public function sceneAdd()
@@ -114,17 +132,17 @@ class DiagnosisValidate extends BaseValidate
return $this->only(['diagnosis_id']);
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
// The global diagnosis_id rule is required for diagnosis APIs, but
// video QR codes identify the doctor instead. Keep diagnosis_id
// optional here while still validating it when it is supplied.
->remove('diagnosis_id', 'require')
->append('patient_id', 'require|integer|checkQrcodeIds')
->append('share_user_id', 'require|integer')
->append('doctor_id', 'integer');
}
public function sceneGenerateQrcode()
{
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
// The global diagnosis_id rule is required for diagnosis APIs, but
// video QR codes identify the doctor instead. Keep diagnosis_id
// optional here while still validating it when it is supplied.
->remove('diagnosis_id', 'require')
->append('patient_id', 'require|integer|checkQrcodeIds')
->append('share_user_id', 'require|integer')
->append('doctor_id', 'integer');
}
public function sceneGenerateOrderQrcode()
{
@@ -150,6 +168,48 @@ class DiagnosisValidate extends BaseValidate
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
}
public function sceneAiReports()
{
return $this->only(['id']);
}
public function sceneAiAssistant()
{
return $this->only(['id', 'task', 'prompt']);
}
public function sceneAiAnalysis()
{
return $this->only(['id', 'model'])
->remove('id', 'checkDiagnosis')
->append('id', 'integer|gt:0|checkAiAnalysisPayload');
}
public function scenePatientAiReports()
{
return $this->only(['patient_id'])
->append('patient_id', 'require|integer|gt:0|checkPatientAiReportsPayload');
}
public function sceneGeneratePatientAiReport()
{
return $this->only(['patient_id', 'model'])
->append('patient_id', 'require|integer|gt:0|checkGeneratePatientAiReportPayload')
->append('model', 'require|in:qwen,openai');
}
public function sceneGenerateAiReports()
{
return $this->only(['id']);
}
public function sceneEditAiReport()
{
return $this->only(['id', 'report_id', 'content'])
->append('report_id', 'require|number|gt:0')
->append('content', 'require|max:12000');
}
protected function checkDiagnosis($value)
{
$diagnosis = Diagnosis::findOrEmpty($value);
@@ -183,11 +243,71 @@ class DiagnosisValidate extends BaseValidate
protected function checkQrcodeIds($value, $rule, $data = [])
{
$page = $data['mini_program_path'] ?? '';
$hasDoctor = !empty($data['doctor_id']);
$hasDoctor = !empty($data['doctor_id']);
$hasDiagnosis = !empty($data['diagnosis_id']);
if ($page === 'pages/login/login') {
return $hasDoctor ? true : '视频二维码需传挂号医生ID';
}
return $hasDiagnosis ? true : '诊单ID不能为空';
}
}
/**
* AI 智能分析仅接受诊单 ID 和可选的固定模型键。客户端不得提供
* provider、凭据、上游地址、提示词等服务端配置或控制字段。
*
* @param mixed $value
* @param mixed $rule
* @param array<string,mixed> $data
* @return bool|string
*/
protected function checkAiAnalysisPayload($value, $rule, array $data = [])
{
if (!array_key_exists('id', $data)) {
return 'AI智能分析请求缺少id参数';
}
$extraFields = array_diff(array_keys($data), ['id', 'model']);
if ($extraFields !== []) {
return 'AI智能分析请求仅允许id及可选model参数';
}
if (array_key_exists('model', $data)) {
$model = $data['model'];
if (!is_string($model) || !in_array($model, ['qwen', 'openai'], true)) {
return 'AI模型仅支持qwen或openai';
}
}
return true;
}
/** @param array<string,mixed> $data */
protected function checkPatientAiReportsPayload($value, $rule, array $data = [])
{
if (!array_key_exists('patient_id', $data)) {
return '请求缺少patient_id参数';
}
$extraFields = array_diff(array_keys($data), ['patient_id']);
return $extraFields === [] ? true : '请求仅允许patient_id参数';
}
/**
* 患者报告生成端点只接受稳定患者ID与服务端模型白名单键。
* provider、凭据、BASE_URL、提示词和任意来源正文均不得由客户端注入。
*
* @param array<string,mixed> $data
*/
protected function checkGeneratePatientAiReportPayload($value, $rule, array $data = [])
{
if (!array_key_exists('patient_id', $data) || !array_key_exists('model', $data)) {
return '请求缺少patient_id或model参数';
}
$extraFields = array_diff(array_keys($data), ['patient_id', 'model']);
if ($extraFields !== []) {
return '请求仅允许patient_id和model参数';
}
if (!is_string($data['model']) || !in_array($data['model'], ['qwen', 'openai'], true)) {
return 'AI模型仅支持qwen或openai';
}
return true;
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单 AI 报告。
*/
class DiagnosisAiReport extends BaseModel
{
protected $name = 'diagnosis_ai_report';
protected $autoWriteTimestamp = true;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 患者级 AI 诊断报告快照。
*
* 每次生成均新增一行;本模型没有更新/覆盖历史报告的业务方法。
*/
class PatientAiReport extends BaseModel
{
protected $name = 'patient_ai_report';
protected $autoWriteTimestamp = false;
protected $dateFormat = false;
}
+225 -31
View File
@@ -5,12 +5,20 @@ declare(strict_types=1);
namespace app\common\service;
/**
* Dify Chat App blocking 客户端。
* 处方/诊单 AI 上游客户端。
*
* 只接受服务端配置中的模型 profile,避免把上游地址和密钥暴露给前端
* 兼容 Dify blocking chat-messages OpenAI-compatible chat completions
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
*/
class DifyChatService
{
/** @var array<int,string> */
private const ALLOWED_PROFILES = ['qwen', 'openai'];
private const MIN_TIMEOUT = 1;
private const MAX_TIMEOUT = 300;
/**
* @param array<string,mixed> $inputs
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
@@ -19,45 +27,197 @@ class DifyChatService
{
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', '处方 AI 解释未启用');
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = $config['models'][$profile] ?? null;
if (!is_array($modelConfig)) {
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$apiKey = trim((string) ($modelConfig['api_key'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型尚未配置 Dify 地址或 App Key');
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$payload = [
'inputs' => $inputs,
'query' => $query,
'response_mode' => 'blocking',
'user' => $user,
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
$startedAt = microtime(true);
$lastResponse = null;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendRequest(
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout
);
$lastResponse = $response;
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
// 避免因业务参数错误而重复提交同一份临床数据。
$hasFallback = isset($requestSpecs[$index + 1]);
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
continue;
}
return self::formatResponse($response, $startedAt);
}
return self::formatResponse($lastResponse ?? [
'body' => '',
'errno' => 0,
'http_code' => 0,
], $startedAt);
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>|null
*/
private static function resolveProfileConfig(array $config, string $profile): ?array
{
if (!in_array($profile, self::ALLOWED_PROFILES, true)) {
return null;
}
$modelConfig = $config['models'][$profile] ?? null;
return is_array($modelConfig) ? $modelConfig : null;
}
/**
* @param array<string,mixed> $inputs
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
*/
private static function buildRequestSpecs(
string $baseUrl,
string $model,
array $inputs,
string $query,
string $user
): array {
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
$difySpec = [
'protocol' => 'dify',
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
'payload' => [
'inputs' => $inputs,
'query' => $query,
'response_mode' => 'blocking',
'user' => $user,
],
];
$openAiSpec = [
'protocol' => 'openai',
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
'payload' => [
'model' => $model,
'messages' => [
['role' => 'user', 'content' => $query],
],
],
];
if (str_ends_with($path, '/chat-messages')) {
return [$difySpec];
}
if (str_ends_with($path, '/chat/completions')) {
return [$openAiSpec];
}
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
return [$difySpec, $openAiSpec];
}
private static function buildEndpoint(string $baseUrl, string $endpoint): string
{
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) {
return $baseUrl;
}
if (str_ends_with($path, '/v1')) {
return $baseUrl . '/' . $endpoint;
}
return $baseUrl . '/v1/' . $endpoint;
}
private static function isValidBaseUrl(string $baseUrl): bool
{
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
return false;
}
$parts = parse_url($baseUrl);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
return in_array($scheme, ['http', 'https'], true)
&& trim((string) ($parts['host'] ?? '')) !== ''
&& !isset($parts['user'])
&& !isset($parts['pass'])
&& !isset($parts['query'])
&& !isset($parts['fragment']);
}
private static function isValidTimeout(int $timeout): bool
{
return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT;
}
/**
* @param array<string,mixed> $payload
* @return array{body:string,errno:int,http_code:int}
*/
private static function sendRequest(
string $url,
array $payload,
string $apiKey,
int $timeout
): array {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return self::error('REQUEST_BUILD_FAILED', '处方数据编码失败');
return ['body' => '', 'errno' => -1, 'http_code' => 0];
}
$timeout = max(10, min(120, (int) ($config['timeout'] ?? 90)));
$ch = curl_init();
if ($ch === false) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求');
return ['body' => '', 'errno' => -2, 'http_code' => 0];
}
curl_setopt_array($ch, [
CURLOPT_URL => self::buildEndpoint($baseUrl),
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => min(8, max(3, (int) ceil($timeout / 4))),
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
@@ -68,35 +228,56 @@ class DifyChatService
],
]);
$startedAt = microtime(true);
$responseBody = curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$latencyMs = (int) round((microtime(true) - $startedAt) * 1000);
return [
'body' => is_string($responseBody) ? $responseBody : '',
'errno' => $errno,
'http_code' => $httpCode,
];
}
/**
* @param array{body:string,errno:int,http_code:int} $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
private static function formatResponse(array $response, float $startedAt): array
{
$latencyMs = self::elapsedMilliseconds($startedAt);
$errno = $response['errno'];
$httpCode = $response['http_code'];
if ($errno !== 0) {
if ($errno === CURLE_OPERATION_TIMEDOUT) {
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
}
if ($errno === -1) {
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
}
if ($errno === -2) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
}
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
}
$decoded = json_decode((string) $responseBody, true);
$decoded = json_decode($response['body'], true);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', '模型 App Key 无效或无权限', $latencyMs);
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400) {
if ($httpCode >= 400 || $httpCode < 200) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (!is_array($decoded)) {
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
}
$answer = trim((string) ($decoded['answer'] ?? ''));
$answer = self::extractContent($decoded);
if ($answer === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
@@ -104,21 +285,34 @@ class DifyChatService
return [
'ok' => true,
'content' => $answer,
'message_id' => (string) ($decoded['message_id'] ?? ''),
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
'latency_ms' => $latencyMs,
];
}
private static function buildEndpoint(string $baseUrl): string
/** @param array<string,mixed> $decoded */
private static function extractContent(array $decoded): string
{
$baseUrl = rtrim($baseUrl, '/');
if (str_ends_with($baseUrl, '/chat-messages')) {
return $baseUrl;
$content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? '';
if (is_string($content)) {
return trim($content);
}
if (str_ends_with($baseUrl, '/v1')) {
return $baseUrl . '/chat-messages';
if (!is_array($content)) {
return '';
}
return $baseUrl . '/v1/chat-messages';
$parts = [];
foreach ($content as $part) {
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
$parts[] = $part['text'];
}
}
return trim(implode('', $parts));
}
private static function elapsedMilliseconds(float $startedAt): int
{
return (int) round((microtime(true) - $startedAt) * 1000);
}
/**

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