fix: 前端深度审计全量修复 — 安全/功能/代码质量
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

严重 BUG 修复:
- 修复 Token 过期后 hash 重定向导致无法跳转登录页
- 修复文章编辑器新建后提交审核使用错误 ID

安全加固:
- HTML 清理函数替换为 ammonia 专业库(替代自定义解析器)
- 文件上传添加 magic bytes 校验(防 Content-Type 伪造)
- 登录添加账户级失败锁定(5次失败→15分钟锁定)
- 审计日志 9 个关键更新操作补充变更前后值(with_changes)

功能缺陷修复:
- 登录/登出时清理 API 缓存(防多账户数据污染)
- 文章编辑器上传改用统一 HTTP 客户端(自动 token 刷新)
- 添加全局 HTTP 错误处理和后端错误消息展示
- PrivateRoute 增加路由级权限检查(系统管理页面)
- 健康数据三个 Tab 添加编辑/删除功能
- 预约创建增加排班可用性校验提示
- 医生详情 API 返回解密后的原始执照号

代码清理:
- 删除未使用的 auth.ts refresh() 函数
- 删除重复的 AuthGuard.tsx 组件
- 删除未使用的 getHealthSummary API
This commit is contained in:
iven
2026-04-26 21:47:26 +08:00
parent f0c3426792
commit 787e64d9a9
23 changed files with 1152 additions and 482 deletions

View File

