Переглянути джерело

feat(device): 添加设备错误日志分类统计功能

- 在 deviceStatus store 中新增 isErrorLogMessage 函数用于识别错误日志
- 添加 errorUnreadCount 状态用于统计以 ! 开头的未读错误日志数量
- 实现错误日志的独立计数和清空功能
- 在 header-bar 中添加设备状态错误日志徽章显示
- 更新 check 组件中的日志展示样式和布局结构
- 在 photography 页面中添加删除单张图片功能
- 重构 header 菜单逻辑以支持动态菜单项处理
panqiuyao 1 місяць тому
батько
коміт
503fcd1243

+ 56 - 9
frontend/src/components/check/index.vue

@@ -72,9 +72,10 @@
 
         <div class="device-log-box">
           <div v-if="recentLogs.length === 0" class="log-empty">暂无设备上报信息</div>
-          <div v-else class="log-lines">
-            <div v-for="(log, index) in recentLogs" :key="index" class="log-line">
-              {{ log.message }}
+          <div v-else class="log-list">
+            <div v-for="(log, index) in recentLogs" :key="index" class="log-item">
+              <span class="log-time">{{ log.current_time }}</span>
+              <span class="log-message">{{ log.message }}</span>
             </div>
           </div>
         </div>
@@ -144,7 +145,7 @@ const mcuConnected = computed(() => checkInfoStore.mcuConnected)
 const serialSummary = computed(() => checkInfoStore.serialSummary)
 const cameraConnected = computed(() => checkInfoStore.devices.cam_control.status === 2)
 const recentLogs = computed(() =>
-  (deviceStatusStore.logs || []).filter((log) => log.message).slice(0, 6)
+  (deviceStatusStore.logs || []).filter((log) => log.message)
 )
 
 function getStatusText(status) {
@@ -269,6 +270,7 @@ onUnmounted(() => {
 <style lang="scss" scoped>
 .hardware-check-container {
   padding: 8px 8px 0;
+  text-align: left;
 }
 
 .step-content {
@@ -423,23 +425,58 @@ onUnmounted(() => {
 .device-log-box {
   background: #F5F6F7;
   border-radius: 8px;
-  padding: 12px 14px;
+  padding: 8px 12px;
   min-height: 108px;
-  max-height: 132px;
+  max-height: 180px;
   overflow-y: auto;
+  width: 100%;
+  box-sizing: border-box;
+  text-align: left !important;
 }
 
 .log-empty {
   color: #999;
   font-size: 13px;
   line-height: 22px;
+  text-align: left !important;
+}
+
+.log-list {
+  width: 100%;
+  text-align: left !important;
 }
 
-.log-line {
-  color: #666;
+.log-item {
+  display: flex;
+  justify-content: flex-start;
+  align-items: flex-start;
+  width: 100%;
+  box-sizing: border-box;
+  padding: 8px 0;
+  border-bottom: 1px solid #f0f0f0;
   font-size: 13px;
-  line-height: 22px;
+  text-align: left !important;
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.log-time {
+  flex-shrink: 0;
+  color: #999;
+  margin-right: 12px;
+  min-width: 150px;
+  line-height: 20px;
+  text-align: left !important;
+}
+
+.log-message {
+  flex: 1;
+  color: #333;
   word-break: break-all;
+  line-height: 20px;
+  text-align: left !important;
 }
 </style>
 
@@ -456,6 +493,16 @@ onUnmounted(() => {
 
   .el-dialog__body {
     padding-top: 10px;
+    text-align: left !important;
+  }
+
+  .device-log-box,
+  .log-list,
+  .log-item,
+  .log-time,
+  .log-message,
+  .log-empty {
+    text-align: left !important;
   }
 }
 </style>

+ 12 - 1
frontend/src/components/device-status-dialog/index.vue

@@ -77,6 +77,7 @@
             v-for="(log, index) in filteredLogs"
             :key="index"
             class="log-item"
+            :class="{ 'is-error': isErrorLog(log.message) }"
           >
             <span class="log-time">{{ log.current_time }}</span>
             <span class="log-message">{{ log.message }}</span>
@@ -93,10 +94,14 @@
 
 <script setup lang="ts">
 import { ref, computed } from 'vue';
-import useDeviceStatusStore from '@/stores/modules/deviceStatus';
+import useDeviceStatusStore, { isErrorLogMessage } from '@/stores/modules/deviceStatus';
 
 const deviceStatusStore = useDeviceStatusStore();
 
+function isErrorLog(message?: string) {
+  return isErrorLogMessage(message);
+}
+
 // 搜索关键词
 const searchKeyword = ref('');
 
@@ -273,6 +278,12 @@ function handleClose() {
           color: #333;
           word-break: break-all;
         }
+
+        &.is-error {
+          .log-message {
+            color: #F56C6C;
+          }
+        }
       }
     }
   }

+ 28 - 1
frontend/src/components/header-bar/index.vue

@@ -67,12 +67,16 @@
       <!-- 设备状态按钮 -->
       <div
           v-if="showDeviceStatus"
-          class="header-bar__menu-item"
+          class="header-bar__menu-item header-bar__device-status"
           @click="openDeviceStatus"
           title="设备状态"
       >
         <div class="header-bar__menu-item-content flex">
           <span class="header-bar__menu-name">设备状态</span>
+          <span
+            v-if="deviceStatusStore.errorUnreadCount > 0"
+            class="header-bar__badge"
+          >{{ deviceStatusStore.errorUnreadCount > 99 ? '99+' : deviceStatusStore.errorUnreadCount }}</span>
         </div>
       </div>
       <!-- 设备状态按钮 -->
@@ -479,6 +483,29 @@ function handleOutsideClick(event: MouseEvent) {
   font-size: 14px;
 }
 
+.header-bar__device-status {
+  position: relative;
+  -webkit-app-region: no-drag;
+  app-region: no-drag;
+}
+
+.header-bar__badge {
+  position: absolute;
+  top: 1px;
+  right: 2px;
+  min-width: 14px;
+  height: 14px;
+  padding: 0 4px;
+  border-radius: 8px;
+  background: #F56C6C;
+  color: #fff;
+  font-size: 10px;
+  line-height: 14px;
+  text-align: center;
+  font-weight: 600;
+  box-sizing: border-box;
+}
+
 .header-bar__title {
   position: absolute;
   top: 0;

+ 19 - 1
frontend/src/stores/modules/deviceStatus.ts

@@ -4,6 +4,10 @@ import client from "@/stores/modules/client";
 import icpList from '@/utils/ipc';
 import socket from "@/stores/modules/socket";
 
+export function isErrorLogMessage(message?: string) {
+  return String(message || '').trimStart().startsWith('!');
+}
+
 export interface McuErrorLog {
   message: string;
   current_time: string;
@@ -25,9 +29,12 @@ const useDeviceStatusStore = defineStore('deviceStatus', () => {
   // 日志列表
   const logs = ref<McuErrorLog[]>([]);
 
-  // 未读数量
+  // 未读数量(全部日志)
   const unreadCount = ref(0);
 
+  // 以 ! 开头的未读报错数量(用于顶部设备状态气泡)
+  const errorUnreadCount = ref(0);
+
   // 弹窗是否显示
   const showDialog = ref(false);
 
@@ -121,6 +128,15 @@ const useDeviceStatusStore = defineStore('deviceStatus', () => {
           raw: result
         };
         logs.value.unshift(logEntry);
+        if (!showDialog.value && isErrorLogMessage(logEntry.message)) {
+          errorUnreadCount.value += 1;
+        }
+      }
+    });
+
+    fetchLogs().then(() => {
+      if (!showDialog.value) {
+        errorUnreadCount.value = logs.value.filter((log) => isErrorLogMessage(log.message)).length;
       }
     });
   }
@@ -150,6 +166,7 @@ const useDeviceStatusStore = defineStore('deviceStatus', () => {
       clientStore.ipc.send(icpList.mcu.clearErrorUnread);
       clientStore.ipc.once(icpList.mcu.clearErrorUnread, (event, result) => {
         unreadCount.value = 0;
+        errorUnreadCount.value = 0;
         resolve(result);
       });
     });
@@ -218,6 +235,7 @@ const useDeviceStatusStore = defineStore('deviceStatus', () => {
   return {
     logs,
     unreadCount,
+    errorUnreadCount,
     showDialog,
     runStatus,
     statusLoading,

+ 38 - 0
frontend/src/views/Photography/mixin/usePhotography.ts

@@ -423,6 +423,43 @@ export default function usePhotography() {
 
     }
 
+    // 删除单张图片
+    const deletePhotoRecord = async (img) => {
+      if (!img?.id) return
+
+      try {
+        await ElMessageBox.confirm('确定要删除这张图片吗?', '提示', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+        })
+        await clickLog({ describe: { action: '点击确认删除单张图片', goods_art_no: img.goods_art_no, record_id: img.id, action_name: img.action_name } }, route)
+      } catch (e) {
+        await clickLog({ describe: { action: '点击取消删除单张图片', goods_art_no: img.goods_art_no, record_id: img.id, action_name: img.action_name } }, route)
+        return
+      }
+
+      const channel = icpList.socket.message + '_delete_photo_record'
+      clientStore.ipc.removeAllListeners(channel)
+      socketStore.sendMessage({
+        type: 'delete_photo_record',
+        data: {
+          record_id: img.id,
+        },
+      })
+      clientStore.ipc.on(channel, (event, result) => {
+        clientStore.ipc.removeAllListeners(channel)
+        console.log('_delete_photo_record', result)
+        if (result.code === 0) {
+          ElMessage.success(result.msg || '图片删除成功')
+          getPhotoRecords()
+        } else if (result.msg) {
+          ElMessage.error(result.msg)
+        } else {
+          ElMessage.error('图片删除失败')
+        }
+      })
+    }
+
     const resetStatus = () => {
       runLoading.value = false;
       takePictureLoading.value = false;
@@ -1167,6 +1204,7 @@ const onRemoteControl = async (type) => {
       deleteAllGoods,
       reTakePicture,
       reTakePictureNos,
+      deletePhotoRecord,
       oneClickStop,
       onRemoteControl,
       openPhotographyDetail,

+ 66 - 34
frontend/src/views/Photography/shot.vue

@@ -3,7 +3,7 @@
       title="拍摄商品"
       showUser
       showDeviceStatus
-      :menu="menu"
+      :menu="headerMenu"
   />
 
   <hardware-check @confirm="onHardwareCheckConfirm"/>
@@ -92,13 +92,6 @@
 
         </div>
 
-        <!-- 演示模式按钮(仅惠利玛用户可见) -->
-        <div class="demo-mode-btn-wrap" v-if="isHLM">
-          <el-button type="warning" size="large" @click="openDemoMode" :disabled="isDemoMode">
-            演示模式
-          </el-button>
-        </div>
-
         <div class="step-section">
           <div class="step-number flex-col"><span class="text_22">2</span></div>
           <div class="step-two flex-col justify-between">
@@ -215,7 +208,20 @@
                         </div>
                       </template>
                     </el-image>
-                    <el-button v-if="image.action_name && !runLoading && !takePictureLoading" :disabled="runLoading || takePictureLoading" class="reset-button" @click.stop="reTakePicture(image.PhotoRecord)" v-log="{ describe: { action: '重拍单张图片', goods_art_no: image.PhotoRecord.goods_art_no, action_name: image.action_name } }">重拍</el-button>
+                    <div v-if="image.action_name && !runLoading && !takePictureLoading" class="image-actions">
+                      <el-button
+                        :disabled="runLoading || takePictureLoading"
+                        class="reset-button"
+                        @click.stop="reTakePicture(image.PhotoRecord)"
+                        v-log="{ describe: { action: '重拍单张图片', goods_art_no: image.PhotoRecord.goods_art_no, action_name: image.action_name } }"
+                      >重拍</el-button>
+                      <el-button
+                        :disabled="runLoading || takePictureLoading"
+                        class="reset-button delete-button"
+                        @click.stop="deletePhotoRecord(image.PhotoRecord)"
+                        v-log="{ describe: { action: '删除单张图片', goods_art_no: image.PhotoRecord.goods_art_no, record_id: image.PhotoRecord.id, action_name: image.action_name } }"
+                      >删除</el-button>
+                    </div>
                   </div>
                 </div>
               </div>
@@ -352,6 +358,7 @@ const {
   deleteAllGoods,
   reTakePicture,
   reTakePictureNos,
+  deletePhotoRecord,
   onRemoteControl,
   openPhotographyDetail,
   onGenerateCLick,
@@ -374,6 +381,30 @@ const {
   onDemoProgramTypeChange,
 } = usePhotography()
 
+const headerMenu = computed(() => {
+  const items: any[] = []
+  for (const item of menu.value || []) {
+    if (item.name === '快捷操作') {
+      const extra = (item.children || []).filter(
+        (child: any) => child.type !== 'OneClickRelease' && child.type !== 'OneKeyDown'
+      )
+      items.push(...extra)
+      continue
+    }
+    items.push(item)
+  }
+  if (isHLM.value) {
+    const insertAt = items.findIndex((item) => item.type === 'setting')
+    items.splice(insertAt >= 0 ? insertAt + 1 : 0, 0, {
+      name: '演示模式',
+      click() {
+        if (!isDemoMode.value) openDemoMode()
+      }
+    })
+  }
+  return items
+})
+
 const fabOpen = ref(false)
 
 async function handleOneClickRelease() {
@@ -724,19 +755,6 @@ watch(goodsList, () => {
   }
 }
 
-// 演示模式按钮样式
-.demo-mode-btn-wrap {
-  display: flex;
-  justify-content: center;
-  margin: 20px 0 30px 50px;
-
-  .el-button {
-    width: 200px;
-    height: 44px;
-    font-size: 16px;
-  }
-}
-
 .photography-page {
   background-color: rgba(255, 255, 255, 1);
   position: relative;
@@ -1122,27 +1140,41 @@ watch(goodsList, () => {
               display: flex;
               width: 100%;
               height: 100%;
+              position: relative;
+
+              .image-actions {
+                position: absolute;
+                left: 50%;
+                top: 50%;
+                transform: translate(-50%, -50%);
+                display: none;
+                flex-direction: column;
+                align-items: center;
+                gap: 6px;
+                z-index: 2;
+              }
 
               .reset-button {
-                width: 40px;
+                width: 48px;
                 text-align: center;
-                height: 20px;
-                position: absolute;
-                left:50%;
-                top:50%;
-                padding: 0px;
-                margin-left:-20px;
-                margin-top:-10px;
+                height: 22px;
+                padding: 0;
+                margin: 0;
                 color: #ffffff;
-                font-size: 14px;
+                font-size: 13px;
                 background: rgba(0,0,0,0.6);
                 border-radius: 12px;
-                display: none;
+                border: none;
                 cursor: pointer;
               }
+
+              .delete-button {
+                background: rgba(255, 76, 0, 0.85);
+              }
+
               &:hover {
-                .reset-button {
-                  display: block;
+                .image-actions {
+                  display: flex;
                 }
               }
             }