feat(web): Web 前端功能完善 — API 扩展 + 组件优化
- 新增 AI 透析分析 API + 药物提醒 API - MediaPicker/ThemeSwitcher/usePaginatedData 优化 - 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等) - PluginCRUDPage 导入优化
This commit is contained in:
23
apps/web/src/api/ai/dialysis.ts
Normal file
23
apps/web/src/api/ai/dialysis.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import client from '../client';
|
||||||
|
|
||||||
|
export interface DialysisRiskRequest {
|
||||||
|
patient_id: string;
|
||||||
|
dialysis_session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialysisRiskAssessment {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
risk_level: string;
|
||||||
|
risk_factors: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
kdigo_stage?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dialysisRiskApi = {
|
||||||
|
assess: async (data: DialysisRiskRequest) => {
|
||||||
|
const resp = await client.post('/ai/dialysis/risk-assessment', data);
|
||||||
|
return resp.data.data as DialysisRiskAssessment;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -27,6 +27,10 @@ export const suggestionApi = {
|
|||||||
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
|
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
|
||||||
return resp.data.data as { id: string; status: string };
|
return resp.data.data as { id: string; status: string };
|
||||||
},
|
},
|
||||||
|
execute: async (id: string) => {
|
||||||
|
const resp = await client.post(`/ai/suggestions/${id}/execute`);
|
||||||
|
return resp.data.data as { id: string; status: string };
|
||||||
|
},
|
||||||
getComparison: async (id: string) => {
|
getComparison: async (id: string) => {
|
||||||
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
|
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
|
||||||
return resp.data.data as ComparisonReport;
|
return resp.data.data as ComparisonReport;
|
||||||
|
|||||||
@@ -9,6 +9,42 @@ export interface TypeDistribution {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider_type: string;
|
||||||
|
is_active: boolean;
|
||||||
|
model_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderHealth {
|
||||||
|
provider_id: string;
|
||||||
|
status: string;
|
||||||
|
latency_ms?: number;
|
||||||
|
last_checked_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaSummary {
|
||||||
|
provider_id: string;
|
||||||
|
quota_limit: number;
|
||||||
|
quota_used: number;
|
||||||
|
quota_remaining: number;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetStatus {
|
||||||
|
total_budget: number;
|
||||||
|
spent: number;
|
||||||
|
remaining: number;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
analysis_type: string;
|
||||||
|
estimated_cost: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const usageApi = {
|
export const usageApi = {
|
||||||
overview: async () => {
|
overview: async () => {
|
||||||
const resp = await client.get('/ai/usage/overview');
|
const resp = await client.get('/ai/usage/overview');
|
||||||
@@ -18,4 +54,24 @@ export const usageApi = {
|
|||||||
const resp = await client.get('/ai/usage/by-type');
|
const resp = await client.get('/ai/usage/by-type');
|
||||||
return resp.data.data as TypeDistribution[];
|
return resp.data.data as TypeDistribution[];
|
||||||
},
|
},
|
||||||
|
listProviders: async () => {
|
||||||
|
const resp = await client.get('/ai/providers');
|
||||||
|
return resp.data.data as ProviderInfo[];
|
||||||
|
},
|
||||||
|
getProvidersHealth: async () => {
|
||||||
|
const resp = await client.get('/ai/providers/health');
|
||||||
|
return resp.data.data as ProviderHealth[];
|
||||||
|
},
|
||||||
|
getQuotaSummary: async () => {
|
||||||
|
const resp = await client.get('/ai/quota/summary');
|
||||||
|
return resp.data.data as QuotaSummary[];
|
||||||
|
},
|
||||||
|
getBudgetStatus: async () => {
|
||||||
|
const resp = await client.get('/ai/budget/status');
|
||||||
|
return resp.data.data as BudgetStatus;
|
||||||
|
},
|
||||||
|
getCostEstimate: async (params: { analysis_type: string }) => {
|
||||||
|
const resp = await client.get('/ai/cost/estimate', { params });
|
||||||
|
return resp.data.data as CostEstimate;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,3 +86,32 @@ export const alertRuleApi = {
|
|||||||
deactivate: (id: string, version: number) =>
|
deactivate: (id: string, version: number) =>
|
||||||
client.put(`/health/alert-rules/${id}/deactivate`, { version }).then((r) => r.data.data as AlertRule),
|
client.put(`/health/alert-rules/${id}/deactivate`, { version }).then((r) => r.data.data as AlertRule),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Critical Alerts API ---
|
||||||
|
|
||||||
|
export interface CriticalAlert {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
patient_name?: string;
|
||||||
|
alert_type: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
acknowledged_by?: string;
|
||||||
|
acknowledged_at?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const criticalAlertApi = {
|
||||||
|
list: (params?: { page?: number; page_size?: number }) =>
|
||||||
|
client.get('/health/critical-alerts', { params }).then((r) => r.data.data as PaginatedResponse<CriticalAlert>),
|
||||||
|
|
||||||
|
get: (id: string) =>
|
||||||
|
client.get(`/health/critical-alerts/${id}`).then((r) => r.data.data as CriticalAlert),
|
||||||
|
|
||||||
|
acknowledge: (id: string, req: { notes?: string }) =>
|
||||||
|
client.post(`/health/critical-alerts/${id}/acknowledge`, req).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|||||||
@@ -149,11 +149,11 @@ export const articleApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string) => {
|
delete: async (id: string, version: number) => {
|
||||||
const { data } = await client.delete<{
|
const { data } = await client.delete<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: null;
|
data: null;
|
||||||
}>(`/health/articles/${id}`);
|
}>(`/health/articles/${id}`, { data: { version } });
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -196,6 +196,14 @@ export const articleApi = {
|
|||||||
}>(`/health/articles/${id}/view`);
|
}>(`/health/articles/${id}/view`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listRevisions: async (id: string, params?: { page?: number; page_size?: number }) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<Record<string, unknown>>;
|
||||||
|
}>(`/health/articles/${id}/revisions`, { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Category API ---
|
// --- Category API ---
|
||||||
|
|||||||
@@ -102,4 +102,36 @@ export const consultationApi = {
|
|||||||
}>('/health/consultation-messages', req);
|
}>('/health/consultation-messages', req);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pollMessages: async (
|
||||||
|
sessionId: string,
|
||||||
|
afterId?: string,
|
||||||
|
) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: Message[];
|
||||||
|
}>(`/health/consultation-sessions/${sessionId}/messages/poll`, {
|
||||||
|
params: { after_id: afterId, timeout: 25 },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markSessionRead: async (id: string) => {
|
||||||
|
await client.put(`/health/consultation-sessions/${id}/read`);
|
||||||
|
},
|
||||||
|
|
||||||
|
exportSessions: async (params?: {
|
||||||
|
status?: string;
|
||||||
|
patient_id?: string;
|
||||||
|
doctor_id?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<Session>;
|
||||||
|
}>('/health/consultation-sessions/export', { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,4 +105,12 @@ export const dialysisApi = {
|
|||||||
}>(`/health/dialysis-records/${id}/review`, req);
|
}>(`/health/dialysis-records/${id}/review`, req);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
completeRecord: async (id: string, version: number) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: DialysisRecord;
|
||||||
|
}>(`/health/dialysis-records/${id}/complete`, { version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const doctorApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string) => {
|
delete: async (id: string, version: number) => {
|
||||||
await client.delete(`/health/doctors/${id}`);
|
await client.delete(`/health/doctors/${id}`, { data: { version } });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const familyProxyApi = {
|
|||||||
const { data } = await client.post<{
|
const { data } = await client.post<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: FamilyMember;
|
data: FamilyMember;
|
||||||
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access?version=${version}`, req);
|
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/grant-access`, { ...req, version });
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export const familyProxyApi = {
|
|||||||
const { data } = await client.put<{
|
const { data } = await client.put<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: FamilyMember;
|
data: FamilyMember;
|
||||||
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access?version=${version}`);
|
}>(`/health/patients/${patientId}/family-members/${familyMemberId}/revoke-access`, { version });
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export const familyProxyApi = {
|
|||||||
const { data } = await client.get<{
|
const { data } = await client.get<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: FamilyPatientSummary[];
|
data: FamilyPatientSummary[];
|
||||||
}>('/health/family/my-patients');
|
}>('/health/family/patients');
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ export const healthDataApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
generateTrend: async (patientId: string, req: { indicator: string; start_date?: string; end_date?: string }) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: TrendData;
|
||||||
|
}>(`/health/patients/${patientId}/trends/generate`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
|
getIndicatorTimeseries: async (patientId: string, indicator: string) => {
|
||||||
const { data } = await client.get<{
|
const { data } = await client.get<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
75
apps/web/src/api/health/medicationReminders.ts
Normal file
75
apps/web/src/api/health/medicationReminders.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import client from '../client';
|
||||||
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface MedicationReminder {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage?: string;
|
||||||
|
frequency: string;
|
||||||
|
time_slots: string[];
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMedicationReminderReq {
|
||||||
|
patient_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage?: string;
|
||||||
|
frequency?: string;
|
||||||
|
time_slots?: string[];
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMedicationReminderReq {
|
||||||
|
medication_name?: string;
|
||||||
|
dosage?: string;
|
||||||
|
frequency?: string;
|
||||||
|
time_slots?: string[];
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
|
||||||
|
export const medicationReminderApi = {
|
||||||
|
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<MedicationReminder>;
|
||||||
|
}>(`/health/patients/${patientId}/medication-reminders`, { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req: CreateMedicationReminderReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: MedicationReminder;
|
||||||
|
}>('/health/medication-reminders', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, req: UpdateMedicationReminderReq & { version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: MedicationReminder;
|
||||||
|
}>(`/health/medication-reminders/${id}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string, version: number) => {
|
||||||
|
await client.delete(`/health/medication-reminders/${id}`, { data: { version } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -294,7 +294,7 @@ export const pointsApi = {
|
|||||||
const { data } = await client.put<{
|
const { data } = await client.put<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: PointsRule;
|
data: PointsRule;
|
||||||
}>(`/health/admin/points/rules/${id}`, { data: req, version: req.version });
|
}>(`/health/admin/points/rules/${id}`, req);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ export const pointsApi = {
|
|||||||
const { data } = await client.put<{
|
const { data } = await client.put<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: PointsProduct;
|
data: PointsProduct;
|
||||||
}>(`/health/admin/points/products/${id}`, { data: req, version: req.version });
|
}>(`/health/admin/points/products/${id}`, req);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={720}
|
width={720}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function ThemeSwitcher() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown dropdownRender={() => content} trigger={['click']} placement="bottomRight">
|
<Dropdown popupRender={() => content} trigger={['click']} placement="bottomRight">
|
||||||
<div className="erp-header-btn" title="切换主题">
|
<div className="erp-header-btn" title="切换主题">
|
||||||
<BgColorsOutlined style={{ fontSize: 16 }} />
|
<BgColorsOutlined style={{ fontSize: 16 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,9 +59,12 @@ export function usePaginatedData<T, F = string>(
|
|||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
|
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (p?: number) => {
|
async (p?: number) => {
|
||||||
const targetPage = p ?? state.page;
|
const targetPage = p ?? stateRef.current.page;
|
||||||
setState((s) => ({ ...s, loading: true }));
|
setState((s) => ({ ...s, loading: true }));
|
||||||
try {
|
try {
|
||||||
const result = await (fetchFnRef.current as any)(
|
const result = await (fetchFnRef.current as any)(
|
||||||
@@ -75,7 +78,7 @@ export function usePaginatedData<T, F = string>(
|
|||||||
setState((s) => ({ ...s, loading: false }));
|
setState((s) => ({ ...s, loading: false }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pageSize, state.page],
|
[pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function ImportModal({ open, pluginId, entityName, onClose, onSuc
|
|||||||
footer={importResult ? (
|
footer={importResult ? (
|
||||||
<Button onClick={handleClose}>关闭</Button>
|
<Button onClick={handleClose}>关闭</Button>
|
||||||
) : null}
|
) : null}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{importResult ? (
|
{importResult ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export default function PluginCRUDPageInner({
|
|||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
|
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function AiPromptList() {
|
|||||||
onCancel={() => setModalOpen(false)}
|
onCancel={() => setModalOpen(false)}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
width={600}
|
width={600}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ export default function ArticleManageList() {
|
|||||||
|
|
||||||
// ---- 操作 ----
|
// ---- 操作 ----
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string, version: number) => {
|
||||||
try {
|
try {
|
||||||
await articleApi.delete(id);
|
await articleApi.delete(id, version);
|
||||||
message.success('文章已删除');
|
message.success('文章已删除');
|
||||||
refresh();
|
refresh();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -231,7 +231,7 @@ export default function ArticleManageList() {
|
|||||||
<AuthButton code="health.articles.manage">
|
<AuthButton code="health.articles.manage">
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除此文章?"
|
title="确定删除此文章?"
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id, record.version)}
|
||||||
>
|
>
|
||||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export default function BannerManage() {
|
|||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
okText={editingRecord ? '保存' : '创建'}
|
okText={editingRecord ? '保存' : '创建'}
|
||||||
width={600}
|
width={600}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { AuthButton } from "../../components/AuthButton";
|
|||||||
import { EntityName } from "../../components/EntityName";
|
import { EntityName } from "../../components/EntityName";
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
const POLL_INTERVAL = 10_000;
|
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
return new Date(value).toLocaleString("zh-CN", {
|
return new Date(value).toLocaleString("zh-CN", {
|
||||||
@@ -64,7 +63,7 @@ export default function ConsultationDetail() {
|
|||||||
|
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
const shouldScrollRef = useRef(true);
|
const shouldScrollRef = useRef(true);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
@@ -114,39 +113,32 @@ export default function ConsultationDetail() {
|
|||||||
fetchMessages(1, false);
|
fetchMessages(1, false);
|
||||||
}, [fetchSession, fetchMessages]);
|
}, [fetchSession, fetchMessages]);
|
||||||
|
|
||||||
// Poll new messages while session is active
|
// Long-poll new messages while session is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || session.status === "closed") return;
|
if (!session || session.status === "closed") return;
|
||||||
|
|
||||||
const stopPolling = () => {
|
let cancelled = false;
|
||||||
if (pollRef.current) {
|
|
||||||
clearInterval(pollRef.current);
|
const longPoll = async () => {
|
||||||
pollRef.current = null;
|
while (!cancelled) {
|
||||||
|
try {
|
||||||
|
const realMsgs = messages.filter((m) => !m.id.startsWith("temp_"));
|
||||||
|
const lastId =
|
||||||
|
realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
|
||||||
|
const newMsgs = await consultationApi.pollMessages(sessionId, lastId);
|
||||||
|
if (!cancelled && newMsgs.length > 0) {
|
||||||
|
setMessages((prev) => [...prev, ...newMsgs]);
|
||||||
|
shouldScrollRef.current = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// timeout or network error, retry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
stopPolling();
|
longPoll();
|
||||||
pollRef.current = setInterval(async () => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
try {
|
|
||||||
const realMsgs = messages.filter((m) => !m.id.startsWith("temp_"));
|
|
||||||
const lastId =
|
|
||||||
realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined;
|
|
||||||
const result = await consultationApi.listMessages(sessionId, {
|
|
||||||
page: 1,
|
|
||||||
page_size: 50,
|
|
||||||
after_id: lastId,
|
|
||||||
});
|
|
||||||
if (result.data.length > 0) {
|
|
||||||
setMessages((prev) => [...prev, ...result.data]);
|
|
||||||
shouldScrollRef.current = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}, POLL_INTERVAL);
|
|
||||||
|
|
||||||
return stopPolling;
|
return () => { cancelled = true; };
|
||||||
}, [session?.status, sessionId, messages.length]);
|
}, [session?.status, sessionId, messages.length]);
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
// Auto-scroll to bottom on new messages
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ export default function ConsultationList() {
|
|||||||
confirmLoading={createLoading}
|
confirmLoading={createLoading}
|
||||||
okText="创建"
|
okText="创建"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" autoComplete="off">
|
<Form form={createForm} layout="vertical" autoComplete="off">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export default function DialysisManageList() {
|
|||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={640}
|
width={640}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export default function DoctorList() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={560}
|
width={560}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ export default function DoctorSchedule() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={520}
|
width={520}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ export default function FollowUpTaskList() {
|
|||||||
confirmLoading={createLoading}
|
confirmLoading={createLoading}
|
||||||
okText="创建"
|
okText="创建"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" autoComplete="off">
|
<Form form={createForm} layout="vertical" autoComplete="off">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -501,7 +501,7 @@ export default function FollowUpTaskList() {
|
|||||||
confirmLoading={assignLoading}
|
confirmLoading={assignLoading}
|
||||||
okText="确认"
|
okText="确认"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={assignForm} layout="vertical" autoComplete="off">
|
<Form form={assignForm} layout="vertical" autoComplete="off">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function FollowUpTemplateList() {
|
|||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
onCancel={() => setModalOpen(false)}
|
onCancel={() => setModalOpen(false)}
|
||||||
width={720}
|
width={720}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
|
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export default function MediaLibrary() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 上传弹窗 */}
|
{/* 上传弹窗 */}
|
||||||
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnClose>
|
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnHidden>
|
||||||
<Form form={uploadForm} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={uploadForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="folder_id" label="目标文件夹">
|
<Form.Item name="folder_id" label="目标文件夹">
|
||||||
<TreeSelect allowClear placeholder="根目录" treeDefaultExpandAll fieldNames={{ label: 'name', value: 'id', children: 'children' }} treeData={buildTree(folders)} style={{ width: '100%' }} />
|
<TreeSelect allowClear placeholder="根目录" treeDefaultExpandAll fieldNames={{ label: 'name', value: 'id', children: 'children' }} treeData={buildTree(folders)} style={{ width: '100%' }} />
|
||||||
@@ -279,7 +279,7 @@ export default function MediaLibrary() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 编辑弹窗 */}
|
{/* 编辑弹窗 */}
|
||||||
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnClose>
|
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnHidden>
|
||||||
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="filename" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}><Input placeholder="文件名" /></Form.Item>
|
<Form.Item name="filename" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}><Input placeholder="文件名" /></Form.Item>
|
||||||
<Form.Item name="alt_text" label="替代文本"><Input.TextArea rows={2} placeholder="描述图片内容(用于无障碍访问)" /></Form.Item>
|
<Form.Item name="alt_text" label="替代文本"><Input.TextArea rows={2} placeholder="描述图片内容(用于无障碍访问)" /></Form.Item>
|
||||||
@@ -288,7 +288,7 @@ export default function MediaLibrary() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 移动弹窗 */}
|
{/* 移动弹窗 */}
|
||||||
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnClose>
|
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnHidden>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Typography.Paragraph type="secondary">选择目标文件夹:</Typography.Paragraph>
|
<Typography.Paragraph type="secondary">选择目标文件夹:</Typography.Paragraph>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@@ -299,7 +299,7 @@ export default function MediaLibrary() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 文件夹创建/重命名弹窗 */}
|
{/* 文件夹创建/重命名弹窗 */}
|
||||||
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnClose>
|
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnHidden>
|
||||||
<Form form={folderForm} onFinish={handleFolderSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={folderForm} onFinish={handleFolderSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
|
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export default function OfflineEventList() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={620}
|
width={620}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export default function PointsOrderList() {
|
|||||||
}}
|
}}
|
||||||
onOk={() => verifyForm.submit()}
|
onOk={() => verifyForm.submit()}
|
||||||
confirmLoading={verifying}
|
confirmLoading={verifying}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={440}
|
width={440}
|
||||||
>
|
>
|
||||||
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
|
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export default function PointsRuleList() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={560}
|
width={560}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function DailyMonitoringTab({ patientId }: Props) {
|
|||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={560}
|
width={560}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function HealthRecordsTab({ patientId }: Props) {
|
|||||||
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={520}
|
width={520}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState, useMemo } from 'react';
|
import { useCallback, useState, useMemo } from 'react';
|
||||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space, Card } from 'antd';
|
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space, Card } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import { dayjs } from '../../../utils/dayjs';
|
import { dayjs } from '../../../utils/dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
@@ -30,6 +30,8 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
const [reviewSubmitting, setReviewSubmitting] = useState(false);
|
const [reviewSubmitting, setReviewSubmitting] = useState(false);
|
||||||
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
|
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
|
||||||
const [analysisContent, setAnalysisContent] = useState('');
|
const [analysisContent, setAnalysisContent] = useState('');
|
||||||
|
const [summaryReportId, setSummaryReportId] = useState<string | null>(null);
|
||||||
|
const [summaryContent, setSummaryContent] = useState('');
|
||||||
|
|
||||||
const handleAiAnalysis = async (reportId: string) => {
|
const handleAiAnalysis = async (reportId: string) => {
|
||||||
setAnalyzingReportId(reportId);
|
setAnalyzingReportId(reportId);
|
||||||
@@ -44,6 +46,19 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReportSummary = async (reportId: string) => {
|
||||||
|
setSummaryReportId(reportId);
|
||||||
|
setSummaryContent('');
|
||||||
|
await startAnalysis('report-summary', { report_id: reportId }, {
|
||||||
|
onChunk: (content) => setSummaryContent(prev => prev + content),
|
||||||
|
onError: (msg) => { message.error(msg); setSummaryReportId(null); },
|
||||||
|
onDone: () => {
|
||||||
|
message.success('报告摘要生成完成');
|
||||||
|
setSummaryReportId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
async (page: number, pageSize: number) => {
|
async (page: number, pageSize: number) => {
|
||||||
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
|
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
|
||||||
@@ -163,6 +178,11 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
AI 解读
|
AI 解读
|
||||||
</Button>
|
</Button>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
|
<AuthButton code="ai.analysis.manage">
|
||||||
|
<Button type="link" size="small" icon={<FileTextOutlined />} loading={summaryReportId === record.id} onClick={(e) => { e.stopPropagation(); handleReportSummary(record.id); }}>
|
||||||
|
摘要
|
||||||
|
</Button>
|
||||||
|
</AuthButton>
|
||||||
{record.status === 'pending' && (
|
{record.status === 'pending' && (
|
||||||
<Button type="link" size="small" icon={<AuditOutlined />} onClick={() => openReviewModal(record)}>
|
<Button type="link" size="small" icon={<AuditOutlined />} onClick={() => openReviewModal(record)}>
|
||||||
审核
|
审核
|
||||||
@@ -207,13 +227,18 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
|
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{summaryContent && (
|
||||||
|
<Card title="报告摘要" style={{ marginTop: 16 }} size="small">
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{summaryContent}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
|
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={520}
|
width={520}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
@@ -235,7 +260,7 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
onCancel={() => { setReviewOpen(false); setReviewRecord(null); }}
|
onCancel={() => { setReviewOpen(false); setReviewRecord(null); }}
|
||||||
onOk={handleReview}
|
onOk={handleReview}
|
||||||
confirmLoading={reviewSubmitting}
|
confirmLoading={reviewSubmitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={480}
|
width={480}
|
||||||
>
|
>
|
||||||
{reviewRecord && (
|
{reviewRecord && (
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function VitalSignsTab({ patientId }: Props) {
|
|||||||
}}
|
}}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
confirmLoading={submitting}
|
confirmLoading={submitting}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
width={600}
|
width={600}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function ProcessDefinitions() {
|
|||||||
onCancel={() => setDesignerOpen(false)}
|
onCancel={() => setDesignerOpen(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={1200}
|
width={1200}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
|
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
|
||||||
<ProcessDesigner
|
<ProcessDesigner
|
||||||
|
|||||||
Reference in New Issue
Block a user