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,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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function ArticleList() {
|
||||
|
||||
useDidShow(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchData(1).finally(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>✓</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'>✓</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -252,11 +252,98 @@ fn parse_range_days(range: &Option<String>) -> i64 {
|
||||
match range.as_deref() {
|
||||
Some("30d") => 30,
|
||||
Some("90d") => 90,
|
||||
// 默认 7 天(包括 "7d" 和 None)
|
||||
_ => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据参考范围计算指标状态
|
||||
fn compute_status(value: f64, low: f64, high: f64) -> &'static str {
|
||||
if value < low {
|
||||
"low"
|
||||
} else if value > high {
|
||||
"high"
|
||||
} else {
|
||||
"normal"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- parse_range_days ---
|
||||
#[test]
|
||||
fn range_7d() {
|
||||
assert_eq!(7, parse_range_days(&Some("7d".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_30d() {
|
||||
assert_eq!(30, parse_range_days(&Some("30d".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_90d() {
|
||||
assert_eq!(90, parse_range_days(&Some("90d".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_none_defaults_7() {
|
||||
assert_eq!(7, parse_range_days(&None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_invalid_defaults_7() {
|
||||
assert_eq!(7, parse_range_days(&Some("1y".to_string())));
|
||||
}
|
||||
|
||||
// --- compute_status ---
|
||||
#[test]
|
||||
fn status_normal() {
|
||||
assert_eq!("normal", compute_status(75.0, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_low() {
|
||||
assert_eq!("low", compute_status(50.0, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_high() {
|
||||
assert_eq!("high", compute_status(120.0, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_at_low_boundary() {
|
||||
assert_eq!("normal", compute_status(60.0, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_at_high_boundary() {
|
||||
assert_eq!("normal", compute_status(100.0, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_just_below_low() {
|
||||
assert_eq!("low", compute_status(59.9, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_just_above_high() {
|
||||
assert_eq!("high", compute_status(100.1, 60.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_blood_sugar_normal() {
|
||||
assert_eq!("normal", compute_status(5.0, 3.9, 6.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_blood_sugar_high() {
|
||||
assert_eq!("high", compute_status(7.0, 3.9, 6.1));
|
||||
}
|
||||
}
|
||||
|
||||
/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。
|
||||
///
|
||||
/// 逻辑流程:
|
||||
@@ -319,17 +406,6 @@ pub async fn get_mini_trend(
|
||||
// 小程序今日体征摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据参考范围计算指标状态
|
||||
fn compute_status(value: f64, low: f64, high: f64) -> &'static str {
|
||||
if value < low {
|
||||
"low"
|
||||
} else if value > high {
|
||||
"high"
|
||||
} else {
|
||||
"normal"
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询今日最新体征记录并生成摘要
|
||||
pub async fn get_mini_today(
|
||||
state: &HealthState,
|
||||
|
||||
@@ -1678,4 +1678,91 @@ mod tests {
|
||||
);
|
||||
assert!(result.is_err(), "不支持的 grain 应报错");
|
||||
}
|
||||
|
||||
// ===== sanitize_identifier SQL 注入防护测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_removes_special_chars() {
|
||||
let result = sanitize_identifier("table;name'here\"with`special");
|
||||
assert!(
|
||||
!result.contains(';'),
|
||||
"分号应被替换: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains('\''),
|
||||
"单引号应被替换: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains('"'),
|
||||
"双引号应被替换: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains('`'),
|
||||
"反引号应被替换: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_allows_alphanumeric_underscore() {
|
||||
let result = sanitize_identifier("my_table_123");
|
||||
assert_eq!(
|
||||
result, "my_table_123",
|
||||
"合法标识符应原样保留"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_handles_drop_table() {
|
||||
let result = sanitize_identifier("users; DROP TABLE users;");
|
||||
assert_eq!(
|
||||
result, "users__DROP_TABLE_users_",
|
||||
"DROP TABLE 注入应被清理为下划线: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains(';'),
|
||||
"不应包含分号: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_handles_sql_comment() {
|
||||
let result = sanitize_identifier("users--");
|
||||
assert_eq!(
|
||||
result, "users__",
|
||||
"SQL 注释应被替换为下划线: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains('-'),
|
||||
"不应包含连字符: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_handles_union_injection() {
|
||||
let result = sanitize_identifier("users UNION SELECT");
|
||||
assert_eq!(
|
||||
result, "users_UNION_SELECT",
|
||||
"UNION 注入中空格应被替换为下划线: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains(' '),
|
||||
"不应包含空格: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_empty_string() {
|
||||
let result = sanitize_identifier("");
|
||||
assert_eq!(result, "", "空字符串应保持为空");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ mod m20260424_000045_health_indexes;
|
||||
mod m20260424_000046_health_constraints_fix;
|
||||
mod m20260424_000047_health_index_fix;
|
||||
mod m20260425_000048_add_patient_id_number_hash;
|
||||
mod m20260425_000049_widen_patient_id_number;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -103,6 +104,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260424_000046_health_constraints_fix::Migration),
|
||||
Box::new(m20260424_000047_health_index_fix::Migration),
|
||||
Box::new(m20260425_000048_add_patient_id_number_hash::Migration),
|
||||
Box::new(m20260425_000049_widen_patient_id_number::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260425_000049_widen_patient_id_number"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// 先删除依赖 id_number 列的唯一索引
|
||||
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number")
|
||||
.await?;
|
||||
|
||||
// 加宽 id_number 列:varchar(20) → varchar(255),容纳 AES-256-GCM 加密值(~88 字符)
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(255)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 重建唯一索引(partial,排除软删除和空值)
|
||||
conn.execute_unprepared(
|
||||
"CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number) WHERE deleted_at IS NULL AND id_number IS NOT NULL",
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number")
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(20)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number) WHERE deleted_at IS NULL AND id_number IS NOT NULL",
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ mod plugin_tests;
|
||||
mod workflow_tests;
|
||||
#[path = "integration/health_patient_tests.rs"]
|
||||
mod health_patient_tests;
|
||||
#[path = "integration/health_appointment_tests.rs"]
|
||||
mod health_appointment_tests;
|
||||
|
||||
248
crates/erp-server/tests/integration/health_appointment_tests.rs
Normal file
248
crates/erp-server/tests/integration/health_appointment_tests.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! erp-health 预约排班集成测试
|
||||
//!
|
||||
//! 验证预约 CRUD、租户隔离、CAS 排班名额等核心行为。
|
||||
//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。
|
||||
//! 预约创建依赖患者 + 医护档案 + 排班三条前置数据。
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
use erp_health::dto::appointment_dto::CreateAppointmentReq;
|
||||
use erp_health::dto::doctor_dto::CreateDoctorReq;
|
||||
use erp_health::dto::patient_dto::CreatePatientReq;
|
||||
use erp_health::service::{
|
||||
appointment_service, doctor_service, patient_service,
|
||||
};
|
||||
use erp_health::state::HealthState;
|
||||
use erp_health::HealthCrypto;
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
/// 构建测试用 HealthState
|
||||
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
|
||||
HealthState {
|
||||
db: db.clone(),
|
||||
event_bus: EventBus::new(100),
|
||||
crypto: HealthCrypto::dev_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建患者并返回其 ID
|
||||
async fn seed_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: uuid::Uuid,
|
||||
name: &str,
|
||||
) -> uuid::Uuid {
|
||||
let req = CreatePatientReq {
|
||||
name: name.to_string(),
|
||||
gender: Some("male".to_string()),
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
let patient = patient_service::create_patient(state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
patient.id
|
||||
}
|
||||
|
||||
/// 创建医护档案并返回其 ID
|
||||
async fn seed_doctor(
|
||||
state: &HealthState,
|
||||
tenant_id: uuid::Uuid,
|
||||
name: &str,
|
||||
) -> uuid::Uuid {
|
||||
let req = CreateDoctorReq {
|
||||
user_id: None,
|
||||
name: name.to_string(),
|
||||
department: Some("内科".to_string()),
|
||||
title: Some("主治医师".to_string()),
|
||||
specialty: Some("心血管内科".to_string()),
|
||||
license_number: None,
|
||||
bio: None,
|
||||
};
|
||||
let doctor = doctor_service::create_doctor(state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建医护档案应成功");
|
||||
doctor.id
|
||||
}
|
||||
|
||||
/// 创建排班并返回其 ID
|
||||
async fn seed_schedule(
|
||||
state: &HealthState,
|
||||
tenant_id: uuid::Uuid,
|
||||
doctor_id: uuid::Uuid,
|
||||
date: chrono::NaiveDate,
|
||||
) -> uuid::Uuid {
|
||||
let req = erp_health::dto::appointment_dto::CreateScheduleReq {
|
||||
doctor_id,
|
||||
schedule_date: date,
|
||||
period_type: Some("am".to_string()),
|
||||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||||
end_time: chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
|
||||
max_appointments: 10,
|
||||
};
|
||||
let schedule = appointment_service::create_schedule(state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建排班应成功");
|
||||
schedule.id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试 1: 创建预约
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_appointment() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 前置:患者 + 医护 + 排班
|
||||
let patient_id = seed_patient(&state, tenant_id, "预约测试患者").await;
|
||||
let doctor_id = seed_doctor(&state, tenant_id, "预约测试医生").await;
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
|
||||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||||
|
||||
let req = CreateAppointmentReq {
|
||||
patient_id,
|
||||
doctor_id: Some(doctor_id),
|
||||
appointment_type: Some("outpatient".to_string()),
|
||||
appointment_date: date,
|
||||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||||
notes: Some("首次就诊".to_string()),
|
||||
};
|
||||
|
||||
let appointment =
|
||||
appointment_service::create_appointment(&state, tenant_id, Some(operator_id), req)
|
||||
.await
|
||||
.expect("创建预约应成功");
|
||||
|
||||
assert_eq!(appointment.patient_id, patient_id);
|
||||
assert_eq!(appointment.doctor_id, Some(doctor_id));
|
||||
assert_eq!(appointment.appointment_type, "outpatient");
|
||||
assert_eq!(appointment.status, "pending");
|
||||
assert_eq!(appointment.version, 1);
|
||||
assert_eq!(
|
||||
appointment.notes,
|
||||
Some("首次就诊".to_string())
|
||||
);
|
||||
|
||||
// 通过 get_appointment 验证存储正确
|
||||
let found = appointment_service::get_appointment(&state, tenant_id, appointment.id)
|
||||
.await
|
||||
.expect("查询预约应成功");
|
||||
assert_eq!(found.id, appointment.id);
|
||||
assert_eq!(found.status, "pending");
|
||||
assert_eq!(found.version, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试 2: 列表查询 — 创建 2 条预约后验证分页计数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_appointments() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let patient_id = seed_patient(&state, tenant_id, "列表测试患者").await;
|
||||
let doctor_id = seed_doctor(&state, tenant_id, "列表测试医生").await;
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
|
||||
seed_schedule(&state, tenant_id, doctor_id, date).await;
|
||||
|
||||
// 创建 2 条预约(同一个排班时段,CAS 按排班 start_time 匹配)
|
||||
for _i in 0..2 {
|
||||
let req = CreateAppointmentReq {
|
||||
patient_id,
|
||||
doctor_id: Some(doctor_id),
|
||||
appointment_type: None,
|
||||
appointment_date: date,
|
||||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||||
notes: None,
|
||||
};
|
||||
appointment_service::create_appointment(&state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建预约应成功");
|
||||
}
|
||||
|
||||
let result = appointment_service::list_appointments(
|
||||
&state,
|
||||
tenant_id,
|
||||
1,
|
||||
10,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("列表查询应成功");
|
||||
|
||||
assert_eq!(result.total, 2, "应有 2 条预约记录");
|
||||
assert_eq!(result.data.len(), 2, "当前页应返回 2 条");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试 3: 租户隔离 — 租户 A 的预约对租户 B 不可见
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_appointment_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 创建完整链路:患者 + 医护 + 排班 + 预约
|
||||
let patient_a = seed_patient(&state, tenant_a, "租户A患者").await;
|
||||
let doctor_a = seed_doctor(&state, tenant_a, "租户A医生").await;
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
|
||||
seed_schedule(&state, tenant_a, doctor_a, date).await;
|
||||
|
||||
let req = CreateAppointmentReq {
|
||||
patient_id: patient_a,
|
||||
doctor_id: Some(doctor_a),
|
||||
appointment_type: None,
|
||||
appointment_date: date,
|
||||
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
|
||||
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
||||
notes: None,
|
||||
};
|
||||
let appointment_a =
|
||||
appointment_service::create_appointment(&state, tenant_a, None, req)
|
||||
.await
|
||||
.expect("租户 A 创建预约应成功");
|
||||
|
||||
// 租户 B 列表查询应看不到租户 A 的预约
|
||||
let result_b = appointment_service::list_appointments(
|
||||
&state,
|
||||
tenant_b,
|
||||
1,
|
||||
10,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("租户 B 列表查询应成功");
|
||||
assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的预约");
|
||||
assert!(result_b.data.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的预约应返回错误
|
||||
let lookup_result =
|
||||
appointment_service::get_appointment(&state, tenant_b, appointment_a.id).await;
|
||||
assert!(
|
||||
lookup_result.is_err(),
|
||||
"跨租户查询预约应返回错误"
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,8 @@ React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.
|
||||
| `apps/web/src/api/` | 21 个 API 服务文件 |
|
||||
| `apps/web/vite.config.ts` | Vite 配置 + API 代理 |
|
||||
|
||||
> 微信小程序(患者端)是独立前端项目,详见 [[miniprogram]]
|
||||
|
||||
### 路由结构
|
||||
|
||||
**公开**: `/login`
|
||||
@@ -100,4 +102,5 @@ ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-24 | 添加小程序交叉引用 |
|
||||
| 2026-04-23 | 重构为 5 节结构,更新为当前完整前端状态 |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| 数据库表 | 30+ 基础表 + 16 健康业务表(规划中) |
|
||||
| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 1 业务 (health) |
|
||||
| 健康模块页面 | 13 个(规划中) |
|
||||
| 微信小程序 | Taro 4.2 + React 18 + TypeScript |
|
||||
| API 文档 | `http://localhost:3000/api/docs/openapi.json` |
|
||||
|
||||
## 症状导航
|
||||
@@ -24,6 +25,9 @@
|
||||
| 端口被占用 | [[infrastructure]] dev.ps1 | 端口 5174-5189 进程 | 残留 Vite 进程 |
|
||||
| 预约超额 | [[erp-health]] 排班并发 | appointment CAS 操作 | 并发控制未走原子 CAS |
|
||||
| 跨租户数据泄漏 | [[architecture]] 多租户策略 | [[database]] tenant_id | 查询缺少 tenant_id 过滤 |
|
||||
| 小程序页面空白 | [[miniprogram]] defineConstants | `process.env` 未替换 | 编译时未注入环境变量 |
|
||||
| 小程序登录失败 `btoa is not defined` | [[miniprogram]] secure-storage | Web API 不可用 | 使用 `Taro.arrayBufferToBase64` 替代 |
|
||||
| 微信登录 500 | [[database]] wechat_users 表结构 | Entity 字段与表不匹配 | 补 `created_by/updated_by/version` 列 |
|
||||
|
||||
## 模块导航
|
||||
|
||||
@@ -44,6 +48,9 @@
|
||||
### 组装层
|
||||
- [[erp-server]] — Axum 入口 · AppState · 模块注册 · 后台任务 · 优雅关闭
|
||||
|
||||
### 患者端
|
||||
- [[miniprogram]] — **微信小程序** · Taro 4.2 · 微信登录 · 手机绑定 · 健康数据查看
|
||||
|
||||
### 基础设施
|
||||
- [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**)
|
||||
- [[database]] — SeaORM 迁移 · 多租户表结构
|
||||
|
||||
@@ -58,6 +58,16 @@ curl -s http://localhost:3000/api/v1/auth/login \
|
||||
|
||||
Accessibility / SEO / Best Practices 均 100,LCP 840ms,CLS 0.02
|
||||
|
||||
### 微信小程序验证
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && pnpm build:weapp # 构建
|
||||
# 在微信开发者工具中打开 apps/miniprogram 项目
|
||||
# 点击"编译" → 勾选协议 → 点击"微信一键登录"
|
||||
```
|
||||
|
||||
验证点:登录成功 → 首页加载 → 各 Tab 页面可访问
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 集成契约
|
||||
@@ -104,5 +114,6 @@ SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; --
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-24 | 添加微信小程序验证步骤 |
|
||||
| 2026-04-23 | 重构为 5 节结构,去除与 infrastructure.md 重复 |
|
||||
| 2026-04-18 | Lighthouse 审计 + 性能优化 |
|
||||
|
||||
Reference in New Issue
Block a user