feat(web): 专家会诊管理页(#18)
This commit is contained in:
@@ -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<Consultation[]>('/consultations', { params });
|
||||||
|
export const getConsultation = (id: string) => get<Consultation>(`/consultations/${id}`);
|
||||||
|
export const createConsultation = (data: Partial<Consultation>) =>
|
||||||
|
post<Consultation>('/consultations', data);
|
||||||
|
export const updateConsultation = (id: string, data: Partial<Consultation>) =>
|
||||||
|
patch<Consultation>(`/consultations/${id}`, data);
|
||||||
|
export const resolveConsultation = (id: string, data: { opinion: string; plan: string }) =>
|
||||||
|
post<Consultation>(`/consultations/${id}/resolve`, data);
|
||||||
|
export const archiveConsultation = (id: string) => post<Consultation>(`/consultations/${id}/archive`);
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
ShoppingOutlined,
|
ShoppingOutlined,
|
||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
|
TeamOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -40,6 +41,7 @@ const menuData = [
|
|||||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||||
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
||||||
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
||||||
|
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 角色中文名
|
// 角色中文名
|
||||||
|
|||||||
@@ -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<string, { color: string; text: string }> = {
|
||||||
|
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<ActionType>();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<Partial<Consultation>>();
|
||||||
|
const [detail, setDetail] = useState<Consultation | null>(null);
|
||||||
|
const [resolveOpen, setResolveOpen] = useState(false);
|
||||||
|
const [resolveForm] = Form.useForm<{ opinion: string; plan: string }>();
|
||||||
|
|
||||||
|
const columns: ProColumns<Consultation>[] = [
|
||||||
|
{ 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) => <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
<a key="view" onClick={() => setDetail(r)}>
|
||||||
|
详情
|
||||||
|
</a>,
|
||||||
|
...(canWrite()
|
||||||
|
? [
|
||||||
|
r.status === 'pending' ? (
|
||||||
|
<a
|
||||||
|
key="take"
|
||||||
|
onClick={async () => {
|
||||||
|
await updateConsultation(r.id, { status: 'consulting' });
|
||||||
|
message.success('已受理');
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
受理
|
||||||
|
</a>
|
||||||
|
) : null,
|
||||||
|
r.status === 'pending' || r.status === 'consulting' ? (
|
||||||
|
<a
|
||||||
|
key="resolve"
|
||||||
|
onClick={() => {
|
||||||
|
setDetail(r);
|
||||||
|
resolveForm.resetFields();
|
||||||
|
setResolveOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
出方案
|
||||||
|
</a>
|
||||||
|
) : null,
|
||||||
|
r.status === 'resolved' ? (
|
||||||
|
<Popconfirm
|
||||||
|
key="archive"
|
||||||
|
title="确认归档该会诊?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await archiveConsultation(r.id);
|
||||||
|
message.success('已归档');
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a>归档</a>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<Consultation>
|
||||||
|
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()
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="new"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发起会诊
|
||||||
|
</Button>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="发起会诊"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await createForm.validateFields();
|
||||||
|
await createConsultation(v);
|
||||||
|
message.success('会诊单已创建(自动打包病例快照)');
|
||||||
|
setCreateOpen(false);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item label="标题" name="title">
|
||||||
|
<Input placeholder="留空则自动生成" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="关联 LAMP 任务 ID" name="lampTestId">
|
||||||
|
<Input placeholder="可填写 LAMP 任务单 ID(自动带病例快照)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="病情摘要" name="summary">
|
||||||
|
<Input.TextArea rows={3} placeholder="如:交叉验证不一致,AI 检出异常但 LAMP 阴性" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer title={detail?.title || '会诊详情'} open={!!detail} width={620} onClose={() => setDetail(null)}>
|
||||||
|
{detail ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
蚕房:{detail.roomName || '-'} · 状态:
|
||||||
|
<Tag color={STATUS_LABELS[detail.status]?.color}>{STATUS_LABELS[detail.status]?.text || detail.status}</Tag>
|
||||||
|
<br />
|
||||||
|
创建:{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm') : '-'}
|
||||||
|
{detail.summary ? (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
摘要:{detail.summary}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{detail.snapshot ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5}>病例快照</Typography.Title>
|
||||||
|
{detail.snapshot.inspection ? (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<b>巡检记录</b>
|
||||||
|
{detail.snapshot.inspection.imageUrl ? (
|
||||||
|
<Image src={detail.snapshot.inspection.imageUrl} width="100%" style={{ borderRadius: 8, margin: '8px 0' }} />
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
风险分:
|
||||||
|
{detail.snapshot.inspection.riskScore !== undefined
|
||||||
|
? Math.round(detail.snapshot.inspection.riskScore)
|
||||||
|
: '-'}{' '}
|
||||||
|
等级:
|
||||||
|
{detail.snapshot.inspection.riskLevel || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.snapshot.lampTest ? (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<b>LAMP 检测</b>
|
||||||
|
<div>
|
||||||
|
病种:{(detail.snapshot.lampTest.diseases || []).join('、') || '-'} · 结果:
|
||||||
|
{detail.snapshot.lampTest.result || '-'} · 交叉验证:
|
||||||
|
{detail.snapshot.lampTest.crossStatus || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.snapshot.batch ? (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<b>批次</b>
|
||||||
|
<div>
|
||||||
|
{detail.snapshot.batch.name} · {detail.snapshot.batch.variety || ''} ·{' '}
|
||||||
|
{detail.snapshot.batch.instar ? `${detail.snapshot.batch.instar}龄` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.snapshot.weatherAlerts && detail.snapshot.weatherAlerts.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<b>天气预警</b>
|
||||||
|
{detail.snapshot.weatherAlerts.map((w: any) => (
|
||||||
|
<div key={w.id}>
|
||||||
|
{w.disease}({w.level}):{w.reason}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail.opinion || detail.plan ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5}>会诊结论</Typography.Title>
|
||||||
|
{detail.opinion ? <Typography.Paragraph>意见:{detail.opinion}</Typography.Paragraph> : null}
|
||||||
|
{detail.plan ? (
|
||||||
|
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
防控方案:{detail.plan}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="出具会诊意见与防控方案"
|
||||||
|
open={resolveOpen}
|
||||||
|
onCancel={() => 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));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={resolveForm} layout="vertical">
|
||||||
|
<Form.Item label="会诊意见" name="opinion">
|
||||||
|
<Input.TextArea rows={3} placeholder="诊断分析与依据" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="防控方案" name="plan" rules={[{ required: true, message: '请输入防控方案' }]}>
|
||||||
|
<Input.TextArea rows={4} placeholder="隔离/消毒/用药等具体措施" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import Batches from './pages/Batches';
|
|||||||
import LampTests from './pages/LampTests';
|
import LampTests from './pages/LampTests';
|
||||||
import Consumables from './pages/Consumables';
|
import Consumables from './pages/Consumables';
|
||||||
import Inspections from './pages/Inspections';
|
import Inspections from './pages/Inspections';
|
||||||
|
import Consultations from './pages/Consultations';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import { BasicLayout } from './layout/BasicLayout';
|
import { BasicLayout } from './layout/BasicLayout';
|
||||||
import { authService } from './services/auth';
|
import { authService } from './services/auth';
|
||||||
@@ -56,6 +57,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
||||||
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
||||||
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
||||||
|
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
||||||
{ path: '404', element: <NotFound /> },
|
{ path: '404', element: <NotFound /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user