chore: 同步本地 v9 整改与运营能力

This commit is contained in:
weijuesen
2026-08-17 21:43:26 +08:00
parent 9a426039b7
commit d205d4845b
58 changed files with 4042 additions and 115 deletions
+126
View File
@@ -0,0 +1,126 @@
import { get, post, patch, del } from '../api/http';
export interface DeviceMaintenanceRecord {
id: string;
deviceId: string;
kind: 'calibration' | 'fault' | 'maintenance' | 'firmware';
title: string;
scheduledAt?: string;
performedAt?: string;
performerId?: string;
result?: string;
firmwareFrom?: string;
firmwareTo?: string;
costAmount?: number;
costUnit?: string;
note?: string;
createdAt?: string;
}
export interface ProductionLossRecord {
id: string;
roomId?: string;
batchId?: string;
recordDate: string;
deathCount?: number;
culledCount?: number;
yieldKg?: number;
lossKg?: number;
costType?: 'medicine' | 'disinfection' | 'detection' | 'labor' | 'other';
costAmount?: number;
note?: string;
createdAt?: string;
}
export interface ProductionLossStat {
roomId: string;
roomName?: string;
batchId: string;
batchName?: string;
deathCount: number;
culledCount: number;
yieldKg: number;
lossKg: number;
costAmount: number;
recordCount: number;
}
export interface CaseStudy {
id: string;
title: string;
disease: string;
sourceConsultationId?: string;
sourceDiseaseEventId?: string;
sourceRoomId?: string;
region?: string;
caseDate?: string;
summary?: string;
desensitizedPayload?: Record<string, unknown>;
status: 'draft' | 'pending_review' | 'published' | 'rejected';
reviewNote?: string;
reviewedAt?: string;
publishedAt?: string;
createdAt?: string;
}
export interface LaboratoryResult {
id: string;
traceRecordId?: string;
diseaseEventId?: string;
sampleId?: string;
roomId?: string;
batchId?: string;
labName: string;
reportNo: string;
testType: 'molecular_typing' | 'pathogen' | 'environment_sample' | 'other';
resultType: 'positive' | 'negative' | 'indeterminate' | 'invalid';
pathogen?: string;
genotype?: string;
method?: string;
sampleNo?: string;
sampleType?: string;
findings?: string;
reportUrl?: string;
testedAt?: string;
concludedAt?: string;
createdAt?: string;
}
export const listDeviceMaintenance = (params?: any) =>
get<DeviceMaintenanceRecord[]>('/device-maintenance', { params });
export const createDeviceMaintenance = (data: Partial<DeviceMaintenanceRecord>) =>
post<DeviceMaintenanceRecord>('/device-maintenance', data);
export const updateDeviceMaintenance = (id: string, data: Partial<DeviceMaintenanceRecord>) =>
patch<DeviceMaintenanceRecord>(`/device-maintenance/${id}`, data);
export const deleteDeviceMaintenance = (id: string) =>
del<{ id: string }>(`/device-maintenance/${id}`);
export const listProductionLossRecords = (params?: any) =>
get<ProductionLossRecord[]>('/production-loss-records', { params });
export const getProductionLossStats = (params?: any) =>
get<ProductionLossStat[]>('/production-loss-records/stats', { params });
export const createProductionLossRecord = (data: Partial<ProductionLossRecord>) =>
post<ProductionLossRecord>('/production-loss-records', data);
export const updateProductionLossRecord = (id: string, data: Partial<ProductionLossRecord>) =>
patch<ProductionLossRecord>(`/production-loss-records/${id}`, data);
export const deleteProductionLossRecord = (id: string) =>
del<{ id: string }>(`/production-loss-records/${id}`);
export const listCaseStudies = (params?: any) =>
get<CaseStudy[]>('/case-studies', { params });
export const createCaseStudy = (data: Partial<CaseStudy>) =>
post<CaseStudy>('/case-studies', data);
export const createCaseStudyFromConsultation = (id: string) =>
post<CaseStudy>(`/case-studies/from-consultation/${id}`);
export const updateCaseStudy = (id: string, data: Partial<CaseStudy>) =>
patch<CaseStudy>(`/case-studies/${id}`, data);
export const reviewCaseStudy = (id: string, data: { status: string; note?: string }) =>
post<CaseStudy>(`/case-studies/${id}/review`, data);
export const listLaboratoryResults = (params?: any) =>
get<LaboratoryResult[]>('/laboratory-results', { params });
export const createLaboratoryResult = (data: Partial<LaboratoryResult>) =>
post<LaboratoryResult>('/laboratory-results', data);
export const updateLaboratoryResult = (id: string, data: Partial<LaboratoryResult>) =>
patch<LaboratoryResult>(`/laboratory-results/${id}`, data);
export const deleteLaboratoryResult = (id: string) => del<{ id: string }>(`/laboratory-results/${id}`);
+47
View File
@@ -0,0 +1,47 @@
import { get, post, patch, del } from '../api/http';
export interface Organization {
id: string;
name: string;
code: string;
description?: string;
status: string;
createdAt?: string;
updatedAt?: string;
}
export interface OrganizationMember {
id: string;
organizationId: string;
userId: string;
role: string;
createdAt?: string;
username: string;
email: string;
fullName?: string;
}
export interface UserOption {
id: string;
username: string;
email: string;
fullName?: string;
role: string;
active: boolean;
createdAt?: string;
}
export const listOrganizations = () => get<Organization[]>('/organizations');
export const createOrganization = (data: Partial<Organization>) =>
post<Organization>('/organizations', data);
export const updateOrganization = (id: string, data: Partial<Organization>) =>
patch<Organization>(`/organizations/${id}`, data);
export const listOrganizationMembers = (organizationId: string) =>
get<OrganizationMember[]>(`/organizations/${organizationId}/members`);
export const addOrganizationMember = (organizationId: string, data: { userId: string; role: string }) =>
post<OrganizationMember>(`/organizations/${organizationId}/members`, data);
export const removeOrganizationMember = (organizationId: string, userId: string) =>
del<{ organizationId: string; userId: string }>(`/organizations/${organizationId}/members/${userId}`);
export const listUsers = () => get<UserOption[]>('/users');
+3
View File
@@ -22,6 +22,7 @@ import {
ApiOutlined,
ControlOutlined as ControlOutlinedIcon,
KeyOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import { ProLayout } from '@ant-design/pro-components';
import { useState } from 'react';
@@ -38,6 +39,7 @@ const menuData = [
{ path: '/alerts', name: '告警中心', icon: <BellOutlined />, permission: 'alarm:read' },
{ path: '/videos', name: '视频监控', icon: <VideoCameraOutlined />, permission: 'video:read' },
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
{ path: '/organizations', name: '组织管理', icon: <TeamOutlined />, permission: 'organization:manage' },
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
@@ -47,6 +49,7 @@ const menuData = [
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
{ path: '/business', name: '运营能力', icon: <AppstoreOutlined />, permission: 'case:read' },
];
// 角色中文名
+543
View File
@@ -0,0 +1,543 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Tabs, Tag, 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 {
createCaseStudy,
createCaseStudyFromConsultation,
createDeviceMaintenance,
createLaboratoryResult,
createProductionLossRecord,
deleteDeviceMaintenance,
deleteLaboratoryResult,
deleteProductionLossRecord,
getProductionLossStats,
listCaseStudies,
listDeviceMaintenance,
listLaboratoryResults,
listProductionLossRecords,
reviewCaseStudy,
updateDeviceMaintenance,
updateCaseStudy,
updateLaboratoryResult,
updateProductionLossRecord,
type CaseStudy,
type DeviceMaintenanceRecord,
type LaboratoryResult,
type ProductionLossRecord,
type ProductionLossStat,
} from '../dal/business';
import { fetchRooms } from '../dal/dashboard';
import { listBatches, type Batch } from '../dal/trayBatch';
import { authService } from '../services/auth';
const canWrite = (permission: string) => authService.hasPermission(permission);
const MAINTENANCE_KINDS: Record<string, string> = {
calibration: '校准',
fault: '故障',
maintenance: '维护',
firmware: '固件',
};
const COST_TYPES: Record<string, string> = {
medicine: '用药',
disinfection: '消毒',
detection: '检测',
labor: '人工',
other: '其他',
};
const CASE_STATUS: Record<string, string> = {
draft: '草稿',
pending_review: '待审核',
published: '已发布',
rejected: '已驳回',
};
const TEST_TYPES: Record<string, string> = {
molecular_typing: '分子分型',
pathogen: '病原检测',
environment_sample: '环境样本',
other: '其他',
};
const RESULT_TYPES: Record<string, string> = {
positive: '阳性',
negative: '阴性',
indeterminate: '待复核',
invalid: '无效',
};
function ProductionStats() {
const [stats, setStats] = useState<ProductionLossStat[]>([]);
useEffect(() => {
getProductionLossStats().then(setStats).catch(() => setStats([]));
}, []);
if (stats.length === 0) return null;
return (
<Card title="产量损失统计" size="small" style={{ marginBottom: 12 }}>
<Space wrap size={[16, 8]}>
{stats.map((s) => (
<Tag key={`${s.roomId}-${s.batchId}`} color="blue">
{s.roomName || s.roomId || '-'}
{s.batchName ? ` / ${s.batchName}` : ''}
{s.deathCount} {s.culledCount} {s.yieldKg}kg {s.lossKg}kg {s.costAmount}
</Tag>
))}
</Space>
</Card>
);
}
export default function BusinessExtensionsPage() {
const maintenanceAction = useRef<ActionType>();
const productionAction = useRef<ActionType>();
const caseAction = useRef<ActionType>();
const labAction = useRef<ActionType>();
const [rooms, setRooms] = useState<{ id: string; name: string }[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [productionOpen, setProductionOpen] = useState(false);
const [caseOpen, setCaseOpen] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [labOpen, setLabOpen] = useState(false);
const [reviewing, setReviewing] = useState<CaseStudy | null>(null);
const [maintenanceEditing, setMaintenanceEditing] = useState<DeviceMaintenanceRecord | null>(null);
const [productionEditing, setProductionEditing] = useState<ProductionLossRecord | null>(null);
const [labEditing, setLabEditing] = useState<LaboratoryResult | null>(null);
const [maintenanceForm] = Form.useForm();
const [productionForm] = Form.useForm();
const [caseForm] = Form.useForm();
const [reviewForm] = Form.useForm();
const [labForm] = Form.useForm();
useEffect(() => {
Promise.all([
fetchRooms().catch(() => [] as { id: string; name: string }[]),
listBatches().catch(() => [] as Batch[]),
]).then(([roomRes, batchRes]) => {
setRooms(roomRes);
setBatches(batchRes);
});
}, []);
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
const batchName = (id?: string) => batches.find((b) => b.id === id)?.name || id || '-';
const maintenanceColumns: ProColumns<DeviceMaintenanceRecord>[] = [
{ title: '类型', dataIndex: 'kind', render: (_, r) => <Tag>{MAINTENANCE_KINDS[r.kind] || r.kind}</Tag> },
{ title: '标题', dataIndex: 'title' },
{ title: '设备 ID', dataIndex: 'deviceId', search: false },
{ title: '计划时间', dataIndex: 'scheduledAt', search: false, render: (_, r) => r.scheduledAt ? dayjs(r.scheduledAt).format('YYYY-MM-DD HH:mm') : '-' },
{ title: '执行时间', dataIndex: 'performedAt', search: false, render: (_, r) => r.performedAt ? dayjs(r.performedAt).format('YYYY-MM-DD HH:mm') : '-' },
{ title: '固件', dataIndex: 'firmwareTo', search: false, render: (_, r) => (r.firmwareFrom && r.firmwareTo ? `${r.firmwareFrom}${r.firmwareTo}` : r.firmwareTo || '-') },
{ title: '成本', dataIndex: 'costAmount', search: false, render: (_, r) => (r.costAmount ? `${r.costAmount}${r.costUnit || ''}` : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="edit" onClick={() => {
setMaintenanceEditing(r);
maintenanceForm.setFieldsValue({
...r,
scheduledAt: r.scheduledAt ? dayjs(r.scheduledAt) : undefined,
performedAt: r.performedAt ? dayjs(r.performedAt) : undefined,
});
setMaintenanceOpen(true);
}}></a>,
...(canWrite('device:write')
? [
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
await deleteDeviceMaintenance(r.id);
message.success('已删除');
maintenanceAction.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
const productionColumns: ProColumns<ProductionLossRecord>[] = [
{ title: '日期', dataIndex: 'recordDate', valueType: 'date', render: (_, r) => dayjs(r.recordDate).format('YYYY-MM-DD') },
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
{ title: '死亡', dataIndex: 'deathCount', search: false },
{ title: '淘汰', dataIndex: 'culledCount', search: false },
{ title: '产量 kg', dataIndex: 'yieldKg', search: false },
{ title: '损失 kg', dataIndex: 'lossKg', search: false },
{ title: '成本', dataIndex: 'costAmount', search: false, render: (_, r) => (r.costAmount ? `${r.costAmount}${COST_TYPES[r.costType || ''] || r.costType || '-'}` : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="edit" onClick={() => {
setProductionEditing(r);
productionForm.setFieldsValue({
...r,
recordDate: r.recordDate ? dayjs(r.recordDate) : undefined,
});
setProductionOpen(true);
}}></a>,
...(canWrite('farm:write')
? [
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
await deleteProductionLossRecord(r.id);
message.success('已删除');
productionAction.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
const caseColumns: ProColumns<CaseStudy>[] = [
{ title: '标题', dataIndex: 'title' },
{ title: '病种', dataIndex: 'disease' },
{ title: '区域', dataIndex: 'region', search: false },
{ title: '状态', dataIndex: 'status', render: (_, r) => <Tag>{CASE_STATUS[r.status] || r.status}</Tag> },
{ title: '审核备注', dataIndex: 'reviewNote', search: false },
{ title: '发布时间', dataIndex: 'publishedAt', search: false, render: (_, r) => r.publishedAt ? dayjs(r.publishedAt).format('YYYY-MM-DD HH:mm') : '-' },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="edit" onClick={() => {
setReviewing(r);
caseForm.setFieldsValue({
...r,
caseDate: r.caseDate ? dayjs(r.caseDate) : undefined,
desensitizedPayload: JSON.stringify(r.desensitizedPayload || {}, null, 2),
});
setCaseOpen(true);
}}></a>,
...(canWrite('case:write')
? [
<a key="review" onClick={() => {
setReviewing(r);
reviewForm.resetFields();
reviewForm.setFieldsValue({ status: r.status === 'draft' ? 'pending_review' : 'published' });
setReviewOpen(true);
}}></a>,
]
: []),
],
},
];
const labColumns: ProColumns<LaboratoryResult>[] = [
{ title: '实验室', dataIndex: 'labName' },
{ title: '报告编号', dataIndex: 'reportNo' },
{ title: '检测类型', dataIndex: 'testType', render: (_, r) => TEST_TYPES[r.testType] || r.testType },
{ title: '结果', dataIndex: 'resultType', render: (_, r) => <Tag color={r.resultType === 'positive' ? 'red' : 'default'}>{RESULT_TYPES[r.resultType] || r.resultType}</Tag> },
{ title: '病原', dataIndex: 'pathogen', search: false },
{ title: '分型', dataIndex: 'genotype', search: false },
{ title: '检测时间', dataIndex: 'testedAt', search: false, render: (_, r) => r.testedAt ? dayjs(r.testedAt).format('YYYY-MM-DD HH:mm') : '-' },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="edit" onClick={() => {
setLabEditing(r);
labForm.setFieldsValue({
...r,
testedAt: r.testedAt ? dayjs(r.testedAt) : undefined,
concludedAt: r.concludedAt ? dayjs(r.concludedAt) : undefined,
});
setLabOpen(true);
}}></a>,
...(canWrite('lab:write')
? [
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
await deleteLaboratoryResult(r.id);
message.success('已删除');
labAction.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
return (
<>
<Tabs
items={[
{
key: 'maintenance',
label: '设备维护',
children: (
<ProTable<DeviceMaintenanceRecord>
actionRef={maintenanceAction}
rowKey="id"
columns={maintenanceColumns}
search={false}
request={async () => {
const res = await listDeviceMaintenance();
return { data: res, total: res.length, success: true };
}}
toolBarRender={() => canWrite('device:write') ? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
setMaintenanceEditing(null);
maintenanceForm.resetFields();
setMaintenanceOpen(true);
}}></Button>,
] : []}
/>
),
},
{
key: 'production',
label: '产量损失',
children: (
<>
<ProductionStats />
<ProTable<ProductionLossRecord>
actionRef={productionAction}
rowKey="id"
columns={productionColumns}
search={false}
request={async () => {
const res = await listProductionLossRecords();
return { data: res, total: res.length, success: true };
}}
toolBarRender={() => canWrite('farm:write') ? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
setProductionEditing(null);
productionForm.resetFields();
productionForm.setFieldsValue({ recordDate: dayjs() });
setProductionOpen(true);
}}></Button>,
] : []}
/>
</>
),
},
{
key: 'cases',
label: '案例库',
children: (
<ProTable<CaseStudy>
actionRef={caseAction}
rowKey="id"
columns={caseColumns}
search={false}
request={async () => {
const res = await listCaseStudies();
return { data: res, total: res.length, success: true };
}}
toolBarRender={() => canWrite('case:write') ? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
setReviewing(null);
caseForm.resetFields();
caseForm.setFieldsValue({ desensitizedPayload: '{}' });
setCaseOpen(true);
}}></Button>,
<Button key="from" onClick={async () => {
const id = window.prompt('请输入会诊单 ID');
if (!id) return;
await createCaseStudyFromConsultation(id);
message.success('已生成案例草稿');
caseAction.current?.reload();
}}></Button>,
] : []}
/>
),
},
{
key: 'lab',
label: '实验室结果',
children: (
<ProTable<LaboratoryResult>
actionRef={labAction}
rowKey="id"
columns={labColumns}
search={false}
request={async () => {
const res = await listLaboratoryResults();
return { data: res, total: res.length, success: true };
}}
toolBarRender={() => canWrite('lab:write') ? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => {
setLabEditing(null);
labForm.resetFields();
setLabOpen(true);
}}></Button>,
] : []}
/>
),
},
]}
/>
<Modal
title={maintenanceEditing ? '编辑设备维护' : '新增设备维护'}
open={maintenanceOpen}
onCancel={() => setMaintenanceOpen(false)}
onOk={async () => {
const v = await maintenanceForm.validateFields();
if (maintenanceEditing) {
await updateDeviceMaintenance(maintenanceEditing.id, v);
message.success('维护记录已更新');
} else {
await createDeviceMaintenance(v);
message.success('维护记录已创建');
}
setMaintenanceOpen(false);
maintenanceAction.current?.reload();
}}
>
<Form form={maintenanceForm} layout="vertical">
<Form.Item label="设备 ID" name="deviceId" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="类型" name="kind" rules={[{ required: true }]}>
<Select options={Object.entries(MAINTENANCE_KINDS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="标题" name="title" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="计划时间" name="scheduledAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Form.Item label="执行时间" name="performedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Form.Item label="结果" name="result"><Input.TextArea rows={2} /></Form.Item>
<Space.Compact block>
<Form.Item label="固件版本" name="firmwareFrom" style={{ width: '50%' }}><Input placeholder="升级前" /></Form.Item>
<Form.Item label="目标版本" name="firmwareTo" style={{ width: '50%' }}><Input placeholder="升级后" /></Form.Item>
</Space.Compact>
<Form.Item label="成本" name="costAmount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="成本单位" name="costUnit"><Input /></Form.Item>
<Form.Item label="备注" name="note"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<Modal
title={productionEditing ? '编辑产量损失记录' : '新增产量损失记录'}
open={productionOpen}
onCancel={() => setProductionOpen(false)}
onOk={async () => {
const v = await productionForm.validateFields();
const body = { ...v, recordDate: v.recordDate?.toISOString() };
if (productionEditing) {
await updateProductionLossRecord(productionEditing.id, body);
message.success('记录已更新');
} else {
await createProductionLossRecord(body);
message.success('记录已创建');
}
setProductionOpen(false);
productionAction.current?.reload();
}}
>
<Form form={productionForm} layout="vertical">
<Form.Item label="蚕房" name="roomId"><Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} /></Form.Item>
<Form.Item label="批次" name="batchId"><Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} /></Form.Item>
<Form.Item label="记录日期" name="recordDate" rules={[{ required: true }]}><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item label="死亡数" name="deathCount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="淘汰数" name="culledCount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="产量 kg" name="yieldKg"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="损失 kg" name="lossKg"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="成本类型" name="costType"><Select allowClear options={Object.entries(COST_TYPES).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item label="成本金额" name="costAmount"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item label="备注" name="note"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<Modal
title={reviewing ? '编辑案例' : '新增案例'}
open={caseOpen}
onCancel={() => setCaseOpen(false)}
onOk={async () => {
const v = await caseForm.validateFields();
const payload = typeof v.desensitizedPayload === 'string' ? JSON.parse(v.desensitizedPayload || '{}') : v.desensitizedPayload;
const body = {
...v,
caseDate: v.caseDate?.toISOString(),
desensitizedPayload: payload,
};
if (reviewing) await updateCaseStudy(reviewing.id, body);
else await createCaseStudy(body);
message.success('案例已保存');
setCaseOpen(false);
caseAction.current?.reload();
}}
>
<Form form={caseForm} layout="vertical">
<Form.Item label="标题" name="title" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="病种" name="disease" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="区域" name="region"><Input /></Form.Item>
<Form.Item label="案例日期" name="caseDate"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Form.Item label="摘要" name="summary"><Input.TextArea rows={2} /></Form.Item>
<Form.Item label="脱敏载荷 JSON" name="desensitizedPayload" rules={[{ required: true }]}><Input.TextArea rows={6} /></Form.Item>
<Form.Item label="来源会诊 ID" name="sourceConsultationId"><Input /></Form.Item>
</Form>
</Modal>
<Modal
title="审核案例"
open={reviewOpen}
onCancel={() => setReviewOpen(false)}
onOk={async () => {
const v = await reviewForm.validateFields();
if (!reviewing) return;
await reviewCaseStudy(reviewing.id, v);
message.success('审核完成');
setReviewOpen(false);
caseAction.current?.reload();
}}
>
<Form form={reviewForm} layout="vertical">
<Form.Item label="状态" name="status" rules={[{ required: true }]}>
<Select options={Object.entries(CASE_STATUS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="审核备注" name="note"><Input.TextArea rows={3} /></Form.Item>
</Form>
</Modal>
<Modal
title={labEditing ? '编辑实验室结果' : '新增实验室结果'}
open={labOpen}
onCancel={() => setLabOpen(false)}
onOk={async () => {
const v = await labForm.validateFields();
const body = {
...v,
testedAt: v.testedAt?.toISOString(),
concludedAt: v.concludedAt?.toISOString(),
};
if (labEditing) await updateLaboratoryResult(labEditing.id, body);
else await createLaboratoryResult(body);
message.success('结果已保存');
setLabOpen(false);
labAction.current?.reload();
}}
>
<Form form={labForm} layout="vertical">
<Form.Item label="实验室名称" name="labName" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="报告编号" name="reportNo" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="检测类型" name="testType" rules={[{ required: true }]}>
<Select options={Object.entries(TEST_TYPES).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="结果" name="resultType" rules={[{ required: true }]}>
<Select options={Object.entries(RESULT_TYPES).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="溯源 ID" name="traceRecordId"><Input /></Form.Item>
<Form.Item label="发病事件 ID" name="diseaseEventId"><Input /></Form.Item>
<Form.Item label="样本 ID" name="sampleId"><Input /></Form.Item>
<Form.Item label="蚕房" name="roomId"><Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} /></Form.Item>
<Form.Item label="批次" name="batchId"><Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} /></Form.Item>
<Form.Item label="病原" name="pathogen"><Input /></Form.Item>
<Form.Item label="分型" name="genotype"><Input /></Form.Item>
<Form.Item label="方法" name="method"><Input /></Form.Item>
<Form.Item label="样本号" name="sampleNo"><Input /></Form.Item>
<Form.Item label="样本类型" name="sampleType"><Input /></Form.Item>
<Form.Item label="结论" name="findings"><Input.TextArea rows={2} /></Form.Item>
<Form.Item label="报告 URL" name="reportUrl"><Input /></Form.Item>
<Form.Item label="检测时间" name="testedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Form.Item label="出具时间" name="concludedAt"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message,
} from 'antd';
import { PlusOutlined, TeamOutlined } from '@ant-design/icons';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import dayjs from 'dayjs';
import {
addOrganizationMember,
createOrganization,
listOrganizationMembers,
listOrganizations,
listUsers,
removeOrganizationMember,
updateOrganization,
type Organization,
type OrganizationMember,
type UserOption,
} from '../dal/organization';
export default function OrganizationsPage() {
const actionRef = useRef<ActionType>();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Organization | null>(null);
const [form] = Form.useForm();
const [drawerOpen, setDrawerOpen] = useState(false);
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
const [members, setMembers] = useState<OrganizationMember[]>([]);
const [users, setUsers] = useState<UserOption[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
const [memberForm] = Form.useForm();
const loadMembers = useCallback(async (orgId: string) => {
setMemberLoading(true);
try {
const rows = await listOrganizationMembers(orgId);
setMembers(rows);
} finally {
setMemberLoading(false);
}
}, []);
useEffect(() => {
listUsers().then(setUsers).catch(() => setUsers([]));
}, []);
const openMembers = async (org: Organization) => {
setCurrentOrg(org);
setMembers([]);
setDrawerOpen(true);
memberForm.resetFields();
memberForm.setFieldsValue({ role: 'member' });
await loadMembers(org.id);
};
const columns: ProColumns<Organization>[] = [
{ title: '组织名称', dataIndex: 'name' },
{ title: '编码', dataIndex: 'code', search: false },
{ title: '说明', dataIndex: 'description', search: false, render: (_, r) => r.description || '-' },
{ title: '状态', dataIndex: 'status', search: false, render: (_, r) => <Tag color={r.status === 'active' ? 'green' : 'default'}>{r.status === 'active' ? '启用' : 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="members" onClick={() => openMembers(r)}>
<TeamOutlined />
</a>,
<a
key="edit"
onClick={() => {
setEditing(r);
form.setFieldsValue(r);
setModalOpen(true);
}}
>
</a>,
],
},
];
const memberColumns = [
{ title: '用户名', dataIndex: 'username' },
{ title: '姓名', dataIndex: 'fullName', render: (_: unknown, r: OrganizationMember) => r.fullName || '-' },
{ title: '组织角色', dataIndex: 'role', render: (_: unknown, r: OrganizationMember) => <Tag>{r.role}</Tag> },
{ title: '邮箱', dataIndex: 'email' },
{ title: '加入时间', dataIndex: 'createdAt', render: (_: unknown, r: OrganizationMember) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
{
title: '操作',
render: (_: unknown, r: OrganizationMember) => (
<Popconfirm
title="确认移除该成员?"
onConfirm={async () => {
if (!currentOrg) return;
await removeOrganizationMember(currentOrg.id, r.userId);
message.success('已移除');
await loadMembers(currentOrg.id);
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>
),
},
];
const availableUsers = users.filter((u) => !members.some((m) => m.userId === u.id));
return (
<>
<ProTable<Organization>
actionRef={actionRef}
rowKey="id"
columns={columns}
search={false}
request={async () => {
const res = await listOrganizations();
return { data: res, total: res.length, success: true };
}}
toolBarRender={() => [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>,
]}
/>
<Modal
title={editing ? '编辑组织' : '新增组织'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const values = await form.validateFields();
if (editing) {
await updateOrganization(editing.id, values);
message.success('组织已更新');
} else {
await createOrganization(values);
message.success('组织已创建');
}
setModalOpen(false);
actionRef.current?.reload();
}}
>
<Form form={form} layout="vertical">
<Form.Item label="组织名称" name="name" rules={[{ required: true, message: '请输入组织名称' }]}>
<Input />
</Form.Item>
<Form.Item label="组织编码" name="code" rules={[{ required: true, message: '请输入组织编码' }]}>
<Input placeholder="如:farm-001" />
</Form.Item>
<Form.Item label="说明" name="description">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Drawer
title={currentOrg ? `成员管理:${currentOrg.name}` : '成员管理'}
width={720}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Form form={memberForm} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="用户" name="userId" rules={[{ required: true, message: '请选择用户' }]}>
<Select
showSearch
optionFilterProp="label"
style={{ minWidth: 240 }}
placeholder="选择用户"
options={availableUsers.map((u) => ({
value: u.id,
label: `${u.username}${u.fullName ? `${u.fullName}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item label="组织角色" name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select
style={{ minWidth: 120 }}
options={[
{ value: 'member', label: '成员' },
{ value: 'admin', label: '管理员' },
]}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
onClick={async () => {
const values = await memberForm.validateFields();
if (!currentOrg) return;
await addOrganizationMember(currentOrg.id, values);
message.success('成员已添加');
memberForm.resetFields();
memberForm.setFieldsValue({ role: 'member' });
await loadMembers(currentOrg.id);
}}
>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
size="small"
loading={memberLoading}
dataSource={members}
columns={memberColumns}
pagination={false}
/>
<Space style={{ marginTop: 16 }} direction="vertical">
<span></span>
<span>访</span>
</Space>
</Drawer>
</>
);
}
+4
View File
@@ -19,6 +19,8 @@ import Biosecurity from './pages/Biosecurity';
import Inspections from './pages/Inspections';
import Consultations from './pages/Consultations';
import Traces from './pages/Traces';
import BusinessExtensions from './pages/BusinessExtensions';
import Organizations from './pages/Organizations';
import NotFound from './pages/NotFound';
import { BasicLayout } from './layout/BasicLayout';
import { authService } from './services/auth';
@@ -64,6 +66,8 @@ export const router = createBrowserRouter([
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
{ path: 'business', element: <RequirePermission permission="case:read"><BusinessExtensions /></RequirePermission> },
{ path: 'organizations', element: <RequirePermission permission="organization:manage"><Organizations /></RequirePermission> },
{ path: '404', element: <NotFound /> },
],
},