From ce44d803b73a65b2cc31db5bcc662139029463d3 Mon Sep 17 00:00:00 2001
From: doum <doum>
Date: 星期五, 03 七月 2026 10:07:32 +0800
Subject: [PATCH] 海康电表维护

---
 admin/src/views/business/collectionMedia.vue |  370 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 359 insertions(+), 11 deletions(-)

diff --git a/admin/src/views/business/collectionMedia.vue b/admin/src/views/business/collectionMedia.vue
index d6ca607..2745fb5 100644
--- a/admin/src/views/business/collectionMedia.vue
+++ b/admin/src/views/business/collectionMedia.vue
@@ -51,19 +51,38 @@
         <el-table-column prop="endTime" label="缁撴潫鏃堕棿" min-width="160" />
         <el-table-column prop="downloadStatus" label="涓嬭浇鐘舵��" width="90">
           <template slot-scope="{row}">
-            <span v-if="row.downloadStatus === 1">宸蹭笅杞�</span>
-            <span v-else-if="row.downloadStatus === 2">澶辫触</span>
-            <span v-else-if="row.downloadStatus === 3">涓嬭浇涓�</span>
-            <span v-else>寰呬笅杞�</span>
+            <span :class="downloadStatusClass(row)">{{ downloadStatusLabel(row) }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="160" show-overflow-tooltip />
-        <el-table-column label="鎿嶄綔" width="220" fixed="right">
+        <el-table-column prop="snapshotStatus" label="蹇収" width="90">
           <template slot-scope="{row}">
-            <el-button type="text" v-if="canPreview(row)" @click="handlePreview(row)">棰勮</el-button>
+            <span :class="snapshotStatusClass(row)">{{ snapshotStatusLabel(row) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="120">
+          <template slot-scope="{row}">
+            <div v-if="isDownloadedVideo(row)" class="list-video-cell" @click="handlePreview(row)">
+              <video
+                :key="'thumb-' + row.id"
+                :src="buildListVideoSrc(row)"
+                class="list-video-thumb"
+                muted
+                preload="metadata"
+                playsinline
+              />
+            </div>
+            <span v-else class="path-text" :title="row.filePathLocal">{{ row.filePathLocal || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="鎿嶄綔" width="240" fixed="right">
+          <template slot-scope="{row}">
             <el-button type="text" v-if="canDownloadToStation(row)" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']"
               @click="handleDownload(row)">涓嬭浇</el-button>
             <el-button type="text" v-if="row.downloadStatus === 1" @click="handleSaveLocal(row)">涓嬭浇鍒版湰鍦�</el-button>
+            <el-button type="text" v-if="row.downloadStatus === 1 && row.snapshotStatus !== 1"
+              @click="handleAnalyzeSnapshot(row)">鐢熸垚蹇収</el-button>
+            <el-button type="text" v-if="row.downloadStatus === 1" @click="openManualCorrect(row)">浜哄伐绾犳</el-button>
+            <el-button type="text" v-if="row.snapshotStatus === 2" @click="handleViewSnapshot(row)">鏌ョ湅蹇収</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -82,6 +101,44 @@
         <div v-else-if="!previewLoading" class="preview-empty">鏆備笉鏀寔璇ョ被鍨嬮瑙�</div>
       </div>
     </el-dialog>
+
+    <el-dialog :title="snapshotTitle" :visible.sync="snapshotVisible" width="720px" append-to-body>
+      <div v-loading="snapshotLoading" class="snapshot-wrap">
+        <div v-if="snapshotList.length" class="snapshot-grid">
+          <div v-for="item in snapshotList" :key="item.id + '-' + snapshotCacheVersion" class="snapshot-item">
+            <div class="snapshot-label">{{ snapshotTypeLabel(item.snapshotType) }}</div>
+            <el-image v-if="item.fileUrlFull" :src="buildSnapshotImageUrl(item.fileUrlFull)" fit="contain"
+              class="snapshot-image" :preview-src-list="[buildSnapshotImageUrl(item.fileUrlFull)]" />
+            <div class="snapshot-meta">
+              <span>鏃跺埢: {{ item.timestampSec }}s</span>
+              <span v-if="item.confidence">缃俊搴�: {{ item.confidence }}</span>
+              <span>鏉ユ簮: {{ item.source || '-' }}</span>
+            </div>
+          </div>
+        </div>
+        <div v-else-if="!snapshotLoading" class="preview-empty">鏆傛棤蹇収锛岃鍏堢偣鍑汇�岀敓鎴愬揩鐓с��</div>
+        <div v-if="snapshotMediaRow" class="snapshot-actions">
+          <el-button type="primary" plain @click="openManualCorrect(snapshotMediaRow)">浜哄伐绾犳鏃跺埢</el-button>
+        </div>
+      </div>
+    </el-dialog>
+
+    <el-dialog title="浜哄伐绾犳蹇収鏃跺埢" :visible.sync="manualCorrectVisible" width="860px" append-to-body
+      @close="closeManualCorrect">
+      <div v-loading="manualCorrectLoading" class="manual-correct-wrap">
+        <video v-if="manualPreviewSrc" ref="manualVideo" :src="manualPreviewSrc" controls preload="metadata"
+          playsinline class="preview-video" @loadedmetadata="onManualVideoLoaded" @timeupdate="onManualTimeUpdate" />
+        <div class="manual-slider">
+          <span>褰撳墠鏃跺埢: {{ manualTimestampSec.toFixed(1) }}s</span>
+          <el-slider v-model="manualTimestampSec" :min="0" :max="manualDurationSec" :step="0.5"
+            @input="seekManualVideo" />
+        </div>
+        <div class="manual-buttons">
+          <el-button type="primary" :loading="manualSaving" @click="saveManualSnapshot(1)">璁句负闂ㄥご鍥�</el-button>
+          <el-button type="success" :loading="manualSaving" @click="saveManualSnapshot(2)">璁句负浜や粯鍥�</el-button>
+        </div>
+      </div>
+    </el-dialog>
   </TableLayout>
 </template>
 
@@ -90,7 +147,16 @@
 import TableLayout from '@/layouts/TableLayout'
 import Pagination from '@/components/common/Pagination'
 import { syncMedia, downloadMedia, batchDownloadMedia, list as fetchStationList } from '@/api/business/collectionStation'
-import { fetchPreviewText, fetchPreviewBlob, fetchMediaFile, ensureMp4Blob, buildPreviewUrl } from '@/api/business/collectionMedia'
+import {
+  fetchPreviewText,
+  fetchPreviewBlob,
+  fetchMediaFile,
+  ensureMp4Blob,
+  buildPreviewUrl,
+  analyzeMediaSnapshot,
+  fetchMediaSnapshots,
+  saveManualMediaSnapshot
+} from '@/api/business/collectionMedia'
 
 export default {
   name: 'CollectionMedia',
@@ -113,7 +179,23 @@
       previewRow: null,
       previewUseDirectUrl: false,
       previewBlobUrl: '',
-      downloadPollTimer: null
+      downloadPollTimer: null,
+      snapshotVisible: false,
+      snapshotLoading: false,
+      snapshotTitle: '閰嶉�佸揩鐓�',
+      snapshotList: [],
+      snapshotPollTimer: null,
+      snapshotMediaRow: null,
+      manualCorrectVisible: false,
+      manualCorrectLoading: false,
+      manualCorrectRow: null,
+      manualPreviewSrc: '',
+      manualBlobUrl: '',
+      manualTimestampSec: 0,
+      manualDurationSec: 600,
+      manualSaving: false,
+      manualSeekFromSlider: false,
+      snapshotCacheVersion: 0
     }
   },
   created () {
@@ -131,7 +213,9 @@
   },
   beforeDestroy () {
     this.revokePreviewUrl()
+    this.revokeManualPreviewUrl()
     this.stopDownloadPoll()
+    this.stopSnapshotPoll()
   },
   methods: {
     loadStations () {
@@ -151,8 +235,11 @@
       if (row.mediaType === 2) return '闊抽'
       return '瑙嗛'
     },
-    canPreview (row) {
-      return row.downloadStatus === 1 && (row.fileUrlFull || row.filePathLocal)
+    isDownloadedVideo (row) {
+      return row.downloadStatus === 1 && row.filePathLocal && this.resolvePreviewMode(row) === 'video'
+    },
+    buildListVideoSrc (row) {
+      return row.fileUrlFull || buildPreviewUrl(row.id)
     },
     canDownloadToStation (row) {
       return row.downloadStatus !== 1 && row.downloadStatus !== 3
@@ -199,6 +286,94 @@
       }).catch(err => {
         this.$message.error(err.message || '涓嬭浇鍒版湰鍦板け璐�')
       })
+    },
+    snapshotTypeLabel (type) {
+      return type === 2 ? '璐у搧浜や粯鍥�' : '鍒板簵闂ㄥご鍥�'
+    },
+    downloadStatusLabel (row) {
+      const map = { 0: '寰呬笅杞�', 1: '宸蹭笅杞�', 2: '澶辫触', 3: '涓嬭浇涓�' }
+      return map[row.downloadStatus] != null ? map[row.downloadStatus] : '寰呬笅杞�'
+    },
+    downloadStatusClass (row) {
+      const map = { 0: 'status-info', 1: 'status-success', 2: 'status-danger', 3: 'status-primary' }
+      return map[row.downloadStatus] || 'status-info'
+    },
+    snapshotStatusLabel (row) {
+      if (row.snapshotStatus === 2) return '宸插畬鎴�'
+      if (row.snapshotStatus === 1) return '鍒嗘瀽涓�'
+      if (row.snapshotStatus === 3) return '澶辫触'
+      if (row.downloadStatus === 1) return '鏈垎鏋�'
+      return '-'
+    },
+    snapshotStatusClass (row) {
+      if (row.snapshotStatus === 2) return 'status-success'
+      if (row.snapshotStatus === 1) return 'status-warning'
+      if (row.snapshotStatus === 3) return 'status-danger'
+      if (row.downloadStatus === 1) return 'status-info'
+      return 'status-muted'
+    },
+    buildSnapshotImageUrl (url) {
+      if (!url) return ''
+      const sep = url.indexOf('?') >= 0 ? '&' : '?'
+      return `${url}${sep}_t=${this.snapshotCacheVersion}`
+    },
+    handleAnalyzeSnapshot (row) {
+      analyzeMediaSnapshot(row.id).then(res => {
+        this.$message.success(res || '宸叉彁浜ゅ揩鐓у垎鏋�')
+        this.search()
+        this.startSnapshotPoll(row.id)
+      })
+    },
+    handleViewSnapshot (row) {
+      this.snapshotMediaRow = row
+      this.snapshotTitle = (row.fileName || '濯掍綋') + ' - 閰嶉�佸揩鐓�'
+      this.snapshotVisible = true
+      this.loadSnapshots(row.id)
+    },
+    loadSnapshots (mediaId) {
+      this.snapshotLoading = true
+      this.snapshotList = []
+      this.snapshotCacheVersion = Date.now()
+      fetchMediaSnapshots(mediaId, this.snapshotCacheVersion).then(list => {
+        this.snapshotList = list || []
+        this.snapshotLoading = false
+      }).catch(err => {
+        this.snapshotLoading = false
+        this.$message.error(err.message || '鍔犺浇蹇収澶辫触')
+      })
+    },
+    startSnapshotPoll (mediaId) {
+      this.stopSnapshotPoll()
+      let count = 0
+      this.snapshotPollTimer = setInterval(() => {
+        count++
+        if (count > 40) {
+          this.stopSnapshotPoll()
+          return
+        }
+        this.api.fetchList({
+          page: this.tableData.pagination.pageIndex,
+          capacity: this.tableData.pagination.pageSize,
+          model: this.searchForm,
+          sorts: this.tableData.sorts
+        }).then(data => {
+          this.tableData.list = data.records
+          this.tableData.pagination.total = data.total
+          const row = (data.records || []).find(item => item.id === mediaId)
+          if (row && row.snapshotStatus !== 1) {
+            this.stopSnapshotPoll()
+            if (row.snapshotStatus === 2 && this.snapshotVisible) {
+              this.loadSnapshots(mediaId)
+            }
+          }
+        }).catch(() => {})
+      }, 3000)
+    },
+    stopSnapshotPoll () {
+      if (this.snapshotPollTimer) {
+        clearInterval(this.snapshotPollTimer)
+        this.snapshotPollTimer = null
+      }
     },
     startDownloadPoll () {
       this.stopDownloadPoll()
@@ -333,6 +508,92 @@
         URL.revokeObjectURL(this.previewBlobUrl)
         this.previewBlobUrl = ''
       }
+    },
+    openManualCorrect (row) {
+      if (row.downloadStatus !== 1) {
+        this.$message.warning('璇峰厛涓嬭浇濯掍綋鏂囦欢')
+        return
+      }
+      this.manualCorrectRow = row
+      this.manualCorrectVisible = true
+      this.manualCorrectLoading = true
+      this.manualTimestampSec = 0
+      this.manualDurationSec = 600
+      this.revokeManualPreviewUrl()
+      fetchPreviewBlob(row.id)
+        .then(blob => ensureMp4Blob(blob))
+        .then(blob => {
+          this.manualBlobUrl = URL.createObjectURL(blob)
+          this.manualPreviewSrc = this.manualBlobUrl
+          this.manualCorrectLoading = false
+        })
+        .catch(err => {
+          this.manualCorrectLoading = false
+          this.$message.error(err.message || '鍔犺浇瑙嗛澶辫触')
+        })
+    },
+    closeManualCorrect () {
+      this.revokeManualPreviewUrl()
+      this.manualPreviewSrc = ''
+      this.manualCorrectRow = null
+    },
+    revokeManualPreviewUrl () {
+      if (this.manualBlobUrl) {
+        URL.revokeObjectURL(this.manualBlobUrl)
+        this.manualBlobUrl = ''
+      }
+    },
+    onManualVideoLoaded () {
+      const video = this.$refs.manualVideo
+      if (video && video.duration && isFinite(video.duration)) {
+        this.manualDurationSec = Math.max(1, Math.floor(video.duration))
+      }
+    },
+    onManualTimeUpdate () {
+      if (this.manualSeekFromSlider) {
+        return
+      }
+      const video = this.$refs.manualVideo
+      if (video) {
+        this.manualTimestampSec = Math.round(video.currentTime * 2) / 2
+      }
+    },
+    seekManualVideo (val) {
+      const video = this.$refs.manualVideo
+      if (!video) {
+        return
+      }
+      this.manualSeekFromSlider = true
+      video.currentTime = val
+      setTimeout(() => {
+        this.manualSeekFromSlider = false
+      }, 200)
+    },
+    saveManualSnapshot (snapshotType) {
+      if (!this.manualCorrectRow) {
+        return
+      }
+      this.manualSaving = true
+      saveManualMediaSnapshot({
+        mediaId: this.manualCorrectRow.id,
+        snapshotType,
+        timestampSec: this.manualTimestampSec
+      }).then(res => {
+        this.manualSaving = false
+        this.$message.success(res || '淇濆瓨鎴愬姛')
+        const mediaId = this.manualCorrectRow.id
+        this.snapshotCacheVersion = Date.now()
+        this.search()
+        if (this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+          this.snapshotMediaRow = { ...this.snapshotMediaRow, snapshotStatus: 2 }
+        }
+        if (this.snapshotVisible && this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+          this.loadSnapshots(mediaId)
+        }
+      }).catch(err => {
+        this.manualSaving = false
+        this.$message.error(err.message || '淇濆瓨澶辫触')
+      })
     }
   }
 }
@@ -369,4 +630,91 @@
   color: #909399;
   padding: 40px 0;
 }
