feat(web): Web 前端功能完善 — API 扩展 + 组件优化

- 新增 AI 透析分析 API + 药物提醒 API
- MediaPicker/ThemeSwitcher/usePaginatedData 优化
- 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等)
- PluginCRUDPage 导入优化
This commit is contained in:
iven
2026-05-13 23:28:22 +08:00
parent 616e0a1539
commit e4e5ef04d4
36 changed files with 332 additions and 69 deletions

View File

@@ -27,7 +27,7 @@ export default function ImportModal({ open, pluginId, entityName, onClose, onSuc
footer={importResult ? (
<Button onClick={handleClose}></Button>
) : null}
destroyOnClose
destroyOnHidden
>
{importResult ? (
<div>

View File

@@ -440,7 +440,7 @@ export default function PluginCRUDPageInner({
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditRecord(null); setFormValues({}); }}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={(_, allValues) => setFormValues(allValues)}>
{fields.map((field) => {

View File

@@ -230,7 +230,7 @@ export default function AiPromptList() {
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
width={600}
destroyOnClose
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item

View File

@@ -105,9 +105,9 @@ export default function ArticleManageList() {
// ---- 操作 ----
const handleDelete = async (id: string) => {
const handleDelete = async (id: string, version: number) => {
try {
await articleApi.delete(id);
await articleApi.delete(id, version);
message.success('文章已删除');
refresh();
} catch {
@@ -231,7 +231,7 @@ export default function ArticleManageList() {
<AuthButton code="health.articles.manage">
<Popconfirm
title="确定删除此文章?"
onConfirm={() => handleDelete(record.id)}
onConfirm={() => handleDelete(record.id, record.version)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>

View File

@@ -441,7 +441,7 @@ export default function BannerManage() {
confirmLoading={submitting}
okText={editingRecord ? '保存' : '创建'}
width={600}
destroyOnClose
destroyOnHidden
>
<Form
form={form}

View File

@@ -18,7 +18,6 @@ import { AuthButton } from "../../components/AuthButton";
import { EntityName } from "../../components/EntityName";
const PAGE_SIZE = 30;
const POLL_INTERVAL = 10_000;
function formatTime(value: string): string {
return new Date(value).toLocaleString("zh-CN", {
@@ -64,7 +63,7 @@ export default function ConsultationDetail() {
const chatEndRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(true);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isDark = useThemeMode();
@@ -114,39 +113,32 @@ export default function ConsultationDetail() {
fetchMessages(1, false);
}, [fetchSession, fetchMessages]);
// Poll new messages while session is active
// Long-poll new messages while session is active
useEffect(() => {
if (!session || session.status === "closed") return;
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
let cancelled = false;
const longPoll = async () => {
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();
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);
longPoll();
return stopPolling;
return () => { cancelled = true; };
}, [session?.status, sessionId, messages.length]);
// Auto-scroll to bottom on new messages

View File

@@ -334,7 +334,7 @@ export default function ConsultationList() {
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={createForm} layout="vertical" autoComplete="off">
<Form.Item

View File

@@ -297,7 +297,7 @@ export default function DialysisManageList() {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={640}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -373,7 +373,7 @@ export default function DoctorList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -366,7 +366,7 @@ export default function DoctorSchedule() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -403,7 +403,7 @@ export default function FollowUpTaskList() {
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={createForm} layout="vertical" autoComplete="off">
<Form.Item
@@ -501,7 +501,7 @@ export default function FollowUpTaskList() {
confirmLoading={assignLoading}
okText="确认"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={assignForm} layout="vertical" autoComplete="off">
<Form.Item

View File

@@ -253,7 +253,7 @@ export default function FollowUpTemplateList() {
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={720}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>

View File

@@ -264,7 +264,7 @@ export default function MediaLibrary() {
</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.Item name="folder_id" label="目标文件夹">
<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 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.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>
@@ -288,7 +288,7 @@ export default function MediaLibrary() {
</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 }}>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -299,7 +299,7 @@ export default function MediaLibrary() {
</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.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
</Form>

View File

@@ -345,7 +345,7 @@ export default function OfflineEventList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={620}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -264,7 +264,7 @@ export default function PointsOrderList() {
}}
onOk={() => verifyForm.submit()}
confirmLoading={verifying}
destroyOnClose
destroyOnHidden
width={440}
>
<Form form={verifyForm} layout="vertical" onFinish={handleVerify}>

View File

@@ -342,7 +342,7 @@ export default function PointsRuleList() {
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -199,7 +199,7 @@ export function DailyMonitoringTab({ patientId }: Props) {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -167,7 +167,7 @@ export function HealthRecordsTab({ patientId }: Props) {
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -1,6 +1,6 @@
import { useCallback, useState, useMemo } from 'react';
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 type { Dayjs } from 'dayjs';
import { healthDataApi } from '../../../api/health/healthData';
@@ -30,6 +30,8 @@ export function LabReportsTab({ patientId }: Props) {
const [reviewSubmitting, setReviewSubmitting] = useState(false);
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(null);
const [analysisContent, setAnalysisContent] = useState('');
const [summaryReportId, setSummaryReportId] = useState<string | null>(null);
const [summaryContent, setSummaryContent] = useState('');
const handleAiAnalysis = async (reportId: string) => {
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(
async (page: number, pageSize: number) => {
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
@@ -163,6 +178,11 @@ export function LabReportsTab({ patientId }: Props) {
AI
</Button>
</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' && (
<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>
</Card>
)}
{summaryContent && (
<Card title="报告摘要" style={{ marginTop: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{summaryContent}</div>
</Card>
)}
<Modal
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
@@ -235,7 +260,7 @@ export function LabReportsTab({ patientId }: Props) {
onCancel={() => { setReviewOpen(false); setReviewRecord(null); }}
onOk={handleReview}
confirmLoading={reviewSubmitting}
destroyOnClose
destroyOnHidden
width={480}
>
{reviewRecord && (

View File

@@ -289,7 +289,7 @@ export function VitalSignsTab({ patientId }: Props) {
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
destroyOnHidden
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>

View File

@@ -189,7 +189,7 @@ export default function ProcessDefinitions() {
onCancel={() => setDesignerOpen(false)}
footer={null}
width={1200}
destroyOnClose
destroyOnHidden
>
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
<ProcessDesigner