完善细节

This commit is contained in:
BBIT-Kai
2025-11-14 18:08:04 +08:00
parent d39c37f896
commit 530cede0bd
11 changed files with 561 additions and 14 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# 应用标题
VITE_APP_TITLE=主干AI实验室
VITE_APP_TITLE=AI实验室
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
VITE_APP_NAMESPACE=vben-web-antd
@@ -7,7 +7,7 @@ const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:round-remove-red-eye',
authority: ['iva', 'sca', 'sca2', 'ysa', 'ticket', 'license'],
authority: ['iva', 'iva-sc', 'sca', 'sca2', 'ysa', 'ticket', 'license'],
keepAlive: true,
order: 2,
title: $t('计算机视觉'),
@@ -21,11 +21,22 @@ const routes: RouteRecordRaw[] = [
meta: {
authority: ['iva'],
icon: 'mdi:video',
title: $t('ai.intelligence_video_analysis'),
title: '工作视频分析',
keepAlive: true,
},
component: () => import('#/views/cv/iva/index.vue'),
},
{
name: 'IVA-sc',
path: '/cv/iva-sc',
meta: {
authority: ['iva-sc'],
icon: 'mdi:video-image',
title: '蚕茧视频分析',
keepAlive: true,
},
component: () => import('#/views/cv/iva-sc/index.vue'),
},
{
name: 'SCA',
path: '/cv/sca',
@@ -97,6 +97,16 @@ const routes: RouteRecordRaw[] = [
title: '数据可视化',
},
},
{
name: 'VMManager-webui',
path: '/set/VMManager',
component: IFrameView,
meta: {
icon: 'mdi:monitor-dashboard',
link: `https://10.10.12.100/ui/#/host`,
title: '虚拟机管理',
},
},
],
},
];
@@ -0,0 +1,502 @@
<script setup lang="ts">
import type Player from 'video.js/dist/types/player';
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { EchartsUIType } from '@vben/plugins/echarts';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { AnalysisOverview, useVbenModal } from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
} from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Button, Form, Input, message } from 'ant-design-vue';
import videojs from 'video.js';
import * as api from '#/api';
import { createImageTaskV2 } from '#/api';
import 'video.js/dist/video-js.css';
const list = ref<any[]>([]);
const error = ref<null | string>(null);
const filterKeyword = ref('');
const activeTab = ref<'detail' | 'video'>('detail');
const selectedItem = ref<any>(null);
const detailList = ref<any[]>([]);
const videoEl = ref<HTMLVideoElement | null>(null);
const player = ref<null | Player>(null);
async function loadList() {
error.value = null;
const res = await api.refreshVideoList(filterKeyword.value);
list.value = res || [];
}
function refreshList() {
filterKeyword.value = '';
loadList();
message.success('视频列表加载完成');
}
function createTask() {
modalApi.open();
}
async function selectItem(item: any) {
const res = await api.refreshVideoDetail(item.v_id);
selectedItem.value = res;
refreshLineChart();
}
const tabs = [
{ key: 'detail', label: '分析详情' },
{ key: 'video', label: '分析视频' },
];
let overviewItems: AnalysisOverviewItem[];
// 监听关键词变化,调用防抖接口
watch(filterKeyword, () => {
loadList();
});
watch(selectedItem, () => {
overviewItems = [
{
icon: SvgDownloadIcon,
title: '出现的蚕茧数量',
totalTitle: '',
totalValue: 0,
value: selectedItem.value.v_a_count_people,
},
{
icon: SvgCardIcon,
title: '出现最多的种类',
totalTitle: '',
totalValue: 0,
value: selectedItem.value.v_a_max_action,
},
{
icon: SvgCakeIcon,
title: '最多同框蚕茧数量',
totalTitle: '',
totalValue: 0,
value: selectedItem.value.v_a_total_people,
},
{
icon: SvgBellIcon,
title: '时间',
totalTitle: '最长停留时间',
totalValue: 0,
value: selectedItem.value.v_a_max_stay_time,
},
];
});
watch([activeTab, selectedItem], async ([tab]) => {
if (tab === 'video' && selectedItem.value?.v_video_play_path) {
refreshVideoPlayer();
}
});
onMounted(() => {
loadList();
});
onBeforeUnmount(() => {
player.value?.dispose();
player.value = null;
});
// ✅ 切换视频项时销毁并重建
function refreshVideoPlayer() {
nextTick(() => {
if (player.value) {
player.value.src([
{
src: selectedItem.value.v_video_play_path,
type: 'video/mp4',
},
]);
} else {
if (!videoEl.value) return;
player.value = videojs(videoEl.value, {
controls: true,
autoplay: false,
preload: 'auto',
sources: [
{
src: selectedItem.value.v_video_play_path,
type: 'video/mp4',
},
],
});
}
});
}
const showInfoStr = ref<Record<string, number | string>>({});
const chartRef1 = ref<EchartsUIType>();
const { renderEcharts: renderEcharts1 } = useEcharts(chartRef1);
const chartRef2 = ref<EchartsUIType>();
const { renderEcharts: renderEcharts2 } = useEcharts(chartRef2);
function refreshLineChart() {
const data = selectedItem.value;
showInfoStr.value = {
项目名: data.v_name,
文件名: data.v_file_name,
文件大小: `${data.v_size} MB`,
总时长: `${data.v_duration}`,
分辨率: data.v_resolution,
视频编码格式: data.v_video_codec,
音频编码格式: data.v_audio_codec,
总体比特率: data.v_overall_bit_rate,
};
detailList.value = data.v_details_list || [];
const detail = selectedItem.value.v_a_details;
let yTotalData = Array.isArray(detail.yTotalData)
? detail.yTotalData.map((item: any) => [item.first, item.second])
: []; // 默认值为空数组
let yMaskedData = Array.isArray(detail.yMaskedData)
? detail.yMaskedData.map((item: any) => [item.first, item.second])
: []; // 默认值为空数组
const areaData = Array.isArray(detail.areaData)
? detail.areaData.map((actionGroup: any) => {
return actionGroup.map((action: any) => {
return { xAxis: action.xAxis, itemStyle: action.itemStyle };
});
})
: []; // 默认值为空数组
yTotalData = yTotalData.map((item: any) => [
new Date(item[0]).getTime(),
item[1],
]);
yMaskedData = yMaskedData.map((item: any) => [
new Date(item[0]).getTime(),
item[1],
]);
renderEcharts1({
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
series: [
{
name: '总人数',
type: 'line',
step: 'end',
data: yTotalData,
markArea: {
itemStyle: { color: 'rgba(255, 173, 177, 0.4)' },
data: areaData,
},
},
{ name: '口罩佩戴人数', type: 'line', step: 'end', data: yMaskedData },
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#019680',
width: 1,
},
},
trigger: 'axis',
},
xAxis: { type: 'time' },
yAxis: { type: 'value' },
});
const maskedRatio = data.v_a_average_masked_ratio * 100;
const noMaskedRatio = 100 - maskedRatio;
renderEcharts2({
legend: { top: '5%', left: 'center' },
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{
value: maskedRatio,
name: '非正茧(%)',
},
{
value: noMaskedRatio,
name: '正茧(%)',
},
],
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
},
itemStyle: {
borderRadius: 10,
borderWidth: 3,
},
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
padAngle: 5,
name: '正茧平均占比',
radius: ['40%', '70%'],
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
if (activeTab.value === 'video' && player.value) {
// 如果当前是视频标签页,刷新播放器
refreshVideoPlayer();
}
}
const projectName = ref('');
const fileName = ref('');
const selectedFile = ref<File | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const [Modal, modalApi] = useVbenModal({
title: '新建蚕茧分析任务',
class: 'w-[600px]',
onCancel() {
modalApi.close();
},
onConfirm() {
if (!selectedFile.value) {
message.warning('请选择蚕茧图片');
return;
}
uploadFile();
},
});
async function uploadFile() {
// 先关闭弹窗
modalApi.close();
const formData = new FormData();
formData.append('file', selectedFile.value!);
formData.append('projectName', projectName.value);
await createImageTaskV2(formData).then(() => {
// 清空表单
projectName.value = '';
fileName.value = '';
selectedFile.value = null;
});
}
function selectFile() {
fileInputRef.value?.click();
}
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files && files.length > 0) {
selectedFile.value = files[0];
fileName.value = files[0].name;
}
}
// 分页参数
const page = ref(1);
const pageSize = ref(9);
const total = ref(0); // 总条数
// 计算总页数
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
function changePage(newPage) {
page.value = newPage;
loadList();
}
</script>
<template>
<div class="flex h-full w-full flex-col">
<BaseModal />
<Modal>
<Form layout="vertical">
<Form.Item label="任务名称">
<Input v-model:value="projectName" />
</Form.Item>
<Form.Item label="上传视频*" required>
<div
@click="selectFile"
style="
padding: 16px;
text-align: center;
cursor: pointer;
border: 1px dashed #d9d9d9;
"
>
{{ fileName || '点击选择文件' }}
<input
type="file"
accept="video/*"
ref="fileInputRef"
@change="handleFileChange"
style="display: none"
/>
</div>
</Form.Item>
</Form>
</Modal>
<div class="flex h-full w-full bg-gray-50">
<!-- 左侧筛选 + 列表 -->
<div class="flex w-64 flex-col border-r bg-white p-4">
<!-- 按钮组 -->
<div class="mb-4 flex justify-between space-x-2">
<Button type="primary" @click="createTask" class="flex-1">
新建任务
</Button>
<Button @click="refreshList" class="flex-1"> 刷新列表 </Button>
</div>
<!-- 筛选框 -->
<input
v-model="filterKeyword"
placeholder="筛选分析任务"
class="focus:ring-primary mb-4 rounded-md border px-3 py-2 focus:outline-none focus:ring-2"
/>
<!-- 列表 -->
<div class="flex-1 space-y-2 overflow-auto">
<div
v-for="item in list"
:key="item.v_id"
@click="selectItem(item)"
class="cursor-pointer rounded border p-3 hover:bg-gray-100"
:class="{ 'bg-gray-100': item.v_id === selectedItem?.v_id }"
>
<div class="text-base font-medium">{{ item.v_name }}</div>
<div class="text-sm text-gray-400">{{ item.v_a_time }}</div>
</div>
</div>
<!-- 分页 -->
<div class="mt-2 flex justify-center space-x-2">
<button
:disabled="page === 1"
@click="changePage(page - 1)"
class="rounded border px-1"
>
上一页
</button>
<span> {{ page }} / {{ totalPages }} </span>
<button
:disabled="page === totalPages"
@click="changePage(page + 1)"
class="rounded border px-1"
>
下一页
</button>
</div>
</div>
<!-- 右侧Tab 内容区 -->
<div class="flex flex-1 flex-col overflow-hidden p-6">
<!-- Tab 标题 -->
<div class="mb-4 flex shrink-0 space-x-4 border-b">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key as 'detail' | 'video'"
class="px-4 py-2"
:class="[
activeTab === tab.key
? 'border-primary text-primary border-b-2'
: 'hover:text-primary text-gray-500',
]"
>
{{ tab.label }}
</button>
</div>
<!-- Tab 内容滚动区域 -->
<div class="flex-1 overflow-auto">
<div
v-if="!selectedItem"
class="flex h-full items-center justify-center text-gray-400"
>
请先选择左侧列表中的分析任务
</div>
<template v-else>
<div
v-show="activeTab === 'detail'"
class="flex h-full flex-col gap-4"
>
<!-- 主内容区域左右结构 -->
<div class="flex flex-1 gap-4">
<!-- 左侧 -->
<div class="flex w-72 flex-col gap-4">
<!-- 视频基础信息展示 -->
<div
class="w-full rounded border bg-white p-4"
id="video_base_info"
>
<div
v-for="(value, key) in showInfoStr"
:key="key"
class="mb-2 flex text-sm text-gray-700"
>
<div class="w-32 font-medium text-gray-900">
{{ key }}
</div>
<div class="flex-1 break-all text-gray-600">
{{ value || '—' }}
</div>
</div>
</div>
<!-- 空白卡片 -->
<div class="h-[300px] flex-1 rounded border bg-white p-4">
<EchartsUI ref="chartRef2" />
</div>
</div>
<!-- 右侧 -->
<div class="flex flex-1 flex-col gap-4">
<!-- 四个统计卡片 -->
<AnalysisOverview
:items="overviewItems"
class="grid grid-cols-4 gap-4"
/>
<!-- 折线图区域 -->
<div class="flex-1 rounded border bg-white p-4">
<EchartsUI ref="chartRef1" />
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'video'" class="flex h-full space-x-4">
<video
ref="videoEl"
class="video-js vjs-default-skin h-full w-full"
preload="auto"
controls
></video>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -19,9 +19,16 @@ const cv: WorkbenchQuickNavItem[] = [
color: '#3fb27f',
authority: ['iva'],
icon: 'mdi:video',
title: '视频智能分析',
title: '工作视频分析',
url: '/cv/iva',
},
{
color: '#3fb27f',
authority: ['iva-sc'],
icon: 'mdi:video-image',
title: '蚕茧视频分析',
url: '/cv/iva-sc',
},
{
color: '#3fb27f',
authority: ['sca'],
@@ -139,7 +146,7 @@ function getGreeting() {
{{ getGreeting() }}, {{ userStore.userInfo?.username }},
开始您一天的工作吧
</template>
<template #description> 欢迎使用主干AI实验室</template>
<template #description> 欢迎使用AI实验室</template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">