Explorar el Código

优化隐患提示

zkn hace 1 semana
padre
commit
5e80d43ae5

+ 31 - 10
shudao-go-backend/controllers/hazard.go

@@ -330,18 +330,12 @@ func (c *HazardController) Hazard() {
 
 	//获取labels对应的二级场景和三级场景
 	var thirdSceneNames []string
-	// 判断是否为高速公路场景
-	isHighwayScene := strings.Contains(scene.SceneName, "运营高速公路")
-	//隧道和简支梁
-	isTunnelScene := strings.Contains(scene.SceneName, "隧道")
-	isSimplySupportedBeamScene := strings.Contains(scene.SceneName, "简支梁")
+	var displayLabels []string
+	elementHazards := make(map[string][]string)
 	fmt.Println("yoloResp.Labels", yoloResp.Labels)
 	for _, label := range yoloResp.Labels {
-		// 处理标签:对于高速公路场景,需要去掉前缀
-		processedLabel := label
-		if isHighwayScene || isTunnelScene || isSimplySupportedBeamScene {
-			processedLabel = processHighwayLabel(label)
-		}
+		processedLabel := normalizeSceneLabel(label, scene.SceneName)
+		displayLabels = append(displayLabels, processedLabel)
 
 		var secondScene models.SecondScene
 		models.DB.Where("second_scene_name = ? and is_deleted = ?", processedLabel, 0).First(&secondScene)
@@ -355,15 +349,23 @@ func (c *HazardController) Hazard() {
 			var thirdScene []models.ThirdScene
 			models.DB.Where("second_scene_id = ? and is_deleted = ?", secondScene.ID, 0).Find(&thirdScene)
 			if len(thirdScene) > 0 {
+				var currentElementHazards []string
 				for _, thirdScene := range thirdScene {
 					thirdSceneNames = append(thirdSceneNames, thirdScene.ThirdSceneName)
+					currentElementHazards = append(currentElementHazards, thirdScene.ThirdSceneName)
 				}
+				elementHazards[processedLabel] = removeDuplicates(
+					append(elementHazards[processedLabel], currentElementHazards...),
+				)
+			} else if _, exists := elementHazards[processedLabel]; !exists {
+				elementHazards[processedLabel] = []string{}
 			}
 		}
 	}
 
 	//对thirdSceneNames去重
 	thirdSceneNames = removeDuplicates(thirdSceneNames)
+	displayLabels = removeDuplicates(displayLabels)
 
 	// 将三级场景名称数组更新到recognitionRecord的Description
 	if len(thirdSceneNames) > 0 {
@@ -386,7 +388,9 @@ func (c *HazardController) Hazard() {
 			"original_image":   requestData.Image,
 			"annotated_image":  annotatedImageURL, //前端用这个预链接渲染
 			"labels":           strings.Join(yoloResp.Labels, ", "),
+			"display_labels":   displayLabels,
 			"third_scenes":     thirdSceneNames, //三级场景名称数组
+			"element_hazards":  elementHazards,
 		},
 	}
 	c.ServeJSON()
@@ -949,3 +953,20 @@ func processHighwayLabel(label string) string {
 	// 返回从下划线之后的内容
 	return label[underscoreIndex+1:]
 }
+
+func normalizeSceneLabel(label, sceneName string) string {
+	if shouldTrimSceneLabelPrefix(sceneName) {
+		return processHighwayLabel(label)
+	}
+	return label
+}
+
+func shouldTrimSceneLabelPrefix(sceneName string) bool {
+	return strings.Contains(sceneName, "运营高速公路") ||
+		strings.Contains(sceneName, "operate_highway") ||
+		strings.Contains(sceneName, "隧道") ||
+		strings.Contains(sceneName, "tunnel") ||
+		strings.Contains(sceneName, "简支梁") ||
+		strings.Contains(sceneName, "桥梁") ||
+		strings.Contains(sceneName, "simple_supported_bridge")
+}

+ 32 - 8
shudao-go-backend/controllers/scene.go

@@ -97,14 +97,36 @@ func (c *SceneController) GetRecognitionRecordDetail() {
 		thirdScenes = strings.Split(record.Description, " ")
 	}
 
-	// 通过关联表查询二级场景名称
-	// var secondSceneNames []string
-	// var recognitionRecordSecondScenes []models.RecognitionRecordSecondScene
-	// models.DB.Preload("SecondScene").Where("recognition_record_id = ?", record.ID).Find(&recognitionRecordSecondScenes)
+	// 通过关联表查询二级场景名称及其对应的三级隐患
+	var displayLabels []string
+	elementHazards := make(map[string][]string)
+	var recognitionRecordSecondScenes []models.RecognitionRecordSecondScene
+	models.DB.Preload("SecondScene").Where("recognition_record_id = ?", record.ID).Find(&recognitionRecordSecondScenes)
+
+	for _, relation := range recognitionRecordSecondScenes {
+		label := relation.SecondScene.SecondSceneName
+		if label == "" {
+			continue
+		}
+		displayLabels = append(displayLabels, label)
+
+		var thirdSceneRecords []models.ThirdScene
+		models.DB.Where("second_scene_id = ? and is_deleted = ?", relation.SecondSceneID, 0).Find(&thirdSceneRecords)
+		for _, thirdSceneRecord := range thirdSceneRecords {
+			elementHazards[label] = append(elementHazards[label], thirdSceneRecord.ThirdSceneName)
+		}
+		elementHazards[label] = removeDuplicates(elementHazards[label])
+	}
 
-	// for _, rrss := range recognitionRecordSecondScenes {
-	// 	secondSceneNames = append(secondSceneNames, rrss.SecondScene.SecondSceneName)
-	// }
+	if len(displayLabels) == 0 && record.Labels != "" {
+		for _, label := range strings.Split(record.Labels, ",") {
+			normalizedLabel := strings.TrimSpace(normalizeSceneLabel(label, record.TagType))
+			if normalizedLabel != "" {
+				displayLabels = append(displayLabels, normalizedLabel)
+			}
+		}
+	}
+	displayLabels = removeDuplicates(displayLabels)
 
 	// 将原始OSS URL转换为代理URL,前端需要显示图片
 	originalImageURL := record.OriginalImageUrl
@@ -134,7 +156,9 @@ func (c *SceneController) GetRecognitionRecordDetail() {
 		"updated_at":            record.UpdatedAt,
 		// 添加与隐患识别接口一致的字段
 		"labels":            record.Labels, // 二级场景名称数组
-		"third_scenes":      thirdScenes,   // 三级场景名称数组
+		"display_labels":    displayLabels,
+		"third_scenes":      thirdScenes, // 三级场景名称数组
+		"element_hazards":   elementHazards,
 		"tag_type":          record.TagType,
 		"scene_match":       record.SceneMatch,
 		"tip_accuracy":      record.TipAccuracy,

+ 519 - 95
shudao-vue-frontend/src/views/HazardDetection.vue

@@ -110,53 +110,12 @@
                                 </p>
                             </div>
 
-                            <!-- 步骤一:选择场景 -->
+                            <!-- 步骤一:上传图片 -->
                             <div class="step-section">
-                                <h4>步骤一:选择场景</h4>
+                                <h4>步骤一:上传需要识别的场景图片</h4>
                                 <p class="step-description">
-                                    请先选择您要识别的工程场景
+                                    系统将自动识别场景与关键要素,无需手动选择
                                 </p>
-                                <div class="scenario-tags">
-                                    <div
-                                        v-for="(scenario, key) in scenarios"
-                                        :key="key"
-                                        :class="[
-                                            'scenario-tag',
-                                            {
-                                                active:
-                                                    selectedScenario === key,
-                                                disabled:
-                                                    key !== 'gas_station' &&
-                                                    key !==
-                                                        'simple_supported_bridge' &&
-                                                    key !== 'tunnel' &&
-                                                    key !==
-                                                        'special_equipment' &&
-                                                    key !== 'operate_highway',
-                                                'identifying-disabled':
-                                                    isIdentifying,
-                                            },
-                                        ]"
-                                        @click="
-                                            !isIdentifying &&
-                                            (key === 'gas_station' ||
-                                                key ===
-                                                    'simple_supported_bridge' ||
-                                                key === 'tunnel' ||
-                                                key === 'special_equipment' ||
-                                                key === 'operate_highway')
-                                                ? selectScenario(key)
-                                                : null
-                                        "
-                                    >
-                                        {{ scenario.name }}
-                                    </div>
-                                </div>
-                            </div>
-
-                            <!-- 步骤二:上传图片 -->
-                            <div class="step-section">
-                                <h4>步骤二:上传需要识别的场景图片</h4>
                                 <div
                                     class="upload-area"
                                     @click="triggerFileUpload"
@@ -442,9 +401,10 @@
                                 </span>
                             </div>
 
-                            <div class="image-container">
+                            <div class="image-container" ref="imageContainerRef">
                                 <!-- 显示图片:扫描时显示原图,扫描完成后显示标注图 -->
                                 <img
+                                    ref="mainImageRef"
                                     :src="
                                         showScanningEffect
                                             ? uploadedImageUrl
@@ -461,9 +421,42 @@
                                         cursor: pointer;
                                         transform: none !important;
                                     "
+                                    @load="handleMainImageLoad"
                                     @error="handleMainImageError"
                                 />
 
+                                <div
+                                    v-if="
+                                        !showScanningEffect &&
+                                        selectedKeyElement &&
+                                        elementOverlayStyle
+                                    "
+                                    ref="elementCardRef"
+                                    class="element-overlay-card"
+                                    :style="elementOverlayStyle"
+                                    @click.stop
+                                >
+                                    <div class="element-card-title">
+                                        当前选中:{{ selectedKeyElement }}
+                                    </div>
+                                    <ul
+                                        v-if="filteredHazards.length"
+                                        class="element-card-list"
+                                    >
+                                        <li
+                                            v-for="(
+                                                hazard, index
+                                            ) in filteredHazards"
+                                            :key="index"
+                                        >
+                                            {{ hazard }}
+                                        </li>
+                                    </ul>
+                                    <div v-else class="element-card-empty">
+                                        暂无对应隐患
+                                    </div>
+                                </div>
+
                                 <!-- 扫描效果覆盖层 -->
                                 <div
                                     v-if="showScanningEffect"
