fix: 前端深度审计全量修复 — 安全/功能/代码质量
严重 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:
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [] });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user