| | |
| | | <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> |
| | |
| | | <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> |
| | | |
| | |
| | | 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', |
| | |
| | | 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 () { |
| | |
| | | }, |
| | | beforeDestroy () { |
| | | this.revokePreviewUrl() |
| | | this.revokeManualPreviewUrl() |
| | | this.stopDownloadPoll() |
| | | this.stopSnapshotPoll() |
| | | }, |
| | | methods: { |
| | | loadStations () { |
| | |
| | | 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 |
| | |
| | | }).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() |
| | |
| | | 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 || '保存失败') |
| | | }) |
| | | } |
| | | } |
| | | } |
| | |
| | | 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> |