@@ -22,11 +22,12 @@ import {
} from '@vben/icons' ;
import { EchartsUI , useEcharts } from '@vben/plugins/echarts' ;
import { Button , Form , Input , message } from 'ant-design-vue' ;
import { Button , Form , message } from 'ant-design-vue' ;
import axios from 'axios' ;
import videojs from 'video.js' ;
import * as api from '#/api' ;
import { createImageTaskV2 } from '#/api' ;
import { getIVASCUploadToken } from '#/api' ;
import 'video.js/dist/video-js.css' ;
@@ -37,11 +38,15 @@ const activeTab = ref<'detail' | 'video'>('detail');
const selectedItem = ref < any > ( null ) ;
const detailList = ref < any [ ] > ( [ ] ) ;
const videoEl = ref < HTMLVideoElement | null > ( null ) ;
const originalVideoEl = ref < HTMLVideoElement | null > ( null ) ;
const originalPlayer = ref < null | Player > ( null ) ;
const player = ref < null | Player > ( null ) ;
async function loadList ( ) {
error . value = null ;
const res = await api . refreshVideoList ( filterKeyword . value ) ;
list . value = res || [ ] ;
const res = await api . refreshSCVideoList ( filterKeyword . value ) ;
list . value = res . items || [ ] ;
total . value = res . total ;
}
function refreshList ( ) {
filterKeyword . value = '' ;
@@ -53,15 +58,18 @@ function createTask() {
}
async function selectItem ( item : any ) {
const res = await api . refreshVideoDetail ( item . v _id ) ;
selectedItem . value = res ;
const res = await api . refreshSCVideoDetail ( item . id ) ;
detailList . value = res ;
selectedItem . value = item ;
refreshLineChart ( ) ;
}
const tabs = [
{ key : 'detail' , label : '分析详情' } ,
{ key : 'original' , label : '原视频' } , // 新增
{ key : 'video' , label : '分析视频' } ,
] ;
let overviewItems : AnalysisOverviewItem [ ] ;
// 监听关键词变化,调用防抖接口
@@ -72,45 +80,50 @@ watch(selectedItem, () => {
overviewItems = [
{
icon : SvgDownloadIcon ,
title : '出现的蚕茧数量 ' ,
title : '出现的蚕茧总 数' ,
totalTitle : '' ,
totalValue : 0 ,
value : selectedItem . value . v _a _count _people ,
value : selectedItem . value . sc _analysis _total _count ,
} ,
{
icon : SvgCardIcon ,
title : '出现最多的种类' ,
totalTitle : '' ,
totalValue : 0 ,
value : selectedItem . value . v _a _max _action ,
value : selectedItem . value . sc _analysis _primary _type ,
} ,
{
icon : SvgCakeIcon ,
title : '最多同框蚕茧数量 ' ,
title : '出现次多的种类 ' ,
totalTitle : '' ,
totalValue : 0 ,
value : selectedItem . value . v _a _total _people ,
value : selectedItem . value . sc _analysis _secondary _type ,
} ,
{
icon : SvgBellIcon ,
title : '时间 ' ,
totalTitle : '最长停留时间 ' ,
title : '最多同框蚕茧数量 ' ,
totalTitle : '' ,
totalValue : 0 ,
value : selectedItem . value . v _a _max _stay _time ,
value : selectedItem . value . sc _analysis _max _count ,
} ,
] ;
} ) ;
watch ( [ activeTab , selectedItem ] , async ( [ tab ] ) => {
if ( tab === 'video' && selectedItem . value ? . v _video _play _path ) {
if ( tab === 'video' && selectedItem . value ? . ai _video _url ) {
refreshVideoPlayer ( ) ;
} else if ( tab === 'original' && selectedItem . value ? . raw _video _url ) {
refreshOriginalPlayer ( ) ;
}
} ) ;
onMounted ( ( ) => {
loadList ( ) ;
} ) ;
onBeforeUnmount ( ( ) => {
player . value ? . dispose ( ) ;
originalPlayer . value ? . dispose ( ) ;
player . value = null ;
originalPlayer . value = null ;
} ) ;
// ✅ 切换视频项时销毁并重建
@@ -119,7 +132,7 @@ function refreshVideoPlayer() {
if ( player . value ) {
player . value . src ( [
{
src : selectedItem . value . v _video _play _path ,
src : selectedItem . value . ai _video _url ,
type : 'video/mp4' ,
} ,
] ) ;
@@ -131,7 +144,7 @@ function refreshVideoPlayer() {
preload : 'auto' ,
sources : [
{
src : selectedItem . value . v _video _play _path ,
src : selectedItem . value . ai _video _url ,
type : 'video/mp4' ,
} ,
] ,
@@ -139,6 +152,32 @@ function refreshVideoPlayer() {
}
} ) ;
}
function refreshOriginalPlayer ( ) {
nextTick ( ( ) => {
if ( originalPlayer . value ) {
originalPlayer . value . src ( [
{
src : selectedItem . value . raw _video _url ,
type : 'video/mp4' ,
} ,
] ) ;
} else {
if ( ! originalVideoEl . value ) return ;
originalPlayer . value = videojs ( originalVideoEl . value , {
controls : true ,
autoplay : false ,
preload : 'auto' ,
sources : [
{
src : selectedItem . value . raw _video _url ,
type : 'video/mp4' ,
} ,
] ,
} ) ;
}
} ) ;
}
const showInfoStr = ref < Record < string , number | string > > ( { } ) ;
const chartRef1 = ref < EchartsUIType > ( ) ;
@@ -150,73 +189,63 @@ 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 ,
项目名 : data . name ,
文件大小 : ` ${ data . size _kb } KB ` ,
总时长 : ` ${ data . duration } 秒 ` ,
分辨率 : data . resolution ,
视频编码格式 : data . video _codec ,
音 频编码格式: data . audio _codec ,
总体比特率 : data . overall _bit _rate ,
} ;
detailList . value = data . v _details _list || [ ] ;
const temp = detailList . value ;
// 1. X轴
const xAxisData = temp . map ( ( item ) => item . time _stamp ) ;
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 ] ,
] ) ;
// 2. 获取类别(other_info 的 key)
const categories = Object . keys ( temp [ 0 ] ? . other _info || { } ) ;
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' ,
// 3. 构造 series
const series = categories . map ( ( key ) => ( {
name : key ,
type : 'line' ,
stack : 'Total ' ,
data : temp . map ( ( item ) => item . other _info [ key ] ) ,
} ) ) ;
const option = {
tooltip : { trigger : 'axis ' } ,
legend : { data : categories } ,
grid : {
left : '3%' ,
right : '4%' ,
bottom : '3%' ,
containLabel : true ,
} ,
xAxis : { type : 'time' } ,
yAxis : { type : 'value' } ,
} ) ;
xAxis : {
type : 'category' ,
boundaryGap : false ,
data : xAxisData ,
} ,
yAxis : {
type : 'value' ,
} ,
series ,
} ;
renderEcharts1 ( option ) ;
const chartData = data . other _info ;
// 将对象转换为 echarts 的 [{value, name}] 数组
const seriesData = Object . entries ( chartData ) . map ( ( [ name , value ] ) => ( {
name ,
value ,
} ) ) ;
const maskedRatio = data . v _a _average _masked _ratio * 100 ;
const noMaskedRatio = 100 - maskedRatio ;
renderEcharts2 ( {
legend : { top : '5%' , left : 'center' } ,
series : [
{
animationDelay ( ) {
@@ -225,17 +254,12 @@ function refreshLineChart() {
animationEasing : 'exponentialInOut' ,
animationType : 'scale' ,
avoidLabelOverlap : false ,
// 你原来的颜色保留
color : [ '#5ab1ef' , '#b6a2de' , '#67e0e3' , '#2ec7c9' ] ,
data : [
{
value : maskedRatio ,
name : '非正茧(%)' ,
} ,
{
value : noMaskedRatio ,
name : '正茧(%)' ,
} ,
] ,
data : seriesData , // 关键改动!!!
emphasis : {
label : {
fontSize : '12' ,
@@ -255,11 +279,11 @@ function refreshLineChart() {
show : false ,
} ,
padAngle : 5 ,
name : '正茧平均占比' ,
radius : [ '40%' , '70%' ] ,
type : 'pie' ,
} ,
] ,
tooltip : {
trigger : 'item' ,
} ,
@@ -270,7 +294,6 @@ function refreshLineChart() {
refreshVideoPlayer ( ) ;
}
}
const projectName = ref ( '' ) ;
const fileName = ref ( '' ) ;
const selectedFile = ref < File | null > ( null ) ;
const fileInputRef = ref < HTMLInputElement | null > ( null ) ;
@@ -293,15 +316,19 @@ const [Modal, modalApi] = useVbenModal({
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 ;
const uploadUrl = await getIVASCUploadToken ( ) ;
// 2. 使用 presigned URL 上传文件
const file = selectedFile . value ;
message . success ( '正在上传视频' ) ;
await axios . put ( uploadUrl , file , {
headers : {
'Content-Type' : file . type ,
} ,
} ) ;
message . success ( '正在分析,请稍后刷新列表查看' ) ;
// 清空表单
fileName . value = '' ;
selectedFile . value = null ;
}
function selectFile ( ) {
@@ -334,10 +361,6 @@ function changePage(newPage) {
< BaseModal / >
< Modal >
< Form layout = "vertical" >
< Form .Item label = "任务名称" >
< Input v -model :value = "projectName" / >
< / F o r m . I t e m >
< Form .Item label = "上传视频*" required >
< div
@click ="selectFile"
@@ -380,13 +403,13 @@ function changePage(newPage) {
< div class = "flex-1 space-y-2 overflow-auto" >
< div
v-for = "item in list"
:key = "item.v_ id"
:key = "item.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 }"
: class = "{ 'bg-gray-100': item.id === selectedItem?.id }"
>
< div class = "text-base font-medium" > { { item . v _name } } < / div >
< div class = "text-sm text-gray-400" > { { item . v _a _time } } < / div >
< div class = "text-base font-medium" > { { item . name } } < / div >
< div class = "text-sm text-gray-400" > { { item . created _at } } < / div >
< / div >
< / div >
<!-- 分页 -- >
@@ -494,6 +517,17 @@ function changePage(newPage) {
controls
> < / video >
< / div >
< div
v-show = "activeTab === 'original'"
class = "flex h-full space-x-4"
>
< video
ref = "originalVideoEl"
class = "video-js vjs-default-skin h-full w-full"
preload = "auto"
controls
> < / video >
< / div >
< / template >
< / div >
< / div >