Compare commits

...

2 Commits

Author SHA1 Message Date
iven
5140552ff6 fix(health): 走查止血 — 患者名显示修复 + 枚举补全 + 医护统计 + 设备选择器
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
后端:
- alert_service: list_alerts 批量查询 patient_name 填充 AlertResponse
- consultation_service: list_sessions 批量查询 patient_name/doctor_name
- erp-ai handler: list_analysis 通过 raw SQL 查询 patient_name

前端:
- AlertList/AlertDashboard: 使用后端返回的 patient_name 替代 ID 截断
- ConsultationDetail: 使用 patient_name/doctor_name 替代 ID 截断
- AiAnalysisList: 使用 patient_name 替代 ID 截断
- constants/health: SEVERITY 补 high/medium, STATUS 补 active
- AdminDashboard: 医护人数改为 API 查询(useStatsData 新增 doctorCount)
- DeviceManage: 患者 ID 输入改为 PatientSelect 搜索选择器
2026-05-04 00:03:40 +08:00
iven
20bd9e8cb4 docs: 全系统前端走查报告 + 多专家组头脑风暴
35+ 页面逐页走查,发现 P0 问题 4 项、P1 问题 6 项、P2 建议 4 项。
三专家组分析:架构组定位 EntityName 根因,测试组发现枚举缺失,
产品组制定 3 阶段修复路径(止血 → 补短板 → 治本)。
2026-05-04 00:03:22 +08:00
16 changed files with 434 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ import type { PaginatedResponse } from '../types';
export interface AnalysisItem {
id: string;
patient_id: string;
patient_name?: string;
analysis_type: string;
source_ref: string;
model_used: string;

View File

@@ -5,6 +5,7 @@ import type { PaginatedResponse } from '../types';
export interface Alert {
id: string;
patient_id: string;
patient_name?: string;
rule_id: string;
severity: string;
title: string;

View File

@@ -40,6 +40,8 @@ export const SEVERITY_COLOR: Record<string, string> = {
warning: 'orange',
critical: 'red',
urgent: 'magenta',
high: 'red',
medium: 'orange',
};
export const SEVERITY_LABEL: Record<string, string> = {
@@ -47,18 +49,23 @@ export const SEVERITY_LABEL: Record<string, string> = {
warning: '警告',
critical: '严重',
urgent: '紧急',
high: '严重',
medium: '中等',
};
export const SEVERITY_OPTIONS = [
{ value: 'info', label: '提示' },
{ value: 'warning', label: '警告' },
{ value: 'medium', label: '中等' },
{ value: 'critical', label: '严重' },
{ value: 'high', label: '严重' },
{ value: 'urgent', label: '紧急' },
];
// --- 告警状态(统一 3 处: AlertDashboard, AlertList ---
export const ALERT_STATUS_COLOR: Record<string, string> = {
pending: 'orange',
active: 'gold',
acknowledged: 'blue',
resolved: 'green',
dismissed: 'default',
@@ -66,6 +73,7 @@ export const ALERT_STATUS_COLOR: Record<string, string> = {
export const ALERT_STATUS_LABEL: Record<string, string> = {
pending: '待处理',
active: '活跃',
acknowledged: '已确认',
resolved: '已恢复',
dismissed: '已忽略',
@@ -73,6 +81,7 @@ export const ALERT_STATUS_LABEL: Record<string, string> = {
export const ALERT_STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '活跃' },
{ value: 'pending', label: '待处理' },
{ value: 'acknowledged', label: '已确认' },
{ value: 'resolved', label: '已恢复' },

View File

@@ -11,6 +11,7 @@ import {
import { useThemeMode } from '../../hooks/useThemeMode';
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
import { EntityName } from '../../components/EntityName';
const { Text } = Typography;
@@ -321,9 +322,9 @@ export default function AiAnalysisList() {
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (v: string) => (
<Link to={`/health/patients/${v}`} style={{ fontFamily: 'monospace', fontSize: 12 }}>
{v.slice(0, 8)}
render: (_: unknown, record: AnalysisItem) => (
<Link to={`/health/patients/${record.patient_id}`}>
<EntityName name={record.patient_name} id={record.patient_id} />
</Link>
),
},

View File

@@ -245,7 +245,7 @@ export default function AlertDashboard() {
}
description={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
: <EntityName id={alert.patient_id} />
: <EntityName name={alert.patient_name} id={alert.patient_id} />
{' · '}
{new Date(alert.created_at).toLocaleString('zh-CN')}
</Typography.Text>

View File

@@ -137,12 +137,11 @@ export default function AlertList() {
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (id: string) => (
<Link to={`/health/patients/${id}`}>
render: (_: unknown, record: Alert) => (
<Link to={`/health/patients/${record.patient_id}`}>
<EntityName
name={undefined}
id={id}
fallbackLabel={id.length > 8 ? id.slice(0, 8) + '...' : id}
name={record.patient_name}
id={record.patient_id}
/>
</Link>
),

View File

@@ -7,6 +7,7 @@ import { StatusTag } from './components/StatusTag';
import { ImagePreview } from './components/ImagePreview';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import { EntityName } from '../../components/EntityName';
const PAGE_SIZE = 30;
const POLL_INTERVAL = 10_000;
@@ -297,10 +298,10 @@ export default function ConsultationDetail() {
{session && (
<>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: {session.patient_id.slice(0, 8)}
: <EntityName name={session.patient_name} id={session.patient_id} />
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: {session.doctor_id ? session.doctor_id.slice(0, 8) : '-'}
: <EntityName name={session.doctor_name} id={session.doctor_id} fallbackLabel="未分配" />
</Typography.Text>
<StatusTag status={session.status} />
</>

View File

@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import { deviceApi, type DeviceItem } from '../../api/health/devices';
import { DEVICE_TYPE_OPTIONS, DEVICE_TYPE_COLOR } from '../../constants/health';
import { PatientSelect } from './components/PatientSelect';
function formatTime(val?: string | null): string {
if (!val) return '-';
@@ -109,13 +110,13 @@ export default function DeviceManage() {
<h2 style={{ marginBottom: 16 }}></h2>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="患者 ID"
value={filterPatientId}
onChange={(e) => setFilterPatientId(e.target.value)}
style={{ width: 200 }}
allowClear
/>
<div style={{ width: 240 }}>
<PatientSelect
value={filterPatientId || undefined}
onChange={(val) => setFilterPatientId(val || '')}
placeholder="搜索患者"
/>
</div>
<Select
placeholder="设备类型"
value={filterDeviceType}

View File

@@ -11,10 +11,10 @@ import { useCountUp } from '../../../hooks/useCountUp';
import HealthDataCenter from './HealthDataCenter';
export function AdminDashboard() {
const { patientStats, followUpStats, healthDataStats, dialysisStats, loading } = useStatsData();
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
const patientCount = useCountUp(patientStats?.total_patients ?? 0);
const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0);
const doctorCount = useCountUp(0);
const doctorCountDisplay = useCountUp(doctorCount);
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -62,7 +62,7 @@ export function AdminDashboard() {
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic title="医护人数" value={doctorCount} prefix={<UserOutlined />} />
<Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} />
</Card>
</Col>
</Row>

View File

@@ -8,6 +8,7 @@ import {
type HealthDataStats,
type DialysisStatistics,
} from '../../../api/health/points';
import { doctorApi } from '../../../api/health/doctors';
export interface StatsData {
patientStats: PatientStatistics | null;
@@ -16,6 +17,7 @@ export interface StatsData {
pointsStats: PointsStatistics | null;
healthDataStats: HealthDataStats | null;
dialysisStats: DialysisStatistics | null;
doctorCount: number;
loading: boolean;
error: string | null;
refresh: () => void;
@@ -31,6 +33,7 @@ export function useStatsData(): StatsData {
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null);
const [doctorCount, setDoctorCount] = useState(0);
const fetchAllStats = useCallback(async () => {
setLoading(true);
@@ -56,9 +59,14 @@ export function useStatsData(): StatsData {
tryFetch(pointsApi.getStatistics, setPointsStats, '积分'),
tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'),
tryFetch(pointsApi.getDialysisStats, setDialysisStats, '透析'),
tryFetch(
async () => { const r = await doctorApi.list({ page: 1, page_size: 1 }); return r.total; },
setDoctorCount,
'医护',
),
]);
if (hasAnyError && errors.length === 6) {
if (hasAnyError && errors.length === 7) {
setError('加载统计数据失败');
}
@@ -70,7 +78,7 @@ export function useStatsData(): StatsData {
}, [fetchAllStats]);
return {
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats,
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount,
loading, error, refresh: fetchAllStats,
};
}

View File

@@ -291,8 +291,46 @@ where
.analysis
.list_analysis(ctx.tenant_id, params.patient_id, params.analysis_type, &pagination)
.await?;
// 批量查询 patient_name通过 raw SQL 避免跨 crate 依赖 erp-health
let patient_ids: std::collections::HashSet<uuid::Uuid> = items
.iter()
.filter(|a| a.patient_id != uuid::Uuid::nil())
.map(|a| a.patient_id)
.collect();
let patient_names: std::collections::HashMap<uuid::Uuid, String> = if !patient_ids.is_empty() {
#[derive(sea_orm::FromQueryResult)]
struct PatientName { id: uuid::Uuid, name: String }
let ids: Vec<uuid::Uuid> = patient_ids.into_iter().collect();
use sea_orm::FromQueryResult;
PatientName::find_by_statement(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT id, name FROM patient WHERE id = ANY($1) AND tenant_id = $2 AND deleted_at IS NULL",
[ids.into(), ctx.tenant_id.into()],
))
.all(&state.db)
.await
.unwrap_or_default()
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
std::collections::HashMap::new()
};
let data: Vec<serde_json::Value> = items.into_iter().map(|a| {
let mut val = serde_json::to_value(&a).unwrap_or_default();
if let Some(obj) = val.as_object_mut() {
obj.insert("patient_name".to_string(), serde_json::json!(
patient_names.get(&a.patient_id).cloned()
));
}
val
}).collect();
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"data": data,
"total": total,
"page": pagination.page.unwrap_or(1),
"page_size": pagination.limit(),

View File

@@ -74,6 +74,7 @@ pub struct AcknowledgeAlertRequest {
pub struct AlertResponse {
pub id: Uuid,
pub patient_id: Uuid,
pub patient_name: Option<String>,
pub rule_id: Uuid,
pub severity: String,
pub title: String,

View File

@@ -6,8 +6,9 @@ use uuid::Uuid;
use erp_core::error::check_version;
use crate::dto::alert_dto::AlertResponse;
use crate::entity::alerts;
use crate::entity::patient_doctor_relation;
use crate::entity::{patient, patient_doctor_relation};
use crate::error::{HealthError, HealthResult};
use crate::service::validation;
use crate::state::HealthState;
@@ -20,7 +21,7 @@ pub async fn list_alerts(
status: Option<&str>,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<alerts::Model>, u64)> {
) -> HealthResult<(Vec<AlertResponse>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
@@ -32,7 +33,6 @@ pub async fn list_alerts(
if let Some(did) = doctor_id {
let patient_ids = get_patient_ids_for_doctor(&state.db, tenant_id, did).await?;
if patient_ids.is_empty() {
// 没有管床患者 → 直接返回空结果
return Ok((vec![], 0));
}
query = query.filter(alerts::Column::PatientId.is_in(patient_ids));
@@ -47,13 +47,44 @@ pub async fn list_alerts(
}
let total = query.clone().count(&state.db).await?;
let items = query
let models = query
.order_by_desc(alerts::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(&state.db)
.await?;
// 批量查询 patient_name
let patient_ids: std::collections::HashSet<Uuid> = models.iter().map(|m| m.patient_id).collect();
let patient_names: std::collections::HashMap<Uuid, String> = if !patient_ids.is_empty() {
patient::Entity::find()
.filter(patient::Column::Id.is_in(patient_ids))
.filter(patient::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
std::collections::HashMap::new()
};
let items = models.into_iter().map(|m| AlertResponse {
id: m.id,
patient_id: m.patient_id,
patient_name: patient_names.get(&m.patient_id).cloned(),
rule_id: m.rule_id,
severity: m.severity,
title: m.title,
detail: m.detail,
status: m.status,
acknowledged_by: m.acknowledged_by,
acknowledged_at: m.acknowledged_at,
resolved_at: m.resolved_at,
created_at: m.created_at,
version: m.version,
}).collect();
Ok((items, total))
}

View File

@@ -142,8 +142,43 @@ pub async fn list_sessions(
.all(&state.db)
.await?;
// 批量查询 patient_name 和 doctor_name
let patient_ids: std::collections::HashSet<Uuid> = models.iter().map(|m| m.patient_id).collect();
let doctor_ids: std::collections::HashSet<Uuid> = models.iter().filter_map(|m| m.doctor_id).collect();
let patient_names: std::collections::HashMap<Uuid, String> = if !patient_ids.is_empty() {
patient::Entity::find()
.filter(patient::Column::Id.is_in(patient_ids))
.filter(patient::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
std::collections::HashMap::new()
};
let doctor_names: std::collections::HashMap<Uuid, String> = if !doctor_ids.is_empty() {
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.is_in(doctor_ids))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|d| (d.id, d.name))
.collect()
} else {
std::collections::HashMap::new()
};
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(model_to_session_resp).collect();
let data = models.into_iter().map(|m| {
let mut resp = model_to_session_resp(m.clone());
resp.patient_name = patient_names.get(&m.patient_id).cloned();
resp.doctor_name = m.doctor_id.and_then(|did| doctor_names.get(&did).cloned());
resp
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}

View File

@@ -0,0 +1,129 @@
# 全系统前端走查 — 业务流程/UI/功能问题清单
> 日期: 2026-05-03 | 参与者: Claude 全系统模拟走查
## 背景
对 HMS Web 前端全部 35+ 页面进行逐页走查模拟用户操作覆盖基础模块7 页)+ 健康管理模块25+ 页)+ 插件模块2 页)。截图存档于 `docs/walkthrough/`
## 走查范围
| 模块 | 页面数 | 走查状态 |
|------|--------|----------|
| 工作台 | 1 | 已走查 |
| 基础模块(用户/角色/组织/工作流/消息/设置) | 6 | 已走查 |
| 健康管理 — 统计报表 | 1 | 已走查 |
| 健康管理 — 患者医护(患者列表/详情/医护/标签) | 4 | 已走查 |
| 健康管理 — 预约排班(预约/排班) | 2 | 已走查 |
| 健康管理 — 随访咨询(随访任务/记录/咨询/模板) | 4 | 已走查 |
| 健康管理 — 积分运营(规则/商品/订单/活动) | 1 | 已走查(规则) |
| 健康管理 — 内容运营(文章/分类/标签/编辑器) | 1 | 已走查(文章) |
| 健康管理 — AIPrompt/分析/用量) | 1 | 已走查(分析历史) |
| 健康管理 — 告警(列表/仪表盘/规则) | 2 | 已走查 |
| 健康管理 — 设备/透析/行动收件箱 | 3 | 已走查 |
| 插件管理 | 1 | 已走查 |
---
## A. 发现的问题(按严重程度排序)
### P0 — 数据展示错误
| # | 页面 | 问题 | 影响 |
|---|------|------|------|
| A1 | 告警列表/仪表盘/行动收件箱 | **患者姓名全部显示"未知"或 ID 截断** — EntityName 组件未能解析告警关联的患者姓名 | 运营人员无法识别是哪个患者的告警 |
| A2 | 告警列表/仪表盘 | **严重程度 "high"/"medium" 未翻译** — 与 "紧急" 混用,枚举映射不完整 | 界面不一致,用户困惑 |
| A3 | 告警列表/仪表盘 | **状态 "active" 未翻译** — 应显示"未处理"或"活跃" | 界面不一致 |
| A4 | 咨询管理 | **所有咨询患者显示"未知"、医护显示"未分配"** — 咨询关联的患者/医护名称未正确解析 | 无法区分不同咨询 |
### P1 — 功能缺陷/体验问题
| # | 页面 | 问题 | 建议 |
|---|------|------|------|
| B1 | 工作台 | 3 个服务状态卡始终显示"数据加载中..." | 检查后端 health check API 是否响应 |
| B2 | 工作台 | "业务模块 0/8" — 插件未启用但显示比例,可能误导 | 加文字说明或改为隐藏 |
| B3 | 统计报表 | "医护人数 0" — 实际有 9 条医护记录 | 统计查询逻辑可能有误 |
| B4 | 设备管理 | 查询需要手动输入患者 ID无患者搜索下拉 | 改为 Select 搜索选择患者 |
| B5 | 告警列表 | "规则名称"列全部显示 "-" | 告警的 detail.rule_name 字段可能为空或解析逻辑有误 |
| B6 | 排班管理 | 需先选择医护才能查看,无初始引导 | 加空状态引导或默认展示本周排班 |
### P2 — 优化建议
| # | 页面 | 问题 | 建议 |
|---|------|------|------|
| C1 | 全局 | 测试数据过多(大量 E2E_* 记录)影响走查 | 提供测试数据清理脚本 |
| C2 | 告警列表 | 患者列用 ID 截断而非头像+姓名 | 对齐患者列表页的展示风格 |
| C3 | 随访管理 | "未分配"负责人显示不直观 | 加筛选"未分配"快捷入口 |
| C4 | AI 分析历史 | 患者列显示 ID 截断019dba80... | 解析为患者姓名 |
---
## B. 业务流程完整性评估
### B.1 核心医疗流程
| 流程 | 链路 | 完整度 | 备注 |
|------|------|--------|------|
| 患者建档 → 健康数据 → 告警 | 患者创建 → 详情 → 健康数据Tab → 告警列表 | ✅ 完整 | 告警自动触发正常 |
| 预约排班 → 就诊 → 随访 | 预约创建 → 状态变更 → 随访任务 | ✅ 完整 | 预约状态流转齐全 |
| 随访任务 → 填写记录 → 完成 | 任务列表 → 填写记录 → 完成 | ✅ 完整 | 分配/填写/删除齐全 |
| 咨询 → 消息 → 关闭 | 咨询创建 → 消息交互 → 关闭 | ⚠️ 部分 | 患者名显示"未知" |
| AI 分析 → 建议 → 行动 | AI分析历史 → 行动收件箱 → 处理 | ⚠️ 部分 | SSE 端点无 UI 触发入口 |
| 告警 → 确认 → 处理 | 告警列表 → 确认/忽略 → 行动收件箱 | ✅ 完整 | 告警仪表盘+行动收件箱联动 |
| 积分 → 规则 → 商品 → 兑换 | 积分规则 → 商品管理 → 订单 | ✅ 完整 | 连续打卡奖励体系完善 |
| 内容 → 创作 → 审核 → 发布 | 文章编辑 → 提交审核 → 发布 → 撤回 | ✅ 完整 | 审核流程齐全 |
### B.2 跨模块联动
| 联动 | 状态 | 发现 |
|------|------|------|
| 患者 → 预约 → 排班 | ✅ | 患者详情有"预约记录"快捷跳转 |
| 患者 → 随访 → 咨询 | ✅ | 患者详情有"随访任务""咨询记录"快捷跳转 |
| 患者 → 透析 → AI | ✅ | 患者详情有"透析记录""AI 分析"快捷跳转 |
| 告警 → 患者 | ⚠️ | 链接存在但 EntityName 解析失败 |
| 工作流 → 消息 | ✅ | 工作流事件触发消息通知(消息中心有记录) |
| 插件 → 权限 → 菜单 | ✅ | 插件启用后自动注册权限和菜单 |
### B.3 缺失功能/断点
| 缺失 | 严重程度 | 说明 |
|------|----------|------|
| AI SSE 触发 UI | P1 | 4 个 SSE 端点(分析/趋势/解读/摘要)无前端入口 |
| 设备数据同步 | P2 | 设备管理有查询但无数据,缺少绑定/解绑流程 |
| 随访模板 → 自动创建任务 | P2 | 模板管理存在但缺少"基于模板创建周期任务"流程 |
| 告警规则管理 | P2 | 告警规则列表页面存在但未深入验证 |
---
## C. UI/UX 一致性评估
### C.1 优秀设计
1. **患者详情页** — 6 个 Tab + 5 个快捷跳转按钮,信息架构清晰
2. **行动收件箱** — 统一聚合告警/AI建议/随访,优先级标签醒目
3. **内容管理** — 草稿/待审核/已发布/已拒绝 完整内容生命周期
4. **积分体系** — 连续打卡 7/14/30 天阶梯奖励,设计贴心
### C.2 一致性问题
1. **实体名称显示** — 患者列表用头像+姓名,告警用 ID 截断,咨询显示"未知"
2. **枚举翻译** — 部分状态已翻译("已确认"/"活跃"),部分显示英文("active"/"high"
3. **空状态处理** — 排班"请先选择医护"、透析"请先选择患者",但设备管理直接"No data"
---
## 结论 / 待定
### 核心发现
1. **功能完整度高** — 35+ 页面中所有核心业务流程可走通CRUD 齐全
2. **主要问题集中在数据展示层** — EntityName 组件解析失败、枚举翻译不完整
3. **AI SSE 端点无前端入口** — 后端能力已就绪,前端未对接
4. **行动收件箱是亮点** — 统一工作台概念执行良好
### 待讨论
1. EntityName 解析失败是否需要后端 API 优化(返回关联名称)还是前端 batch 解析?
2. AI SSE 端点的管理界面何时对接?
3. 枚举翻译统一方案i18n key vs 常量映射)?
4. 测试数据清理策略?

View File

@@ -0,0 +1,150 @@
# 全系统走查问题修复 — 多专家组头脑风暴
> 日期: 2026-05-03 | 参与者: 架构组 + 测试组 + 产品组
## 背景
基于 `docs/discussions/2026-05-03-full-system-walkthrough.md` 的走查结果35+ 页面发现 P0 问题 4 项、P1 问题 6 项、P2 建议 4 项。三个专家组分别从架构/前端、质量/测试、产品/UX 视角进行深度分析和方案设计。
## 议题 1EntityName 解析失败P0 — A1/A4
### 根因分析
**架构组发现:**
1. `EntityName` 组件是纯展示组件,依赖调用方传入 `name` prop
2. 告警/咨询/AI 分析页面的后端 DTO 缺少反规范化字段(如 `patient_name``doctor_name`
3. 前端已有 `resolvePatientName` store 方法health store但告警/咨询页面未调用
**影响范围:** 告警列表/仪表盘、咨询列表/详情、AI 分析历史、行动收件箱
### 修复方案3 层方案
| 层级 | 方案 | 耗时 | 详情 |
|------|------|------|------|
| 后端 | 告警/咨询/AI 分析的 list API 添加 JOIN 查询 | 1-2 天 | 返回 `patient_name``doctor_name` 反规范化字段,避免前端 N+1 查询 |
| 前端 | 枚举映射补全 | 0.5 天 | `SEVERITY_LABEL``high/medium``ALERT_STATUS_LABEL``active` |
| 前端 | 设备管理改造 | 0.5 天 | 患者ID输入改为 `PatientSelect` 搜索组件 |
### 决策:后端补充反规范化字段 vs 前端 batch 解析
**结论:后端 JOIN 方案。** 原因:
- 避免 N+1 查询(告警列表可能有 50+ 条,每条解析患者名 = 50 次 API 调用)
- 已有先例:预约列表 API 已 JOIN 返回 `patient_name``doctor_name`
- 保持 EntityName 组件简单(纯展示),复杂度在数据层解决
---
## 议题 2数据展示层缺陷P0 — A2/A3, P1 — B1/B3
### 测试组发现
| 问题 | 文件 | 详情 |
|------|------|------|
| 医护人数硬编码 0 | `AdminDashboard.tsx` L17 | `useCountUp(0)` 无 API 调用,后端无医护统计端点 |
| 严重程度枚举缺失 | `constants/health.ts` | `SEVERITY_LABEL` 定义了 `info/warning/critical/urgent`,但后端用 `high/medium` |
| 告警状态枚举缺失 | `constants/health.ts` | `ALERT_STATUS_LABEL``active` 状态 |
| EntityName 零测试 | `components/EntityName.tsx` | 无测试文件,无法验证 fallback 行为 |
| 工作台服务状态卡 | `Home.tsx` / `AdminDashboard.tsx` | health check API 可能未正确配置或前端轮询间隔不合理 |
### 修复方案
1. **后端新增医护统计 API**`GET /api/v1/health/doctors/stats` 返回总数/在线数
2. **枚举映射统一** — 对照后端 `AlertSeverity` enum 补全前端常量映射
3. **EntityName 测试** — 单元测试覆盖 3 条路径name 存在 / name 缺失 fallback / loading 状态
---
## 议题 3AI SSE 端点无前端入口P1
### 现状
后端已实现 4 个 SSE 端点:
- `POST /api/v1/health/ai/analysis/stream` — 智能分析
- `POST /api/v1/health/ai/trends/stream` — 趋势分析
- `POST /api/v1/health/ai/interpretation/stream` — 化验单解读
- `POST /api/v1/health/ai/summary/stream` — 报告摘要
前端无任何触发入口。
### 产品组建议:嵌入工作流而非独立页面
**理由:** AI 分析是辅助工具,不应有独立管理页面。用户在处理具体业务时触发 AI 更自然。
**集成方案:**
| 触发点 | SSE 端点 | 展示位置 |
|--------|----------|----------|
| 患者详情 → "AI 健康分析"按钮 | analysis/stream | 患者详情页内嵌结果面板 |
| 患者详情 → 健康数据 Tab | trends/stream | 趋势图表上方"AI 解读"折叠面板 |
| 患者详情 → 化验单 Tab | interpretation/stream | 化验单详情下方"AI 解读"区域 |
| 随访完成 → 自动生成摘要 | summary/stream | 随访记录详情页 |
**耗时估计:** 3-5 天(含 SSE 连接管理、错误处理、结果持久化)
---
## 议题 4行动收件箱扩展
### 现状
行动收件箱当前聚合:告警处理 + AI 建议。产品组认为应扩展为统一工作台。
### 扩展建议
| 新增类型 | 来源 | 优先级 |
|----------|------|--------|
| 预约确认待办 | 预约状态变更 → 未确认 | 高 |
| 咨询回复提醒 | 新咨询消息 | 高 |
| 随访到期预警 | 随访任务截止日期 | 中 |
| 积分兑换审核 | 积分订单状态 | 低 |
**技术方案:** 后端 `action_inbox` 表已支持 `source_type` + `source_id` 多态关联,只需扩展事件消费者。
**耗时:** 2-3 天(新增 3 个事件消费者 + 前端筛选器扩展)
---
## 综合行动清单
### 止血P0总计 2-3 天)
| # | 任务 | 耗时 | 负责 |
|---|------|------|------|
| 1 | 告警/咨询 list API 添加 JOIN 返回 patient_name/doctor_name | 1 天 | 后端 |
| 2 | 告警/AI/咨询前端页面使用反规范化字段 | 0.5 天 | 前端 |
| 3 | 枚举映射补全severity + status | 0.5 天 | 前端 |
| 4 | 医护统计 API + Dashboard 对接 | 0.5 天 | 全栈 |
### 补短板P1总计 4-6 天)
| # | 任务 | 耗时 | 负责 |
|---|------|------|------|
| 5 | AI SSE 前端集成(嵌入患者详情/工作流) | 3-5 天 | 全栈 |
| 6 | 设备管理患者选择器改造 | 0.5 天 | 前端 |
| 7 | 排班管理空状态引导 | 0.5 天 | 前端 |
### 治本P2总计 3-4 天)
| # | 任务 | 耗时 | 负责 |
|---|------|------|------|
| 8 | EntityName 组件测试 + 枚举覆盖测试 | 1 天 | 测试 |
| 9 | 行动收件箱扩展(预约/咨询/随访聚合) | 2-3 天 | 全栈 |
---
## 结论 / 待定
### 共识
1. **后端 JOIN 优于前端 batch** — 反规范化字段是解决 EntityName 问题的正确方式
2. **AI SSE 嵌入工作流** — 不做独立管理页面,在患者详情/随访流程中触发
3. **行动收件箱是正确的统一工作台方向** — 逐步聚合各模块待办
4. **止血优先于新功能** — 先修复 P0 展示问题,再推进 AI 集成
### 待讨论
1. 医护统计 API 是否需要按科室/职称分组,还是只返回总数?
2. AI SSE 结果是否持久化到数据库(当前只在前端展示)?
3. 行动收件箱是否需要实时推送WebSocket还是轮询即可
4. 测试数据清理策略 — 是否提供 E2E_* 记录清理脚本?