feat(web/miniapp): LAMP 检测管理页与小程序录入(#14)+ web 构建代码拆分
This commit is contained in:
@@ -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<LampTest[]>('/lamp-tests', status ? { status } : undefined);
|
||||||
|
export const listLampSteps = (id: string) => get<LampTestStep[]>(`/lamp-tests/${id}/steps`);
|
||||||
|
export const updateLampStep = (id: string, stepNo: number, done: boolean) =>
|
||||||
|
patch<LampTestStep>(`/lamp-tests/${id}/steps/${stepNo}`, { done });
|
||||||
|
export const updateLampResult = (id: string, result: string) =>
|
||||||
|
patch<LampTest>(`/lamp-tests/${id}`, { result });
|
||||||
|
|
||||||
|
export const uploadLampResultImage = (id: string, filePath: string): Promise<{ url: string }> => {
|
||||||
|
const token = Taro.getStorageSync('token');
|
||||||
|
const header: Record<string, string> = {};
|
||||||
|
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 || '上传失败')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export default defineAppConfig({
|
|||||||
'pages/knowledge/detail/index',
|
'pages/knowledge/detail/index',
|
||||||
'pages/inspection/index',
|
'pages/inspection/index',
|
||||||
'pages/notification/index',
|
'pages/notification/index',
|
||||||
|
'pages/lamp/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'dark',
|
backgroundTextStyle: 'dark',
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: 'LAMP 检测',
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
pending: '待检测',
|
||||||
|
testing: '检测中',
|
||||||
|
resulted: '已出结果',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_LABELS: Record<string, string> = {
|
||||||
|
positive: '阳性',
|
||||||
|
negative: '阴性',
|
||||||
|
invalid: '无效',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LampPage: React.FC = () => {
|
||||||
|
const [tests, setTests] = useState<LampTest[]>([]);
|
||||||
|
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||||||
|
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<string, LampTestStep[]> = {};
|
||||||
|
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 (
|
||||||
|
<ScrollView className={styles.page} scrollY>
|
||||||
|
{tests.map((t) => {
|
||||||
|
const steps = stepsMap[t.id] || [];
|
||||||
|
const doneCount = steps.filter((s) => s.done).length;
|
||||||
|
const expanded = expandedId === t.id;
|
||||||
|
return (
|
||||||
|
<View key={t.id} className={styles.card}>
|
||||||
|
<View className={styles.cardHeader} onClick={() => toggleExpand(t)}>
|
||||||
|
<View className={styles.cardInfo}>
|
||||||
|
<Text className={styles.cardTitle}>{(t.diseases || []).join('、') || 'LAMP 检测'}</Text>
|
||||||
|
<Text className={styles.cardMeta}>
|
||||||
|
{STATUS_LABELS[t.status] || t.status}
|
||||||
|
{t.sampleInfo ? ` · ${t.sampleInfo}` : ''} · {formatRelativeTime(t.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={styles.cardArrow}>{expanded ? '▲' : '▼'}</Text>
|
||||||
|
</View>
|
||||||
|
{expanded ? (
|
||||||
|
<View className={styles.cardBody}>
|
||||||
|
<Text className={styles.sectionLabel}>操作步骤({doneCount}/{steps.length})</Text>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<View key={s.id} className={styles.stepRow} onClick={() => handleStepToggle(t, s, !s.done)}>
|
||||||
|
<Text className={styles.stepName}>
|
||||||
|
{s.done ? '✅' : '⬜'} {s.stepNo}. {s.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className={styles.sectionLabel}>结果照片</Text>
|
||||||
|
<View className={styles.photoRow}>
|
||||||
|
{t.resultImageUrl ? (
|
||||||
|
<Image className={styles.photo} src={t.resultImageUrl} mode="widthFix" />
|
||||||
|
) : null}
|
||||||
|
<Button className={styles.btn} onClick={() => handleChooseImage(t)}>
|
||||||
|
拍照上传
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className={styles.sectionLabel}>检测结果(天蓝=阳性,紫罗兰=阴性)</Text>
|
||||||
|
<RadioGroup value={result} onChange={(e) => setResult(e.detail.value)}>
|
||||||
|
<View className={styles.radioRow}>
|
||||||
|
<Radio value="positive">阳性</Radio>
|
||||||
|
<Radio value="negative">阴性</Radio>
|
||||||
|
<Radio value="invalid">无效</Radio>
|
||||||
|
</View>
|
||||||
|
</RadioGroup>
|
||||||
|
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
||||||
|
<Button className={`${styles.btn} ${styles.btnPrimary}`} onClick={() => handleSaveResult(t)}>
|
||||||
|
保存结果
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && tests.length === 0 ? (
|
||||||
|
<Text className={styles.emptyText}>暂无检测任务单</Text>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LampPage;
|
||||||
@@ -56,6 +56,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
case 'notification':
|
case 'notification':
|
||||||
Taro.navigateTo({ url: '/pages/notification/index' });
|
Taro.navigateTo({ url: '/pages/notification/index' });
|
||||||
break;
|
break;
|
||||||
|
case 'lamp':
|
||||||
|
Taro.navigateTo({ url: '/pages/lamp/index' });
|
||||||
|
break;
|
||||||
case 'about':
|
case 'about':
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -182,6 +185,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Text className={styles.menuLabel}>消息订阅</Text>
|
<Text className={styles.menuLabel}>消息订阅</Text>
|
||||||
<Text className={styles.menuArrow}>›</Text>
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className={styles.menuItem} onClick={() => handleMenuTap('lamp')}>
|
||||||
|
<Text className={styles.menuIcon}>🧪</Text>
|
||||||
|
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
||||||
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
|
</View>
|
||||||
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
||||||
<Text className={styles.menuIcon}>🔗</Text>
|
<Text className={styles.menuIcon}>🔗</Text>
|
||||||
<Text className={styles.menuLabel}>连接状态</Text>
|
<Text className={styles.menuLabel}>连接状态</Text>
|
||||||
|
|||||||
@@ -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<LampTest[]>('/lamp-tests', { params });
|
||||||
|
export const createLampTest = (data: Partial<LampTest>) => post<LampTest>('/lamp-tests', data);
|
||||||
|
export const updateLampTest = (id: string, data: Partial<LampTest>) =>
|
||||||
|
patch<LampTest>(`/lamp-tests/${id}`, data);
|
||||||
|
export const deleteLampTest = (id: string) => del(`/lamp-tests/${id}`);
|
||||||
|
export const listLampSteps = (id: string) => get<LampTestStep[]>(`/lamp-tests/${id}/steps`);
|
||||||
|
export const updateLampStep = (id: string, stepNo: number, data: Partial<LampTestStep>) =>
|
||||||
|
patch<LampTestStep>(`/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);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -34,6 +35,7 @@ const menuData = [
|
|||||||
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
|
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
|
||||||
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
||||||
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
||||||
|
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 角色中文名
|
// 角色中文名
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
pending: '待检测',
|
||||||
|
testing: '检测中',
|
||||||
|
resulted: '已出结果',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_LABELS: Record<string, string> = {
|
||||||
|
positive: '阳性',
|
||||||
|
negative: '阴性',
|
||||||
|
invalid: '无效',
|
||||||
|
};
|
||||||
|
|
||||||
|
const canWrite = () => authService.hasPermission('lamp:write');
|
||||||
|
|
||||||
|
function LampTab() {
|
||||||
|
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
|
||||||
|
const [batches, setBatches] = useState<Batch[]>([]);
|
||||||
|
const [diseaseOptions, setDiseaseOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
const actionRef = useRef<ActionType>();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<Partial<LampTest>>();
|
||||||
|
const [resultOpen, setResultOpen] = useState(false);
|
||||||
|
const [resultForm] = Form.useForm<Partial<LampTest>>();
|
||||||
|
const [resultTarget, setResultTarget] = useState<LampTest | null>(null);
|
||||||
|
const [imageList, setImageList] = useState<UploadFile[]>([]);
|
||||||
|
const [stepsOpen, setStepsOpen] = useState(false);
|
||||||
|
const [stepsTarget, setStepsTarget] = useState<LampTest | null>(null);
|
||||||
|
const [steps, setSteps] = useState<LampTestStep[]>([]);
|
||||||
|
|
||||||
|
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<LampTest>[] = [
|
||||||
|
{ 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) => <Tag color={r.status === 'resulted' ? 'green' : 'default'}>{STATUS_LABELS[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ 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) => [
|
||||||
|
<a key="steps" onClick={() => openSteps(r)}>
|
||||||
|
步骤
|
||||||
|
</a>,
|
||||||
|
<a key="result" onClick={() => openResult(r)}>
|
||||||
|
结果
|
||||||
|
</a>,
|
||||||
|
...(canWrite()
|
||||||
|
? [
|
||||||
|
<Popconfirm
|
||||||
|
key="del"
|
||||||
|
title="确认删除该任务单?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteLampTest(r.id);
|
||||||
|
message.success('已删除');
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<LampTest>
|
||||||
|
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()
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="new"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
createForm.resetFields();
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建任务单
|
||||||
|
</Button>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建 LAMP 检测任务单"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await createForm.validateFields();
|
||||||
|
await createLampTest(v);
|
||||||
|
message.success('已创建(自动生成 5 步流程)');
|
||||||
|
setCreateOpen(false);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={createForm} 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="diseases" rules={[{ required: true, message: '请选择检测病种' }]}>
|
||||||
|
<Checkbox.Group options={diseaseOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="采样信息" name="sampleInfo">
|
||||||
|
<Input placeholder="如:5龄第3天,1号匾" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" name="note">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`结果录入 - ${resultTarget ? roomName(resultTarget.roomId) : ''}`}
|
||||||
|
open={resultOpen}
|
||||||
|
onCancel={() => 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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={resultForm} layout="vertical">
|
||||||
|
<Form.Item label="检测结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
|
||||||
|
<Radio.Group
|
||||||
|
options={[
|
||||||
|
{ value: 'positive', label: '阳性(天蓝色)' },
|
||||||
|
{ value: 'negative', label: '阴性(紫罗兰色)' },
|
||||||
|
{ value: 'invalid', label: '无效' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="结果照片">
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
maxCount={1}
|
||||||
|
accept=".jpg,.jpeg,.png,.webp"
|
||||||
|
fileList={imageList}
|
||||||
|
customRequest={async ({ file, onSuccess, onError }) => {
|
||||||
|
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 : (
|
||||||
|
<div>
|
||||||
|
<PlusOutlined />
|
||||||
|
<div style={{ marginTop: 8 }}>上传</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`操作步骤 - ${stepsTarget ? roomName(stepsTarget.roomId) : ''}`}
|
||||||
|
open={stepsOpen}
|
||||||
|
onCancel={() => setStepsOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag>{`${s.stepNo}. ${s.name}`}</Tag>
|
||||||
|
<Switch
|
||||||
|
checked={s.done}
|
||||||
|
onChange={async (done) => {
|
||||||
|
await updateLampStep(s.lampTestId, s.stepNo, { done });
|
||||||
|
setSteps(await listLampSteps(s.lampTestId));
|
||||||
|
message.success('已更新');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LampTestsPage() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="lamp"
|
||||||
|
items={[{ key: 'lamp', label: 'LAMP 检测', children: <LampTab /> }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import Video from './pages/Video';
|
|||||||
import Log from './pages/Log';
|
import Log from './pages/Log';
|
||||||
import Knowledge from './pages/Knowledge';
|
import Knowledge from './pages/Knowledge';
|
||||||
import Batches from './pages/Batches';
|
import Batches from './pages/Batches';
|
||||||
|
import LampTests from './pages/LampTests';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import { BasicLayout } from './layout/BasicLayout';
|
import { BasicLayout } from './layout/BasicLayout';
|
||||||
import { authService } from './services/auth';
|
import { authService } from './services/auth';
|
||||||
@@ -50,6 +51,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'logs', element: <RequirePermission permission="log:read"><Log /></RequirePermission> },
|
{ path: 'logs', element: <RequirePermission permission="log:read"><Log /></RequirePermission> },
|
||||||
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
||||||
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
||||||
|
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
||||||
{ path: '404', element: <NotFound /> },
|
{ path: '404', element: <NotFound /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,30 @@ import react from '@vitejs/plugin-react';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5174,
|
port: 5174,
|
||||||
|
|||||||
Reference in New Issue
Block a user