Compare commits
3 Commits
994119ded1
...
cdbf381060
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbf381060 | ||
|
|
6296ce22d2 | ||
|
|
778ae79d84 |
@@ -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>
|
||||
|
||||
160
apps/web/src/api/health/appointments.ts
Normal file
160
apps/web/src/api/health/appointments.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
107
apps/web/src/api/health/consultations.ts
Normal file
107
apps/web/src/api/health/consultations.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
83
apps/web/src/api/health/doctors.ts
Normal file
83
apps/web/src/api/health/doctors.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
131
apps/web/src/api/health/followUp.ts
Normal file
131
apps/web/src/api/health/followUp.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
240
apps/web/src/api/health/healthData.ts
Normal file
240
apps/web/src/api/health/healthData.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
182
apps/web/src/api/health/patients.ts
Normal file
182
apps/web/src/api/health/patients.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
10
apps/web/src/pages/health/AppointmentList.tsx
Normal file
10
apps/web/src/pages/health/AppointmentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/ConsultationDetail.tsx
Normal file
10
apps/web/src/pages/health/ConsultationDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/ConsultationList.tsx
Normal file
10
apps/web/src/pages/health/ConsultationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/DoctorList.tsx
Normal file
10
apps/web/src/pages/health/DoctorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/DoctorSchedule.tsx
Normal file
10
apps/web/src/pages/health/DoctorSchedule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/FollowUpRecordList.tsx
Normal file
10
apps/web/src/pages/health/FollowUpRecordList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/FollowUpTaskList.tsx
Normal file
10
apps/web/src/pages/health/FollowUpTaskList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/PatientDetail.tsx
Normal file
10
apps/web/src/pages/health/PatientDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/PatientList.tsx
Normal file
10
apps/web/src/pages/health/PatientList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/pages/health/PatientTagManage.tsx
Normal file
10
apps/web/src/pages/health/PatientTagManage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/pages/health/components/CalendarView.tsx
Normal file
42
apps/web/src/pages/health/components/CalendarView.tsx
Normal 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} />;
|
||||
}
|
||||
74
apps/web/src/pages/health/components/ChatBubble.tsx
Normal file
74
apps/web/src/pages/health/components/ChatBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/health/components/DoctorSelect.tsx
Normal file
55
apps/web/src/pages/health/components/DoctorSelect.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/pages/health/components/ExportButton.tsx
Normal file
45
apps/web/src/pages/health/components/ExportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/pages/health/components/ImagePreview.tsx
Normal file
25
apps/web/src/pages/health/components/ImagePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/health/components/PatientSelect.tsx
Normal file
55
apps/web/src/pages/health/components/PatientSelect.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/pages/health/components/StatusTag.tsx
Normal file
30
apps/web/src/pages/health/components/StatusTag.tsx
Normal 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>;
|
||||
}
|
||||
55
apps/web/src/pages/health/components/VitalSignsChart.tsx
Normal file
55
apps/web/src/pages/health/components/VitalSignsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user