@@ -534,11 +527,7 @@
                                                     : "未知场景"
                                             }}</span
                                         >场景,检测到的关键要素为<span
-                                            v-for="(
-                                                label, index
-                                            ) in detectionResult.labels?.split(
-                                                '、'
-                                            ) || []"
+                                            v-for="(label, index) in displayLabels"
                                             :key="index"
                                             class="label-tag"
                                             >{{ label }}</span
@@ -554,6 +543,44 @@
                                     根据安全规范和施工标准,我为您梳理出以下需要重点关注的安全隐患
                                 </p>
 
+                                <div
+                                    v-if="
+                                        !isStreamingAnalysis &&
+                                        detectionResult &&
+                                        keyElements.length
+                                    "
+                                    class="key-elements-section"
+                                >
+                                    <div class="key-elements-title">
+                                        关键要素
+                                    </div>
+                                    <div class="key-elements-buttons">
+                                        <button
+                                            v-for="element in keyElements"
+                                            :key="element"
+                                            :class="[
+                                                'key-element-btn',
+                                                {
+                                                    active:
+                                                        selectedKeyElement ===
+                                                        element,
+                                                },
+                                            ]"
+                                            @click="
+                                                toggleKeyElement(element)
+                                            "
+                                        >
+                                            {{ element }}
+                                        </button>
+                                    </div>
+                                    <div
+                                        v-if="!selectedKeyElement"
+                                        class="key-elements-hint"
+                                    >
+                                        点击关键要素可查看对应隐患
+                                    </div>
+                                </div>
+
                                 <!-- 场景隐患列表 -->
                                 <div
                                     class="hazards-section"
