diff --git a/miniapp/src/api/trayBatch.ts b/miniapp/src/api/trayBatch.ts new file mode 100644 index 0000000..d45752f --- /dev/null +++ b/miniapp/src/api/trayBatch.ts @@ -0,0 +1,29 @@ +import { get } from './request'; + +export interface Tray { + id: string; + name: string; + code?: string; + position?: string; + roomId: string; + enabled?: boolean; +} + +export interface Batch { + id: string; + roomId: string; + name: string; + variety?: string; + source?: string; + instar?: number; + status: string; + enteredAt?: string; +} + +export const getTrays = (roomId: string) => get('/trays', { roomId }); +export const getBatches = (roomId: string) => get('/batches', { roomId }); + +export const batchStatusLabel = (status: string) => { + const labels: Record = { rearing: '饲养中', mounted: '已上蔟', finished: '已结束' }; + return labels[status] || status; +}; diff --git a/miniapp/src/pages/rooms/detail/index.tsx b/miniapp/src/pages/rooms/detail/index.tsx index 9d5d2a5..0e584c2 100644 --- a/miniapp/src/pages/rooms/detail/index.tsx +++ b/miniapp/src/pages/rooms/detail/index.tsx @@ -5,6 +5,7 @@ import styles from './index.module.scss'; import { getRoomById } from '@/api/rooms'; import { getDevices } from '@/api/devices'; import { getLatestTelemetry, fetchTrend } from '@/api/telemetry'; +import { getTrays, getBatches, batchStatusLabel, type Tray, type Batch } from '@/api/trayBatch'; import MiniChart from '@/components/MiniChart'; import { roomStatusLabel, @@ -24,6 +25,8 @@ const RoomDetailPage: React.FC = () => { const [devices, setDevices] = useState([]); const [telemetryMap, setTelemetryMap] = useState>({}); const [trend, setTrend] = useState([]); + const [trays, setTrays] = useState([]); + const [batches, setBatches] = useState([]); const [trendLoading, setTrendLoading] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -37,12 +40,14 @@ const RoomDetailPage: React.FC = () => { } try { setError(''); - const [roomRes, devicesRes] = await Promise.all([ + const [roomRes, devicesRes, traysRes, batchesRes] = await Promise.all([ getRoomById(roomId).catch((e) => { console.error('[RoomDetail] 获取蚕房失败:', e); return null; }), getDevices().catch(() => [] as Device[]), + getTrays(roomId).catch(() => [] as Tray[]), + getBatches(roomId).catch(() => [] as Batch[]), ]); if (roomRes) { @@ -51,6 +56,8 @@ const RoomDetailPage: React.FC = () => { const roomDevices = devicesRes.filter((d) => d.roomId === roomId); setDevices(roomDevices); + setTrays(traysRes); + setBatches(batchesRes); // 获取每个设备的最新遥测 const telemetryEntries = await Promise.all( @@ -271,6 +278,61 @@ const RoomDetailPage: React.FC = () => { )} + + {/* 蚕匾 */} + + 蚕匾 + {trays.length > 0 ? ( + + {trays.map((t) => ( + + + 🪵 + + {t.name} + + {[t.code, t.position].filter(Boolean).join(' · ') || '无位置信息'} + + + + + ))} + + ) : ( + + 📭 + 该蚕房暂无蚕匾 + + )} + + + {/* 当前批次 */} + + 当前批次 + {batches.length > 0 ? ( + + {batches.map((b) => ( + + + 🐛 + + {b.name} + + {batchStatusLabel(b.status)} · {b.instar ? `${b.instar}龄` : '龄期未设置'} + {b.variety ? ` · ${b.variety}` : ''} + + + + + ))} + + ) : ( + + 📭 + 该蚕房暂无批次 + + )} + ); }; diff --git a/web/src/dal/trayBatch.ts b/web/src/dal/trayBatch.ts new file mode 100644 index 0000000..1295de7 --- /dev/null +++ b/web/src/dal/trayBatch.ts @@ -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('/trays', { params }); +export const createTray = (data: Partial) => post('/trays', data); +export const updateTray = (id: string, data: Partial) => patch(`/trays/${id}`, data); +export const deleteTray = (id: string) => del(`/trays/${id}`); + +export const listBatches = (params?: any) => get('/batches', { params }); +export const createBatch = (data: Partial) => post('/batches', data); +export const updateBatch = (id: string, data: Partial) => patch(`/batches/${id}`, data); +export const deleteBatch = (id: string) => del(`/batches/${id}`); + +export const listRearingRecords = (batchId: string) => + get('/rearing-records', { params: { batchId } }); +export const createRearingRecord = (data: Partial) => + post('/rearing-records', data); +export const deleteRearingRecord = (id: string) => del(`/rearing-records/${id}`); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index e513de1..098d982 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -8,6 +8,7 @@ import { VideoCameraOutlined, FileTextOutlined, BookOutlined, + ProfileOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -32,6 +33,7 @@ const menuData = [ { path: '/videos', name: '视频监控', icon: , permission: 'video:read' }, { path: '/logs', name: '控制日志', icon: , permission: 'log:read' }, { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read' }, + { path: '/batches', name: '批次管理', icon: , permission: 'batch:read' }, ]; // 角色中文名 diff --git a/web/src/pages/Batches.tsx b/web/src/pages/Batches.tsx new file mode 100644 index 0000000..35b0982 --- /dev/null +++ b/web/src/pages/Batches.tsx @@ -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 = { + rearing: '饲养中', + mounted: '已上蔟', + finished: '已结束', +}; + +const canWrite = () => authService.hasPermission('batch:write'); + +type BatchForm = Omit, 'enteredAt' | 'mountAt'> & { + enteredAt?: dayjs.Dayjs; + mountAt?: dayjs.Dayjs; +}; + +type RearingForm = Omit, 'recordDate'> & { + recordDate?: dayjs.Dayjs; +}; + +function BatchTab() { + const [rooms, setRooms] = useState([]); + const actionRef = useRef(); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [editing, setEditing] = useState(null); + const [recordOpen, setRecordOpen] = useState(false); + const [recordBatch, setRecordBatch] = useState(null); + const [records, setRecords] = useState([]); + const [recordForm] = Form.useForm(); + + 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[] = [ + { 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) => {STATUS_LABELS[r.status] || r.status}, + }, + { title: '入房时间', dataIndex: 'enteredAt', search: false, render: (_, r) => (r.enteredAt ? dayjs(r.enteredAt).format('YYYY-MM-DD') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + openRecords(r)}> + 饲养记录 + , + { + setEditing(r); + form.setFieldsValue({ + ...r, + enteredAt: r.enteredAt ? dayjs(r.enteredAt) : undefined, + mountAt: r.mountAt ? dayjs(r.mountAt) : undefined, + }); + setModalOpen(true); + }} + > + 编辑 + , + ...(canWrite() + ? [ + { + await deleteBatch(r.id); + message.success('已删除'); + actionRef.current?.reload(); + }} + > + 删除 + , + ] + : []), + ], + }, + ]; + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ status: 'rearing' }); + setModalOpen(true); + }; + + return ( + <> + + 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() + ? [ + , + ] + : [] + } + /> + 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(); + }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 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) => [ + { + await deleteRearingRecord(r.id); + message.success('已删除'); + if (recordBatch) setRecords(await listRearingRecords(recordBatch.id)); + }} + > + 删除 + , + ], + }, + ]} + /> +
+ + ); +} + +function TrayTab() { + const [rooms, setRooms] = useState([]); + const actionRef = useRef(); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm>(); + const [editing, setEditing] = useState(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[] = [ + { 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 ? 停用 : 启用), + }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + { + setEditing(r); + form.setFieldsValue(r); + setModalOpen(true); + }} + > + 编辑 + , + ...(canWrite() + ? [ + { + await deleteTray(r.id); + message.success('已删除'); + actionRef.current?.reload(); + }} + > + 删除 + , + ] + : []), + ], + }, + ]; + + return ( + <> + + 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() + ? [ + , + ] + : [] + } + /> + 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(); + }} + > +
+ + + + + + + + + + +