Compare commits

..

3 Commits

Author SHA1 Message Date
iven
cdbf381060 feat(web): 路由和菜单集成 + 10 页面占位
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
- App.tsx 添加 10 条 lazy 路由(患者/医护/预约/随访/咨询)
- MainLayout.tsx 添加健康管理菜单组(7 项菜单 + 10 条标题映射)
- 创建 10 个页面占位组件
2026-04-25 00:42:12 +08:00
iven
6296ce22d2 feat(web): 健康模块通用组件 8 个
- StatusTag: 通用状态标签(预约/随访/咨询/患者状态)
- PatientSelect: 患者远程搜索选择器
- DoctorSelect: 医护远程搜索选择器
- VitalSignsChart: ECharts 趋势图(可切换指标)
- CalendarView: 日历视图(排班展示)
- ChatBubble: 聊天气泡(角色区分左右布局)
- ImagePreview: 图片预览(Ant Design Image.PreviewGroup)
- ExportButton: 导出按钮(blob 下载)
2026-04-25 00:40:11 +08:00
iven
778ae79d84 feat(web): 健康模块 API 服务层 6 文件 47 端点
- patients.ts: 患者CRUD/标签/家庭/医护关联 14端点
- healthData.ts: 体征/化验/健康档案CRUD + 趋势 18端点
- appointments.ts: 预约CRUD + 排班管理 + 日历 8端点
- followUp.ts: 随访任务/记录CRUD 7端点
- consultations.ts: 咨询会话/消息CRUD + 导出 6端点
- doctors.ts: 医护CRUD 5端点
2026-04-25 00:37:59 +08:00
26 changed files with 1446 additions and 0 deletions

View File

