fix: QA 第二轮修复 — PatientDetail 重构/测试覆盖/id_number 列宽/小程序 URL 规范化
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

- 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:
iven
2026-04-25 10:22:44 +08:00
parent 55a3fd32d0
commit 0bf1822fa9
34 changed files with 1110 additions and 641 deletions

View File

@@ -1,8 +1,22 @@
import { PropsWithChildren } from 'react';
import { useEffect, PropsWithChildren } from 'react';
import Taro from '@tarojs/taro';
import ErrorBoundary from './components/ErrorBoundary';
import { flushEvents } from './services/analytics';
import './app.scss';
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
useEffect(() => {
const timer = setInterval(() => {
flushEvents();
}, 30000);
const onHide = () => { flushEvents(); };
Taro.eventCenter.on('appHide', onHide);
return () => {
clearInterval(timer);
Taro.eventCenter.off('appHide', onHide);
};
}, []);
return <ErrorBoundary>{children}</ErrorBoundary>;
}

View File

@@ -29,7 +29,7 @@ export default function ArticleList() {
useDidShow(() => {
fetchData(1);
}, [fetchData]);
});
usePullDownRefresh(() => {
fetchData(1).finally(() => {

View File

@@ -46,7 +46,7 @@ export default function FollowUpDetail() {
trackEvent('followup_submit', { task_id: id });
const tmplId = TEMPLATE_IDS.FOLLOWUP_REMINDER;
if (tmplId) {
try { await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ }
try { await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ }
}
setContent('');
} catch {
@@ -91,7 +91,7 @@ export default function FollowUpDetail() {
if (error || !task) {
return (
<View className='detail-page'>
<ErrorState message='任务不存在' />
<ErrorState text='任务不存在' />
</View>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { useRouter } from '@tarojs/taro';
import { useHealthStore } from '@/stores/health';
import TrendChart from '@/components/TrendChart';
import './index.scss';

View File

@@ -29,7 +29,14 @@ export default function Index() {
];
const handleServiceClick = (path: string) => {
Taro.navigateTo({ url: path });
// tabBar 页面必须使用 switchTab其他页面用 navigateTo
const isTabBar = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index']
.some((p) => path.includes(p));
if (isTabBar) {
Taro.switchTab({ url: path });
} else {
Taro.navigateTo({ url: path });
}
};
const healthItems = [

View File

@@ -1,13 +1,16 @@
@import '../../styles/variables.scss';
.login-scroll {
height: 100vh;
}
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 60px;
padding: 120px 60px 60px;
}
.login-header {
@@ -69,6 +72,7 @@
align-items: flex-start;
margin-top: 32px;
gap: 12px;
width: 100%;
}
.checkbox {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { View, Text, Button, Image } from '@tarojs/components';
import { View, Text, Button, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import './index.scss';
@@ -16,15 +16,16 @@ export default function Login() {
}
try {
const { code } = await Taro.login();
const success = await login(code);
if (success) {
const result = await login(code);
if (result) {
Taro.switchTab({ url: '/pages/index/index' });
} else {
// 未绑定需要获取手机号openid 已由 store 缓存到 Storage
setNeedBind(true);
Taro.showToast({ title: '请授权手机号完成绑定', icon: 'none' });
}
} catch {
Taro.showToast({ title: '登录失败', icon: 'none' });
} catch (err: any) {
const msg = err?.message || '登录失败,请重试';
Taro.showToast({ title: msg.substring(0, 20), icon: 'none', duration: 3000 });
}
};
@@ -42,48 +43,50 @@ export default function Login() {
if (success) {
Taro.switchTab({ url: '/pages/index/index' });
} else {
Taro.showToast({ title: '绑定失败', icon: 'none' });
Taro.showToast({ title: '绑定失败,请重试', icon: 'none' });
}
};
return (
<View className='login-page'>
<View className='login-header'>
<View className='login-logo'>
<Text className='login-logo-text'>+</Text>
<ScrollView scrollY className='login-scroll'>
<View className='login-page'>
<View className='login-header'>
<View className='login-logo'>
<Text className='login-logo-text'>+</Text>
</View>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
<Text className='login-title'></Text>
<Text className='login-subtitle'></Text>
</View>
<View className='login-body'>
{!needBind ? (
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
</Button>
) : (
<Button
className='login-btn'
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhone}
loading={loading}
>
</Button>
)}
</View>
<View className='agreement-row'>
<View className={`checkbox ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
{agreed && <Text className='check-mark'>&#10003;</Text>}
<View className='login-body'>
{!needBind ? (
<Button className='login-btn' onClick={handleWechatLogin} loading={loading}>
</Button>
) : (
<Button
className='login-btn'
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhone}
loading={loading}
>
</Button>
)}
</View>
<View className='agreement-row'>
<View className={`checkbox ${agreed ? 'checked' : ''}`} onClick={() => setAgreed(!agreed)}>
{agreed && <Text className='check-mark'>&#10003;</Text>}
</View>
<Text className='agreement-text'>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/user-agreement' })}></Text>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}></Text>
</Text>
</View>
<Text className='agreement-text'>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/user-agreement' })}></Text>
<Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}></Text>
</Text>
</View>
</View>
</ScrollView>
);
}

View File

@@ -4,7 +4,6 @@ import { useAuthStore } from '../../stores/auth';
import './index.scss';
const MENU_ITEMS = [
{ label: '消息中心', icon: '🔔', path: '/pages/profile/messages/index', badge: true },
{ label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' },
{ label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' },
{ label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' },
@@ -14,7 +13,6 @@ const MENU_ITEMS = [
export default function Profile() {
const { user, restore: restoreAuth, logout } = useAuthStore();
const unreadCount = 0; // MVP 占位,后续对接 erp-message API
useDidShow(() => {
restoreAuth();
@@ -56,11 +54,6 @@ export default function Profile() {
>
<Text className='menu-icon'>{item.icon}</Text>
<Text className='menu-label'>{item.label}</Text>
{item.badge && unreadCount > 0 && (
<View className='menu-badge'>
<Text className='menu-badge-text'>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
<Text className='menu-arrow'></Text>
</View>
))}

View File

@@ -29,7 +29,10 @@ export interface DoctorSchedule {
}
export async function listAppointments(page = 1) {
return api.get<{ data: Appointment[]; total: number }>(`/health/appointments?page=${page}&page_size=20`);
return api.get<{ data: Appointment[]; total: number }>('/health/appointments', {
page,
page_size: 20,
});
}
export async function getAppointment(id: string) {
@@ -56,17 +59,25 @@ export async function cancelAppointment(id: string, version: number) {
}
export async function getDoctorSchedules(doctorId: string, startDate: string, endDate: string) {
return api.get<{ data: DoctorSchedule[]; total: number }>(
`/health/doctor-schedules?doctor_id=${doctorId}&start_date=${startDate}&end_date=${endDate}&page_size=50`
);
return api.get<{ data: DoctorSchedule[]; total: number }>('/health/doctor-schedules', {
doctor_id: doctorId,
start_date: startDate,
end_date: endDate,
page_size: 50,
});
}
export async function listDoctors(department?: string) {
const deptParam = department ? `&department=${department}` : '';
return api.get<{ data: Doctor[]; total: number }>(`/health/doctors?page_size=100${deptParam}`);
return api.get<{ data: Doctor[]; total: number }>('/health/doctors', {
page_size: 100,
...(department && { department }),
});
}
export async function calendarView(startDate: string, endDate: string, doctorId?: string) {
const docParam = doctorId ? `&doctor_id=${doctorId}` : '';
return api.get<DoctorSchedule[]>(`/health/doctor-schedules/calendar?start_date=${startDate}&end_date=${endDate}${docParam}`);
return api.get<DoctorSchedule[]>('/health/doctor-schedules/calendar', {
start_date: startDate,
end_date: endDate,
...(doctorId && { doctor_id: doctorId }),
});
}

View File

@@ -12,7 +12,10 @@ export interface Article {
}
export async function listArticles(page = 1) {
return api.get<{ data: Article[]; total: number }>(`/health/articles?page=${page}&page_size=20`);
return api.get<{ data: Article[]; total: number }>('/health/articles', {
page,
page_size: 20,
});
}
export async function getArticleDetail(id: string) {

View File

@@ -23,10 +23,11 @@ export interface FollowUpRecord {
}
export async function listTasks(status?: string) {
const statusParam = status ? `&status=${status}` : '';
return api.get<{ data: FollowUpTask[]; total: number }>(
`/health/follow-up-tasks?page=1&page_size=50${statusParam}`
);
return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', {
page: 1,
page_size: 50,
...(status && { status }),
});
}
export async function getTaskDetail(id: string) {
@@ -38,8 +39,9 @@ export async function submitRecord(data: { task_id: string; content: FollowUpCon
}
export async function listRecords(taskId?: string) {
const taskParam = taskId ? `&task_id=${taskId}` : '';
return api.get<{ data: FollowUpRecord[]; total: number }>(
`/health/follow-up-records?page=1&page_size=50${taskParam}`
);
return api.get<{ data: FollowUpRecord[]; total: number }>('/health/follow-up-records', {
page: 1,
page_size: 50,
...(taskId && { task_id: taskId }),
});
}

View File

@@ -25,6 +25,7 @@ export async function inputVitalSign(patientId: string, data: VitalSignInput) {
export async function getTrend(indicator: string, range: string) {
return api.get<{ indicator: string; data_points: { date: string; value: number }[] }>(
`/health/vital-signs/trend?indicator=${indicator}&range=${range}`
'/health/vital-signs/trend',
{ indicator, range },
);
}

View File

@@ -12,7 +12,10 @@ export interface Patient {
}
export async function listPatients() {
return api.get<{ data: Patient[]; total: number }>('/health/patients?page=1&page_size=100');
return api.get<{ data: Patient[]; total: number }>('/health/patients', {
page: 1,
page_size: 100,
});
}
export async function createPatient(data: {

View File

@@ -20,7 +20,8 @@ export interface LabReport {
export async function listReports(patientId: string, page = 1) {
return api.get<{ data: LabReport[]; total: number }>(
`/health/patients/${patientId}/lab-reports?page=${page}&page_size=20`
`/health/patients/${patientId}/lab-reports`,
{ page, page_size: 20 },
);
}

View File

@@ -2,6 +2,7 @@ import Taro from '@tarojs/taro';
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
const IS_DEV = process.env.NODE_ENV !== 'production';
interface ApiResponse<T> {
success: boolean;
@@ -44,12 +45,23 @@ async function tryRefreshToken(): Promise<boolean> {
export async function request<T>(method: string, path: string, data?: unknown): Promise<T> {
const headers = await getHeaders();
const res = await Taro.request({ url: `${BASE_URL}${path}`, method, data, header: headers });
const url = `${BASE_URL}${path}`;
if (IS_DEV) {
console.log(`[API] ${method} ${path}`, data ?? '');
}
const res = await Taro.request({ url, method: method as any, data, header: headers, timeout: 30000 });
if (IS_DEV) {
console.log(`[API] ${method} ${path}${res.statusCode}`);
}
if (res.statusCode === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) return request<T>(method, path, data);
Taro.redirectTo({ url: '/pages/login/index' });
const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || '';
if (!currentPath.includes('pages/login')) {
Taro.redirectTo({ url: '/pages/login/index' });
}
throw new Error('登录已过期');
}
@@ -58,8 +70,20 @@ export async function request<T>(method: string, path: string, data?: unknown):
return body.data as T;
}
function buildQuery(params?: Record<string, string | number | undefined>): string {
if (!params) return '';
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
return entries.length > 0
? '?' +
entries
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
: '';
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
request<T>('GET', `${path}${buildQuery(params)}`),
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
delete: <T>(path: string) => request<T>('DELETE', path),

View File

@@ -13,6 +13,8 @@ export interface Appointment {
status: string;
cancel_reason?: string;
notes?: string;
patient_name?: string;
doctor_name?: string;
created_at: string;
updated_at: string;
version: number;

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { message } from 'antd';
interface PaginatedState<T> {
@@ -8,9 +8,23 @@ interface PaginatedState<T> {
loading: boolean;
}
/**
* 通用分页数据 Hook封装 data / total / page / loading / fetch 逻辑。
*
* 支持两种签名:
* 1. 三参数 (page, pageSize, search) — 带搜索的列表页
* 2. 两参数 (page, pageSize) — 纯分页,不含搜索
*
* @param fetchFn - 数据获取函数
* @param pageSize - 每页条数,默认 20
* @param autoFetch - 是否在 mount / fetchFn 变化时自动请求第一页,默认 true
*/
export function usePaginatedData<T>(
fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>,
fetchFn:
| ((page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>)
| ((page: number, pageSize: number) => Promise<{ data: T[]; total: number }>),
pageSize = 20,
autoFetch = true,
) {
const [state, setState] = useState<PaginatedState<T>>({
data: [],
@@ -20,17 +34,44 @@ export function usePaginatedData<T>(
});
const [searchText, setSearchText] = useState('');
const refresh = useCallback(async (p?: number) => {
const targetPage = p ?? state.page;
setState(s => ({ ...s, loading: true }));
try {
const result = await fetchFn(targetPage, pageSize, searchText);
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
} catch {
message.error('加载数据失败');
setState(s => ({ ...s, loading: false }));
// 用 ref 保存最新 fetchFn避免 refresh 因闭包引用过期 fetchFn 而频繁重建
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
// 用 ref 保存最新 searchText,同理
const searchTextRef = useRef(searchText);
searchTextRef.current = searchText;
const refresh = useCallback(
async (p?: number) => {
const targetPage = p ?? state.page;
setState((s) => ({ ...s, loading: true }));
try {
// 统一按三参数调用;若 fetchFn 只接受两参数,第三个参数会被忽略
const result = await (fetchFnRef.current as (
page: number,
pageSize: number,
search: string,
) => Promise<{ data: T[]; total: number }>)(
targetPage,
pageSize,
searchTextRef.current,
);
setState({ data: result.data, total: result.total, page: targetPage, loading: false });
} catch {
message.error('加载数据失败');
setState((s) => ({ ...s, loading: false }));
}
},
[pageSize, state.page],
);
// mount 或 fetchFn 变化时自动请求
useEffect(() => {
if (autoFetch) {
refresh(1);
}
}, [fetchFn, pageSize, searchText, state.page]);
}, [autoFetch, refresh]);
return { ...state, searchText, setSearchText, refresh };
}

View File

@@ -168,7 +168,7 @@ export default function AppointmentList() {
key: 'patient_name',
width: 100,
render: (_: unknown, record: Appointment) =>
(record as unknown as Record<string, unknown>).patient_name as string || record.patient_id.slice(0, 8),
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '医护',
@@ -176,8 +176,7 @@ export default function AppointmentList() {
key: 'doctor_name',
width: 100,
render: (_: unknown, record: Appointment) => {
const name = (record as unknown as Record<string, unknown>).doctor_name as string | undefined;
return name || record.doctor_id?.slice(0, 8) || '-';
return record.doctor_name || record.doctor_id?.slice(0, 8) || '-';
},
},
{

View File

@@ -4,7 +4,6 @@ import {
Card,
Descriptions,
Tabs,
Table,
Button,
Space,
Modal,
@@ -12,29 +11,20 @@ import {
Input,
Select,
DatePicker,
Tag,
message,
Spin,
} from 'antd';
import {
ArrowLeftOutlined,
EditOutlined,
} from '@ant-design/icons';
import { ArrowLeftOutlined, EditOutlined } from '@ant-design/icons';
import { patientApi } from '../../api/health/patients';
import type {
PatientDetail as PatientDetailType,
UpdatePatientReq,
} from '../../api/health/patients';
import { healthDataApi } from '../../api/health/healthData';
import type {
VitalSigns,
LabReport,
HealthRecord,
} from '../../api/health/healthData';
import { followUpApi } from '../../api/health/followUp';
import type { FollowUpRecord } from '../../api/health/followUp';
import { StatusTag } from './components/StatusTag';
import { VitalSignsChart } from './components/VitalSignsChart';
import { VitalSignsTab } from './components/VitalSignsTab';
import { LabReportsTab } from './components/LabReportsTab';
import { HealthRecordsTab } from './components/HealthRecordsTab';
import { FollowUpTab } from './components/FollowUpTab';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
import { useThemeMode } from '../../hooks/useThemeMode';
@@ -53,28 +43,7 @@ export default function PatientDetail() {
const [form] = Form.useForm();
const isDark = useThemeMode();
// 健康数据子 tab 的状态
const [vitalSigns, setVitalSigns] = useState<VitalSigns[]>([]);
const [vitalSignsTotal, setVitalSignsTotal] = useState(0);
const [vitalSignsPage, setVitalSignsPage] = useState(1);
const [vitalSignsLoading, setVitalSignsLoading] = useState(false);
const [labReports, setLabReports] = useState<LabReport[]>([]);
const [labReportsTotal, setLabReportsTotal] = useState(0);
const [labReportsPage, setLabReportsPage] = useState(1);
const [labReportsLoading, setLabReportsLoading] = useState(false);
const [healthRecords, setHealthRecords] = useState<HealthRecord[]>([]);
const [healthRecordsTotal, setHealthRecordsTotal] = useState(0);
const [healthRecordsPage, setHealthRecordsPage] = useState(1);
const [healthRecordsLoading, setHealthRecordsLoading] = useState(false);
// 随访记录状态
const [followUpRecords, setFollowUpRecords] = useState<FollowUpRecord[]>([]);
const [followUpTotal, setFollowUpTotal] = useState(0);
const [followUpPage, setFollowUpPage] = useState(1);
const [followUpLoading, setFollowUpLoading] = useState(false);
// --- 加载患者基本信息 ---
const fetchPatient = useCallback(async () => {
if (!id) return;
setLoading(true);
@@ -87,103 +56,11 @@ export default function PatientDetail() {
setLoading(false);
}, [id]);
const fetchVitalSigns = useCallback(
async (p = vitalSignsPage) => {
if (!id) return;
setVitalSignsLoading(true);
try {
const result = await healthDataApi.listVitalSigns(id, {
page: p,
page_size: 10,
});
setVitalSigns(result.data);
setVitalSignsTotal(result.total);
} catch {
message.error('加载体征数据失败');
}
setVitalSignsLoading(false);
},
[id, vitalSignsPage],
);
const fetchLabReports = useCallback(
async (p = labReportsPage) => {
if (!id) return;
setLabReportsLoading(true);
try {
const result = await healthDataApi.listLabReports(id, {
page: p,
page_size: 10,
});
setLabReports(result.data);
setLabReportsTotal(result.total);
} catch {
message.error('加载化验报告失败');
}
setLabReportsLoading(false);
},
[id, labReportsPage],
);
const fetchHealthRecords = useCallback(
async (p = healthRecordsPage) => {
if (!id) return;
setHealthRecordsLoading(true);
try {
const result = await healthDataApi.listHealthRecords(id, {
page: p,
page_size: 10,
});
setHealthRecords(result.data);
setHealthRecordsTotal(result.total);
} catch {
message.error('加载健康档案失败');
}
setHealthRecordsLoading(false);
},
[id, healthRecordsPage],
);
const fetchFollowUpRecords = useCallback(
async (p = followUpPage) => {
if (!id) return;
setFollowUpLoading(true);
try {
const result = await followUpApi.listRecords({
patient_id: id,
page: p,
page_size: 10,
});
setFollowUpRecords(result.data);
setFollowUpTotal(result.total);
} catch {
message.error('加载随访记录失败');
}
setFollowUpLoading(false);
},
[id, followUpPage],
);
useEffect(() => {
fetchPatient();
}, [fetchPatient]);
useEffect(() => {
fetchVitalSigns();
}, [fetchVitalSigns]);
useEffect(() => {
fetchLabReports();
}, [fetchLabReports]);
useEffect(() => {
fetchHealthRecords();
}, [fetchHealthRecords]);
useEffect(() => {
fetchFollowUpRecords();
}, [fetchFollowUpRecords]);
// --- 编辑患者 ---
const handleEdit = async (values: {
name?: string;
gender?: string;
@@ -231,146 +108,7 @@ export default function PatientDetail() {
setEditModalOpen(true);
};
// 体征数据列定义
const vitalSignsColumns = [
{
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` : '-'),
},
];
// 化验报告列定义
const labReportColumns = [
{
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'),
},
];
// 健康档案列定义
const healthRecordColumns = [
{
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'),
},
];
// 随访记录列定义
const followUpColumns = [
{
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 || '-',
},
];
// --- 加载状态 ---
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
@@ -388,17 +126,17 @@ export default function PatientDetail() {
);
}
// --- 主题卡片样式 ---
const cardStyle = {
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
};
return (
<div>
{/* 顶部导航 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/health/patients')}
@@ -409,14 +147,7 @@ export default function PatientDetail() {
</div>
{/* 患者基本信息卡片 */}
<Card
style={{
marginBottom: 16,
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Card style={{ ...cardStyle, marginBottom: 16 }}>
<div
style={{
display: 'flex',
@@ -471,21 +202,13 @@ export default function PatientDetail() {
{patient.source || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{patient.created_at
? new Date(patient.created_at).toLocaleString('zh-CN')
: '-'}
{patient.created_at ? new Date(patient.created_at).toLocaleString('zh-CN') : '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 标签页 */}
<Card
style={{
borderRadius: 12,
background: isDark ? '#111827' : '#FFFFFF',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Card style={cardStyle}>
<Tabs
defaultActiveKey="info"
items={[
@@ -493,159 +216,65 @@ export default function PatientDetail() {
key: 'info',
label: '基本信息',
children: (
<div>
<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="状态">
<StatusTag status={patient.status} />
</Descriptions.Item>
<Descriptions.Item label="认证状态">
<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>
</div>
<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="状态">
<StatusTag status={patient.status} />
</Descriptions.Item>
<Descriptions.Item label="认证状态">
<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: 'health',
label: '健康数据',
children: (
children: id ? (
<Tabs
defaultActiveKey="vital"
items={[
{
key: 'vital',
label: '体征数据',
children: (
<div>
{id && (
<div style={{ marginBottom: 16 }}>
<VitalSignsChart patientId={id} />
</div>
)}
<Table
columns={vitalSignsColumns}
dataSource={vitalSigns}
rowKey="id"
loading={vitalSignsLoading}
size="small"
pagination={{
current: vitalSignsPage,
total: vitalSignsTotal,
pageSize: 10,
onChange: (p) => setVitalSignsPage(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
</div>
),
},
{
key: 'lab',
label: '化验报告',
children: (
<Table
columns={labReportColumns}
dataSource={labReports}
rowKey="id"
loading={labReportsLoading}
size="small"
pagination={{
current: labReportsPage,
total: labReportsTotal,
pageSize: 10,
onChange: (p) => setLabReportsPage(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
),
},
{
key: 'records',
label: '健康档案',
children: (
<Table
columns={healthRecordColumns}
dataSource={healthRecords}
rowKey="id"
loading={healthRecordsLoading}
size="small"
pagination={{
current: healthRecordsPage,
total: healthRecordsTotal,
pageSize: 10,
onChange: (p) => setHealthRecordsPage(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
),
},
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
]}
/>
),
) : null,
},
{
key: 'followup',
label: '随访记录',
children: (
<Table
columns={followUpColumns}
dataSource={followUpRecords}
rowKey="id"
loading={followUpLoading}
size="small"
pagination={{
current: followUpPage,
total: followUpTotal,
pageSize: 10,
onChange: (p) => setFollowUpPage(p),
showTotal: (t) => `${t}`,
style: { margin: 0 },
}}
/>
),
children: id ? <FollowUpTab patientId={id} /> : null,
},
]}
/>
@@ -659,12 +288,7 @@ export default function PatientDetail() {
onOk={() => form.submit()}
width={600}
>
<Form
form={form}
onFinish={handleEdit}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form form={form} onFinish={handleEdit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="姓名"
@@ -677,11 +301,7 @@ export default function PatientDetail() {
<Select options={GENDER_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
<Form.Item name="blood_type" label="血型" style={{ flex: 1 }}>
<Select
options={BLOOD_TYPE_OPTIONS}
placeholder="请选择"
allowClear
/>
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择" allowClear />
</Form.Item>
</div>
<Form.Item name="birth_date" label="出生日期">
@@ -697,18 +317,10 @@ export default function PatientDetail() {
<Input.TextArea rows={2} placeholder="请输入病史摘要" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="emergency_contact_name"
label="紧急联系人"
style={{ flex: 1 }}
>
<Form.Item name="emergency_contact_name" label="紧急联系人" style={{ flex: 1 }}>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item
name="emergency_contact_phone"
label="紧急联系电话"
style={{ flex: 1 }}
>
<Form.Item name="emergency_contact_phone" label="紧急联系电话" style={{ flex: 1 }}>
<Input placeholder="联系电话" />
</Form.Item>
</div>

View File

@@ -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>
);
}

View File

@@ -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;

View 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 },
}}
/>
);
}

View 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 },
}}
/>
);
}

View 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 },
}}
/>
);
}

View 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>
);
}