From d626f9d4428b8368f0a5151fb56e886912352711 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 17:52:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(web/miniapp):=20LAMP=20=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E4=B8=8E=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=BD=95=E5=85=A5=EF=BC=88#14=EF=BC=89+=20web=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=BB=A3=E7=A0=81=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapp/src/api/lamp.ts | 62 +++++ miniapp/src/app.config.ts | 1 + miniapp/src/pages/lamp/index.config.ts | 3 + miniapp/src/pages/lamp/index.module.scss | 117 ++++++++++ miniapp/src/pages/lamp/index.tsx | 174 ++++++++++++++ miniapp/src/pages/settings/index.tsx | 8 + web/src/dal/lamp.ts | 40 ++++ web/src/layout/BasicLayout.tsx | 2 + web/src/pages/LampTests.tsx | 283 +++++++++++++++++++++++ web/src/router.tsx | 2 + web/vite.config.ts | 24 ++ 11 files changed, 716 insertions(+) create mode 100644 miniapp/src/api/lamp.ts create mode 100644 miniapp/src/pages/lamp/index.config.ts create mode 100644 miniapp/src/pages/lamp/index.module.scss create mode 100644 miniapp/src/pages/lamp/index.tsx create mode 100644 web/src/dal/lamp.ts create mode 100644 web/src/pages/LampTests.tsx diff --git a/miniapp/src/api/lamp.ts b/miniapp/src/api/lamp.ts new file mode 100644 index 0000000..0ae045d --- /dev/null +++ b/miniapp/src/api/lamp.ts @@ -0,0 +1,62 @@ +import Taro from '@tarojs/taro'; +import { get, patch } from './request'; +import { getApiBaseUrl } from './config'; + +export interface LampTest { + id: string; + roomId?: string; + diseases?: string[]; + status: string; + sampleInfo?: string; + result?: string; + resultImageUrl?: string; + note?: string; + createdAt?: string; +} + +export interface LampTestStep { + id: string; + lampTestId: string; + stepNo: number; + name: string; + done: boolean; + note?: string; +} + +export const listLampTests = (status?: string) => + get('/lamp-tests', status ? { status } : undefined); +export const listLampSteps = (id: string) => get(`/lamp-tests/${id}/steps`); +export const updateLampStep = (id: string, stepNo: number, done: boolean) => + patch(`/lamp-tests/${id}/steps/${stepNo}`, { done }); +export const updateLampResult = (id: string, result: string) => + patch(`/lamp-tests/${id}`, { result }); + +export const uploadLampResultImage = (id: string, filePath: string): Promise<{ url: string }> => { + const token = Taro.getStorageSync('token'); + const header: Record = {}; + if (token) { + header['Authorization'] = `Bearer ${token}`; + } + return new Promise((resolve, reject) => { + Taro.uploadFile({ + url: `${getApiBaseUrl()}/lamp-tests/${id}/result-image`, + filePath, + name: 'file', + header, + timeout: 60000, + success: (res) => { + try { + const body = JSON.parse(res.data || '{}'); + if (res.statusCode >= 400) { + reject(new Error(body?.error || `上传失败 (${res.statusCode})`)); + return; + } + resolve(body); + } catch { + reject(new Error('解析失败')); + } + }, + fail: (err) => reject(new Error(err?.errMsg || '上传失败')), + }); + }); +}; diff --git a/miniapp/src/app.config.ts b/miniapp/src/app.config.ts index 0c82a2a..5b6936f 100644 --- a/miniapp/src/app.config.ts +++ b/miniapp/src/app.config.ts @@ -15,6 +15,7 @@ export default defineAppConfig({ 'pages/knowledge/detail/index', 'pages/inspection/index', 'pages/notification/index', + 'pages/lamp/index', ], window: { backgroundTextStyle: 'dark', diff --git a/miniapp/src/pages/lamp/index.config.ts b/miniapp/src/pages/lamp/index.config.ts new file mode 100644 index 0000000..85325e3 --- /dev/null +++ b/miniapp/src/pages/lamp/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: 'LAMP 检测', +}); diff --git a/miniapp/src/pages/lamp/index.module.scss b/miniapp/src/pages/lamp/index.module.scss new file mode 100644 index 0000000..624c1e3 --- /dev/null +++ b/miniapp/src/pages/lamp/index.module.scss @@ -0,0 +1,117 @@ +@use '@/styles/variables.scss' as *; + +.page { + min-height: 100vh; + background: $color-bg-page; + padding: $spacing-md $spacing-lg; + box-sizing: border-box; +} + +.card { + background: $color-bg-card; + border-radius: $radius-lg; + padding: $spacing-lg; + box-shadow: $shadow-card; + margin-bottom: $spacing-md; +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardInfo { + flex: 1; + min-width: 0; +} + +.cardTitle { + display: block; + font-size: $font-size-md; + font-weight: $font-weight-semibold; + color: $color-text-primary; +} + +.cardMeta { + display: block; + font-size: $font-size-xs; + color: $color-text-secondary; + margin-top: 4rpx; +} + +.cardArrow { + font-size: $font-size-sm; + color: $color-text-tertiary; + margin-left: $spacing-sm; +} + +.cardBody { + margin-top: $spacing-md; +} + +.sectionLabel { + display: block; + font-size: $font-size-xs; + color: $color-text-tertiary; + margin: $spacing-md 0 $spacing-xs; +} + +.stepRow { + padding: $spacing-xs 0; +} + +.stepName { + font-size: $font-size-sm; + color: $color-text-primary; +} + +.photoRow { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.photo { + width: 100%; + border-radius: $radius-sm; +} + +.btn { + margin: 0; + @include button-reset; + border: 1rpx solid rgba(0, 0, 0, 0.1); + border-radius: $radius-button; + height: $button-height-md; + font-size: $font-size-md; + color: $color-text-primary; + background: $color-bg-card; +} + +.btnPrimary { + margin-top: $spacing-md; + background: $color-primary; + color: $color-text-white; + border: none; +} + +.radioRow { + display: flex; + gap: $spacing-lg; + font-size: $font-size-sm; +} + +.resultHint { + display: block; + font-size: $font-size-xs; + color: $color-success; + margin-top: $spacing-xs; +} + +.emptyText { + display: block; + text-align: center; + font-size: $font-size-sm; + color: $color-text-tertiary; + padding: $spacing-xl 0; +} diff --git a/miniapp/src/pages/lamp/index.tsx b/miniapp/src/pages/lamp/index.tsx new file mode 100644 index 0000000..163b4e6 --- /dev/null +++ b/miniapp/src/pages/lamp/index.tsx @@ -0,0 +1,174 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Button, Image, Radio, RadioGroup, ScrollView, Text, View } from '@tarojs/components'; +import Taro, { usePullDownRefresh } from '@tarojs/taro'; +import styles from './index.module.scss'; +import { + listLampSteps, + listLampTests, + updateLampResult, + updateLampStep, + uploadLampResultImage, + type LampTest, + type LampTestStep, +} from '@/api/lamp'; +import { formatRelativeTime } from '@/utils/format'; + +const STATUS_LABELS: Record = { + pending: '待检测', + testing: '检测中', + resulted: '已出结果', +}; + +const RESULT_LABELS: Record = { + positive: '阳性', + negative: '阴性', + invalid: '无效', +}; + +const LampPage: React.FC = () => { + const [tests, setTests] = useState([]); + const [stepsMap, setStepsMap] = useState>({}); + const [expandedId, setExpandedId] = useState(''); + const [result, setResult] = useState(''); + const [imagePath, setImagePath] = useState(''); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const list = await listLampTests().catch(() => [] as LampTest[]); + setTests(list); + const map: Record = {}; + await Promise.all( + list.map(async (t) => { + map[t.id] = await listLampSteps(t.id).catch(() => [] as LampTestStep[]); + }), + ); + setStepsMap(map); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + usePullDownRefresh(() => { + fetchData().then(() => Taro.stopPullDownRefresh()); + }); + + const toggleExpand = (t: LampTest) => { + if (expandedId === t.id) { + setExpandedId(''); + return; + } + setExpandedId(t.id); + setResult(t.result || ''); + setImagePath(''); + }; + + const handleStepToggle = async (t: LampTest, s: LampTestStep, done: boolean) => { + await updateLampStep(t.id, s.stepNo, done); + setStepsMap((prev) => ({ + ...prev, + [t.id]: (prev[t.id] || []).map((x) => (x.stepNo === s.stepNo ? { ...x, done } : x)), + })); + }; + + const handleChooseImage = (t: LampTest) => { + Taro.chooseImage({ + count: 1, + sizeType: ['compressed'], + success: async (res) => { + const path = res.tempFilePaths[0]; + setImagePath(path); + try { + const up = await uploadLampResultImage(t.id, path); + setTests((prev) => + prev.map((x) => (x.id === t.id ? { ...x, resultImageUrl: up.url } : x)), + ); + Taro.showToast({ title: '照片已上传', icon: 'success' }); + } catch (err) { + Taro.showToast({ + title: err instanceof Error ? err.message : '上传失败', + icon: 'none', + }); + } + }, + }); + }; + + const handleSaveResult = async (t: LampTest) => { + if (!result) { + Taro.showToast({ title: '请选择检测结果', icon: 'none' }); + return; + } + await updateLampResult(t.id, result); + Taro.showToast({ title: '结果已保存', icon: 'success' }); + fetchData(); + }; + + return ( + + {tests.map((t) => { + const steps = stepsMap[t.id] || []; + const doneCount = steps.filter((s) => s.done).length; + const expanded = expandedId === t.id; + return ( + + toggleExpand(t)}> + + {(t.diseases || []).join('、') || 'LAMP 检测'} + + {STATUS_LABELS[t.status] || t.status} + {t.sampleInfo ? ` · ${t.sampleInfo}` : ''} · {formatRelativeTime(t.createdAt)} + + + {expanded ? '▲' : '▼'} + + {expanded ? ( + + 操作步骤({doneCount}/{steps.length}) + {steps.map((s) => ( + handleStepToggle(t, s, !s.done)}> + + {s.done ? '✅' : '⬜'} {s.stepNo}. {s.name} + + + ))} + + 结果照片 + + {t.resultImageUrl ? ( + + ) : null} + + + + 检测结果(天蓝=阳性,紫罗兰=阴性) + setResult(e.detail.value)}> + + 阳性 + 阴性 + 无效 + + + {t.result ? 当前结果:{RESULT_LABELS[t.result]} : null} + + + ) : null} + + ); + })} + {!loading && tests.length === 0 ? ( + 暂无检测任务单 + ) : null} + + ); +}; + +export default LampPage; diff --git a/miniapp/src/pages/settings/index.tsx b/miniapp/src/pages/settings/index.tsx index 2b92e62..b585a3e 100644 --- a/miniapp/src/pages/settings/index.tsx +++ b/miniapp/src/pages/settings/index.tsx @@ -56,6 +56,9 @@ const SettingsPage: React.FC = () => { case 'notification': Taro.navigateTo({ url: '/pages/notification/index' }); break; + case 'lamp': + Taro.navigateTo({ url: '/pages/lamp/index' }); + break; case 'about': Taro.showModal({ title: '关于', @@ -182,6 +185,11 @@ const SettingsPage: React.FC = () => { 消息订阅 + handleMenuTap('lamp')}> + 🧪 + LAMP 检测 + + handleMenuTap('wsStatus')}> 🔗 连接状态 diff --git a/web/src/dal/lamp.ts b/web/src/dal/lamp.ts new file mode 100644 index 0000000..c9271f8 --- /dev/null +++ b/web/src/dal/lamp.ts @@ -0,0 +1,40 @@ +import { get, post, patch, del } from '../api/http'; + +export interface LampTest { + id: string; + roomId?: string; + batchId?: string; + diseases?: string[]; + status: string; + sampleInfo?: string; + result?: string; + resultImageUrl?: string; + operatorId?: string; + resultedAt?: string; + note?: string; + createdAt?: string; +} + +export interface LampTestStep { + id: string; + lampTestId: string; + stepNo: number; + name: string; + done: boolean; + note?: string; +} + +export const listLampTests = (params?: any) => get('/lamp-tests', { params }); +export const createLampTest = (data: Partial) => post('/lamp-tests', data); +export const updateLampTest = (id: string, data: Partial) => + patch(`/lamp-tests/${id}`, data); +export const deleteLampTest = (id: string) => del(`/lamp-tests/${id}`); +export const listLampSteps = (id: string) => get(`/lamp-tests/${id}/steps`); +export const updateLampStep = (id: string, stepNo: number, data: Partial) => + patch(`/lamp-tests/${id}/steps/${stepNo}`, data); + +export const uploadLampResultImage = (id: string, file: File) => { + const fd = new FormData(); + fd.append('file', file); + return post<{ url: string }>(`/lamp-tests/${id}/result-image`, fd); +}; diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 098d982..6d73a0f 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -9,6 +9,7 @@ import { FileTextOutlined, BookOutlined, ProfileOutlined, + ExperimentOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -34,6 +35,7 @@ const menuData = [ { path: '/logs', name: '控制日志', icon: , permission: 'log:read' }, { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read' }, { path: '/batches', name: '批次管理', icon: , permission: 'batch:read' }, + { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, ]; // 角色中文名 diff --git a/web/src/pages/LampTests.tsx b/web/src/pages/LampTests.tsx new file mode 100644 index 0000000..afe9ca7 --- /dev/null +++ b/web/src/pages/LampTests.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Switch, Tabs, Tag, Upload, message, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { UploadFile } from 'antd'; +import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import { + listLampTests, + createLampTest, + updateLampTest, + deleteLampTest, + listLampSteps, + updateLampStep, + uploadLampResultImage, + type LampTest, + type LampTestStep, +} from '../dal/lamp'; +import { listDiseases } from '../dal/knowledge'; +import { listHouses, type SilkwormHouse } from '../dal/silkworm'; +import { listBatches, type Batch } from '../dal/trayBatch'; +import { authService } from '../services/auth'; + +const STATUS_LABELS: Record = { + pending: '待检测', + testing: '检测中', + resulted: '已出结果', +}; + +const RESULT_LABELS: Record = { + positive: '阳性', + negative: '阴性', + invalid: '无效', +}; + +const canWrite = () => authService.hasPermission('lamp:write'); + +function LampTab() { + const [rooms, setRooms] = useState([]); + const [batches, setBatches] = useState([]); + const [diseaseOptions, setDiseaseOptions] = useState<{ value: string; label: string }[]>([]); + const actionRef = useRef(); + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm>(); + const [resultOpen, setResultOpen] = useState(false); + const [resultForm] = Form.useForm>(); + const [resultTarget, setResultTarget] = useState(null); + const [imageList, setImageList] = useState([]); + const [stepsOpen, setStepsOpen] = useState(false); + const [stepsTarget, setStepsTarget] = useState(null); + const [steps, setSteps] = useState([]); + + useEffect(() => { + Promise.all([ + listHouses().catch(() => ({ items: [] as SilkwormHouse[] })), + listBatches().catch(() => [] as Batch[]), + listDiseases().catch(() => [] as any[]), + ]).then(([houseRes, batchRes, diseaseRes]) => { + setRooms(houseRes.items); + setBatches(batchRes); + setDiseaseOptions(diseaseRes.map((d: any) => ({ value: d.name, label: d.name }))); + }); + }, []); + + const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-'; + const roomEnum = Object.fromEntries(rooms.map((r) => [r.id, { text: r.name }])); + + const openSteps = async (t: LampTest) => { + setStepsTarget(t); + setSteps(await listLampSteps(t.id).catch(() => [] as LampTestStep[])); + setStepsOpen(true); + }; + + const openResult = (t: LampTest) => { + setResultTarget(t); + resultForm.setFieldsValue({ result: t.result }); + setImageList( + t.resultImageUrl ? [{ uid: '-1', name: '结果照片', status: 'done', url: t.resultImageUrl }] : [], + ); + setResultOpen(true); + }; + + const columns: ProColumns[] = [ + { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, + { title: '蚕房', dataIndex: 'roomId', valueType: 'select', valueEnum: roomEnum, render: (_, r) => roomName(r.roomId) }, + { title: '检测病种', dataIndex: 'diseases', search: false, render: (_, r) => (r.diseases || []).join('、') || '-' }, + { title: '采样信息', dataIndex: 'sampleInfo', search: false, render: (_, r) => r.sampleInfo || '-' }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v }])), + render: (_, r) => {STATUS_LABELS[r.status] || r.status}, + }, + { title: '结果', dataIndex: 'result', search: false, render: (_, r) => (r.result ? RESULT_LABELS[r.result] || r.result : '-') }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + openSteps(r)}> + 步骤 + , + openResult(r)}> + 结果 + , + ...(canWrite() + ? [ + { + await deleteLampTest(r.id); + message.success('已删除'); + actionRef.current?.reload(); + }} + > + 删除 + , + ] + : []), + ], + }, + ]; + + return ( + <> + + actionRef={actionRef} + rowKey="id" + columns={columns} + search={{ labelWidth: 'auto' }} + request={async (params) => { + const res = await listLampTests({ roomId: params.roomId, status: params.status }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + + setCreateOpen(false)} + onOk={async () => { + const v = await createForm.validateFields(); + await createLampTest(v); + message.success('已创建(自动生成 5 步流程)'); + setCreateOpen(false); + actionRef.current?.reload(); + }} + > +
+ + ({ value: b.id, label: b.name }))} + /> + + + + + + + + + + +
+
+ + setResultOpen(false)} + onOk={async () => { + if (!resultTarget) return; + const v = await resultForm.validateFields(); + await updateLampTest(resultTarget.id, { result: v.result }); + message.success('结果已保存'); + setResultOpen(false); + actionRef.current?.reload(); + }} + > +
+ + + + + { + if (!resultTarget) return; + try { + const res = await uploadLampResultImage(resultTarget.id, file as File); + setImageList([{ uid: '-1', name: '结果照片', status: 'done', url: res.url }]); + onSuccess?.(res); + } catch (e) { + onError?.(e as Error); + } + }} + onRemove={() => setImageList([])} + > + {imageList.length >= 1 ? null : ( +
+ +
上传
+
+ )} +
+
+
+
+ + setStepsOpen(false)} + footer={null} + > + {steps.map((s) => ( +
+ {`${s.stepNo}. ${s.name}`} + { + await updateLampStep(s.lampTestId, s.stepNo, { done }); + setSteps(await listLampSteps(s.lampTestId)); + message.success('已更新'); + }} + /> +
+ ))} +
+ + ); +} + +export default function LampTestsPage() { + return ( + }]} + /> + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 27176ac..aa6d849 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -12,6 +12,7 @@ import Video from './pages/Video'; import Log from './pages/Log'; import Knowledge from './pages/Knowledge'; import Batches from './pages/Batches'; +import LampTests from './pages/LampTests'; import NotFound from './pages/NotFound'; import { BasicLayout } from './layout/BasicLayout'; import { authService } from './services/auth'; @@ -50,6 +51,7 @@ export const router = createBrowserRouter([ { path: 'logs', element: }, { path: 'knowledge', element: }, { path: 'batches', element: }, + { path: 'lamp-tests', element: }, { path: '404', element: }, ], }, diff --git a/web/vite.config.ts b/web/vite.config.ts index 72b452f..b51fc98 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,30 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + output: { + manualChunks(id: string) { + if (id.includes('node_modules/react') || id.includes('node_modules/react-dom') || id.includes('node_modules/react-router')) { + return 'react'; + } + if (id.includes('node_modules/@ant-design/pro-components')) { + return 'pro'; + } + if (id.includes('node_modules/antd') || id.includes('node_modules/@ant-design')) { + return 'antd'; + } + if (id.includes('node_modules/echarts')) { + return 'echarts'; + } + if (id.includes('node_modules')) { + return 'vendor'; + } + return undefined; + }, + }, + }, + }, server: { host: '0.0.0.0', port: 5174,