From 12a896406d5ea1195c2130bb050c4c561e0b8c95 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 19:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=B8=93=E5=AE=B6=E4=BC=9A?= =?UTF-8?q?=E8=AF=8A=E7=AE=A1=E7=90=86=E9=A1=B5=EF=BC=88#18=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/dal/consultation.ts | 30 ++++ web/src/layout/BasicLayout.tsx | 2 + web/src/pages/Consultations.tsx | 261 ++++++++++++++++++++++++++++++++ web/src/router.tsx | 2 + 4 files changed, 295 insertions(+) create mode 100644 web/src/dal/consultation.ts create mode 100644 web/src/pages/Consultations.tsx diff --git a/web/src/dal/consultation.ts b/web/src/dal/consultation.ts new file mode 100644 index 0000000..30c752d --- /dev/null +++ b/web/src/dal/consultation.ts @@ -0,0 +1,30 @@ +import { get, post, patch } from '../api/http'; + +export interface Consultation { + id: string; + roomId?: string; + roomName?: string; + batchId?: string; + lampTestId?: string; + title: string; + summary?: string; + snapshot?: any; + status: string; + expertId?: string; + opinion?: string; + plan?: string; + resolvedAt?: string; + archivedAt?: string; + createdAt?: string; +} + +export const listConsultations = (params?: any) => + get('/consultations', { params }); +export const getConsultation = (id: string) => get(`/consultations/${id}`); +export const createConsultation = (data: Partial) => + post('/consultations', data); +export const updateConsultation = (id: string, data: Partial) => + patch(`/consultations/${id}`, data); +export const resolveConsultation = (id: string, data: { opinion: string; plan: string }) => + post(`/consultations/${id}/resolve`, data); +export const archiveConsultation = (id: string) => post(`/consultations/${id}/archive`); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 77e3157..2657676 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -12,6 +12,7 @@ import { ExperimentOutlined, ShoppingOutlined, CameraOutlined, + TeamOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -40,6 +41,7 @@ const menuData = [ { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, { path: '/consumables', name: '耗材管理', icon: , permission: 'consumable:read' }, { path: '/inspections', name: '巡检记录', icon: , permission: 'inspection:read' }, + { path: '/consultations', name: '专家会诊', icon: , permission: 'consultation:read' }, ]; // 角色中文名 diff --git a/web/src/pages/Consultations.tsx b/web/src/pages/Consultations.tsx new file mode 100644 index 0000000..74352e0 --- /dev/null +++ b/web/src/pages/Consultations.tsx @@ -0,0 +1,261 @@ +import { useRef, useState } from 'react'; +import { Button, Drawer, Form, Image, Input, Modal, Popconfirm, Tag, Typography, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import { + listConsultations, + createConsultation, + updateConsultation, + resolveConsultation, + archiveConsultation, + type Consultation, +} from '../dal/consultation'; +import { authService } from '../services/auth'; + +const STATUS_LABELS: Record = { + pending: { color: 'gold', text: '待会诊' }, + consulting: { color: 'blue', text: '会诊中' }, + resolved: { color: 'green', text: '已出方案' }, + archived: { color: 'default', text: '已归档' }, +}; + +const canWrite = () => authService.hasPermission('consultation:write'); + +export default function ConsultationsPage() { + const actionRef = useRef(); + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm>(); + const [detail, setDetail] = useState(null); + const [resolveOpen, setResolveOpen] = useState(false); + const [resolveForm] = Form.useForm<{ opinion: string; plan: string }>(); + + const columns: ProColumns[] = [ + { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, + { title: '标题', dataIndex: 'title', ellipsis: true }, + { title: '蚕房', dataIndex: 'roomName', search: false, render: (_, r) => r.roomName || '-' }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + valueEnum: Object.fromEntries( + Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v.text }]), + ), + render: (_, r) => {STATUS_LABELS[r.status]?.text || r.status}, + }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + setDetail(r)}> + 详情 + , + ...(canWrite() + ? [ + r.status === 'pending' ? ( + { + await updateConsultation(r.id, { status: 'consulting' }); + message.success('已受理'); + actionRef.current?.reload(); + }} + > + 受理 + + ) : null, + r.status === 'pending' || r.status === 'consulting' ? ( + { + setDetail(r); + resolveForm.resetFields(); + setResolveOpen(true); + }} + > + 出方案 + + ) : null, + r.status === 'resolved' ? ( + { + await archiveConsultation(r.id); + message.success('已归档'); + actionRef.current?.reload(); + }} + > + 归档 + + ) : null, + ] + : []), + ], + }, + ]; + + return ( + <> + + actionRef={actionRef} + rowKey="id" + columns={columns} + search={{ labelWidth: 'auto' }} + request={async (params) => { + const res = await listConsultations({ status: params.status }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + + setCreateOpen(false)} + onOk={async () => { + const v = await createForm.validateFields(); + await createConsultation(v); + message.success('会诊单已创建(自动打包病例快照)'); + setCreateOpen(false); + actionRef.current?.reload(); + }} + > +
+ + + + + + + + + +
+
+ + setDetail(null)}> + {detail ? ( +
+ + 蚕房:{detail.roomName || '-'} · 状态: + {STATUS_LABELS[detail.status]?.text || detail.status} +
+ 创建:{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm') : '-'} + {detail.summary ? ( + <> +
+ 摘要:{detail.summary} + + ) : null} +
+ + {detail.snapshot ? ( + <> + 病例快照 + {detail.snapshot.inspection ? ( +
+ 巡检记录 + {detail.snapshot.inspection.imageUrl ? ( + + ) : null} +
+ 风险分: + {detail.snapshot.inspection.riskScore !== undefined + ? Math.round(detail.snapshot.inspection.riskScore) + : '-'}{' '} + 等级: + {detail.snapshot.inspection.riskLevel || '-'} +
+
+ ) : null} + {detail.snapshot.lampTest ? ( +
+ LAMP 检测 +
+ 病种:{(detail.snapshot.lampTest.diseases || []).join('、') || '-'} · 结果: + {detail.snapshot.lampTest.result || '-'} · 交叉验证: + {detail.snapshot.lampTest.crossStatus || '-'} +
+
+ ) : null} + {detail.snapshot.batch ? ( +
+ 批次 +
+ {detail.snapshot.batch.name} · {detail.snapshot.batch.variety || ''} ·{' '} + {detail.snapshot.batch.instar ? `${detail.snapshot.batch.instar}龄` : ''} +
+
+ ) : null} + {detail.snapshot.weatherAlerts && detail.snapshot.weatherAlerts.length > 0 ? ( +
+ 天气预警 + {detail.snapshot.weatherAlerts.map((w: any) => ( +
+ {w.disease}({w.level}):{w.reason} +
+ ))} +
+ ) : null} + + ) : null} + + {detail.opinion || detail.plan ? ( + <> + 会诊结论 + {detail.opinion ? 意见:{detail.opinion} : null} + {detail.plan ? ( + + 防控方案:{detail.plan} + + ) : null} + + ) : null} +
+ ) : null} +
+ + setResolveOpen(false)} + onOk={async () => { + if (!detail) return; + const v = await resolveForm.validateFields(); + await resolveConsultation(detail.id, v); + message.success('方案已下发'); + setResolveOpen(false); + actionRef.current?.reload(); + setDetail(await listConsultations({}).then((l) => l.find((x) => x.id === detail.id) || detail)); + }} + > +
+ + + + + + +
+
+ + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index b54aaf3..f1c7f09 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -15,6 +15,7 @@ import Batches from './pages/Batches'; import LampTests from './pages/LampTests'; import Consumables from './pages/Consumables'; import Inspections from './pages/Inspections'; +import Consultations from './pages/Consultations'; import NotFound from './pages/NotFound'; import { BasicLayout } from './layout/BasicLayout'; import { authService } from './services/auth'; @@ -56,6 +57,7 @@ export const router = createBrowserRouter([ { path: 'lamp-tests', element: }, { path: 'consumables', element: }, { path: 'inspections', element: }, + { path: 'consultations', element: }, { path: '404', element: }, ], },