Преглед изворни кода

合并安全培训和AI写作分支,并限制“开始识别按钮”的点击次数

zkn пре 1 месец
родитељ
комит
bb9b24a9fb

+ 146 - 0
shudao-vue-frontend/src/views/HazardDetection.singleClick.test.js

@@ -0,0 +1,146 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import HazardDetection from './HazardDetection.vue'
+import MobileHazardDetection from './mobile/m-HazardDetection.vue'
+
+const apiMocks = vi.hoisted(() => ({
+  getHazardHistory: vi.fn(),
+  getLatestRecognitionRecord: vi.fn(),
+  hazardDetection: vi.fn(),
+  uploadImage: vi.fn(),
+  getRecognitionRecordDetail: vi.fn(),
+  getThirdSceneExampleImage: vi.fn(),
+  submitEvaluation: vi.fn(),
+  deleteRecognitionRecord: vi.fn()
+}))
+
+const messageMocks = vi.hoisted(() => ({
+  success: vi.fn(),
+  warning: vi.fn(),
+  error: vi.fn()
+}))
+
+const routerMocks = vi.hoisted(() => ({
+  go: vi.fn(),
+  back: vi.fn(),
+  push: vi.fn()
+}))
+
+vi.mock('@/request/apis.js', () => ({
+  apis: apiMocks
+}))
+
+vi.mock('element-plus', () => ({
+  ElMessage: messageMocks
+}))
+
+vi.mock('vue-router', () => ({
+  useRouter: () => routerMocks
+}))
+
+vi.mock('@/utils/nativeBridge.js', () => ({
+  initNativeNavForSubPage: vi.fn()
+}))
+
+const successfulHazardResponse = {
+  statusCode: 200,
+  data: {
+    scene_name: 'tunnel',
+    labels: ['person'],
+    display_labels: ['person'],
+    third_scenes: ['person_fall'],
+    detections: [],
+    element_hazards: {},
+    annotated_image: 'https://example.test/result.png'
+  }
+}
+
+const mountDesktopHazardDetection = () =>
+  mount(HazardDetection, {
+    global: {
+      stubs: {
+        Sidebar: true,
+        DeleteConfirmModal: true,
+        'el-icon': true
+      }
+    }
+  })
+
+const mountMobileHazardDetection = () =>
+  mount(MobileHazardDetection, {
+    global: {
+      stubs: {
+        MobileHeader: true,
+        MobileHistoryDrawer: true,
+        DeleteConfirmModal: true,
+        MobileToast: true
+      }
+    }
+  })
+
+const resolvePendingLatestChecks = async (resolvers) => {
+  resolvers.forEach((resolve) => {
+    resolve({
+      statusCode: 200,
+      data: {
+        effect_evaluation: 5
+      }
+    })
+  })
+  await Promise.resolve()
+  await nextTick()
+}
+
+describe('HazardDetection start recognition single click guard', () => {
+  let latestRecordResolvers
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    latestRecordResolvers = []
+    apiMocks.getHazardHistory.mockResolvedValue({
+      statusCode: 200,
+      total: 0,
+      data: []
+    })
+    apiMocks.getLatestRecognitionRecord.mockImplementation(
+      () =>
+        new Promise((resolve) => {
+          latestRecordResolvers.push(resolve)
+        })
+    )
+    apiMocks.hazardDetection.mockResolvedValue(successfulHazardResponse)
+  })
+
+  it('locks the desktop start button before the latest-record check finishes', async () => {
+    const wrapper = mountDesktopHazardDetection()
+    wrapper.vm.uploadedImageUrl = 'https://example.test/upload.png'
+    await nextTick()
+
+    const startButton = wrapper.find('.start-identify-btn')
+    await startButton.trigger('click')
+    await startButton.trigger('click')
+
+    expect(apiMocks.getLatestRecognitionRecord).toHaveBeenCalledTimes(1)
+    expect(startButton.attributes('disabled')).toBeDefined()
+
+    await resolvePendingLatestChecks(latestRecordResolvers)
+    wrapper.unmount()
+  })
+
+  it('locks the mobile start button before the latest-record check finishes', async () => {
+    const wrapper = mountMobileHazardDetection()
+    wrapper.vm.uploadedImageUrl = 'https://example.test/upload.png'
+    await nextTick()
+
+    const startButton = wrapper.find('.start-identify-btn')
+    await startButton.trigger('click')
+    await startButton.trigger('click')
+
+    expect(apiMocks.getLatestRecognitionRecord).toHaveBeenCalledTimes(1)
+    expect(startButton.attributes('disabled')).toBeDefined()
+
+    await resolvePendingLatestChecks(latestRecordResolvers)
+    wrapper.unmount()
+  })
+})

