feat(web/miniapp): 批次/蚕匾管理与蚕房详情展示(#7)

This commit is contained in:
weijuesen
2026-08-12 17:15:48 +08:00
parent 362a70b8a8
commit 0a5e2b18e1
6 changed files with 588 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
import { get, post, patch, del } from '../api/http';
export interface Tray {
id: string;
name: string;
code?: string;
position?: string;
roomId: string;
enabled?: boolean;
createdAt?: string;
}
export interface Batch {
id: string;
roomId: string;
name: string;
variety?: string;
source?: string;
seedBatchNo?: string;
quarantineNo?: string;
instar?: number;
enteredAt?: string;
mountAt?: string;
status: string;
note?: string;
createdAt?: string;
}
export interface RearingRecord {
id: string;
batchId: string;
recordDate: string;
instar?: number;
mulberrySource?: string;
density?: string;
note?: string;
createdAt?: string;
}
export const listTrays = (params?: any) => get<Tray[]>('/trays', { params });
export const createTray = (data: Partial<Tray>) => post<Tray>('/trays', data);
export const updateTray = (id: string, data: Partial<Tray>) => patch<Tray>(`/trays/${id}`, data);
export const deleteTray = (id: string) => del(`/trays/${id}`);
export const listBatches = (params?: any) => get<Batch[]>('/batches', { params });
export const createBatch = (data: Partial<Batch>) => post<Batch>('/batches', data);
export const updateBatch = (id: string, data: Partial<Batch>) => patch<Batch>(`/batches/${id}`, data);
export const deleteBatch = (id: string) => del(`/batches/${id}`);
export const listRearingRecords = (batchId: string) =>
get<RearingRecord[]>('/rearing-records', { params: { batchId } });
export const createRearingRecord = (data: Partial<RearingRecord>) =>
post<RearingRecord>('/rearing-records', data);
export const deleteRearingRecord = (id: string) => del(`/rearing-records/${id}`);
+2
View File
@@ -8,6 +8,7 @@ import {
VideoCameraOutlined,
FileTextOutlined,
BookOutlined,
ProfileOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
@@ -32,6 +33,7 @@ const menuData = [
{ path: '/videos', name: '视频监控', icon: <VideoCameraOutlined />, permission: 'video:read' },
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
];
// 角色中文名
+438
View File
@@ -0,0 +1,438 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Switch, 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 {
listTrays,
createTray,
updateTray,
deleteTray,
listBatches,
createBatch,
updateBatch,
deleteBatch,
listRearingRecords,
createRearingRecord,
deleteRearingRecord,
type Tray,
type Batch,
type RearingRecord,
} from '../dal/trayBatch';
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
import { authService } from '../services/auth';
const STATUS_LABELS: Record<string, string> = {
rearing: '饲养中',
mounted: '已上蔟',
finished: '已结束',
};
const canWrite = () => authService.hasPermission('batch:write');
type BatchForm = Omit<Partial<Batch>, 'enteredAt' | 'mountAt'> & {
enteredAt?: dayjs.Dayjs;
mountAt?: dayjs.Dayjs;
};
type RearingForm = Omit<Partial<RearingRecord>, 'recordDate'> & {
recordDate?: dayjs.Dayjs;
};
function BatchTab() {
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
const actionRef = useRef<ActionType>();
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<BatchForm>();
const [editing, setEditing] = useState<Batch | null>(null);
const [recordOpen, setRecordOpen] = useState(false);
const [recordBatch, setRecordBatch] = useState<Batch | null>(null);
const [records, setRecords] = useState<RearingRecord[]>([]);
const [recordForm] = Form.useForm<RearingForm>();
useEffect(() => {
listHouses()
.then((r) => setRooms(r.items))
.catch(() => {});
}, []);
const roomEnum = Object.fromEntries(rooms.map((r) => [r.id, { text: r.name }]));
const roomName = (id: string) => rooms.find((r) => r.id === id)?.name || id;
const openRecords = async (b: Batch) => {
setRecordBatch(b);
const list = await listRearingRecords(b.id).catch(() => [] as RearingRecord[]);
setRecords(list);
recordForm.setFieldsValue({ recordDate: dayjs() });
setRecordOpen(true);
};
const columns: ProColumns<Batch>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '名称', dataIndex: 'name' },
{
title: '蚕房',
dataIndex: 'roomId',
valueType: 'select',
valueEnum: roomEnum,
render: (_, r) => roomName(r.roomId),
},
{ title: '品种', dataIndex: 'variety', search: false, render: (_, r) => r.variety || '-' },
{ title: '龄期', dataIndex: 'instar', search: false, width: 70, render: (_, r) => (r.instar ? `${r.instar}` : '-') },
{
title: '状态',
dataIndex: 'status',
search: false,
render: (_, r) => <Tag color={r.status === 'rearing' ? 'green' : 'default'}>{STATUS_LABELS[r.status] || r.status}</Tag>,
},
{ title: '入房时间', dataIndex: 'enteredAt', search: false, render: (_, r) => (r.enteredAt ? dayjs(r.enteredAt).format('YYYY-MM-DD') : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="records" onClick={() => openRecords(r)}>
</a>,
<a
key="edit"
onClick={() => {
setEditing(r);
form.setFieldsValue({
...r,
enteredAt: r.enteredAt ? dayjs(r.enteredAt) : undefined,
mountAt: r.mountAt ? dayjs(r.mountAt) : undefined,
});
setModalOpen(true);
}}
>
</a>,
...(canWrite()
? [
<Popconfirm
key="del"
title="确认删除该批次?其饲养记录将一并删除。"
onConfirm={async () => {
await deleteBatch(r.id);
message.success('已删除');
actionRef.current?.reload();
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ status: 'rearing' });
setModalOpen(true);
};
return (
<>
<ProTable<Batch>
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listBatches({ roomId: params.roomId, status: params.status });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>,
]
: []
}
/>
<Modal
title={editing ? '编辑批次' : '新建批次'}
open={modalOpen}
width={680}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const v = await form.validateFields();
const payload = {
...v,
enteredAt: v.enteredAt ? v.enteredAt.toISOString() : undefined,
mountAt: v.mountAt ? v.mountAt.toISOString() : undefined,
};
if (editing) await updateBatch(editing.id, payload);
else await createBatch(payload);
message.success('保存成功');
setModalOpen(false);
actionRef.current?.reload();
}}
>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如:2026春-1批" />
</Form.Item>
<Form.Item label="蚕房" name="roomId" rules={[{ required: true, message: '请选择蚕房' }]}>
<Select options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
</Form.Item>
<Form.Item label="品种" name="variety">
<Input />
</Form.Item>
<Form.Item label="蚕种来源" name="source">
<Input />
</Form.Item>
<Form.Item label="蚕种批次号" name="seedBatchNo">
<Input />
</Form.Item>
<Form.Item label="检疫证明编号" name="quarantineNo">
<Input />
</Form.Item>
<Form.Item label="龄期(1-5" name="instar">
<InputNumber min={1} max={5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="入房时间" name="enteredAt">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="上蔟时间" name="mountAt">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="备注" name="note">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={recordBatch ? `饲养记录 - ${recordBatch.name}` : '饲养记录'}
open={recordOpen}
width={720}
onCancel={() => setRecordOpen(false)}
footer={null}
>
<Form form={recordForm} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="日期" name="recordDate">
<DatePicker />
</Form.Item>
<Form.Item label="龄期" name="instar">
<InputNumber min={1} max={5} />
</Form.Item>
<Form.Item label="桑叶来源" name="mulberrySource">
<Input />
</Form.Item>
<Form.Item label="密度" name="density">
<Input />
</Form.Item>
<Form.Item label="备注" name="note">
<Input />
</Form.Item>
<Form.Item>
<Button
type="primary"
onClick={async () => {
if (!recordBatch) return;
const v = await recordForm.validateFields();
await createRearingRecord({
batchId: recordBatch.id,
...v,
recordDate: v.recordDate ? v.recordDate.toISOString() : new Date().toISOString(),
});
message.success('已添加');
recordForm.setFieldsValue({ recordDate: dayjs(), instar: undefined, mulberrySource: undefined, density: undefined, note: undefined });
setRecords(await listRearingRecords(recordBatch.id));
}}
>
</Button>
</Form.Item>
</Form>
<ProTable<RearingRecord>
rowKey="id"
search={false}
options={false}
pagination={false}
dataSource={records}
columns={[
{ title: '日期', dataIndex: 'recordDate', render: (_, r) => dayjs(r.recordDate).format('YYYY-MM-DD') },
{ title: '龄期', dataIndex: 'instar', render: (_, r) => (r.instar ? `${r.instar}` : '-') },
{ title: '桑叶来源', dataIndex: 'mulberrySource', render: (_, r) => r.mulberrySource || '-' },
{ title: '密度', dataIndex: 'density', render: (_, r) => r.density || '-' },
{ title: '备注', dataIndex: 'note', render: (_, r) => r.note || '-' },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<Popconfirm
key="del"
title="确认删除该记录?"
onConfirm={async () => {
await deleteRearingRecord(r.id);
message.success('已删除');
if (recordBatch) setRecords(await listRearingRecords(recordBatch.id));
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
]}
/>
</Modal>
</>
);
}
function TrayTab() {
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
const actionRef = useRef<ActionType>();
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<Partial<Tray>>();
const [editing, setEditing] = useState<Tray | null>(null);
useEffect(() => {
listHouses()
.then((r) => setRooms(r.items))
.catch(() => {});
}, []);
const roomEnum = Object.fromEntries(rooms.map((r) => [r.id, { text: r.name }]));
const roomName = (id: string) => rooms.find((r) => r.id === id)?.name || id;
const columns: ProColumns<Tray>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '名称', dataIndex: 'name' },
{ title: '编号', dataIndex: 'code', search: false, render: (_, r) => r.code || '-' },
{ title: '位置', dataIndex: 'position', search: false, render: (_, r) => r.position || '-' },
{
title: '蚕房',
dataIndex: 'roomId',
valueType: 'select',
valueEnum: roomEnum,
render: (_, r) => roomName(r.roomId),
},
{
title: '启用',
dataIndex: 'enabled',
search: false,
width: 80,
render: (_, r) => (r.enabled === false ? <Tag></Tag> : <Tag color="green"></Tag>),
},
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a
key="edit"
onClick={() => {
setEditing(r);
form.setFieldsValue(r);
setModalOpen(true);
}}
>
</a>,
...(canWrite()
? [
<Popconfirm
key="del"
title="确认删除该蚕匾?"
onConfirm={async () => {
await deleteTray(r.id);
message.success('已删除');
actionRef.current?.reload();
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
return (
<>
<ProTable<Tray>
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listTrays({ roomId: params.roomId });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ enabled: true });
setModalOpen(true);
}}
>
</Button>,
]
: []
}
/>
<Modal
title={editing ? '编辑蚕匾' : '新建蚕匾'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const v = await form.validateFields();
if (editing) await updateTray(editing.id, v);
else await createTray(v);
message.success('保存成功');
setModalOpen(false);
actionRef.current?.reload();
}}
>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如:1号匾" />
</Form.Item>
<Form.Item label="编号" name="code">
<Input />
</Form.Item>
<Form.Item label="位置" name="position">
<Input placeholder="如:东侧第 2 排" />
</Form.Item>
<Form.Item label="蚕房" name="roomId" rules={[{ required: true, message: '请选择蚕房' }]}>
<Select options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
export default function BatchesPage() {
return (
<Tabs
defaultActiveKey="batches"
items={[
{ key: 'batches', label: '批次管理', children: <BatchTab /> },
{ key: 'trays', label: '蚕匾管理', children: <TrayTab /> },
]}
/>
);
}
+2
View File
@@ -11,6 +11,7 @@ import Alert from './pages/Alert';
import Video from './pages/Video';
import Log from './pages/Log';
import Knowledge from './pages/Knowledge';
import Batches from './pages/Batches';
import NotFound from './pages/NotFound';
import { BasicLayout } from './layout/BasicLayout';
import { authService } from './services/auth';
@@ -48,6 +49,7 @@ export const router = createBrowserRouter([
{ path: 'videos', element: <RequirePermission permission="video:read"><Video /></RequirePermission> },
{ path: 'logs', element: <RequirePermission permission="log:read"><Log /></RequirePermission> },
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
{ path: '404', element: <NotFound /> },
],
},