@@ -24,6 +24,18 @@ const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) =>
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
// 健康管理模块
const PatientList = lazy(() => import('./pages/health/PatientList'));
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
const PatientTagManage = lazy(() => import('./pages/health/PatientTagManage'));
const DoctorList = lazy(() => import('./pages/health/DoctorList'));
const AppointmentList = lazy(() => import('./pages/health/AppointmentList'));
const DoctorSchedule = lazy(() => import('./pages/health/DoctorSchedule'));
const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
@@ -153,6 +165,17 @@ export default function App() {
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
{/* 健康管理 */}
<Route path="/health/patients" element={<PatientList />} />
<Route path="/health/patients/:id" element={<PatientDetail />} />
<Route path="/health/tags" element={<PatientTagManage />} />
<Route path="/health/doctors" element={<DoctorList />} />
<Route path="/health/appointments" element={<AppointmentList />} />
<Route path="/health/schedules" element={<DoctorSchedule />} />
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
<Route path="/health/consultations" element={<ConsultationList />} />
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -0,0 +1,160 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Appointment {
id: string;
patient_id: string;
doctor_id?: string;
appointment_type: string;
appointment_date: string;
start_time: string;
end_time: string;
status: string;
cancel_reason?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateAppointmentReq {
patient_id: string;
doctor_id?: string;
appointment_type?: string;
appointment_date: string;
start_time: string;
end_time: string;
notes?: string;
}
export interface UpdateAppointmentStatusReq {
status: string;
cancel_reason?: string;
}
export interface Schedule {
id: string;
doctor_id: string;
schedule_date: string;
period_type: string;
start_time: string;
end_time: string;
max_appointments: number;
current_appointments: number;
status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateScheduleReq {
doctor_id: string;
schedule_date: string;
period_type?: string;
start_time: string;
end_time: string;
max_appointments: number;
}
export interface UpdateScheduleReq {
start_time?: string;
end_time?: string;
max_appointments?: number;
status?: string;
}
export interface CalendarDay {
date: string;
schedules: Schedule[];
}
// --- API ---
export const appointmentApi = {
list: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
date?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Appointment>;
}>('/health/appointments', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}`);
return data.data;
},
create: async (req: CreateAppointmentReq) => {
const { data } = await client.post<{
success: boolean;
data: Appointment;
}>('/health/appointments', req);
return data.data;
},
updateStatus: async (
id: string,
req: UpdateAppointmentStatusReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Appointment;
}>(`/health/appointments/${id}/status`, req);
return data.data;
},
// Schedules
listSchedules: async (params: {
page?: number;
page_size?: number;
doctor_id?: string;
date?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Schedule>;
}>('/health/doctor-schedules', { params });
return data.data;
},
createSchedule: async (req: CreateScheduleReq) => {
const { data } = await client.post<{
success: boolean;
data: Schedule;
}>('/health/doctor-schedules', req);
return data.data;
},
updateSchedule: async (
id: string,
req: UpdateScheduleReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Schedule;
}>(`/health/doctor-schedules/${id}`, req);
return data.data;
},
calendar: async (params: {
start_date: string;
end_date: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: CalendarDay[];
}>('/health/doctor-schedules/calendar', { params });
return data.data;
},
};

View File

@@ -0,0 +1,107 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Session {
id: string;
patient_id: string;
doctor_id?: string;
consultation_type: string;
status: string;
last_message_at?: string;
unread_count_patient: number;
unread_count_doctor: number;
created_at: string;
}
export interface CreateSessionReq {
patient_id: string;
doctor_id?: string;
consultation_type?: string;
}
export interface Message {
id: string;
session_id: string;
sender_id: string;
sender_role: string;
content_type: string;
content: string;
is_read: boolean;
created_at: string;
}
export interface CreateMessageReq {
session_id: string;
sender_id: string;
sender_role: string;
content_type?: string;
content: string;
}
// --- API ---
export const consultationApi = {
listSessions: async (params: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Session>;
}>('/health/consultation-sessions', { params });
return data.data;
},
createSession: async (req: CreateSessionReq) => {
const { data } = await client.post<{
success: boolean;
data: Session;
}>('/health/consultation-sessions', req);
return data.data;
},
closeSession: async (
id: string,
req: { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: Session;
}>(`/health/consultation-sessions/${id}/close`, req);
return data.data;
},
listMessages: async (
sessionId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Message>;
}>(`/health/consultation-sessions/${sessionId}/messages`, { params });
return data.data;
},
createMessage: async (req: CreateMessageReq) => {
const { data } = await client.post<{
success: boolean;
data: Message;
}>('/health/consultation-messages', req);
return data.data;
},
exportSessions: async (params: {
status?: string;
patient_id?: string;
doctor_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: Session[];
}>('/health/consultation-sessions/export', { params });
return data.data;
},
};

View File

@@ -0,0 +1,83 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface Doctor {
id: string;
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateDoctorReq {
user_id?: string;
name: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
}
export interface UpdateDoctorReq {
name?: string;
department?: string;
title?: string;
specialty?: string;
license_number?: string;
bio?: string;
online_status?: string;
}
// --- API ---
export const doctorApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
department?: string;
title?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<Doctor>;
}>('/health/doctors', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`);
return data.data;
},
create: async (req: CreateDoctorReq) => {
const { data } = await client.post<{
success: boolean;
data: Doctor;
}>('/health/doctors', req);
return data.data;
},
update: async (id: string, req: UpdateDoctorReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: Doctor;
}>(`/health/doctors/${id}`, req);
return data.data;
},
delete: async (id: string) => {
await client.delete(`/health/doctors/${id}`);
},
};

View File