@@ -579,11 +606,16 @@
                                             v-else
                                             class="hazard-cards-container"
                                         >
+                                            <div
+                                                v-if="!filteredHazards.length"
+                                                class="hazard-empty"
+                                            >
+                                                暂无对应隐患
+                                            </div>
                                             <div
                                                 v-for="(
                                                     hazard, index
-                                                ) in detectionResult?.third_scenes ||
-                                                []"
+                                                ) in filteredHazards"
                                                 :key="index"
                                                 class="hazard-card"
                                                 :class="{
@@ -1095,7 +1127,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, computed } from "vue";
+import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from "vue";
 import { ElMessage } from "element-plus";
 import { Upload, View, DataAnalysis, Bell } from "@element-plus/icons-vue";
 import Sidebar from "@/components/Sidebar.vue";
@@ -1106,7 +1138,7 @@ import startIdentifyActiveImg from "@/assets/Hazard/3.png";
 
 // 响应式数据
 const messageText = ref("");
-const selectedScenario = ref("tunnel"); // 默认选择隧道工程
+const selectedScenario = ref(""); // 自动识别出的场景
 const uploadedImage = ref(null);
 const uploadedImageUrl = ref(""); // 新增:存储上传后的图片URL
 const fileInput = ref(null);
@@ -1145,6 +1177,11 @@ const isStreamingAnalysis = ref(false); // 控制整个分析文本流式输出
 const streamingAnalysis = ref(""); // 流式输出的完整分析文本
 const showAnalysisPrompt = ref(false); // 控制分析提示显示
 const visibleHazardCards = ref({}); // 控制每个隐患卡片的显示状态
