doum
7 小时以前 ce44d803b73a65b2cc31db5bcc662139029463d3
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>