+ 25 - 3
shudao-vue-frontend/src/views/HazardDetection.vue

@@ -196,8 +196,10 @@
                                 <button
                                     class="start-identify-btn"
                                     @click="startIdentification"
-                                    :disabled="isIdentifying"
-                                    :class="{ 'btn-disabled': isIdentifying }"
+                                    :disabled="isStartIdentifyDisabled"
+                                    :class="{ 'btn-disabled': isStartIdentifyDisabled }"
+                                    :aria-disabled="isStartIdentifyDisabled"
+                                    :title="startIdentifyButtonTitle"
                                 >
                                     <img
                                         :src="
@@ -1074,6 +1076,7 @@ const deleteTargetItem = ref(null);
 const isUploading = ref(false);
 const isDragOver = ref(false);
 const isIdentifying = ref(false);
+const isStartIdentifyLocked = ref(false);
 
 const detectionResult = ref(null);
 const annotatedImageUrl = ref("");
@@ -1122,6 +1125,13 @@ const shouldShowRemarkSection = computed(() => {
     return true;
 });
 
+const isStartIdentifyDisabled = computed(
+    () => isStartIdentifyLocked.value || isIdentifying.value
+);
+const startIdentifyButtonTitle = computed(() =>
+    isStartIdentifyDisabled.value ? "识别进行中,请稍候" : "开始识别"
+);
+
 const scenarios = {
     tunnel: { name: "隧道工程", color: "#3366E6" },
     simple_supported_bridge: { name: "桥梁工程", color: "#22B850" },
@@ -1487,6 +1497,7 @@ const createNewChat = () => {
     isUploading.value = false;
     isDragOver.value = false;
     isIdentifying.value = false;
+    isStartIdentifyLocked.value = false;
     annotatedImageUrl.value = "";
     previewImageUrl.value = "";
     exampleImages.value = {};
@@ -1648,8 +1659,10 @@ const uploadFileToServer = async (file) => {
 };
 
 const startIdentification = async () => {
+    let releaseStartIdentifyLock = true;
+
     try {
-        if (isIdentifying.value) {
+        if (isStartIdentifyDisabled.value) {
             ElMessage.warning("识别正在进行中,请勿重复点击");
             return;
         }
@@ -1660,6 +1673,8 @@ const startIdentification = async () => {
             return;
         }
 
+        isStartIdentifyLocked.value = true;
+
         try {
             const latestRecordResponse = await apis.getLatestRecognitionRecord(
                 {}
@@ -1742,9 +1757,11 @@ const startIdentification = async () => {
             }
 
             isTransitioning.value = true;
+            releaseStartIdentifyLock = false;
             setTimeout(() => {
                 currentView.value = "detail";
                 isTransitioning.value = false;
+                isStartIdentifyLocked.value = false;
                 showScanningEffect.value = true;
                 showAnalysisPrompt.value = true;
             }, 1000);
@@ -1785,9 +1802,11 @@ const startIdentification = async () => {
                     response.data?.result_image_url ||
                     annotatedImageUrl.value;
                 isTransitioning.value = true;
+                releaseStartIdentifyLock = false;
                 setTimeout(() => {
                     currentView.value = "detail";
                     isTransitioning.value = false;
+                    isStartIdentifyLocked.value = false;
                     showScanningEffect.value = true;
                     showAnalysisPrompt.value = true;
                 }, 1000);
@@ -1808,6 +1827,9 @@ const startIdentification = async () => {
         isDragOver.value = false;
     } finally {
         isIdentifying.value = false;
+        if (releaseStartIdentifyLock) {
+            isStartIdentifyLocked.value = false;
+        }
     }
 };
 

+ 19 - 5
shudao-vue-frontend/src/views/mobile/m-HazardDetection.vue

@@ -46,10 +46,10 @@
                 :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,
+                  'identifying-disabled': isStartIdentifyDisabled,
                   'compact': key === 'operate_highway'
                 }]"
-                @click="!isIdentifying && (key === 'gas_station' || key === 'simple_supported_bridge' || key === 'tunnel' || key === 'special_equipment' || key === 'operate_highway') ? selectScenario(key) : null"
+                @click="!isStartIdentifyDisabled && (key === 'gas_station' || key === 'simple_supported_bridge' || key === 'tunnel' || key === 'special_equipment' || key === 'operate_highway') ? selectScenario(key) : null"
               >
                 {{ scenario.name }}
               </div>
@@ -104,7 +104,14 @@
 
           <!-- 开始识别按钮 -->
           <div class="action-section">
-            <button class="start-identify-btn" @click="startIdentification" :disabled="isIdentifying" :class="{ 'btn-disabled': isIdentifying }">
+            <button
+              class="start-identify-btn"
+              @click="startIdentification"
+              :disabled="isStartIdentifyDisabled"
+              :class="{ 'btn-disabled': isStartIdentifyDisabled }"
+              :aria-disabled="isStartIdentifyDisabled"
+              :title="startIdentifyButtonTitle"
+            >
               <img :src="uploadedImageUrl ? startIdentifyActiveImg : startIdentifyImg" alt="开始识别" class="btn-bg" />
             </button>
           </div>
@@ -476,7 +483,7 @@ const goBack = () => {
 
 // 显示历史记录抽屉的方法
 const showHistoryDrawer = () => {
-  if (!isIdentifying.value) {
+  if (!isStartIdentifyDisabled.value) {
     showHistory.value = true
   }
   // AI处理中时不执行任何操作,不记录点击意图
@@ -522,6 +529,7 @@ const selectedHistoryItem = ref(null); // 选中的历史记录项
 const isUploading = ref(false); // 修改:上传状态标识
 const isDragOver = ref(false); // 修改:拖拽上传区域是否悬停
 const isIdentifying = ref(false); // 修改:识别状态标识
+const isStartIdentifyLocked = ref(false); // 点击后立即锁定,避免异步校验期间重复进入
 const detectionResult = ref(null); // 存储识别结果
 const annotatedImageUrl = ref(""); // 存储标注后的图片URL
 
@@ -534,6 +542,8 @@ const streamingLabels = ref(''); // 流式输出的标签内容
 const isStreamingAnalysis = ref(false); // 控制整个分析文本流式输出状态
 const streamingAnalysis = ref(''); // 流式输出的完整分析文本
 const showAnalysisPrompt = ref(false); // 控制分析提示显示
+const isStartIdentifyDisabled = computed(() => isStartIdentifyLocked.value || isIdentifying.value)
+const startIdentifyButtonTitle = computed(() => isStartIdentifyDisabled.value ? '识别进行中,请稍候' : '开始识别')
 
 // 历史记录相关状态
 const historyData = ref([])
@@ -738,6 +748,7 @@ const createNewTask = () => {
   isUploading.value = false;
   isDragOver.value = false; // 重置拖拽状态
   isIdentifying.value = false; // 重置识别状态
+  isStartIdentifyLocked.value = false; // 重置按钮锁定状态
   annotatedImageUrl.value = "";
   exampleImages.value = {}; // 清空示例图数据
   isLoadingExample.value = false; // 重置示例图加载状态
@@ -1051,7 +1062,7 @@ const startIdentification = async () => {
     console.log("startIdentification 被调用");
     
     // 防抖检查:如果正在识别中,直接返回
-    if (isIdentifying.value) {
+    if (isStartIdentifyDisabled.value) {
       console.log("识别正在进行中,忽略重复点击");
       showWarning("识别正在进行中,请勿重复点击");
       return;
@@ -1069,6 +1080,8 @@ const startIdentification = async () => {
       return;
     }
     
+    isStartIdentifyLocked.value = true;
+
     // 检查用户最新识别记录是否已点评
     try {
       console.log("检查最新识别记录是否已点评");
@@ -1187,6 +1200,7 @@ const startIdentification = async () => {
   } finally {
     // 清除识别状态
     isIdentifying.value = false;
+    isStartIdentifyLocked.value = false;
   }
 };