@@ -0,0 +1,131 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface FollowUpTask {
id: string;
patient_id: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
status: string;
content_template?: string;
related_appointment_id?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpTaskReq {
patient_id: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
content_template?: string;
related_appointment_id?: string;
}
export interface UpdateFollowUpTaskReq {
assigned_to?: string;
follow_up_type?: string;
planned_date?: string;
content_template?: string;
status?: string;
}
export interface FollowUpRecord {
id: string;
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFollowUpRecordReq {
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
}
// --- API ---
export const followUpApi = {
// Tasks
listTasks: async (params: {
page?: number;
page_size?: number;
patient_id?: string;
assigned_to?: string;
status?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpTask>;
}>('/health/follow-up-tasks', { params });
return data.data;
},
getTask: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`);
return data.data;
},
createTask: async (req: CreateFollowUpTaskReq) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpTask;
}>('/health/follow-up-tasks', req);
return data.data;
},
updateTask: async (
id: string,
req: UpdateFollowUpTaskReq & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FollowUpTask;
}>(`/health/follow-up-tasks/${id}`, req);
return data.data;
},
deleteTask: async (id: string, version: number) => {
await client.delete(`/health/follow-up-tasks/${id}`, {
data: { version },
});
},
// Records
listRecords: async (params: {
page?: number;
page_size?: number;
task_id?: string;
patient_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<FollowUpRecord>;
}>('/health/follow-up-records', { params });
return data.data;
},
createRecord: async (taskId: string, req: Omit<CreateFollowUpRecordReq, 'task_id'>) => {
const { data } = await client.post<{
success: boolean;
data: FollowUpRecord;
}>(`/health/follow-up-tasks/${taskId}/records`, { ...req, task_id: taskId });
return data.data;
},
};

View File

@@ -0,0 +1,240 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface VitalSigns {
id: string;
patient_id: string;
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateVitalSignsReq {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
systolic_bp_evening?: number;
diastolic_bp_evening?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
}
export interface LabReport {
id: string;
patient_id: string;
report_date: string;
report_type: string;
indicators?: Record<string, unknown>;
image_urls?: string[];
doctor_interpretation?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateLabReportReq {
report_date: string;
report_type: string;
indicators?: Record<string, unknown>;
image_urls?: string[];
doctor_interpretation?: string;
}
export interface HealthRecord {
id: string;
patient_id: string;
record_type: string;
record_date: string;
content?: string;
attachment_urls?: string[];
created_at: string;
updated_at: string;
version: number;
}
export interface CreateHealthRecordReq {
record_type: string;
record_date: string;
content?: string;
attachment_urls?: string[];
}
export interface TrendData {
id: string;
patient_id: string;
indicator: string;
trend_data: { date: string; value: number }[];
generated_at: string;
}
// --- API ---
export const healthDataApi = {
// Vital Signs
listVitalSigns: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<VitalSigns>;
}>(`/health/patients/${patientId}/vital-signs`, { params });
return data.data;
},
createVitalSigns: async (patientId: string, req: CreateVitalSignsReq) => {
const { data } = await client.post<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs`, req);
return data.data;
},
updateVitalSigns: async (
patientId: string,
id: string,
req: Partial<CreateVitalSignsReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: VitalSigns;
}>(`/health/patients/${patientId}/vital-signs/${id}`, req);
return data.data;
},
deleteVitalSigns: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/vital-signs/${id}`);
},
// Lab Reports
listLabReports: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<LabReport>;
}>(`/health/patients/${patientId}/lab-reports`, { params });
return data.data;
},
createLabReport: async (patientId: string, req: CreateLabReportReq) => {
const { data } = await client.post<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports`, req);
return data.data;
},
updateLabReport: async (
patientId: string,
id: string,
req: Partial<CreateLabReportReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: LabReport;
}>(`/health/patients/${patientId}/lab-reports/${id}`, req);
return data.data;
},
deleteLabReport: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/lab-reports/${id}`);
},
// Health Records
listHealthRecords: async (
patientId: string,
params: { page?: number; page_size?: number },
) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<HealthRecord>;
}>(`/health/patients/${patientId}/health-records`, { params });
return data.data;
},
createHealthRecord: async (
patientId: string,
req: CreateHealthRecordReq,
) => {
const { data } = await client.post<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records`, req);
return data.data;
},
updateHealthRecord: async (
patientId: string,
id: string,
req: Partial<CreateHealthRecordReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: HealthRecord;
}>(`/health/patients/${patientId}/health-records/${id}`, req);
return data.data;
},
deleteHealthRecord: async (patientId: string, id: string) => {
await client.delete(`/health/patients/${patientId}/health-records/${id}`);
},
// Trends
listTrends: async (patientId: string) => {
const { data } = await client.get<{
success: boolean;
data: TrendData[];
}>(`/health/patients/${patientId}/trends`);
return data.data;
},
generateTrend: async (patientId: string) => {
const { data } = await client.post<{
success: boolean;
data: TrendData[];
}>(`/health/patients/${patientId}/trends/generate`);
return data.data;
},
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
const { data } = await client.get<{
success: boolean;
data: { date: string; value: number }[];
}>(`/health/patients/${patientId}/trends/${encodeURIComponent(indicator)}`);
return data.data;
},
// Mini program endpoints
getMiniTrend: async (params: { indicator?: string; days?: number }) => {
const { data } = await client.get<{
success: boolean;
data: { date: string; value: number }[];
}>('/health/vital-signs/trend', { params });
return data.data;
},
getMiniToday: async () => {
const { data } = await client.get<{
success: boolean;
data: VitalSigns | null;
}>('/health/vital-signs/today');
return data.data;
},
};