+const selectedKeyElement = ref(null); // 当前选中的关键要素
+const imageContainerRef = ref(null); // 图片容器引用
+const mainImageRef = ref(null); // 主图引用
+const elementCardRef = ref(null); // 关键要素卡片引用
+const elementOverlayStyle = ref(null); // 关键要素卡片定位样式
 
 // 历史记录相关数据
 const historyData = ref([]); // 存储历史记录数据
@@ -1192,6 +1229,14 @@ const scenarios = {
     operate_highway: { name: "运营高速公路", color: "#722ED1" },
 };
 
+const autoSceneOrder = [
+    "tunnel",
+    "simple_supported_bridge",
+    "gas_station",
+    "special_equipment",
+    "operate_highway",
+];
+
 // 历史记录数据结构 - 初始为空数组,等待后端数据
 // const historyData = ref([]);
 
@@ -1239,6 +1284,243 @@ const getTagText = (tagType) => {
     return tagTypeConfig[tagType]?.text || "隧道";
 };
 
+const getUniqueLabels = (labels) => {
+    const seen = new Set();
+    return labels.filter((label) => {
+        if (!label || seen.has(label)) return false;
+        seen.add(label);
+        return true;
+    });
+};
+
+const displayLabels = computed(() => {
+    const labels =
+        detectionResult.value?.display_labels || detectionResult.value?.labels;
+    if (!labels) return [];
+
+    const normalizedLabels = (Array.isArray(labels) ? labels : String(labels).split(/[,,、]/))
+        .map((label) => normalizeLabel(String(label).trim()))
+        .filter((label) => label);
+
+    return getUniqueLabels(normalizedLabels);
+});
+
+const keyElements = computed(() => displayLabels.value);
+
+const normalizeLabel = (label) => {
+    if (!label) return "";
+    const parts = String(label).split("_").filter((part) => part);
+    if (parts.length <= 1) return String(label);
+    return parts.slice(1).join("_");
+};
+
+const hazardMatchesElement = (hazard, element) => {
+    if (!hazard || !element) return false;
+    return hazard.includes(element) || element.includes(hazard);
+};
+
+const hazardsMap = computed(() => {
+    const map = {};
+    keyElements.value.forEach((element) => {
+        map[element] = [];
+    });
+    const hazards = detectionResult.value?.third_scenes || [];
+    hazards.forEach((hazard) => {
+        let matched = false;
+        for (const element of keyElements.value) {
+            if (hazardMatchesElement(hazard, element)) {
+                map[element].push(hazard);
+                matched = true;
+                break;
+            }
+        }
+        if (!matched) {
+            if (!map.__unmatched) map.__unmatched = [];
+            map.__unmatched.push(hazard);
+        }
+    });
+    return map;
+});
+
+const filteredHazards = computed(() => {
+    if (!detectionResult.value) return [];
+    if (!selectedKeyElement.value) {
+        return detectionResult.value?.third_scenes || [];
+    }
+    const backendHazards =
+        detectionResult.value?.element_hazards?.[selectedKeyElement.value];
+    if (Array.isArray(backendHazards)) {
+        return backendHazards;
+    }
+    return hazardsMap.value[selectedKeyElement.value] || [];
+});
+
+const selectedDetection = computed(() => {
+    if (!selectedKeyElement.value) return null;
+    const detections = detectionResult.value?.detections || [];
+    return (
+        detections.find((detection) => {
+            const label = normalizeLabel(detection?.label || "");
+            return (
+                label === selectedKeyElement.value ||
+                label.includes(selectedKeyElement.value) ||
+                selectedKeyElement.value.includes(label)
+            );
+        }) || null
+    );
+});
+
+const resetKeyElementState = () => {
+    selectedKeyElement.value = null;
+    elementOverlayStyle.value = null;
+};
+
+const toggleKeyElement = async (element) => {
+    if (selectedKeyElement.value === element) {
+        resetKeyElementState();
+    } else {
+        selectedKeyElement.value = element;
+    }
+    await nextTick();
+    updateElementOverlayPosition();
+};
+
+const setVisibleHazardCards = (hazards = filteredHazards.value) => {
+    const nextVisibleHazards = {};
+    hazards.forEach((_, index) => {
+        nextVisibleHazards[index] = true;
+    });
+    visibleHazardCards.value = nextVisibleHazards;
+};
+
+const getAutoSceneCandidates = () => {
+    if (
+        selectedScenario.value &&
+        autoSceneOrder.includes(selectedScenario.value)
+    ) {
+        return [
+            selectedScenario.value,
+            ...autoSceneOrder.filter(
+                (sceneKey) => sceneKey !== selectedScenario.value
+            ),
+        ];
+    }
+    return autoSceneOrder;
+};
+
+const detectSceneAutomatically = async (baseRequestData) => {
+    let lastErrorMessage = "";
+
+    for (const sceneKey of getAutoSceneCandidates()) {
+        try {
+            const response = await apis.hazardDetection({
+                ...baseRequestData,
+                scene_name: sceneKey,
+            });
+            const isSuccess =
+                response.code === 200 || response.statusCode === 200;
+
+            if (isSuccess) {
+                return {
+                    response,
+                    sceneKey,
+                };
+            }
+
+            lastErrorMessage =
+                response.msg || response.message || lastErrorMessage;
+        } catch (error) {
+            lastErrorMessage =
+                error?.msg || error?.message || lastErrorMessage;
+        }
+    }
+
+    throw new Error(
+        lastErrorMessage || "暂未识别到支持的场景,请尝试更换更清晰的图片"
+    );
+};
+
+const updateElementOverlayPosition = async () => {
+    if (
+        !selectedKeyElement.value ||
+        showScanningEffect.value ||
+        currentView.value !== "detail"
+    ) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const container = imageContainerRef.value;
+    const imageElement = mainImageRef.value;
+    const detection = selectedDetection.value;
+
+    if (
+        !container ||
+        !imageElement ||
+        !detection ||
+        !Array.isArray(detection.box) ||
+        detection.box.length < 4 ||
+        !imageElement.naturalWidth ||
+        !imageElement.naturalHeight
+    ) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const containerWidth = container.clientWidth;
+    const containerHeight = container.clientHeight;
+    const naturalWidth = imageElement.naturalWidth;
+    const naturalHeight = imageElement.naturalHeight;
+
+    if (!containerWidth || !containerHeight) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const scale = Math.min(
+        containerWidth / naturalWidth,
+        containerHeight / naturalHeight
+    );
+    const renderedWidth = naturalWidth * scale;
+    const renderedHeight = naturalHeight * scale;
+    const offsetX = (containerWidth - renderedWidth) / 2;
+    const offsetY = (containerHeight - renderedHeight) / 2;
+
+    const [x1, y1, x2, y2] = detection.box.map((value) => Number(value) || 0);
+
+    const boxLeft = offsetX + x1 * scale;
+    const boxTop = offsetY + y1 * scale;
+    const boxRight = offsetX + x2 * scale;
+
+    let cardLeft = boxRight + 12;
+    let cardTop = boxTop;
+    const cardWidth = elementCardRef.value?.offsetWidth || 260;
+    const cardHeight = elementCardRef.value?.offsetHeight || 148;
+    const safePadding = 12;
+
+    if (cardLeft + cardWidth > containerWidth - safePadding) {
+        cardLeft = Math.max(safePadding, boxLeft - cardWidth - 12);
+    }
+    if (cardTop + cardHeight > containerHeight - safePadding) {
+        cardTop = Math.max(
+            safePadding,
+            containerHeight - cardHeight - safePadding
+        );
+    }
+
+    elementOverlayStyle.value = {
+        left: `${cardLeft}px`,
+        top: `${Math.max(safePadding, cardTop)}px`,
+    };
+
+    await nextTick();
+};
+
+const handleMainImageLoad = async () => {
+    await nextTick();
+    updateElementOverlayPosition();
+};
+
 // 删除历史记录
 const deleteHistoryItem = (historyItem, index) => {
     console.log("准备删除隐患提示历史记录:", historyItem);
@@ -1306,7 +1588,7 @@ const createNewChat = () => {
 
     // 重置所有状态
     currentView.value = "main";
-    selectedScenario.value = "tunnel"; // 默认选择隧道工程
+    selectedScenario.value = "";
     uploadedImage.value = null;
     uploadedImageUrl.value = "";
     selectedHistoryItem.value = null;
@@ -1328,6 +1610,8 @@ const createNewChat = () => {
     isStreamingAnalysis.value = false; // 重置分析文本流式输出状态
     streamingAnalysis.value = ""; // 清空分析文本流式输出内容
     showAnalysisPrompt.value = false; // 重置分析提示状态
+    detectionResult.value = null;
+    resetKeyElementState();
     // 清空文件输入
     if (fileInput.value) {
         fileInput.value.value = "";
@@ -1380,13 +1664,18 @@ const handleHistoryItem = async (historyItem, event) => {
                     detailData.tag_type ||
                     getTagTypeFromLabels(detailData.labels),
                 labels: detailData.labels,
+                display_labels: detailData.display_labels || [],
                 total_detections: detailData.labels
                     ? Array.isArray(detailData.labels)
                         ? detailData.labels.length
                         : 0
                     : 0,
                 third_scenes: detailData.third_scenes || [],
+                element_hazards: detailData.element_hazards || {},
+                detections: detailData.detections || [],
             };
+            selectedScenario.value = detectionResult.value.scene_name || "";
+            resetKeyElementState();
 
             // 设置图片URL并检测加载完成
             const newImageUrl =
@@ -1405,11 +1694,9 @@ const handleHistoryItem = async (historyItem, event) => {
             historyItem.tagType = tagType;
 
             // 显示所有隐患卡片(历史记录不需要动画效果)
-            const hazards = detectionResult.value?.third_scenes || [];
-            visibleHazardCards.value = {};
-            hazards.forEach((hazard, index) => {
-                visibleHazardCards.value[index] = true;
-            });
+            setVisibleHazardCards(
+                detectionResult.value?.third_scenes || []
+            );
         } else {
             console.error("获取详情失败:", detailResponse.message);
             ElMessage.error("获取记录详情失败");
@@ -1418,8 +1705,11 @@ const handleHistoryItem = async (historyItem, event) => {
             detectionResult.value = {
                 scene_name: historyItem.tagType || "simple_supported_bridge",
                 labels: historyItem.labels,
+                display_labels: [],
                 total_detections: 0,
                 third_scenes: [],
+                element_hazards: {},
+                detections: [],
             };
             const fallbackImageUrl =
                 historyItem.recognitionImageUrl || historyItem.originalImageUrl;
@@ -1431,7 +1721,7 @@ const handleHistoryItem = async (historyItem, event) => {
             }
 
             // 显示所有隐患卡片
-            visibleHazardCards.value = {};
+            setVisibleHazardCards([]);
         }
 
         // 更新数据层的active状态
@@ -1449,19 +1739,6 @@ const handleHistoryItem = async (historyItem, event) => {
     }
 };
 
-// 选择场景
-const selectScenario = (scenarioKey) => {
-    try {
-        console.log("selectScenario 被调用,场景:", scenarioKey);
-        selectedScenario.value = scenarioKey;
-        isDragOver.value = false; // 重置拖拽状态
-        console.log("选择场景:", scenarios[scenarioKey].name);
-    } catch (error) {
-        console.error("选择场景失败:", error);
-        isDragOver.value = false; // 重置拖拽状态
-    }
-};
-
 // 触发文件上传
 const triggerFileUpload = () => {
     try {
@@ -1527,13 +1804,6 @@ const startIdentification = async () => {
             return;
         }
 
-        if (!selectedScenario.value) {
-            console.log("未选择场景");
-            ElMessage.warning("请先选择场景");
-            isDragOver.value = false; // 重置拖拽状态
-            return;
-        }
-
         if (!uploadedImageUrl.value) {
             // 使用 uploadedImageUrl.value 判断
             console.log("未上传图片");
@@ -1573,13 +1843,11 @@ const startIdentification = async () => {
             // 如果检查失败,继续执行识别流程
         }
 
-        console.log("开始识别:", {
-            scenario: scenarios[selectedScenario.value].name,
-            image: uploadedImageUrl.value, // 使用 uploadedImageUrl.value
-        });
+        console.log("开始自动识别图片场景:", uploadedImageUrl.value);
 
         // 开始识别状态
         isIdentifying.value = true;
+        resetKeyElementState();
 
         // 调用后端API进行隐患提示
         // ElMessage.success("开始进行隐患提示,请稍候...");
@@ -1601,15 +1869,16 @@ const startIdentification = async () => {
 
         const requestData = {
             // ===== 已删除:user_id - 后端从token解析 =====
-            scene_name: selectedScenario.value,
             image: uploadedImageUrl.value,
             account: accountLastFour,
             username: username,
             date: currentDate,
         };
 
-        console.log("发送隐患提示请求:", requestData);
-        const response = await apis.hazardDetection(requestData);
+        console.log("发送自动场景识别请求:", requestData);
+        const { response, sceneKey } = await detectSceneAutomatically(
+            requestData
+        );
         console.log("隐患提示响应:", response);
 
         // 检查响应结构,兼容不同的字段名
@@ -1619,7 +1888,13 @@ const startIdentification = async () => {
             ElMessage.success("隐患提示完成!");
 
             // 保存识别结果
-            detectionResult.value = response.data;
+            detectionResult.value = {
+                ...response.data,
+                scene_name: response.data.scene_name || sceneKey,
+                display_labels: response.data.display_labels || [],
+                element_hazards: response.data.element_hazards || {},
+            };
+            selectedScenario.value = detectionResult.value.scene_name || sceneKey;
 
             // 处理标注后的图片
             if (response.data.annotated_image) {
@@ -1654,6 +1929,10 @@ const startIdentification = async () => {
             // 自动选中最新创建的历史记录
             if (historyData.value.length > 0) {
                 const latestRecord = historyData.value[0]; // 假设最新的记录在数组第一位
+                latestRecord.tagType =
+                    detectionResult.value.scene_name || latestRecord.tagType;
+                latestRecord.detections =
+                    detectionResult.value.detections || [];
                 selectedHistoryItem.value = latestRecord;
 
                 // 更新所有记录的active状态
@@ -1856,14 +2135,14 @@ const startAnalysisStreaming = () => {
         // 重置状态
         isStreamingAnalysis.value = false;
         streamingAnalysis.value = "";
+        resetKeyElementState();
         visibleHazardCards.value = {}; // 重置隐患卡片显示状态
 
         // 立即开始流式输出,不需要延迟
         // 构建完整的分析文本(带HTML标签)
         const sceneName = detectionResult.value?.scene_name;
         const sceneText = sceneName ? scenarios[sceneName]?.name : "未知场景";
-        const labels = detectionResult.value?.labels || "";
-        const labelsArray = labels.split("、");
+        const labelsArray = displayLabels.value;
 
         // 构建带HTML的完整文本
         const labelsTags = labelsArray
@@ -1937,7 +2216,8 @@ const startAnalysisStreaming = () => {
 const showHazardCardsSequentially = () => {
     try {
         console.log("开始逐个显示隐患卡片");
-        const hazards = detectionResult.value?.third_scenes || [];
+        const hazards = filteredHazards.value || [];
+        visibleHazardCards.value = {};
 
         // 在第一个卡片显示之前,先滚动到隐患section
         setTimeout(() => {
@@ -1979,6 +2259,9 @@ const clearUploadedImage = () => {
         console.log("clearUploadedImage 被调用");
         uploadedImage.value = null;
         uploadedImageUrl.value = "";
+        annotatedImageUrl.value = "";
+        detectionResult.value = null;
+        resetKeyElementState();
         isDragOver.value = false; // 重置拖拽状态
         if (fileInput.value) {
             fileInput.value.value = ""; // 清空input的value
@@ -2085,7 +2368,7 @@ const processImageOrientation = (file) => {
 const processFile = async (file) => {
     try {
         console.log("processFile 被调用,文件:", file);
-        // 检查文件大小(10MB限制)
+        // 检查文件大小(5MB限制)
         if (file.size > 5 * 1024 * 1024) {
             console.log("文件大小超过限制:", file.size);
             ElMessage.error("文件大小不能超过5MB");
@@ -2098,7 +2381,7 @@ const processFile = async (file) => {
         console.log("文件类型:", file.type);
         if (!allowedTypes.includes(file.type)) {
             console.log("不支持的文件类型:", file.type);
-            ElMessage.error("只支持JPG、PNG、GIF、BMP、WEBP格式的图片");
+            ElMessage.error("只支持JPG、PNG格式的图片");
             isDragOver.value = false; // 重置拖拽状态
             return;
         }
@@ -2469,9 +2752,53 @@ const submitEvaluation = async () => {
     }
 };
 
-// 页面初始化时获取历史记录
+watch(
+    filteredHazards,
+    (hazards) => {
+        if (
+            showScanningEffect.value ||
+            showAnalysisPrompt.value ||
+            isStreamingAnalysis.value
+        ) {
+            return;
+        }
+        setVisibleHazardCards(hazards);
+    },
+    { deep: false }
+);
+
+watch(
+    [
+        selectedKeyElement,
+        currentView,
+        showScanningEffect,
+        () => detectionResult.value?.detections,
+    ],
+    async () => {
+        if (
+            !selectedKeyElement.value ||
+            currentView.value !== "detail" ||
+            showScanningEffect.value
+        ) {
+            elementOverlayStyle.value = null;
+            return;
+        }
+        await nextTick();
+        updateElementOverlayPosition();
+    }
+);
+
+const handleWindowResize = () => {
+    updateElementOverlayPosition();
+};
+
 onMounted(() => {
     getHistoryRecords();
+    window.addEventListener("resize", handleWindowResize);
+});
+
+onBeforeUnmount(() => {
+    window.removeEventListener("resize", handleWindowResize);
 });
 </script>
 
@@ -3438,6 +3765,41 @@ onMounted(() => {
                 cursor: pointer;
             }
 
+            .element-overlay-card {
+                position: absolute;
+                width: 260px;
+                max-height: 180px;
+                overflow-y: auto;
+                text-align: left;
+                padding: 14px 16px;
+                background: rgba(255, 255, 255, 0.96);
+                border: 1px solid rgba(239, 68, 68, 0.25);
+                border-radius: 14px;
+                box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
+                backdrop-filter: blur(10px);
+                z-index: 6;
+
+                .element-card-title {
+                    font-size: 14px;
+                    font-weight: 600;
+                    color: #b91c1c;
+                    margin-bottom: 10px;
+                }
+
+                .element-card-list {
+                    margin: 0;
+                    padding-left: 18px;
+                    color: #374151;
+                    font-size: 13px;
+                    line-height: 1.6;
+                }
+
+                .element-card-empty {
+                    font-size: 13px;
+                    color: #6b7280;
+                }
+            }
+
             .scanning-overlay {
                 position: absolute;
                 top: 0;
@@ -3626,6 +3988,57 @@ onMounted(() => {
                 margin-bottom: 0.75rem;
             }
 
+            .key-elements-section {
+                margin-bottom: 1rem;
+                padding: 0.875rem 1rem;
+                border-radius: 16px;
+                background: linear-gradient(135deg, #eef4ff 0%, #f8fbff 100%);
+                border: 1px solid #dbeafe;
+
+                .key-elements-title {
+                    font-size: 0.875rem;
+                    font-weight: 600;
+                    color: #1e3a8a;
+                    margin-bottom: 0.75rem;
+                }
+
+                .key-elements-buttons {
+                    display: flex;
+                    flex-wrap: wrap;
+                    gap: 0.625rem;
+                }
+
+                .key-element-btn {
+                    border: 1px solid #bfdbfe;
+                    background: #ffffff;
+                    color: #1d4ed8;
+                    border-radius: 9999px;
+                    padding: 0.45rem 0.9rem;
+                    font-size: 0.875rem;
+                    line-height: 1;
+                    cursor: pointer;
+                    transition: all 0.2s ease;
+
+                    &:hover {
+                        border-color: #60a5fa;
+                        background: #eff6ff;
+                    }
+
+                    &.active {
+                        background: #2563eb;
+                        border-color: #2563eb;
+                        color: #ffffff;
+                        box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
+                    }
+                }
+
+                .key-elements-hint {
+                    margin-top: 0.75rem;
+                    font-size: 0.8125rem;
+                    color: #64748b;
+                }
+            }
+
             /* 场景隐患列表样式 - 移到analysis-section内部 */
             .hazards-section {
                 margin-top: 0;
@@ -3644,6 +4057,17 @@ onMounted(() => {
                         margin-top: 0.75rem;
                     }
 
+                    .hazard-empty {
+                        padding: 1rem 1.25rem;
+                        margin-top: 0.75rem;
+                        background: #ffffff;
+                        border: 1px dashed #cbd5e1;
+                        border-radius: 16px;
+                        font-size: 0.875rem;
+                        color: #64748b;
+                        text-align: center;
+                    }
+
                     .hazard-card {
                         background-color: #ffffff;
                         border-radius: 9999px;

+ 12 - 0
shudao-vue-frontend/vite.config.js

@@ -31,6 +31,18 @@ export default defineConfig({
       },
       // ===== AI对话服务 (ReportGenerator:28002) =====
       // /chatwithai/api/v1/xxx -> http://127.0.0.1:28002/api/v1/xxx
+      '/api/ticket': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
+      '/api/account': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
+      '/api/auth': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
       '/chatwithai/': {
         target: 'http://127.0.0.1:28002',
         changeOrigin: true,