净空前端,在原数据管理右边标签页,后端需同步配置才有用

This commit is contained in:
何德超 2025-08-12 17:49:00 +08:00
parent 7c455f59eb
commit eba5e68e2f
2 changed files with 304 additions and 345 deletions

View File

@ -0,0 +1,55 @@
import http from '@/utils/http'
/* 分页查询 */
export function getVideoPage(params: {
pageNo: number
pageSize: number
projectId: string
turbineId?: string
}) {
return http.get('/video-monitor/page', params )
}
/* 单文件上传 */
export function uploadSingleVideo(
projectId: string,
turbineId: string,
type: string,
file: File
) {
const fd = new FormData()
fd.append('file', file)
return http.post(
`/video-monitor/${projectId}/upload?turbineId=${turbineId}&type=${type}`,
fd,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
}
/* 批量上传 */
export function uploadBatchVideo(
projectId: string,
turbineId: string,
type: string,
files: File[]
) {
const fd = new FormData()
files.forEach(f => fd.append('files', f))
return http.post(
`/video-monitor/${projectId}/upload-batch?turbineId=${turbineId}&type=${type}`,
fd,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
}
/* 删除 */
export function deleteVideo(videoId: string) {
return http.del(`/video-monitor/${videoId}`)
}
/* 下载 */
export function downloadVideo(videoId: string) {
return http
.get(`/video-monitor/download/${videoId}`, { responseType: 'blob' })
.then(blob => URL.createObjectURL(blob))
}

View File

@ -1,407 +1,311 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<div class="raw-data-container"> <div class="raw-data-container">
<!-- <div class="page-header"> <!-- 顶部按钮 -->
<div class="page-title">原始数据管理</div>
<div class="page-subtitle">管理和分析原始视频数据</div>
</div> -->
<div class="action-bar"> <div class="action-bar">
<div class="action-buttons"> <div class="action-buttons">
<a-button type="primary" @click="showUploadModal = true"> <a-button type="primary" @click="openUploadModal">
<template #icon> <template #icon>
<IconUpload /> <IconUpload />
</template> </template>
上传视频 上传视频
</a-button> </a-button>
<a-button type="primary" @click="handleBatchAnalysis">
<template #icon>
<IconPlayCircle />
</template>
批量分析
</a-button>
<a-button type="primary" @click="handleExportData">
<template #icon>
<IconDownload />
</template>
导出数据
</a-button>
</div>
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目">
<a-select v-model="filterForm.projectId" placeholder="请选择项目">
<a-option value="project-1">风电场A区</a-option>
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item>
<a-form-item label="机组号">
<a-input v-model="filterForm.unitNumber" placeholder="请输入机组号" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model="filterForm.status" placeholder="请选择状态">
<a-option value="completed">已完成</a-option>
<a-option value="pending">待分析</a-option>
<a-option value="analyzing">分析中</a-option>
<a-option value="failed">失败</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
</a-form-item>
</a-form>
</div> </div>
</div> </div>
<div class="project-sections"> <!-- 筛选 -->
<div v-for="project in filteredProjects" :key="project.id" class="project-section"> <div class="filter-section">
<div class="project-header"> <a-form :model="filterForm" layout="inline">
<div class="project-title">{{ project.name }}</div> <a-form-item label="项目" required>
<div class="project-stats"> <a-select v-model="filterForm.projectId" placeholder="请选择项目" :options="projectOptions" allow-clear
<div class="stat-item"> @change="" />
<IconVideoCamera /> </a-form-item>
{{ project.totalVideos }} 个视频 <a-form-item label="机组">
</div> <a-select v-model="filterForm.turbineId" placeholder="请选择机组" :options="turbineOptions" allow-clear
<div class="stat-item"> :disabled="!filterForm.projectId" />
<IconCheckCircle /> </a-form-item>
{{ project.completedCount }} 个已完成 <a-form-item>
</div> <a-button type="primary" @click="handleQuery">查询</a-button>
<div class="stat-item"> </a-form-item>
<IconClockCircle /> </a-form>
{{ project.pendingCount }} 个待分析
</div>
</div>
</div>
<div class="units-grid">
<div v-for="unit in project.units" :key="unit.id" class="unit-card">
<div class="unit-header">
<div class="unit-title">{{ unit.number }}</div>
<div class="unit-actions">
<a-button type="primary" size="small" @click="handleViewUnitVideos(unit)">查看全部</a-button>
<a-button type="primary" size="small" @click="handleAnalyzeUnit(unit)">{{
getAnalysisButtonText(unit.status)
}}</a-button>
</div>
</div>
<div class="videos-list">
<div v-for="video in unit.videos" :key="video.id" class="video-item">
<div class="video-thumbnail">
<img :src="video.thumbnail" alt="Video Thumbnail" />
<div class="video-overlay" @click="handlePlayVideo(video)">
<IconPlayArrowFill style="font-size: 24px; color: #fff;" />
</div>
</div>
<div class="video-info">
<div class="video-name">{{ video.name }}</div>
<div class="video-meta">
<span>{{ video.duration }}</span>
<span>{{ video.angle }}°</span>
</div>
<div class="video-status">
<a-tag :color="getStatusColor(video.status)">{{ getStatusText(video.status) }}</a-tag>
</div>
</div>
</div>
</div>
<div class="analysis-progress">
<div class="progress-info">
<span>分析进度</span>
<span>{{ unit.progress }}%</span>
</div>
<a-progress :percent="unit.progress" :show-text="false" status="active" />
</div>
</div>
</div>
</div>
</div> </div>
<!-- 视频播放模态框 --> <!-- 列表 -->
<a-modal v-model:visible="videoModalVisible" title="原始视频播放" width="900px" @ok="videoModalVisible = false" <a-table :columns="columns" :data="tableData" :pagination="pagination" :loading="loading"
@cancel="videoModalVisible = false"> :scroll="{ y: 'calc(100vh - 380px)' }">
<video v-if="selectedVideo" :src="selectedVideo.url" controls <template #type="{ record }">
style="width: 100%; height: 480px; border-radius: 8px; background: #000;"></video> <a-tag>{{ record.type === 'clearance' ? '净空' : '形变' }}</a-tag>
<div v-if="selectedVideo" class="video-meta-info"> </template>
<p>项目{{ selectedVideo.projectName }}</p> <template #status="{ record }">
<p>机组号{{ selectedVideo.unitNumber }}</p> <a-tag :color="record.preTreatment ? 'green' : 'red'">
<p>采集人{{ selectedVideo.collector }}</p> {{ record.preTreatment ? '已处理' : '未处理' }}
<p>风速{{ selectedVideo.windSpeed }} m/s</p> </a-tag>
<p>转速{{ selectedVideo.rpm }} rpm</p> </template>
<p>采集时间{{ selectedVideo.time }}</p> <template #action="{ record }">
<p>角度{{ selectedVideo.angle }}°</p> <a-space>
</div> <a-button size="mini" @click="handlePreview(record)">预览</a-button>
</a-modal> <a-button size="mini" @click="handleDownload(record)">下载</a-button>
<a-popconfirm content="确认删除?" @ok="handleDelete(record)">
<a-button size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
<!-- 上传视频模态框 --> </template>
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" @ok="handleUpload" </a-table>
<!-- 上传弹窗 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" :ok-loading="uploading" @ok="handleUpload"
@cancel="showUploadModal = false"> @cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical"> <a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required> <a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目"> <a-select v-model="uploadForm.projectId" placeholder="请选择项目" :options="projectOptions" allow-clear
<a-option value="project-1">风电场A区</a-option> @change="onProjectChangeUpload" />
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item> </a-form-item>
<a-form-item label="机组号" required> <a-form-item label="机组">
<a-input v-model="uploadForm.unitNumber" placeholder="请输入机组号" /> <a-select v-model="uploadForm.turbineId" placeholder="请选择机组" :options="turbineOptionsUpload" allow-clear
:disabled="!uploadForm.projectId" />
</a-form-item> </a-form-item>
<a-form-item label="采集人" required> <a-form-item label="类型" required>
<a-input v-model="uploadForm.collector" placeholder="请输入采集人姓名" /> <a-select v-model="uploadForm.type" placeholder="请选择类型" :options="typeOptions" />
</a-form-item> </a-form-item>
<a-form-item label="风速 (m/s)"> <a-form-item label="视频文件" required>
<a-input-number v-model="uploadForm.windSpeed" :min="0" /> <a-upload v-model:file-list="uploadForm.fileList" :multiple="uploadMode === 'batch'"
:limit="uploadMode === 'batch' ? 10 : 1" accept="video/*" :auto-upload="false" list-type="picture-card" />
</a-form-item> </a-form-item>
<a-form-item label="转速 (rpm)"> <a-form-item>
<a-input-number v-model="uploadForm.rpm" :min="0" /> <a-radio-group v-model="uploadMode" type="button">
</a-form-item> <a-radio value="single">单文件</a-radio>
<a-form-item label="采集时间" required> <a-radio value="batch">批量</a-radio>
<a-date-picker v-model="uploadForm.time" show-time format="YYYY-MM-DD HH:mm" style="width: 100%;" /> </a-radio-group>
</a-form-item>
<a-form-item label="视频文件可多选建议3个角度" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="true" :limit="3" accept="video/*"
:auto-upload="false" list-type="picture-card">
<template #upload-button>
<a-button>选择视频</a-button>
</template>
</a-upload>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
</div> </div>
</GiPageLayout> </GiPageLayout>
<!-- 视频预览弹窗 -->
<a-modal v-model:visible="previewVisible" title="视频预览" width="800px" :footer="false" @cancel="previewVisible = false">
<a-tabs v-model:active-key="activePreviewTab" @change="activePreviewTab = $event as any">
<!-- 原始视频 -->
<a-tab-pane key="video" title="原始视频">
<video v-if="previewUrl" :src="previewUrl" controls
style="width: 100%; max-height: 60vh; border-radius: 4px"></video>
</a-tab-pane>
<!-- 处理结果 -->
<a-tab-pane key="result" title="处理结果">
<a-spin :loading="loadingResult">
<a-space direction="vertical" size="medium" style="width: 100%">
<!-- 图片 -->
<img v-if="resultImgUrl" :src="resultImgUrl" style="max-width: 100%; border-radius: 4px" alt="last frame" />
<!-- JSON 预览 -->
<a-card title="results.json" size="small">
<pre>{{ JSON.stringify(resultJson, null, 2) }}</pre>
</a-card>
</a-space>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { IconUpload } from '@arco-design/web-vue/es/icon'
import { import {
IconUpload, getProjectList,
IconPlayCircle, getTurbineList
IconDownload, } from '@/apis/industrial-image'
IconVideoCamera, import {
IconCheckCircle, getVideoPage,
IconClockCircle, uploadBatchVideo,
IconPlayArrowFill uploadSingleVideo,
} from '@arco-design/web-vue/es/icon' deleteVideo,
downloadVideo
} from '@/apis/video-monitor'
const showUploadModal = ref(false) /* ---------------- 下拉 & 表单 ---------------- */
const videoModalVisible = ref(false) const projectOptions = ref<{ label: string; value: string }[]>([])
const selectedVideo = ref<any>(null) const turbineOptions = ref<{ label: string; value: string }[]>([]) //
const turbineOptionsUpload = ref<{ label: string; value: string }[]>([]) //
const typeOptions = [
{ label: '净空', value: 'clearance' },
{ label: '形变', value: 'deformation' }
]
const filterForm = reactive({ const filterForm = reactive({
projectId: '', projectId: '',
unitNumber: '', turbineId: ''
status: ''
}) })
const uploadForm = reactive({ const uploadForm = reactive({
projectId: '', projectId: '',
unitNumber: '', turbineId: '',
collector: '', type: '',
windSpeed: null, fileList: [] as any[]
rpm: null,
time: '',
fileList: []
}) })
// const uploadMode = ref<'single' | 'batch'>('single')
const projects = ref([
{
id: 'project-1',
name: '风电场A区',
totalVideos: 6,
completedCount: 4,
pendingCount: 2,
units: [
{
id: 'A-001',
number: 'A-001',
status: 'completed',
progress: 100,
videos: [
{
id: 'v1',
name: 'A-001-正面',
url: '/videos/A-001-front.mp4',
thumbnail: '/images/A-001-front.jpg',
angle: 0,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v2',
name: 'A-001-侧面',
url: '/videos/A-001-side.mp4',
thumbnail: '/images/A-001-side.jpg',
angle: 90,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v3',
name: 'A-001-背面',
url: '/videos/A-001-back.mp4',
thumbnail: '/images/A-001-back.jpg',
angle: 180,
duration: '00:30',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
}
]
},
{
id: 'A-002',
number: 'A-002',
status: 'analyzing',
progress: 60,
videos: [
{
id: 'v4',
name: 'A-002-正面',
url: '/videos/A-002-front.mp4',
thumbnail: '/images/A-002-front.jpg',
angle: 0,
duration: '00:28',
status: 'analyzing',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v5',
name: 'A-002-侧面',
url: '/videos/A-002-side.mp4',
thumbnail: '/images/A-002-side.jpg',
angle: 90,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v6',
name: 'A-002-背面',
url: '/videos/A-002-back.mp4',
thumbnail: '/images/A-002-back.jpg',
angle: 180,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
}
]
}
]
}
// ...
])
const filteredProjects = computed(() => { /* ---------------- 列表 ---------------- */
// const columns: TableColumnData[] = [
return projects.value { title: '文件名', dataIndex: 'videoName', ellipsis: true, width: 220 },
.filter(p => !filterForm.projectId || p.id === filterForm.projectId) // { title: '', dataIndex: 'projectName' },
.map(project => ({ // { title: '', dataIndex: 'turbineName' },
...project, { title: '类型', slotName: 'type' },
units: project.units { title: '上传时间', dataIndex: 'uploadTime' },
.filter(u => !filterForm.unitNumber || u.number === filterForm.unitNumber) { title: '状态', slotName: 'status' },
.map(unit => ({ { title: '操作', slotName: 'action', width: 120, fixed: 'right' }
...unit, ]
videos: unit.videos.filter(v => !filterForm.status || v.status === filterForm.status)
})) const tableData = ref<any[]>([])
.filter(u => u.videos.length > 0) const loading = ref(false)
})) const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
.filter(p => p.units.length > 0)
/* ---------------- 控制弹窗 ---------------- */
const showUploadModal = ref(false)
const uploading = ref(false)
/* ---------------- 初始化 ---------------- */
onMounted(async () => {
const { data } = await getProjectList({ page: 1, pageSize: 1000 })
projectOptions.value = data.map((p: any) => ({ label: p.projectName, value: p.projectId }))
handleQuery()
}) })
const activePreviewTab = ref<'video' | 'result'>('video') //
const resultImgUrl = ref('')
const resultJson = ref<Record<string, any>>({})
const loadingResult = ref(false)
function handleFilterChange() { async function loadResultFiles(row: any) {
// API if (!row.preTreatment) return
loadingResult.value = true
try {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
//
resultImgUrl.value = `${base}${row.preImagePath}/last_frame.jpg`
// JSON
const jsonUrl = `${base}${row.preImagePath}/results.json`
const res = await fetch(jsonUrl)
resultJson.value = await res.json()
} catch (e) {
console.error(e)
resultJson.value = {}
} finally {
loadingResult.value = false
}
console.log('result', resultImgUrl.value)
} }
/* 项目 -> 机组(筛选) */
watch(
() => filterForm.projectId,
async (val) => {
filterForm.turbineId = ''
turbineOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineOptions.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
)
const previewVisible = ref(false)
const previewUrl = ref('')
function handlePlayVideo(video: any) { function handlePreview(row: any) {
selectedVideo.value = video const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
videoModalVisible.value = true previewUrl.value = new URL(row.videoPath.replace(/^\/+/, ''), base).href
} previewVisible.value = true
activePreviewTab.value = 'video' //
function handleViewUnitVideos(unit: any) { if (row.preTreatment) {
// loadResultFiles(row) //
Message.info(`查看机组 ${unit.number} 的所有视频`)
}
function handleAnalyzeUnit(unit: any) {
//
Message.success(`已提交机组 ${unit.number} 的分析任务`)
// API
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'green'
case 'pending': return 'gray'
case 'analyzing': return 'blue'
case 'failed': return 'red'
default: return 'gray'
} }
} }
function getStatusText(status: string) { /* 项目 -> 机组(上传弹窗) */
switch (status) { async function onProjectChangeUpload(projectId: string) {
case 'completed': return '已完成' uploadForm.turbineId = ''
case 'pending': return '待分析' turbineOptionsUpload.value = []
case 'analyzing': return '分析中' if (!projectId) return
case 'failed': return '失败' const { data } = await getTurbineList({ projectId })
default: return '未知' turbineOptionsUpload.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
} }
function getAnalysisButtonText(status: string) {
switch (status) { /* ---------------- 查询 ---------------- */
case 'completed': return '重新分析' function handleQuery() {
case 'pending': return '分析' pagination.current = 1
case 'analyzing': return '分析中...' loadTable()
case 'failed': return '重新分析' }
default: return '分析' async function loadTable() {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
projectId: filterForm.projectId,
turbineId: filterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
console.log(data)
tableData.value = data
pagination.total = data.length
} finally {
loading.value = false
} }
} }
function handleBatchAnalysis() { /* ---------------- 上传 ---------------- */
Message.success('批量分析任务已提交') function openUploadModal() {
uploadForm.projectId = ''
uploadForm.turbineId = ''
uploadForm.type = ''
uploadForm.fileList = []
showUploadModal.value = true
} }
function handleExportData() { async function handleUpload() {
Message.success('数据导出成功') if (!uploadForm.projectId || !uploadForm.type || !uploadForm.fileList.length) {
Message.warning('请完整填写')
return
}
uploading.value = true
try {
const files = uploadForm.fileList.map((f: any) => f.file)
if (uploadMode.value === 'single') {
await uploadSingleVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files[0]
)
} else {
await uploadBatchVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files
)
}
Message.success('上传成功')
showUploadModal.value = false
loadTable()
} finally {
uploading.value = false
}
} }
function handleUpload() {
Message.success('上传成功') /* ---------------- 下载 / 删除 ---------------- */
showUploadModal.value = false async function handleDownload(row: any) {
const url = await downloadVideo(row.videoId)
window.open(url, '_blank')
}
async function handleDelete(row: any) {
await deleteVideo(row.videoId)
Message.success('删除成功')
loadTable()
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.raw-data-container { .raw-data-container {
padding: 20px; padding: 20px;