From 47a2da3d0df55eaa93ff3fa57fe7af0799766978 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 21:57:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E6=A3=80=E6=B5=8B=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E6=8E=A8=E8=8D=90=E5=BC=95=E6=93=8E=EF=BC=88#20?= =?UTF-8?q?=EF=BC=8C=E8=AE=BE=E5=A4=87/=E7=B4=A7=E6=80=A5/=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=80=85/=E6=88=90=E6=9C=AC=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/cmd/server/main.go | 1 + .../internal/handler/detection_method.go | 34 +++++ .../internal/service/detection_recommend.go | 141 ++++++++++++++++++ .../service/detection_recommend_test.go | 64 ++++++++ web/src/dal/lamp.ts | 12 ++ web/src/pages/LampTests.tsx | 87 ++++++++++- 6 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 server-go/internal/handler/detection_method.go create mode 100644 server-go/internal/service/detection_recommend.go create mode 100644 server-go/internal/service/detection_recommend_test.go diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index abbc00d..390a375 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -117,6 +117,7 @@ func main() { handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) handler.RegisterConsumableRoutes(api, db) handler.RegisterConsultationRoutes(api, db) + handler.RegisterDetectionMethodRoutes(api, db) // 启动高发病天气预警定时任务(未配置时跳过) go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) diff --git a/server-go/internal/handler/detection_method.go b/server-go/internal/handler/detection_method.go new file mode 100644 index 0000000..4ad08e2 --- /dev/null +++ b/server-go/internal/handler/detection_method.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RegisterDetectionMethodRoutes 注册检测方式推荐路由 +func RegisterDetectionMethodRoutes(rg *gin.RouterGroup, db *gorm.DB) { + rg.GET("/detection-methods/recommend", + middleware.RequirePermission(db, "lamp:read"), + recommendDetectionMethod()) +} + +// recommendDetectionMethod 按设备/紧急/操作者/成本偏好推荐检测方式(纯计算) +func recommendDetectionMethod() gin.HandlerFunc { + return func(c *gin.Context) { + in := service.DetectionRecommendInput{ + HasLamp: c.Query("hasLamp") == "true", + HasSers: c.Query("hasSers") == "true", + HasQPCR: c.Query("hasQpcr") == "true", + HasHyperspectral: c.Query("hasHyperspectral") == "true", + Urgency: c.DefaultQuery("urgency", "routine"), + OperatorLevel: c.DefaultQuery("operatorLevel", "expert"), + CostPreference: c.DefaultQuery("costPreference", "balanced"), + } + c.JSON(http.StatusOK, service.RecommendMethod(in)) + } +} diff --git a/server-go/internal/service/detection_recommend.go b/server-go/internal/service/detection_recommend.go new file mode 100644 index 0000000..a7b3031 --- /dev/null +++ b/server-go/internal/service/detection_recommend.go @@ -0,0 +1,141 @@ +package service + +import "sort" + +// DetectionRecommendInput 多检测方式推荐输入 +type DetectionRecommendInput struct { + HasLamp bool + HasSers bool + HasQPCR bool + HasHyperspectral bool + Urgency string // routine/urgent + OperatorLevel string // novice/expert + CostPreference string // balanced/low_cost/fastest +} + +// DetectionRecommendation 推荐结果 +type DetectionRecommendation struct { + Method string `json:"method"` + Name string `json:"name"` + Reason string `json:"reason"` + Time string `json:"time"` + Cost string `json:"cost"` + Difficulty string `json:"difficulty"` +} + +type methodScore struct { + info DetectionRecommendation + time int + cost int + diff int +} + +// RecommendMethod 按设备条件/紧急程度/操作者水平/成本偏好推荐检测方式 +// 规则取自规格书 3.3.2 与 11.2 参数对比;高光谱理论可行待验证,仅在其他方式都不可用时推荐 +func RecommendMethod(in DetectionRecommendInput) []DetectionRecommendation { + pool := make([]methodScore, 0, 4) + if in.HasLamp { + pool = append(pool, methodScore{ + info: DetectionRecommendation{ + Method: "lamp", Name: "LAMP", Time: "50-100 分钟", Cost: "设备 <3000 元,单次 5-20 元", Difficulty: "中", + }, + time: 3, cost: 5, diff: 3, + }) + } + if in.HasSers { + pool = append(pool, methodScore{ + info: DetectionRecommendation{ + Method: "sers", Name: "SERS", Time: "约 3 分钟", Cost: "设备 8-15 万,单次 10-30 元", Difficulty: "低", + }, + time: 5, cost: 1, diff: 5, + }) + } + if in.HasQPCR { + pool = append(pool, methodScore{ + info: DetectionRecommendation{ + Method: "qpcr", Name: "qPCR", Time: "2-3 小时", Cost: "设备 5-30 万,单次 50-100 元", Difficulty: "高", + }, + time: 1, cost: 2, diff: 1, + }) + } + if in.HasHyperspectral { + pool = append(pool, methodScore{ + info: DetectionRecommendation{ + Method: "hyperspectral", Name: "高光谱", Time: "秒级~分钟级", Cost: "设备数万-十几万,单次 0 元", Difficulty: "低(待验证)", + }, + time: 5, cost: 2, diff: 5, + }) + } + if len(pool) == 0 { + return nil + } + + // 高光谱理论可行待验证:仅当它是唯一可用方式时推荐,否则剔除 + if len(pool) > 1 { + filtered := pool[:0] + for _, m := range pool { + if m.info.Method != "hyperspectral" { + filtered = append(filtered, m) + } + } + pool = filtered + } + + // 权重 + wt, wc, wd := 0.3, 0.4, 0.3 + if in.Urgency == "urgent" { + wt, wc, wd = 0.6, 0.2, 0.2 + } + if in.OperatorLevel == "novice" { + wt, wc, wd = 0.2, 0.2, 0.6 + } + if in.CostPreference == "low_cost" { + wt, wc, wd = 0.2, 0.6, 0.2 + } else if in.CostPreference == "fastest" { + wt, wc, wd = 0.6, 0.2, 0.2 + } + + scores := make([]float64, len(pool)) + for i, m := range pool { + scores[i] = wt*float64(m.time) + wc*float64(m.cost) + wd*float64(m.diff) + } + order := make([]int, len(pool)) + for i := range order { + order[i] = i + } + sort.SliceStable(order, func(a, b int) bool { + if scores[order[a]] != scores[order[b]] { + return scores[order[a]] > scores[order[b]] + } + return order[a] < order[b] + }) + + result := make([]DetectionRecommendation, 0, len(pool)) + for _, idx := range order { + m := pool[idx] + m.info.Reason = reasonFor(m.info.Method, in) + result = append(result, m.info) + } + return result +} + +func reasonFor(method string, in DetectionRecommendInput) string { + switch method { + case "sers": + return "出结果最快(约 3 分钟)、操作难度低,适合紧急或新手场景;设备投入较高" + case "lamp": + if in.CostPreference == "low_cost" { + return "设备投入最低(<3000 元)、基层最务实,性价比高" + } + if in.Urgency == "urgent" { + return "在无 SERS 时最快的可选方案(50-100 分钟),设备投入低" + } + return "设备投入低、基层最务实,是目前蚕业领域最常用的分子检测方案" + case "qpcr": + return "金标准准确率最高,适合争议仲裁;设备投入大、操作门槛高、耗时 2-3 小时" + case "hyperspectral": + return "非接触秒级检测,但蚕病领域理论可行待验证,建议先做可行性实验" + default: + return "" + } +} diff --git a/server-go/internal/service/detection_recommend_test.go b/server-go/internal/service/detection_recommend_test.go new file mode 100644 index 0000000..b20caa1 --- /dev/null +++ b/server-go/internal/service/detection_recommend_test.go @@ -0,0 +1,64 @@ +package service + +import "testing" + +func TestRecommendMethodNoDevices(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{}) + if len(got) != 0 { + t.Errorf("无可用设备应返回空,实际 %+v", got) + } +} + +func TestRecommendMethodLowCost(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{ + HasLamp: true, HasSers: true, HasQPCR: true, + CostPreference: "low_cost", + }) + if len(got) == 0 || got[0].Method != "lamp" { + t.Errorf("低成本偏好应首选 LAMP,实际 %+v", got) + } +} + +func TestRecommendMethodFastest(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{ + HasLamp: true, HasSers: true, HasQPCR: true, + CostPreference: "fastest", + }) + if len(got) == 0 || got[0].Method != "sers" { + t.Errorf("最快偏好应首选 SERS,实际 %+v", got) + } +} + +func TestRecommendMethodUrgentNoSERS(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{ + HasLamp: true, HasQPCR: true, + Urgency: "urgent", + }) + if len(got) == 0 || got[0].Method != "lamp" { + t.Errorf("紧急且无 SERS 应首选 LAMP,实际 %+v", got) + } +} + +func TestRecommendMethodNoviceAvoidsQPCR(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{ + HasLamp: true, HasSers: true, HasQPCR: true, + OperatorLevel: "novice", + }) + if len(got) == 0 { + t.Fatal("应有推荐") + } + if got[len(got)-1].Method == "qpcr" { + // qPCR 难度高,新手应排在最后(除非仅此可选) + } + // 至少 qPCR 不应排第一 + if got[0].Method == "qpcr" { + t.Errorf("新手不应首选 qPCR,实际 %+v", got) + } +} + +func TestRecommendMethodOnlyHyperspectral(t *testing.T) { + got := RecommendMethod(DetectionRecommendInput{HasHyperspectral: true}) + if len(got) != 1 || got[0].Method != "hyperspectral" { + t.Errorf("仅有高光谱时应推荐且标注待验证,实际 %+v", got) + } +} diff --git a/web/src/dal/lamp.ts b/web/src/dal/lamp.ts index c999240..19346a0 100644 --- a/web/src/dal/lamp.ts +++ b/web/src/dal/lamp.ts @@ -71,3 +71,15 @@ export const uploadSpectrumEntryFile = (file: File) => { return post<{ url: string }>('/spectrum-entries/upload', fd); }; export const deleteSpectrumEntry = (id: string) => del(`/spectrum-entries/${id}`); + +export interface DetectionRecommendation { + method: string; + name: string; + reason: string; + time: string; + cost: string; + difficulty: string; +} + +export const recommendDetectionMethod = (params: any) => + get('/detection-methods/recommend', { params }); diff --git a/web/src/pages/LampTests.tsx b/web/src/pages/LampTests.tsx index 4c24d4b..7ef815d 100644 --- a/web/src/pages/LampTests.tsx +++ b/web/src/pages/LampTests.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { - Button, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Switch, Tabs, Tag, Upload, message, + Button, Card, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Space, Switch, Tabs, Tag, Upload, message, } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd'; @@ -20,6 +20,8 @@ import { createSpectrumEntry, uploadSpectrumEntryFile, deleteSpectrumEntry, + recommendDetectionMethod, + type DetectionRecommendation, type LampTest, type LampTestStep, type SpectrumEntry, @@ -66,6 +68,11 @@ function LampTab() { const [steps, setSteps] = useState([]); const [ctValues, setCtValues] = useState(''); const [threshold, setThreshold] = useState(35); + const [recDevices, setRecDevices] = useState({ hasLamp: true, hasSers: false, hasQpcr: false, hasHyperspectral: false }); + const [recUrgency, setRecUrgency] = useState('routine'); + const [recOperator, setRecOperator] = useState('expert'); + const [recCost, setRecCost] = useState('balanced'); + const [recommendations, setRecommendations] = useState([]); useEffect(() => { Promise.all([ @@ -200,6 +207,84 @@ function LampTab() { + + + + {recommendations.map((r) => ( +
+ {r.name} + {r.time} · {r.difficulty} +
{r.reason}
+
+ ))} + {recommendations.length === 0 ? ( + 勾选可用设备后点击获取推荐 + ) : null} + +