View File

@@ -0,0 +1,182 @@
import client from '../client';
import type { PaginatedResponse } from '../types';
// --- Types ---
export interface PatientListItem {
id: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
status: string;
verification_status: string;
source?: string;
created_at: string;
updated_at: string;
}
export interface PatientDetail {
id: string;
user_id?: string;
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
status: string;
verification_status: string;
source?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreatePatientReq {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface UpdatePatientReq extends Partial<CreatePatientReq> {
status?: string;
verification_status?: string;
}
export interface FamilyMember {
id: string;
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface CreateFamilyMemberReq {
name: string;
relationship: string;
phone?: string;
id_number?: string;
notes?: string;
}
// --- API ---
export const patientApi = {
list: async (params: {
page?: number;
page_size?: number;
search?: string;
status?: string;
tag_id?: string;
}) => {
const { data } = await client.get<{
success: boolean;
data: PaginatedResponse<PatientListItem>;
}>('/health/patients', { params });
return data.data;
},
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`);
return data.data;
},
create: async (req: CreatePatientReq) => {
const { data } = await client.post<{
success: boolean;
data: PatientDetail;
}>('/health/patients', req);
return data.data;
},
update: async (id: string, req: UpdatePatientReq & { version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PatientDetail;
}>(`/health/patients/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
await client.delete(`/health/patients/${id}`, { data: { version } });
},
manageTags: async (id: string, tagIds: string[]) => {
await client.post(`/health/patients/${id}/tags`, { tag_ids: tagIds });
},
getHealthSummary: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: Record<string, unknown>;
}>(`/health/patients/${id}/health-summary`);
return data.data;
},
listFamilyMembers: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: FamilyMember[];
}>(`/health/patients/${id}/family-members`);
return data.data;
},
createFamilyMember: async (id: string, req: CreateFamilyMemberReq) => {
const { data } = await client.post<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${id}/family-members`, req);
return data.data;
},
updateFamilyMember: async (
patientId: string,
memberId: string,
req: Partial<CreateFamilyMemberReq> & { version: number },
) => {
const { data } = await client.put<{
success: boolean;
data: FamilyMember;
}>(`/health/patients/${patientId}/family-members/${memberId}`, req);
return data.data;
},
deleteFamilyMember: async (patientId: string, memberId: string) => {
await client.delete(
`/health/patients/${patientId}/family-members/${memberId}`,
);
},
assignDoctor: async (
id: string,
doctorId: string,
relationshipType: string,
) => {
await client.post(`/health/patients/${id}/doctors`, {
doctor_id: doctorId,
relationship_type: relationshipType,
});
},
removeDoctor: async (id: string, doctorId: string) => {
await client.delete(`/health/patients/${id}/doctors/${doctorId}`);
},
};

View File

@@ -19,6 +19,11 @@ import {
TableOutlined,
TagsOutlined,
RightOutlined,
HeartOutlined,
CalendarOutlined,
PhoneOutlined,
CommentOutlined,
MedicineBoxOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -47,6 +52,16 @@ const bizMenuItems: MenuItem[] = [
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
];
const healthMenuItems: MenuItem[] = [
{ key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
{ key: '/health/doctors', icon: <MedicineBoxOutlined />, label: '医护管理' },
{ key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
{ key: '/health/schedules', icon: <HeartOutlined />, label: '排班管理' },
{ key: '/health/follow-up-tasks', icon: <PhoneOutlined />, label: '随访管理' },
{ key: '/health/consultations', icon: <CommentOutlined />, label: '咨询管理' },
{ key: '/health/tags', icon: <TagsOutlined />, label: '标签管理' },
];
const sysMenuItems: MenuItem[] = [
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
@@ -61,6 +76,16 @@ const routeTitleMap: Record<string, string> = {
'/messages': '消息中心',
'/settings': '系统设置',
'/plugins/admin': '插件管理',
'/health/patients': '患者管理',
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',
'/health/doctors': '医护管理',
'/health/appointments': '预约排班',
'/health/schedules': '排班管理',
'/health/follow-up-tasks': '随访管理',
'/health/follow-up-records': '随访记录',
'/health/consultations': '咨询管理',
'/health/consultations/:id': '咨询详情',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
@@ -263,6 +288,20 @@ export default function MainLayout({ children }: { children: React.ReactNode })
))}
</div>
{/* 菜单组:健康管理 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{healthMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:插件 */}
{pluginMenuGroups.length > 0 && (
<>

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function AppointmentList() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function ConsultationDetail() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function ConsultationList() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function DoctorList() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function DoctorSchedule() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function FollowUpRecordList() {
return (
<Card>
<Typography.Title level={4}>访</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function FollowUpTaskList() {
return (
<Card>
<Typography.Title level={4}>访</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function PatientDetail() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function PatientList() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { Card, Typography } from 'antd';
export default function PatientTagManage() {
return (
<Card>
<Typography.Title level={4}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import { Calendar, Badge } from 'antd';
import type { Dayjs } from 'dayjs';
export interface ScheduleItem {
id: string;
doctor_name?: string;
start_time: string;
end_time: string;
current_appointments: number;
max_appointments: number;
status: string;
}
interface Props {
schedules: Record<string, ScheduleItem[]>;
}
export function CalendarView({ schedules }: Props) {
const cellRender = (date: Dayjs) => {
const key = date.format('YYYY-MM-DD');
const items = schedules[key];
if (!items || items.length === 0) return null;
return (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{items.slice(0, 3).map((s) => (
<li key={s.id}>
<Badge
status={s.current_appointments >= s.max_appointments ? 'error' : 'processing'}
text={`${s.start_time}-${s.end_time}`}
/>
</li>
))}
{items.length > 3 && (
<li style={{ color: '#999', fontSize: 12 }}>+{items.length - 3} </li>
)}
</ul>
);
};
return <Calendar cellRender={cellRender} />;
}

View File

@@ -0,0 +1,74 @@
import { Avatar, Typography, Space } 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

@@ -0,0 +1,55 @@
import { Select } from 'antd';
import { useState, useCallback } from 'react';
import { doctorApi } from '../../../api/health/doctors';
interface Props {
value?: string;
onChange?: (value: string, label: string) => void;
placeholder?: string;
}
export function DoctorSelect({ value, onChange, placeholder }: Props) {
const [options, setOptions] = useState<
{ value: string; label: string }[]
>([]);
const [fetching, setFetching] = useState(false);
const handleSearch = useCallback(async (search: string) => {
if (!search || search.length < 1) {
setOptions([]);
return;
}
setFetching(true);
try {
const result = await doctorApi.list({
search,
page_size: 20,
});
setOptions(
result.data.map((d) => ({
value: d.id,
label: `${d.name}${d.department ? ` - ${d.department}` : ''}`,
})),
);
} finally {
setFetching(false);
}
}, []);
return (
<Select
showSearch
filterOption={false}
onSearch={handleSearch}
onChange={(val) => {
const opt = options.find((o) => o.value === val);
onChange?.(val, opt?.label ?? '');
}}
loading={fetching}
options={options}
value={value}
placeholder={placeholder ?? '搜索医护'}
allowClear
/>
);
}

View File

@@ -0,0 +1,45 @@
import { Button, message } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
interface Props {
fetchUrl: string;
params?: Record<string, string>;
filename?: string;
label?: string;
}
export function ExportButton({
fetchUrl,
params,
filename,
label = '导出',
}: Props) {
const handleExport = async () => {
try {
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}` } : {},
});
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename ?? `export_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
}
};
return (
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{label}
</Button>
);
}

View File

@@ -0,0 +1,25 @@
import { Image, Space } from 'antd';
interface Props {
urls: string[];
width?: number;
}
export function ImagePreview({ urls, width = 100 }: Props) {
if (!urls || urls.length === 0) return null;
return (
<Image.PreviewGroup>
<Space size={8} wrap>
{urls.map((url, idx) => (
<Image
key={idx}
src={url}
width={width}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
))}
</Space>
</Image.PreviewGroup>
);
}

View File

@@ -0,0 +1,55 @@
import { Select } from 'antd';
import { useState, useCallback } from 'react';
import { patientApi } from '../../../api/health/patients';
interface Props {
value?: string;
onChange?: (value: string, label: string) => void;
placeholder?: string;
}
export function PatientSelect({ value, onChange, placeholder }: Props) {
const [options, setOptions] = useState<
{ value: string; label: string }[]
>([]);
const [fetching, setFetching] = useState(false);
const handleSearch = useCallback(async (search: string) => {
if (!search || search.length < 1) {
setOptions([]);
return;
}
setFetching(true);
try {
const result = await patientApi.list({
search,
page_size: 20,
});
setOptions(
result.data.map((p) => ({
value: p.id,
label: `${p.name}${p.gender ? ` (${p.gender})` : ''}`,
})),
);
} finally {
setFetching(false);
}
}, []);
return (
<Select
showSearch
filterOption={false}
onSearch={handleSearch}
onChange={(val) => {
const opt = options.find((o) => o.value === val);
onChange?.(val, opt?.label ?? '');
}}
loading={fetching}
options={options}
value={value}
placeholder={placeholder ?? '搜索患者'}
allowClear
/>
);
}

View File

@@ -0,0 +1,30 @@
import { Tag } from 'antd';
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
// 预约状态
pending: { color: 'gold', label: '待确认' },
confirmed: { color: 'blue', label: '已确认' },
completed: { color: 'green', label: '已完成' },
cancelled: { color: 'default', label: '已取消' },
no_show: { color: 'red', label: '未到诊' },
// 随访状态
overdue: { color: 'red', label: '逾期' },
in_progress: { color: 'processing', label: '进行中' },
// 咨询状态
waiting: { color: 'gold', label: '等待中' },
active: { color: 'green', label: '进行中' },
closed: { color: 'default', label: '已关闭' },
// 患者状态
inactive: { color: 'default', label: '停用' },
deceased: { color: 'default', label: '已故' },
verified: { color: 'green', label: '已认证' },
};
interface Props {
status: string;
}
export function StatusTag({ status }: Props) {
const cfg = STATUS_CONFIG[status] || { color: 'default' as const, label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { Line } from '@ant-design/charts';
import { Spin, Empty, Select, Space } from 'antd';
import { healthDataApi } from '../../../api/health/healthData';
interface Props {
patientId: string;
indicator?: string;
}
const INDICATORS = [
{ value: 'systolic_bp_morning', label: '收缩压(晨)' },
{ value: 'diastolic_bp_morning', label: '舒张压(晨)' },
{ value: 'heart_rate', label: '心率' },
{ value: 'weight', label: '体重' },
{ value: 'blood_sugar', label: '血糖' },
];
export function VitalSignsChart({ patientId, indicator: initialIndicator }: Props) {
const [indicator, setIndicator] = useState(initialIndicator ?? 'systolic_bp_morning');
const [data, setData] = useState<{ date: string; value: number }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!patientId || !indicator) return;
setLoading(true);
healthDataApi
.getIndicatorTimeseries(patientId, indicator)
.then(setData)
.finally(() => setLoading(false));
}, [patientId, indicator]);
if (loading) return <Spin />;
if (data.length === 0) return <Empty description="暂无数据" />;
const config = {
data,
xField: 'date',
yField: 'value',
smooth: true,
point: { shapeField: 'circle', sizeField: 4 },
};
return (
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={indicator}
onChange={setIndicator}
options={INDICATORS}
style={{ width: 180 }}
/>
<Line {...config} />
</Space>
);
}