@@ -52,7 +52,18 @@ const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
const permissions = useAuthStore((s) => s.permissions);
if (!isAuthenticated) return <Navigate to="/login" replace />;
// 路由级权限检查:如果用户对某个模块完全没有权限,重定向到首页
const path = window.location.hash.replace('#', '');
if (path.startsWith('/users') || path.startsWith('/roles') || path.startsWith('/organizations')) {
const hasAuthAccess = permissions.some((p) => p.startsWith('auth.'));
if (!hasAuthAccess) return <Navigate to="/" replace />;
}
return <>{children}</>;
}
const themeConfig = {

View File

@@ -40,14 +40,6 @@ export async function login(req: LoginRequest): Promise<LoginResponse> {
return data.data;
}
export async function refresh(refreshToken: string): Promise<LoginResponse> {
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
'/auth/refresh',
{ refresh_token: refreshToken }
);
return data.data;
}
export async function logout(): Promise<void> {
await client.post('/auth/logout');
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { message as antMessage } from 'antd';
// 请求缓存:短时间内相同请求复用结果
interface CacheEntry {
@@ -138,7 +139,7 @@ client.interceptors.response.use(
processQueue(refreshError, null);
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.hash = '#/login';
window.location.hash = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
@@ -149,6 +150,32 @@ client.interceptors.response.use(
}
);
// 全局错误提示(仅对未被组件处理的错误显示)
let globalErrorTimer: ReturnType<typeof setTimeout> | null = null;
function showGlobalError(msg: string) {
// 防止短时间内弹出大量相同提示
if (globalErrorTimer) return;
antMessage.error(msg, 3);
globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000);
}
// 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行
client.interceptors.response.use(
(response) => response,
(error) => {
if (!error.response) {
showGlobalError('网络连接异常,请检查网络');
} else if (error.response.status === 403) {
showGlobalError('权限不足,无法执行此操作');
} else if (error.response.status === 404) {
// 404 通常由组件自行处理(如跳转),不全局提示
} else if (error.response.status >= 500) {
showGlobalError('服务器异常,请稍后重试');
}
return Promise.reject(error);
}
);
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
@@ -168,4 +195,12 @@ export function clearApiCache() {
requestCache.clear();
}
// 通用错误处理:提取后端错误消息并展示
export function handleApiError(err: unknown, fallback = '操作失败'): string {
const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback;
antMessage.error(msg);
return msg;
}
export default client;

View File

@@ -131,6 +131,7 @@ export const patientApi = {
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
},
// TODO: 未使用,待未来健康摘要功能接入时启用
getHealthSummary: async (id: string) => {
const { data } = await client.get<{
success: boolean;

View File

@@ -1,13 +0,0 @@
import type { ReactNode } from 'react';
import { usePermission } from '../hooks/usePermission';
interface AuthGuardProps {
code: string;
children: ReactNode;
}
export function AuthGuard({ code, children }: AuthGuardProps) {
const { hasPermission } = usePermission(code);
if (!hasPermission) return null;
return <>{children}</>;
}

View File

@@ -13,6 +13,7 @@ import {
message,
Card,
Row,
Alert,
Col,
} from 'antd';
import {
@@ -88,6 +89,10 @@ export default function AppointmentList() {
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
const [nameCache, setNameCache] = useState<Record<string, string>>({});
// 排班校验
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
@@ -194,9 +199,36 @@ export default function AppointmentList() {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
setSelectedDate(null);
setModalOpen(true);
};
// 排班校验:医生 + 日期选定后查询排班
useEffect(() => {
if (!selectedDoctorId || !selectedDate || !modalOpen) {
setScheduleHint(null);
return;
}
let cancelled = false;
appointmentApi.listSchedules({ doctor_id: selectedDoctorId, date: selectedDate, page: 1, page_size: 50 })
.then((result) => {
if (cancelled) return;
const schedules = result.data;
if (schedules.length === 0) {
setScheduleHint(`该医生在 ${selectedDate} 暂无排班,请确认是否需要先创建排班`);
} else {
const slots = schedules
.filter((s) => s.status === 'active' && s.current_appointments < s.max_appointments)
.map((s) => `${s.start_time}-${s.end_time}(${s.current_appointments}/${s.max_appointments})`)
.join('、');
setScheduleHint(slots ? `可约时段:${slots}` : `该医生在 ${selectedDate} 排班已满或已停用`);
}
})
.catch(() => { if (!cancelled) setScheduleHint(null); });
return () => { cancelled = true; };
}, [selectedDoctorId, selectedDate, modalOpen]);
const handleSubmit = async (values: {
appointment_date: Dayjs;
start_time: Dayjs;
@@ -388,11 +420,20 @@ export default function AppointmentList() {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
setScheduleHint(null);
}}
onOk={() => form.submit()}
destroyOnHidden
width={560}
>
{scheduleHint && (
<Alert
message={scheduleHint}
type={scheduleHint.includes('暂无排班') ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item label="患者" required>
<PatientSelect
@@ -415,7 +456,7 @@ export default function AppointmentList() {
label="预约日期"
rules={[{ required: true, message: '请选择预约日期' }]}
>
<DatePicker style={{ width: '100%' }} />
<DatePicker style={{ width: '100%' }} onChange={(d) => setSelectedDate(d ? d.format('YYYY-MM-DD') : null)} />
</Form.Item>
</Col>
<Col span={12}>

View File

@@ -13,6 +13,7 @@ import {
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import client from '../../api/client';
import '@wangeditor/editor/dist/css/style.css';
export default function ArticleEditor() {
@@ -108,15 +109,11 @@ export default function ArticleEditor() {
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const resp = await fetch('/api/v1/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!resp.ok) throw new Error('上传失败');
const result = await resp.json();
const url: string = result.data.url;
const token = localStorage.getItem('access_token');
const urlWithToken = token ? `${url}?token=${token}` : url;
insertFn(urlWithToken, file.name, urlWithToken);
} catch {
@@ -226,12 +223,15 @@ export default function ArticleEditor() {
});
currentVersion = created.version;
setVersion(created.version);
navigate(`/health/articles/${created.id}/edit`, { replace: true });
// 新建后直接提交审核(此时 id 仍为 undefined使用 created.id
await articleApi.submit(created.id, currentVersion);
message.success('已提交审核');
navigate('/health/articles');
return;
}
// 提交审核
if (id || isEdit) {
const articleId = id!;
await articleApi.submit(articleId, currentVersion);
// 编辑模式提交审核
if (id) {
await articleApi.submit(id, currentVersion);
}
message.success('已提交审核');
navigate('/health/articles');
@@ -471,14 +471,9 @@ export default function ArticleEditor() {
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const resp = await fetch('/api/v1/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
const { data: result } = await client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!resp.ok) throw new Error('上传失败');
const result = await resp.json();
setCoverImage(result.data.url);
message.success('封面图上传成功');
} catch {

View File

@@ -1,9 +1,11 @@
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Modal, Form, Select, DatePicker, Input, message, Popconfirm, Space } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { HealthRecord } from '../../../api/health/healthData';
import { AuthButton } from '../../../components/AuthButton';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
interface Props {
@@ -22,15 +24,9 @@ const RECORD_TYPE_MAP: Record<string, string> = {
inpatient: '住院',
};
const columns = [
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
export function HealthRecordsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<HealthRecord | null>(null);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -43,33 +39,112 @@ export function HealthRecordsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
const handleCreate = async (values: {
const isEditing = editingRecord !== null;
const modalTitle = isEditing ? '编辑健康记录' : '添加健康记录';
const openCreateModal = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: HealthRecord) => {
setEditingRecord(record);
form.setFieldsValue({
record_type: record.record_type,
record_date: dayjs(record.record_date),
content: record.content,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
record_type: 'checkup' | 'outpatient' | 'inpatient';
record_date: Dayjs;
content?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createHealthRecord(patientId, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
});
message.success('健康记录添加成功');
if (isEditing && editingRecord) {
await healthDataApi.updateHealthRecord(patientId, editingRecord.id, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
version: editingRecord.version,
});
message.success('健康记录更新成功');
} else {
await healthDataApi.createHealthRecord(patientId, {
record_type: values.record_type,
record_date: values.record_date.format('YYYY-MM-DD'),
content: values.content,
});
message.success('健康记录添加成功');
}
setModalOpen(false);
form.resetFields();
setEditingRecord(null);
refresh();
} catch {
message.error('添加失败');
message.error(isEditing ? '更新失败' : '添加失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: HealthRecord) => {
try {
await healthDataApi.deleteHealthRecord(patientId, record.id);
message.success('健康记录删除成功');
refresh();
} catch {
message.error('删除失败');
}
};
const columns = [
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{RECORD_TYPE_MAP[v] || v}</Tag> },
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
{
title: '操作',
key: 'action',
width: 140,
render: (_: unknown, record: HealthRecord) => (
<Space size="small">
<AuthButton code="health.health-data.manage">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
>
</Button>
</AuthButton>
<AuthButton code="health.health-data.manage">
<Popconfirm
title="确认删除"
description="确定要删除这条健康记录吗?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
@@ -87,15 +162,15 @@ export function HealthRecordsTab({ patientId }: Props) {
}}
/>
<Modal
title="添加健康记录"
title={modalTitle}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请选择类型' }]}>
<Select placeholder="请选择记录类型" options={RECORD_TYPE_OPTIONS} />
</Form.Item>

View File

@@ -1,24 +1,21 @@
import { useCallback, useState } from 'react';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { LabReport } from '../../../api/health/healthData';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
interface Props {
patientId: string;
}
const columns = [
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
];
export function LabReportsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -31,33 +28,96 @@ export function LabReportsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
const handleCreate = async (values: {
const openCreateModal = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: LabReport) => {
setEditingRecord(record);
form.setFieldsValue({
report_date: dayjs(record.report_date),
report_type: record.report_type,
doctor_interpretation: record.doctor_interpretation,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
report_date: Dayjs;
report_type: string;
doctor_interpretation?: string;
}) => {
setSubmitting(true);
try {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
if (editingRecord) {
await healthDataApi.updateLabReport(patientId, editingRecord.id, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
version: editingRecord.version,
});
message.success('化验报告更新成功');
} else {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
}
setModalOpen(false);
setEditingRecord(null);
form.resetFields();
refresh();
} catch {
message.error('添加失败');
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: LabReport) => {
try {
await healthDataApi.deleteLabReport(patientId, record.id);
message.success('化验报告删除成功');
refresh();
} catch (err) {
handleApiError(err, '删除失败');
}
};
const columns = [
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record: LabReport) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
</Button>
<Popconfirm title="确认删除该化验报告?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
</AuthButton>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
@@ -75,15 +135,15 @@ export function LabReportsTab({ patientId }: Props) {
}}
/>
<Modal
title="添加化验报告"
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>

View File

@@ -1,11 +1,14 @@
import { useCallback, useState } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip } from 'antd';
import { PlusOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { useCallback, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { VitalSigns } from '../../../api/health/healthData';
import { VitalSignsChart } from './VitalSignsChart';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
const { Text } = Typography;
@@ -13,61 +16,9 @@ interface Props {
patientId: string;
}
const columns = [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 110,
fixed: 'left' as const,
},
{
title: () => (
<Tooltip title="晨间收缩压">
<span>()</span>
</Tooltip>
),
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: () => (
<Tooltip title="晨间舒张压">
<span>()</span>
</Tooltip>
),
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 90,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
];
export function VitalSignsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<VitalSigns | null>(null);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
@@ -81,7 +32,40 @@ export function VitalSignsTab({ patientId }: Props) {
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
const handleCreate = async (values: {
const handleOpenCreate = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const handleOpenEdit = (record: VitalSigns) => {
setEditingRecord(record);
form.setFieldsValue({
record_date: dayjs(record.record_date),
systolic_bp_morning: record.systolic_bp_morning,
diastolic_bp_morning: record.diastolic_bp_morning,
heart_rate: record.heart_rate,
weight: record.weight,
blood_sugar: record.blood_sugar,
water_intake_ml: record.water_intake_ml,
urine_output_ml: record.urine_output_ml,
notes: record.notes,
});
setModalOpen(true);
};
const handleDelete = async (record: VitalSigns) => {
try {
await healthDataApi.deleteVitalSigns(patientId, record.id);
message.success('删除成功');
refresh();
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, '删除失败');
}
};
const handleSubmit = async (values: {
record_date: Dayjs;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
@@ -94,7 +78,7 @@ export function VitalSignsTab({ patientId }: Props) {
}) => {
setSubmitting(true);
try {
await healthDataApi.createVitalSigns(patientId, {
const payload = {
record_date: values.record_date.format('YYYY-MM-DD'),
systolic_bp_morning: values.systolic_bp_morning,
diastolic_bp_morning: values.diastolic_bp_morning,
@@ -104,18 +88,122 @@ export function VitalSignsTab({ patientId }: Props) {
water_intake_ml: values.water_intake_ml,
urine_output_ml: values.urine_output_ml,
notes: values.notes,
});
message.success('体征数据录入成功');
};
if (editingRecord) {
await healthDataApi.updateVitalSigns(patientId, editingRecord.id, {
...payload,
version: editingRecord.version,
});
message.success('体征数据更新成功');
} else {
await healthDataApi.createVitalSigns(patientId, payload);
message.success('体征数据录入成功');
}
setModalOpen(false);
form.resetFields();
setEditingRecord(null);
refresh();
setChartRefreshKey((k) => k + 1); } catch {
message.error('录入失败');
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '录入失败');
} finally {
setSubmitting(false);
}
};
const columns = useMemo(
() => [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 110,
fixed: 'left' as const,
},
{
title: () => (
<Tooltip title="晨间收缩压">
<span>()</span>
</Tooltip>
),
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: () => (
<Tooltip title="晨间舒张压">
<span>()</span>
</Tooltip>
),
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 90,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right' as const,
render: (_: unknown, record: VitalSigns) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenEdit(record)}
/>
<Popconfirm
title="确认删除"
description="删除后无法恢复,确定要删除这条体征记录吗?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
</AuthButton>
),
},
],
// handleOpenEdit and handleDelete are stable closures that only depend on patientId via refresh
// eslint-disable-next-line react-hooks/exhaustive-deps
[patientId],
);
// 最近一次数据摘要
const latest = data.length > 0 ? data[0] : null;
@@ -167,7 +255,7 @@ export function VitalSignsTab({ patientId }: Props) {
</Text>
<Button
icon={<PlusOutlined />}
onClick={() => { form.resetFields(); setModalOpen(true); }}
onClick={handleOpenCreate}
size="small"
>
@@ -189,19 +277,22 @@ export function VitalSignsTab({ patientId }: Props) {
size: 'small',
style: { margin: 0 },
}}
scroll={{ x: 600 }}
scroll={{ x: 700 }}
/>
<Modal
title="录入体征数据"
title={editingRecord ? '编辑体征数据' : '录入体征数据'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onCancel={() => {
setModalOpen(false);
setEditingRecord(null);
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
import { clearApiCache } from '../api/client';
function extractPermissions(): string[] {
const token = localStorage.getItem('access_token');
@@ -54,6 +55,7 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.setItem('refresh_token', resp.refresh_token);
localStorage.setItem('user', JSON.stringify(resp.user));
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
clearApiCache();
} catch (error) {
set({ loading: false });
throw error;
@@ -69,6 +71,7 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
clearApiCache();
set({ user: null, isAuthenticated: false, permissions: [] });
},