From 716e5e9ef21e7254120d9d01cf6f3c738acfd9c7 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 18:04:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=80=97=E6=9D=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=EF=BC=88=E5=BA=93=E5=AD=98/=E9=A2=84?= =?UTF-8?q?=E8=AD=A6/=E9=87=87=E8=B4=AD=E5=BB=BA=E8=AE=AE=EF=BC=8C#16?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/dal/consumable.ts | 23 ++++ web/src/layout/BasicLayout.tsx | 2 + web/src/pages/Consumables.tsx | 218 +++++++++++++++++++++++++++++++++ web/src/router.tsx | 2 + 4 files changed, 245 insertions(+) create mode 100644 web/src/dal/consumable.ts create mode 100644 web/src/pages/Consumables.tsx diff --git a/web/src/dal/consumable.ts b/web/src/dal/consumable.ts new file mode 100644 index 0000000..e8fe8ad --- /dev/null +++ b/web/src/dal/consumable.ts @@ -0,0 +1,23 @@ +import { get, post, patch, del } from '../api/http'; + +export interface Consumable { + id: string; + name: string; + category: string; + spec?: string; + quantity: number; + unit?: string; + minQuantity: number; + expiryDate?: string; + supplier?: string; + note?: string; + createdAt?: string; +} + +export const listConsumables = (params?: any) => get('/consumables', { params }); +export const createConsumable = (data: Partial) => post('/consumables', data); +export const updateConsumable = (id: string, data: Partial) => + patch(`/consumables/${id}`, data); +export const deleteConsumable = (id: string) => del(`/consumables/${id}`); +export const getConsumableAlerts = () => get('/consumables/alerts'); +export const getPurchaseSuggestions = () => get('/consumables/purchase-suggestions'); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 6d73a0f..a894570 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -10,6 +10,7 @@ import { BookOutlined, ProfileOutlined, ExperimentOutlined, + ShoppingOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -36,6 +37,7 @@ const menuData = [ { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read' }, { path: '/batches', name: '批次管理', icon: , permission: 'batch:read' }, { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, + { path: '/consumables', name: '耗材管理', icon: , permission: 'consumable:read' }, ]; // 角色中文名 diff --git a/web/src/pages/Consumables.tsx b/web/src/pages/Consumables.tsx new file mode 100644 index 0000000..a74df49 --- /dev/null +++ b/web/src/pages/Consumables.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, Card, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, 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 { + listConsumables, + createConsumable, + updateConsumable, + deleteConsumable, + getConsumableAlerts, + getPurchaseSuggestions, + type Consumable, +} from '../dal/consumable'; +import { authService } from '../services/auth'; + +const CATEGORY_LABELS: Record = { + lamp_reagent: 'LAMP 试剂', + lamp_consumable: 'LAMP 耗材', + disinfectant: '消毒剂', + other: '其他', +}; + +const canWrite = () => authService.hasPermission('consumable:write'); + +type ConsumableForm = Omit, 'expiryDate'> & { expiryDate?: dayjs.Dayjs }; + +function AlertsBlock() { + const [alerts, setAlerts] = useState([]); + const [suggestions, setSuggestions] = useState([]); + useEffect(() => { + Promise.all([getConsumableAlerts().catch(() => []), getPurchaseSuggestions().catch(() => [])]).then( + ([a, s]) => { + setAlerts(a); + setSuggestions(s); + }, + ); + }, []); + if (alerts.length === 0 && suggestions.length === 0) return null; + return ( + + {alerts.length > 0 ? ( + + {alerts.map((a) => ( +
+ + {a.alertType === 'low_stock' ? '低库存' : '临期/过期'} + + {a.name} + + 库存 {a.quantity} {a.unit || ''}(安全阈值 {a.minQuantity}) + {a.expiryDate ? ` · 效期 ${dayjs(a.expiryDate).format('YYYY-MM-DD')}` : ''} + +
+ ))} +
+ ) : null} + {suggestions.length > 0 ? ( + + {suggestions.map((s) => ( +
+ 建议采购 + {s.name} + + 建议补足 {s.suggest} {s.unit || ''}(当前 {s.quantity} / 阈值 {s.minQuantity}) + +
+ ))} +
+ ) : null} +
+ ); +} + +export default function ConsumablesPage() { + const actionRef = useRef(); + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [editing, setEditing] = useState(null); + + const columns: ProColumns[] = [ + { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, + { title: '名称', dataIndex: 'name' }, + { + title: '类别', + dataIndex: 'category', + valueType: 'select', + valueEnum: Object.fromEntries(Object.entries(CATEGORY_LABELS).map(([k, v]) => [k, { text: v }])), + render: (_, r) => {CATEGORY_LABELS[r.category] || r.category}, + }, + { title: '规格', dataIndex: 'spec', search: false, render: (_, r) => r.spec || '-' }, + { title: '库存', dataIndex: 'quantity', search: false }, + { title: '单位', dataIndex: 'unit', search: false, render: (_, r) => r.unit || '-' }, + { + title: '安全阈值', + dataIndex: 'minQuantity', + search: false, + render: (_, r) => + r.quantity < r.minQuantity ? 低于阈值 : r.minQuantity, + }, + { title: '效期', dataIndex: 'expiryDate', search: false, render: (_, r) => (r.expiryDate ? dayjs(r.expiryDate).format('YYYY-MM-DD') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + { + setEditing(r); + form.setFieldsValue({ ...r, expiryDate: r.expiryDate ? dayjs(r.expiryDate) : undefined }); + setModalOpen(true); + }} + > + 编辑 + , + ...(canWrite() + ? [ + { + await deleteConsumable(r.id); + message.success('已删除'); + actionRef.current?.reload(); + }} + > + 删除 + , + ] + : []), + ], + }, + ]; + + return ( + <> + + + actionRef={actionRef} + rowKey="id" + columns={columns} + search={{ labelWidth: 'auto' }} + request={async (params) => { + const res = await listConsumables({ category: params.category }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + setModalOpen(false)} + onOk={async () => { + const v = await form.validateFields(); + const payload = { + ...v, + expiryDate: v.expiryDate ? v.expiryDate.toISOString() : undefined, + }; + if (editing) await updateConsumable(editing.id, payload); + else await createConsumable(payload); + message.success('保存成功'); + setModalOpen(false); + actionRef.current?.reload(); + }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index aa6d849..5e12867 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -13,6 +13,7 @@ import Log from './pages/Log'; import Knowledge from './pages/Knowledge'; import Batches from './pages/Batches'; import LampTests from './pages/LampTests'; +import Consumables from './pages/Consumables'; import NotFound from './pages/NotFound'; import { BasicLayout } from './layout/BasicLayout'; import { authService } from './services/auth'; @@ -52,6 +53,7 @@ export const router = createBrowserRouter([ { path: 'knowledge', element: }, { path: 'batches', element: }, { path: 'lamp-tests', element: }, + { path: 'consumables', element: }, { path: '404', element: }, ], },