更新
@@ -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) */
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -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. 同文件队列分页/筛选 fixtures(247-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。
|
||||
@@ -0,0 +1,203 @@
|
||||
# 诊疗 / 病例 / 订单 / AI 报告子窗口蓝白参考审计
|
||||
|
||||
审计日期:2026-08-13
|
||||
审计性质:只读;未修改业务源码、测试、脚本或既有 PNG。
|
||||
审计边界:诊疗详情/编辑抽屉、独立病例只读页、诊断上下文中的业务订单详情、诊断 AI 报告。未审计或改动 `theme.py`、`widgets.py`、处方编辑/患者页/挂号页;AI 部分只看诊断报告模式,不扩展到处方业务。
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
1. **用户所指“蓝白参考”最接近的现有实图是 `artifacts/pixel_exact_v3/consultations.png`**(1710×920,2026-08-13 19:56)。它是当前最新、最完整的蓝白医生工作站视觉:浅蓝壳层、近白内容底、白色卡片、靛蓝主操作、冷灰蓝文字与边框。子窗口应从它取色,不应从旧 Diagnosis 深色图取色。
|
||||
2. **子窗口结构不能统一成同一种 modal。** 现有正确结构分别是:诊疗编辑/viewOnly 为右侧 60% Drawer;独立病例为无 Tabs 的纵向滚动详情页;订单详情为右侧 80% readonly Drawer;AI 报告为 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×720,2026-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 11–12 px;卡片标题 14–15 px;Drawer 标题 18–19 px;AI 报告标题 20 px。
|
||||
- 4 px 间距基线;常用 8/12/16/20/24 px。不要产生 5、13、17、21 等无依据的主布局间距。
|
||||
- 控件/普通按钮高 34 px;诊疗 Footer 主按钮高 40 px;紧凑 close 为 32×32;诊疗 Tab 高 42 px;AI Tab 高 36 px。
|
||||
- 内控件/按钮圆角 7–8 px;字段卡/分区 9–10 px;Hero 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 px,1440×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 4;close 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 的视觉尺度保持 12–12.5 px;label `#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 px,1440 owner 为 1152 px;左侧 20% 为 scrim。
|
||||
- Header/Body/Footer 三段独立;Body 单独纵向滚动,Footer 始终可见。
|
||||
- Header 当前内边距 `20px 15px 18px 15px`,gap 12;标题栈 gap 4;标题 19 px,meta 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 18;selected 字 `#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%,覆盖 owner,readonly 属性与各信息区存在。
|
||||
- 空字段必须显示明确空态,不能伪造 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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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("颗粒"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||