Files
hms/apps/web/src/pages/health/PatientDetail.tsx
iven 8763e10d6e fix: 全局权限优化 — 7 项问题修复
1. 菜单权限修复:补充 10 个菜单的 permission 字段 + 修复 menu_service
   回退逻辑(admin 直接跳过过滤,非 admin 无关联则不显示)+ 收紧前端过滤
2. 管理员重置密码:新增 POST /users/{id}/reset-password 端点 + 前端按钮
3. 告警处理人姓名:AlertResponse 添加 acknowledged_by_name 字段
4. Tab 权限过滤:PatientDetail 6 个 Tab 按权限过滤 + 状态字段 Tooltip
5. 消息中心 UI:添加 Popconfirm/AuthButton,移除 inline isDark
2026-05-15 19:00:48 +08:00

442 lines
17 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { startAnalysis, type AnalysisType } from '../../api/ai/analysisSse';
import {
Card,
Descriptions,
Tabs,
Button,
Space,
Modal,
Form,
Input,
Select,
DatePicker,
message,
Spin,
Tooltip,
} from 'antd';
import { ArrowLeftOutlined, EditOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../stores/auth';
import { patientApi } from '../../api/health/patients';
import { AuthButton } from '../../components/AuthButton';
import { CopilotBadge } from '../../components/Copilot';
import type {
PatientDetail as PatientDetailType,
UpdatePatientReq,
} from '../../api/health/patients';
import { StatusTag } from './components/StatusTag';
import { VitalSignsTab } from './components/VitalSignsTab';
import { LabReportsTab } from './components/LabReportsTab';
import { HealthRecordsTab } from './components/HealthRecordsTab';
import { FollowUpTab } from './components/FollowUpTab';
import { DeviceReadingsTab } from './components/DeviceReadingsTab';
import { PointsAccountTab } from './components/PointsAccountTab';
import { AiSuggestionTab } from './components/AiSuggestionTab';
import { FamilyMembersTab } from './components/FamilyMembersTab';
import { DailyMonitoringTab } from './components/DailyMonitoringTab';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS, GENDER_LABEL } from '../../constants/health';
import { useThemeMode } from '../../hooks/useThemeMode';
export default function PatientDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [patient, setPatient] = useState<PatientDetailType | null>(null);
const [loading, setLoading] = useState(false);
const [analysisResult, setAnalysisResult] = useState('');
const [analyzing, setAnalyzing] = useState(false);
const triggerAnalysis = async (type: AnalysisType) => {
if (!id) return;
setAnalyzing(true);
setAnalysisResult('');
await startAnalysis(type, { patient_id: id }, {
onChunk: (content) => setAnalysisResult(prev => prev + content),
onError: (msg) => { message.error(msg); setAnalyzing(false); },
onDone: () => { message.success('分析完成'); setAnalyzing(false); },
});
};
const [editModalOpen, setEditModalOpen] = useState(false);
const [form] = Form.useForm();
const isDark = useThemeMode();
const permissions = useAuthStore((s) => s.permissions);
/** Tab 权限映射:无权限码的 tab 始终可见 */
const TAB_PERMISSIONS: Record<string, string | undefined> = {
info: undefined,
family: 'health.patient.manage',
health: 'health.health-data.list',
followup: 'health.follow-up.list',
points: 'health.points.list',
ai: 'ai.analysis.list',
};
const hasPermission = (code: string | undefined): boolean => {
if (!code) return true;
return permissions.includes(code);
};
// --- 加载患者基本信息 ---
const fetchPatient = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const data = await patientApi.get(id);
setPatient(data);
} catch {
message.error('加载患者信息失败');
}
setLoading(false);
}, [id]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchPatient();
}, [fetchPatient]);
// --- 编辑患者 ---
const handleEdit = async (values: {
name?: string;
gender?: string;
birth_date?: unknown;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
notes?: string;
}) => {
if (!patient) return;
const formatted = {
...values,
birth_date: values.birth_date && typeof values.birth_date === 'object' && 'format' in (values.birth_date as object)
? (values.birth_date as { format: (f: string) => string }).format('YYYY-MM-DD')
: (values.birth_date as string | undefined),
};
try {
const req: UpdatePatientReq & { version: number } = {
...formatted,
version: patient.version,
};
await patientApi.update(patient.id, req);
message.success('患者信息更新成功');
setEditModalOpen(false);
fetchPatient();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
message.error(errorMsg);
}
};
const openEditModal = () => {
if (!patient) return;
form.setFieldsValue({
name: patient.name,
gender: patient.gender,
birth_date: patient.birth_date,
blood_type: patient.blood_type,
id_number: patient.id_number,
allergy_history: patient.allergy_history,
medical_history_summary: patient.medical_history_summary,
emergency_contact_name: patient.emergency_contact_name,
emergency_contact_phone: patient.emergency_contact_phone,
notes: patient.notes,
});
setEditModalOpen(true);
};
// --- 加载状态 ---
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!patient) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<p></p>
<Button onClick={() => navigate('/health/patients')}></Button>
</div>
);
}
// --- 主题卡片样式 ---
const cardStyle = {
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
return (
<div>
<style>{`.patient-detail-tabs-card .ant-card-body { padding: 0 !important; }`}</style>
{/* 顶部导航 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/health/patients')}
type="text"
>
</Button>
</div>
{/* 患者基本信息卡片 */}
<Card style={{ ...cardStyle, marginBottom: 16 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: 'linear-gradient(135deg, #0ea5e9, #38bdf8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 20,
fontWeight: 600,
}}
>
{(patient.name?.[0] || 'P').toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 18 }}>{patient.name}</div>
<Space size={8} style={{ marginTop: 4 }}>
<StatusTag status={patient.status} />
<StatusTag status={patient.verification_status} />
<CopilotBadge patientId={id} />
</Space>
</div>
</div>
<AuthButton code="health.patient.manage">
<Button icon={<EditOutlined />} onClick={openEditModal}>
</Button>
</AuthButton>
</div>
<Descriptions column={3} size="small">
<Descriptions.Item label="性别">
{GENDER_LABEL[patient.gender || ''] || patient.gender || '-'}
</Descriptions.Item>
<Descriptions.Item label="出生日期">
{patient.birth_date || '-'}
</Descriptions.Item>
<Descriptions.Item label="血型">
{patient.blood_type || '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{patient.id_number || '-'}
</Descriptions.Item>
<Descriptions.Item label="来源">
{patient.source || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{patient.created_at ? new Date(patient.created_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 快捷导航 */}
<Card style={{ marginBottom: 16, padding: '12px 16px' }}>
<Space size={8} wrap>
<span style={{ color: 'var(--ant-color-text-secondary)', marginRight: 8 }}>:</span>
<Button type="link" size="small" onClick={() => navigate(`/health/appointments?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/consultations?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/dialysis?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/follow-up-tasks?patient_id=${id}`)}>访</Button>
<Button type="link" size="small" onClick={() => navigate(`/health/ai-analysis?patient_id=${id}`)}>AI </Button>
</Space>
</Card>
<Card style={cardStyle} className="patient-detail-tabs-card">
<Tabs
defaultActiveKey="info"
style={{ paddingLeft: 16, paddingRight: 16 }}
items={[
{
key: 'info',
label: '基本信息',
children: (
<Descriptions column={2} bordered size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="姓名">{patient.name}</Descriptions.Item>
<Descriptions.Item label="性别">
{GENDER_LABEL[patient.gender || ''] || patient.gender || '-'}
</Descriptions.Item>
<Descriptions.Item label="出生日期">
{patient.birth_date || '-'}
</Descriptions.Item>
<Descriptions.Item label="血型">
{patient.blood_type || '-'}
</Descriptions.Item>
<Descriptions.Item label="身份证号">
{patient.id_number || '-'}
</Descriptions.Item>
<Descriptions.Item label={
<Space size={4}>
<span></span>
<Tooltip title="active=活跃 | inactive=停诊 | deceased=已故。停诊/已故会触发对应业务事件。">
<InfoCircleOutlined style={{ color: 'var(--ant-color-text-quaternary)', fontSize: 12 }} />
</Tooltip>
</Space>
}>
<StatusTag status={patient.status} />
</Descriptions.Item>
<Descriptions.Item label={
<Space size={4}>
<span></span>
<Tooltip title="pending=待认证 | verified=已认证 | rejected=已驳回。已认证会触发 PATIENT_VERIFIED 事件。">
<InfoCircleOutlined style={{ color: 'var(--ant-color-text-quaternary)', fontSize: 12 }} />
</Tooltip>
</Space>
}>
<StatusTag status={patient.verification_status} />
</Descriptions.Item>
<Descriptions.Item label="来源">
{patient.source || '-'}
</Descriptions.Item>
<Descriptions.Item label="过敏史" span={2}>
{patient.allergy_history || '-'}
</Descriptions.Item>
<Descriptions.Item label="病史摘要" span={2}>
{patient.medical_history_summary || '-'}
</Descriptions.Item>
<Descriptions.Item label="紧急联系人">
{patient.emergency_contact_name || '-'}
</Descriptions.Item>
<Descriptions.Item label="紧急联系电话">
{patient.emergency_contact_phone || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{patient.notes || '-'}
</Descriptions.Item>
</Descriptions>
),
},
{
key: 'family',
label: '家属管理',
children: id ? <FamilyMembersTab patientId={id} /> : null,
},
{
key: 'health',
label: '健康数据',
children: id ? (
<Tabs
defaultActiveKey="vital"
size="small"
items={[
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
{ key: 'device', label: '设备数据', children: <DeviceReadingsTab patientId={id} /> },
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
{ key: 'daily', label: '日常监测', children: <DailyMonitoringTab patientId={id} /> },
]}
/>
) : null,
},
{
key: 'followup',
label: '随访记录',
children: id ? <FollowUpTab patientId={id} /> : null,
},
{
key: 'points',
label: '积分账户',
children: id ? <PointsAccountTab patientId={id} /> : null,
},
{
key: 'ai',
label: 'AI 建议',
children: id ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<AuthButton code="ai.analysis.manage">
<Button size="small" loading={analyzing} onClick={() => triggerAnalysis('trends')}>
</Button>
<Button size="small" loading={analyzing} onClick={() => triggerAnalysis('checkup-plan')}>
</Button>
</AuthButton>
</Space>
<AiSuggestionTab patientId={id} />
{analysisResult && (
<Card title="分析结果" size="small" style={{ marginTop: 8 }}>
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisResult}</div>
</Card>
)}
</Space>
) : null,
},
].filter((tab) => hasPermission(TAB_PERMISSIONS[tab.key]))}
/>
</Card>
{/* 编辑弹窗 */}
<Modal
title="编辑患者信息"
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
onOk={() => form.submit()}
width={600}
>
<Form form={form} onFinish={handleEdit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入患者姓名' }]}
>
<Input placeholder="请输入姓名" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="gender" label="性别" style={{ flex: 1 }}>
<Select options={GENDER_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
<Form.Item name="blood_type" label="血型" style={{ flex: 1 }}>
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
</div>
<Form.Item name="birth_date" label="出生日期">
<DatePicker style={{ width: '100%' }} placeholder="请选择" />
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
</Form.Item>
<Form.Item name="allergy_history" label="过敏史">
<Input.TextArea rows={2} placeholder="请输入过敏史" />
</Form.Item>
<Form.Item name="medical_history_summary" label="病史摘要">
<Input.TextArea rows={2} placeholder="请输入病史摘要" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="emergency_contact_name" label="紧急联系人" style={{ flex: 1 }}>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item name="emergency_contact_phone" label="紧急联系电话" style={{ flex: 1 }}>
<Input placeholder="联系电话" />
</Form.Item>
</div>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注" />
</Form.Item>
</Form>
</Modal>
</div>
);
}