# T1+T2 · 二诊只读病例页 + 跟踪矩阵
> 来源:~/.claude/plans/6-7-fluffy-sunset.md(T1 + T2 节)
> 依赖:T4(已完成 — 提供 PatientInfoCard / PatientCaseCard / blood-thresholds.ts);T3(已完成 — 顺手把 unserved_days 用到 readonly 页头部 badge)
> 范围:单 PR,前端 + 后端 + 菜单 SQL,整合进入 fix-diagnosis-20250505 分支
## 用户故事
二中心医助每天进诊单列表,**双击行**或点**「查看」**按钮 → 直达「只读病例页」:
- 患者基本信息 + 完整病例(PatientInfoCard / PatientCaseCard,与接诊台像素级一致)
- 跟踪矩阵:日期为列、6 项指标为行(血糖|血压|西药|胰岛素|饮食|运动),同日血糖三段挤一格,超阈值红字 + ↑
- 顶部 hero:未服务 X 天的红/橙/绿 badge
## 后端
### 接口 `GET /tcm.diagnosis/readonlyDetail?id=:diagnosisId`
**Validate**:`DiagnosisValidate::sceneReadonlyDetail` → `only(['id'])` + `id` 必填且 `checkDiagnosis`
**Logic** `DiagnosisLogic::readonlyDetail($params, $adminId, $adminInfo)`:
1. 校验诊单存在
2. 数据权限:医助 (role_id=2) 仅看 `assistant_id == self`;DataScope 启用时 `assistant_id IN visibleAdminIds`
3. 拼装 `diagnosis`(复用 `self::detail` + `AppointmentLogic::enrichDiagnosisLabels`)
4. 取最近一条挂号(用于 PatientInfoCard 显示挂号信息);没有就给个默认空对象
5. 取最近 30 天的 blood / diet / exercise 三类记录
6. 取医生备注(DoctorNoteLogic)
7. 计算 unserved_days(= today − MAX(blood.record_date) 天)
8. 返回:`{ appointment, diagnosis, blood_records, diet_records, exercise_records, doctor_notes, unserved_days, last_blood_record_at }`
**Controller**:`DiagnosisController::readonlyDetail()` thin wrapper
**AppointmentLogic 小改**:`enrichDiagnosisLabels` 由 `private` 改 `public static`,便于 DiagnosisLogic 复用。其它行为不变。
**菜单 SQL**:`server/sql/1.9.20260420/add_diagnosis_readonly_menu.sql` —— 注册 `tcm.diagnosis/readonlyDetail` 权限节点。
## 前端
### 路由
`/tcm/diagnosis-readonly?id=:diagnosisId` — 注册在 `admin/src/router/routes.ts` 的 **constantRoutes**(不依赖菜单驱动),用 `LAYOUT` 包裹保留侧边栏;`meta.hidden=true` 不显示在导航;`meta.activeMenu='/tcm/diagnosis'` 高亮诊单父菜单。
> 实践教训:admin 的动态路由是后端菜单表驱动的(`createRouteRecord` 只看 menu API);因此「隐藏路由」**不能仅靠不加菜单 SQL** 解决,否则路径根本没注册到 vue-router。最稳的做法是写 constantRoutes 静态路由 + 用与父菜单不同的路径前缀(这里用连字符 `-readonly` 而非 `/readonly` 子路径,避免和动态菜单同前缀互相影响)。
### 入口(在 diagnosis/index.vue 上)
- 操作列**最前**新增 `查看` → `router.push({ path: '/tcm/diagnosis/readonly', query: { id: row.id } })`
- `` 双击同样跳转
- 操作列原有按钮加 `@click.stop`(避免冒泡触发双击)
### 新建 `admin/src/views/tcm/diagnosis/readonly.vue`
```
┌─ Hero:返回 + 患者姓名 + 「未服务 X 天」 badge(color 同 T3)
├─
├─
├─
└─ (可选;先复用 reception 的样式)
```
### 新建 `admin/src/views/tcm/diagnosis/components/TrackingMatrix.vue`
Props:
```ts
{
diagnosisId: number
patientId?: number
age?: number | null // 阈值依赖
bloodRecords: BloodRecord[]
dietRecords: DietRecord[]
exerciseRecords: ExerciseRecord[]
}
```
渲染规则:
| 列 | 来源 | cell |
|---|---|---|
| 日期 (fixed left) | union(blood/diet/exercise.record_date) desc 去重 | `MM-DD(周X)` |
| 血糖 | bloodRecords by date | `空 6.5 餐 8.9 其他 7.2`,缺值 `—`;任一段超阈值 → 红字 + ↑ |
| 血压 | bloodRecords by date | `135/90`;超 → 红 + ↑ |
| 西药 | bloodRecords.western_medicine | tooltip 全文,cell 截断 |
| 胰岛素 | bloodRecords.insulin | 同上 |
| 饮食 | dietRecords by date | `已打卡` / `—`;点 cell 弹出当日早/午/晚 |
| 运动 | exerciseRecords by date | `已打卡 · 30min · 中强度` / `—`;点弹详情 |
时间窗:`7 / 30 / 90 / 365 / 自定义`(默认 30);切换时由父组件重新拉数据或客户端过滤。**本期实现简化**:父组件一次拉 30 天,组件本地过滤切换的窗口。
CSS 类:`.matrix-table` `.cell-blood` `.cell-bp` `.cell-empty` `.cell-high` `.cell-up`(红字 + ↑),文字截断/tooltip 走 el-table 的 `show-overflow-tooltip`。
阈值判定:`@/utils/blood-thresholds` 的 isHigh*(T4 提供)。
### API
`admin/src/api/tcm.ts` 新增:
```ts
export function diagnosisReadonlyDetail(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/readonlyDetail', params })
}
```
## 不做(边界)
- 不做评论 / 编辑 / 删除按钮 — 整页**严格只读**
- 不引入 `useRouter` 之外的全局状态(页面无状态)
- 不做日期窗口的服务端拉取(窗口切换走前端过滤),后续真有量再做
- 不做表头排序(矩阵本身是日期 desc,无意义)
- 不在菜单里展示路由(不配 system_menu type='C')
- DoctorNote 区块复用 reception 的 NoteTimeline 但不绑 refresh 事件(只读)
- 跟踪矩阵不展示「备注」字段(跟踪场景看血糖/血压数值即可)
## 验收
1. 在诊单列表点「查看」 / 双击行 → 跳到 readonly 页
2. PatientInfoCard / PatientCaseCard 视觉与接诊台一致(T4 已确保)
3. 跟踪矩阵:同一天血糖三段挤一格,红色 + ↑ 与 reception 视觉一致
4. 没有任何打卡时矩阵显示空状态(`el-empty`)
5. 编辑/删除按钮全程不存在 — **页面纯只读**
6. 越权访问 → 提示无权限,无 500
7. 时间窗 7/30/90/365 切换有效
8. 操作列其它按钮(编辑/删除/指派)的点击不会触发跳转(`@click.stop`)