fix: QA 第二轮修复 — PatientDetail 重构/测试覆盖/id_number 列宽/小程序 URL 规范化
- refactor(web): PatientDetail.tsx 拆分为 4 个子组件(737→334行) - refactor(web): 提取 usePaginatedData hook 消除重复分页状态 - feat(db): patient.id_number varchar(20)→varchar(255) 容纳加密值 - test(health): 添加预约模块集成测试(创建/列表/租户隔离) - test(plugin): 添加 6 个 SQL 注入 sanitize 测试 - fix(miniprogram): 7 个 service 文件 URL 构建规范化(params 对象) - fix(miniprogram): 跨平台字段名对齐(birth_date/start_time/end_time)
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
import { Avatar, Typography } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
senderRole: 'patient' | 'doctor' | 'system';
|
||||
senderName?: string;
|
||||
content: string;
|
||||
contentType?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ROLE_CONFIG = {
|
||||
patient: { align: 'flex-start' as const, bg: '#f0f0f0', color: '#000' },
|
||||
doctor: { align: 'flex-end' as const, bg: '#1890ff', color: '#fff' },
|
||||
system: { align: 'center' as const, bg: '#fafafa', color: '#999' },
|
||||
};
|
||||
|
||||
export function ChatBubble({
|
||||
senderRole,
|
||||
senderName,
|
||||
content,
|
||||
createdAt,
|
||||
}: Props) {
|
||||
const cfg = ROLE_CONFIG[senderRole] ?? ROLE_CONFIG.system;
|
||||
|
||||
if (senderRole === 'system') {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{content}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: cfg.align, marginBottom: 12 }}>
|
||||
{senderRole === 'patient' && (
|
||||
<Avatar icon={<UserOutlined />} style={{ marginRight: 8, flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ maxWidth: '70%' }}>
|
||||
{senderName && (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, display: 'block', marginBottom: 2 }}
|
||||
>
|
||||
{senderName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
background: cfg.bg,
|
||||
color: cfg.color,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: 0, color: 'inherit' }}
|
||||
>
|
||||
{content}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{createdAt}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{senderRole === 'doctor' && (
|
||||
<Avatar icon={<UserOutlined />} style={{ marginLeft: 8, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, message } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import client from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
fetchUrl: string;
|
||||
@@ -19,12 +20,12 @@ export function ExportButton({
|
||||
const query = params
|
||||
? '?' + new URLSearchParams(params).toString()
|
||||
: '';
|
||||
const token = localStorage.getItem('access_token');
|
||||
const resp = await fetch(`/api/v1${fetchUrl}${query}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
const resp = await client.get(fetchUrl + query, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
if (!resp.ok) throw new Error('导出失败');
|
||||
const blob = await resp.blob();
|
||||
const blob = resp.data instanceof Blob
|
||||
? resp.data
|
||||
: new Blob([resp.data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
83
apps/web/src/pages/health/components/FollowUpTab.tsx
Normal file
83
apps/web/src/pages/health/components/FollowUpTab.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { followUpApi } from '../../../api/health/followUp';
|
||||
import type { FollowUpRecord } from '../../../api/health/followUp';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FollowUpRecord> = [
|
||||
{
|
||||
title: '执行日期',
|
||||
dataIndex: 'executed_date',
|
||||
key: 'executed_date',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '随访结果',
|
||||
dataIndex: 'result',
|
||||
key: 'result',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '患者状况',
|
||||
dataIndex: 'patient_condition',
|
||||
key: 'patient_condition',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '医嘱',
|
||||
dataIndex: 'medical_advice',
|
||||
key: 'medical_advice',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '下次随访日期',
|
||||
dataIndex: 'next_follow_up_date',
|
||||
key: 'next_follow_up_date',
|
||||
width: 130,
|
||||
render: (v?: string) => v || '-',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 随访记录标签页 — 分页表格
|
||||
*/
|
||||
export function FollowUpTab({ patientId }: Props) {
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return followUpApi.listRecords({
|
||||
patient_id: patientId,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<FollowUpRecord>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/pages/health/components/HealthRecordsTab.tsx
Normal file
77
apps/web/src/pages/health/components/HealthRecordsTab.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { HealthRecord } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<HealthRecord> = [
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'record_type',
|
||||
key: 'record_type',
|
||||
width: 120,
|
||||
render: (v: string) => <Tag>{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 fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listHealthRecords(patientId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/pages/health/components/LabReportsTab.tsx
Normal file
77
apps/web/src/pages/health/components/LabReportsTab.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { LabReport } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<LabReport> = [
|
||||
{
|
||||
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 fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listLabReports(patientId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/pages/health/components/VitalSignsTab.tsx
Normal file
98
apps/web/src/pages/health/components/VitalSignsTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { VitalSigns } from '../../../api/health/healthData';
|
||||
import { VitalSignsChart } from './VitalSignsChart';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<VitalSigns> = [
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '收缩压(晨)',
|
||||
dataIndex: 'systolic_bp_morning',
|
||||
key: 'systolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '舒张压(晨)',
|
||||
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: 80,
|
||||
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 体征数据标签页 — 含趋势图 + 分页表格
|
||||
*/
|
||||
export function VitalSignsTab({ patientId }: Props) {
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listVitalSigns(patientId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<VitalSignsChart patientId={patientId} />
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user