chore: 同步本地 v9 整改与运营能力
This commit is contained in:
@@ -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}`);
|
||||
@@ -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');
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
// 角色中文名
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user