diff --git a/miniapp/src/api/lamp.ts b/miniapp/src/api/lamp.ts
index 0ae045d..4a68621 100644
--- a/miniapp/src/api/lamp.ts
+++ b/miniapp/src/api/lamp.ts
@@ -10,6 +10,8 @@ export interface LampTest {
sampleInfo?: string;
result?: string;
resultImageUrl?: string;
+ crossStatus?: string;
+ crossReason?: string;
note?: string;
createdAt?: string;
}
diff --git a/miniapp/src/pages/lamp/index.module.scss b/miniapp/src/pages/lamp/index.module.scss
index 624c1e3..55c2b6a 100644
--- a/miniapp/src/pages/lamp/index.module.scss
+++ b/miniapp/src/pages/lamp/index.module.scss
@@ -108,6 +108,20 @@
margin-top: $spacing-xs;
}
+.crossOk {
+ display: block;
+ font-size: $font-size-xs;
+ color: $color-success;
+ margin-top: $spacing-xs;
+}
+
+.crossWarn {
+ display: block;
+ font-size: $font-size-xs;
+ color: $color-warning;
+ margin-top: $spacing-xs;
+}
+
.emptyText {
display: block;
text-align: center;
diff --git a/miniapp/src/pages/lamp/index.tsx b/miniapp/src/pages/lamp/index.tsx
index 163b4e6..5162fdc 100644
--- a/miniapp/src/pages/lamp/index.tsx
+++ b/miniapp/src/pages/lamp/index.tsx
@@ -156,6 +156,13 @@ const LampPage: React.FC = () => {
{t.result ? 当前结果:{RESULT_LABELS[t.result]} : null}
+ {t.crossStatus && t.crossStatus !== 'pending' ? (
+
+ {t.crossStatus === 'consistent'
+ ? '交叉验证:一致(确认诊断)✓'
+ : `交叉验证:不一致 ⚠ ${t.crossReason || '建议专家会诊'}`}
+
+ ) : null}
diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go
index 2a4cb9d..1a02641 100644
--- a/server-go/internal/handler/lamp.go
+++ b/server-go/internal/handler/lamp.go
@@ -2,6 +2,7 @@ package handler
import (
"bytes"
+ "encoding/json"
"io"
"net/http"
"strconv"
@@ -26,6 +27,7 @@ func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service,
rg.GET("/lamp-tests/:id/steps", read, listLampTestSteps(db))
rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db))
rg.POST("/lamp-tests/:id/result-image", write, uploadLampResultImage(db, s3, imageBucket))
+ rg.GET("/lamp-tests/:id/cross-validation", read, getLampCrossValidation(db))
}
// listLampTests 检测任务单列表(roomId/batchId/status 过滤)
@@ -97,6 +99,8 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+ resultSet := false
+ resultValue := ""
if r, ok := updates["result"]; ok {
result, _ := r.(string)
if result != "" && !model.ValidLampResult(result) {
@@ -109,15 +113,86 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
} else {
updates["status"] = "testing"
}
+ resultSet = true
+ resultValue = result
}
if len(updates) > 0 {
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates)
}
+ // 交叉验证:结果落库后与同房间最近巡检比对
+ if resultSet && resultValue != "" {
+ _ = runCrossValidation(db, id, resultValue)
+ }
db.Where("id = ?", id).First(&t)
c.JSON(http.StatusOK, t)
}
}
+// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason
+func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
+ var t model.LampTest
+ if err := db.Where("id = ?", lampTestID).First(&t).Error; err != nil {
+ return err
+ }
+ if t.RoomID == nil {
+ return nil
+ }
+ var inspection model.InspectionRecord
+ if err := db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error; err != nil {
+ return nil // 无关联巡检,保持 pending
+ }
+ var detections []service.AIDetection
+ if len(inspection.Detections) > 0 {
+ _ = json.Unmarshal(inspection.Detections, &detections)
+ }
+ aiClass := service.AIClassFromDetections(detections)
+ var diseases []string
+ if len(t.Diseases) > 0 {
+ _ = json.Unmarshal(t.Diseases, &diseases)
+ }
+ ok, reason := service.CrossValidate(aiClass, lampResult, diseases)
+ status := "inconsistent"
+ if ok {
+ status = "consistent"
+ }
+ return db.Model(&model.LampTest{}).Where("id = ?", lampTestID).
+ Updates(map[string]interface{}{"cross_status": status, "cross_reason": reason}).Error
+}
+
+// getLampCrossValidation 交叉验证详情(含关联巡检摘要)
+func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ id := c.Param("id")
+ var t model.LampTest
+ if db.Where("id = ?", id).First(&t).Error != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"})
+ return
+ }
+ var related map[string]interface{}
+ if t.RoomID != nil {
+ var inspection model.InspectionRecord
+ if db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error == nil {
+ var detections []service.AIDetection
+ if len(inspection.Detections) > 0 {
+ _ = json.Unmarshal(inspection.Detections, &detections)
+ }
+ related = map[string]interface{}{
+ "id": inspection.ID,
+ "imageUrl": inspection.ImageURL,
+ "aiClass": service.AIClassFromDetections(detections),
+ "riskLevel": inspection.RiskLevel,
+ "createdAt": inspection.CreatedAt,
+ }
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "crossStatus": t.CrossStatus,
+ "crossReason": t.CrossReason,
+ "relatedInspection": related,
+ })
+ }
+}
+
// deleteLampTest 删除任务单(级联删除步骤)
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
diff --git a/server-go/internal/model/lamp.go b/server-go/internal/model/lamp.go
index f8fb4a2..42163bc 100644
--- a/server-go/internal/model/lamp.go
+++ b/server-go/internal/model/lamp.go
@@ -17,6 +17,8 @@ type LampTest struct {
ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"`
OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"`
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
+ CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent
+ CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
diff --git a/server-go/internal/service/cross_validate.go b/server-go/internal/service/cross_validate.go
new file mode 100644
index 0000000..9180c00
--- /dev/null
+++ b/server-go/internal/service/cross_validate.go
@@ -0,0 +1,34 @@
+package service
+
+// CrossValidate 交叉验证:AI 检测结果 vs LAMP 检测结果(规格书:一致→确认诊断;不一致→升级专家会诊)
+func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, string) {
+ switch {
+ case lampResult == "invalid":
+ return false, "LAMP 判读无效,建议复检或专家会诊"
+ case aiClass == "":
+ return false, "未找到关联巡检记录,暂无法交叉验证"
+ case aiClass == "sick" && lampResult == "positive":
+ return true, "AI 检出异常与 LAMP 阳性一致,确认诊断"
+ case aiClass == "healthy" && lampResult == "negative":
+ return true, "AI 未见异常与 LAMP 阴性一致"
+ case aiClass == "sick" && lampResult == "negative":
+ return false, "AI 检出异常但 LAMP 阴性,建议专家会诊"
+ case aiClass == "healthy" && lampResult == "positive":
+ return false, "AI 未见异常但 LAMP 阳性,建议专家会诊"
+ default:
+ return false, "交叉验证结果待确认"
+ }
+}
+
+// AIClassFromDetections 从巡检检测结果归纳 AI 结论(任一非 healthy 视为 sick)
+func AIClassFromDetections(detections []AIDetection) string {
+ if len(detections) == 0 {
+ return ""
+ }
+ for _, d := range detections {
+ if d.ClassName != "healthy" {
+ return "sick"
+ }
+ }
+ return "healthy"
+}
diff --git a/server-go/internal/service/cross_validate_test.go b/server-go/internal/service/cross_validate_test.go
new file mode 100644
index 0000000..adfaba1
--- /dev/null
+++ b/server-go/internal/service/cross_validate_test.go
@@ -0,0 +1,40 @@
+package service
+
+import "testing"
+
+func TestCrossValidate(t *testing.T) {
+ cases := []struct {
+ name string
+ aiClass string
+ lampResult string
+ wantOK bool
+ }{
+ {"AI异常+LAMP阳性 一致", "sick", "positive", true},
+ {"AI健康+LAMP阴性 一致", "healthy", "negative", true},
+ {"AI异常+LAMP阴性 不一致", "sick", "negative", false},
+ {"AI健康+LAMP阳性 不一致", "healthy", "positive", false},
+ {"LAMP无效 不一致", "sick", "invalid", false},
+ {"无AI记录 不一致", "", "positive", false},
+ }
+ for _, c := range cases {
+ ok, reason := CrossValidate(c.aiClass, c.lampResult, []string{"核型多角体病"})
+ if ok != c.wantOK {
+ t.Errorf("%s: ok=%v, want %v(原因 %s)", c.name, ok, c.wantOK, reason)
+ }
+ if reason == "" {
+ t.Errorf("%s: 缺少原因说明", c.name)
+ }
+ }
+}
+
+func TestAIClassFromDetections(t *testing.T) {
+ if got := AIClassFromDetections(nil); got != "" {
+ t.Errorf("空检测应为空,实际 %s", got)
+ }
+ if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy", Confidence: 0.9}}); got != "healthy" {
+ t.Errorf("全健康应为 healthy,实际 %s", got)
+ }
+ if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy"}, {ClassName: "sick", Confidence: 0.6}}); got != "sick" {
+ t.Errorf("含 sick 应为 sick,实际 %s", got)
+ }
+}
diff --git a/web/src/dal/lamp.ts b/web/src/dal/lamp.ts
index c9271f8..d2f84ab 100644
--- a/web/src/dal/lamp.ts
+++ b/web/src/dal/lamp.ts
@@ -11,6 +11,8 @@ export interface LampTest {
resultImageUrl?: string;
operatorId?: string;
resultedAt?: string;
+ crossStatus?: string;
+ crossReason?: string;
note?: string;
createdAt?: string;
}
diff --git a/web/src/pages/LampTests.tsx b/web/src/pages/LampTests.tsx
index afe9ca7..0cc02bd 100644
--- a/web/src/pages/LampTests.tsx
+++ b/web/src/pages/LampTests.tsx
@@ -94,6 +94,19 @@ function LampTab() {
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: 'crossStatus',
+ search: false,
+ render: (_, r) =>
+ r.crossStatus === 'consistent' ? (
+ 一致
+ ) : r.crossStatus === 'inconsistent' ? (
+ 不一致
+ ) : (
+ '-'
+ ),
+ },
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
{
title: '操作',
@@ -211,6 +224,16 @@ function LampTab() {
]}
/>
+ {resultTarget?.crossStatus && resultTarget.crossStatus !== 'pending' ? (
+
+
+ {resultTarget.crossStatus === 'consistent' ? '交叉验证:一致' : '交叉验证:不一致'}
+
+ {resultTarget.crossReason ? (
+ {resultTarget.crossReason}
+ ) : null}
+
+ ) : null}