+.snapshot-wrap {
+  min-height: 120px;
+}
+.snapshot-grid {
+  display: flex;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+.snapshot-item {
+  flex: 1;
+  min-width: 280px;
+}
+.snapshot-label {
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.snapshot-image {
+  width: 100%;
+  max-height: 280px;
+  background: #f5f7fa;
+}
+.snapshot-meta {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #606266;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.snapshot-actions {
+  margin-top: 16px;
+  text-align: center;
+}
+.manual-correct-wrap {
+  min-height: 200px;
+}
+.manual-slider {
+  margin-top: 16px;
+}
+.manual-buttons {
+  margin-top: 16px;
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+}
+.list-video-cell {
+  display: inline-block;
+  cursor: pointer;
+  line-height: 0;
+}
+.list-video-thumb {
+  width: 70px;
+  height: 70px;
+  object-fit: cover;
+  background: #000;
+  border-radius: 4px;
+  vertical-align: middle;
+}
+.path-text {
+  display: inline-block;
+  max-width: 160px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.status-success {
+  color: #67c23a;
+  font-weight: 500;
+}
+.status-danger {
+  color: #f56c6c;
+  font-weight: 500;
+}
+.status-warning {
+  color: #e6a23c;
+  font-weight: 500;
+}
+.status-primary {
+  color: #409eff;
+  font-weight: 500;
+}
+.status-info {
+  color: #909399;
+}
+.status-muted {
+  color: #c0c4cc;
+}
 </style>

--
Gitblit v1.9.3