v1.0.2发布,支持视频分析结果查看
This commit is contained in:
@@ -4,13 +4,13 @@ VITE_PORT=8090
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=/api
|
||||
VITE_GLOB_API_URL=http://localhost:8089/api
|
||||
|
||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||
VITE_NITRO_MOCK=false
|
||||
|
||||
# 是否打开 devtools,true 为打开,false 为关闭
|
||||
VITE_DEVTOOLS=false
|
||||
VITE_DEVTOOLS=true
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"dayjs": "catalog:",
|
||||
"js-sha256": "^0.11.0",
|
||||
"pinia": "catalog:",
|
||||
"video.js": "^8.22.0",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
@@ -61,7 +63,13 @@ setupVbenVxeTable({
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export { useVbenVxeGrid };
|
||||
export type OnActionClickParams<T = Recordable<any>> = {
|
||||
code: string;
|
||||
row: T;
|
||||
};
|
||||
export type OnActionClickFn<T = Recordable<any>> = (
|
||||
params: OnActionClickParams<T>,
|
||||
) => void;
|
||||
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './iva';
|
||||
export * from './menu';
|
||||
export * from './remote';
|
||||
export * from './user';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取已分析的视频列表
|
||||
*/
|
||||
export async function refreshVideoList(name = '') {
|
||||
return requestClient.get('/iva/getVideoList', { params: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已分析的视频列表
|
||||
*/
|
||||
export async function refreshVideoDetail(vId = '') {
|
||||
return requestClient.get('/iva/getAnalyticsDetailByVideoId', {
|
||||
params: { vId },
|
||||
});
|
||||
}
|
||||
@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
|
||||
* 获取用户所有菜单
|
||||
*/
|
||||
export async function getAllMenusApi() {
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/user/menus');
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { requestClient } from '#/api/request';
|
||||
/**
|
||||
* 获取在线设备列表数据
|
||||
*/
|
||||
export async function refreshDeviceList() {
|
||||
return requestClient.get('/remote/refreshDeviceList');
|
||||
export async function refreshDeviceList(name = '') {
|
||||
return requestClient.get('/remote/refreshDeviceList', { params: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemDeptApi {
|
||||
export interface SystemDept {
|
||||
[key: string]: any;
|
||||
children?: SystemDept[];
|
||||
id: string;
|
||||
name: string;
|
||||
remark?: string;
|
||||
status: 0 | 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门列表数据
|
||||
*/
|
||||
async function getDeptList() {
|
||||
return requestClient.get<Array<SystemDeptApi.SystemDept>>(
|
||||
'/system/dept/list',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建部门
|
||||
* @param data 部门数据
|
||||
*/
|
||||
async function createDept(
|
||||
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
|
||||
) {
|
||||
return requestClient.post('/system/dept/add', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新部门
|
||||
*
|
||||
* @param id 部门 ID
|
||||
* @param data 部门数据
|
||||
*/
|
||||
async function updateDept(
|
||||
id: string,
|
||||
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
|
||||
) {
|
||||
return requestClient.put(`/system/dept/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @param id 部门 ID
|
||||
*/
|
||||
async function deleteDept(id: string) {
|
||||
return requestClient.delete(`/system/dept/${id}`);
|
||||
}
|
||||
|
||||
export { createDept, deleteDept, getDeptList, updateDept };
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './dept';
|
||||
export * from './menu';
|
||||
export * from './role';
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemMenuApi {
|
||||
/** 徽标颜色集合 */
|
||||
export const BadgeVariants = [
|
||||
'default',
|
||||
'destructive',
|
||||
'primary',
|
||||
'success',
|
||||
'warning',
|
||||
] as const;
|
||||
/** 徽标类型集合 */
|
||||
export const BadgeTypes = ['dot', 'normal'] as const;
|
||||
/** 菜单类型集合 */
|
||||
export const MenuTypes = [
|
||||
'catalog',
|
||||
'menu',
|
||||
'embedded',
|
||||
'link',
|
||||
'button',
|
||||
] as const;
|
||||
/** 系统菜单 */
|
||||
export interface SystemMenu {
|
||||
[key: string]: any;
|
||||
/** 后端权限标识 */
|
||||
authCode: string;
|
||||
/** 子级 */
|
||||
children?: SystemMenu[];
|
||||
/** 组件 */
|
||||
component?: string;
|
||||
/** 菜单ID */
|
||||
id: string;
|
||||
/** 菜单元数据 */
|
||||
meta?: {
|
||||
/** 激活时显示的图标 */
|
||||
activeIcon?: string;
|
||||
/** 作为路由时,需要激活的菜单的Path */
|
||||
activePath?: string;
|
||||
/** 固定在标签栏 */
|
||||
affixTab?: boolean;
|
||||
/** 在标签栏固定的顺序 */
|
||||
affixTabOrder?: number;
|
||||
/** 徽标内容(当徽标类型为normal时有效) */
|
||||
badge?: string;
|
||||
/** 徽标类型 */
|
||||
badgeType?: (typeof BadgeTypes)[number];
|
||||
/** 徽标颜色 */
|
||||
badgeVariants?: (typeof BadgeVariants)[number];
|
||||
/** 在菜单中隐藏下级 */
|
||||
hideChildrenInMenu?: boolean;
|
||||
/** 在面包屑中隐藏 */
|
||||
hideInBreadcrumb?: boolean;
|
||||
/** 在菜单中隐藏 */
|
||||
hideInMenu?: boolean;
|
||||
/** 在标签栏中隐藏 */
|
||||
hideInTab?: boolean;
|
||||
/** 菜单图标 */
|
||||
icon?: string;
|
||||
/** 内嵌Iframe的URL */
|
||||
iframeSrc?: string;
|
||||
/** 是否缓存页面 */
|
||||
keepAlive?: boolean;
|
||||
/** 外链页面的URL */
|
||||
link?: string;
|
||||
/** 同一个路由最大打开的标签数 */
|
||||
maxNumOfOpenTab?: number;
|
||||
/** 无需基础布局 */
|
||||
noBasicLayout?: boolean;
|
||||
/** 是否在新窗口打开 */
|
||||
openInNewWindow?: boolean;
|
||||
/** 菜单排序 */
|
||||
order?: number;
|
||||
/** 额外的路由参数 */
|
||||
query?: Recordable<any>;
|
||||
/** 菜单标题 */
|
||||
title?: string;
|
||||
};
|
||||
/** 菜单名称 */
|
||||
name: string;
|
||||
/** 路由路径 */
|
||||
path: string;
|
||||
/** 父级ID */
|
||||
pid: string;
|
||||
/** 重定向 */
|
||||
redirect?: string;
|
||||
/** 菜单类型 */
|
||||
type: (typeof MenuTypes)[number];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单数据列表
|
||||
*/
|
||||
async function getMenuList() {
|
||||
return requestClient.get<Array<SystemMenuApi.SystemMenu>>(
|
||||
'/system/menu/list',
|
||||
);
|
||||
}
|
||||
|
||||
async function isMenuNameExists(
|
||||
name: string,
|
||||
id?: SystemMenuApi.SystemMenu['id'],
|
||||
) {
|
||||
return requestClient.get<boolean>('/system/menu/name-exists', {
|
||||
params: { id, name },
|
||||
});
|
||||
}
|
||||
|
||||
async function isMenuPathExists(
|
||||
path: string,
|
||||
id?: SystemMenuApi.SystemMenu['id'],
|
||||
) {
|
||||
return requestClient.get<boolean>('/system/menu/path-exists', {
|
||||
params: { id, path },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建菜单
|
||||
* @param data 菜单数据
|
||||
*/
|
||||
async function createMenu(
|
||||
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
|
||||
) {
|
||||
return requestClient.post('/system/menu', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新菜单
|
||||
*
|
||||
* @param id 菜单 ID
|
||||
* @param data 菜单数据
|
||||
*/
|
||||
async function updateMenu(
|
||||
id: string,
|
||||
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
|
||||
) {
|
||||
return requestClient.put(`/system/menu/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除菜单
|
||||
* @param id 菜单 ID
|
||||
*/
|
||||
async function deleteMenu(id: string) {
|
||||
return requestClient.delete(`/system/menu/${id}`);
|
||||
}
|
||||
|
||||
export {
|
||||
createMenu,
|
||||
deleteMenu,
|
||||
getMenuList,
|
||||
isMenuNameExists,
|
||||
isMenuPathExists,
|
||||
updateMenu,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemRoleApi {
|
||||
export interface SystemRole {
|
||||
[key: string]: any;
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
remark?: string;
|
||||
status: 0 | 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色列表数据
|
||||
*/
|
||||
async function getRoleList(params: Recordable<any>) {
|
||||
return requestClient.get<Array<SystemRoleApi.SystemRole>>(
|
||||
'/system/role/list',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* @param data 角色数据
|
||||
*/
|
||||
async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
|
||||
return requestClient.post('/system/role', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
*
|
||||
* @param id 角色 ID
|
||||
* @param data 角色数据
|
||||
*/
|
||||
async function updateRole(
|
||||
id: string,
|
||||
data: Omit<SystemRoleApi.SystemRole, 'id'>,
|
||||
) {
|
||||
return requestClient.put(`/system/role/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* @param id 角色 ID
|
||||
*/
|
||||
async function deleteRole(id: string) {
|
||||
return requestClient.delete(`/system/role/${id}`);
|
||||
}
|
||||
|
||||
export { createRole, deleteRole, getRoleList, updateRole };
|
||||
@@ -7,7 +7,7 @@
|
||||
"forgetPassword": "忘记密码"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"title": "常规",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
layout: 'header-sidebar-nav',
|
||||
defaultHomePath: '/workspace',
|
||||
enablePreferences: false,
|
||||
layout: 'header-sidebar-nav', // 布局方式
|
||||
defaultHomePath: '/workspace', // 默认首页路径
|
||||
enablePreferences: false, // 是否启用偏好设置
|
||||
loginExpiredMode: 'modal', // 登录过期模式 弹窗登录
|
||||
},
|
||||
theme: {
|
||||
mode: 'light',
|
||||
|
||||
@@ -19,6 +19,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'IVA',
|
||||
path: '/ai/iva',
|
||||
meta: {
|
||||
authority: ['iva'],
|
||||
icon: 'mdi:video',
|
||||
title: $t('ai.intelligence_video_analysis'),
|
||||
},
|
||||
@@ -28,7 +29,8 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'YSA',
|
||||
path: '/ai/ysa',
|
||||
meta: {
|
||||
icon: 'mdi:home',
|
||||
authority: ['ysa'],
|
||||
icon: 'mdi:account-key-outline',
|
||||
title: $t('ai.young_silkworm_analysis'),
|
||||
},
|
||||
component: () => import('#/views/ai/ysa/index.vue'),
|
||||
|
||||
@@ -12,16 +12,6 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
children: [
|
||||
// {
|
||||
// name: 'Analytics',
|
||||
// path: '/analytics',
|
||||
// component: () => import('#/views/dashboard/analytics/index.vue'),
|
||||
// meta: {
|
||||
// affixTab: true,
|
||||
// icon: 'lucide:area-chart',
|
||||
// title: $t('page.dashboard.analytics'),
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'Workspace',
|
||||
path: '/workspace',
|
||||
@@ -31,6 +21,16 @@ const routes: RouteRecordRaw[] = [
|
||||
title: $t('page.dashboard.workspace'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Remote',
|
||||
path: '/remote',
|
||||
component: () => import('#/views/remote/index.vue'),
|
||||
meta: {
|
||||
authority: ['remote'],
|
||||
icon: 'mdi:home',
|
||||
title: $t('remote.remote'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
meta: {
|
||||
icon: 'ic:baseline-view-in-ar',
|
||||
keepAlive: true,
|
||||
order: 1,
|
||||
title: $t('remote.tools'),
|
||||
},
|
||||
name: 'Room',
|
||||
path: '/room',
|
||||
children: [
|
||||
{
|
||||
name: 'Remote',
|
||||
path: '/remote',
|
||||
component: () => import('#/views/remote/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:home',
|
||||
title: $t('remote.remote'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -1,5 +1,488 @@
|
||||
<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 { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { AnalysisOverview } from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
import videojs from 'video.js';
|
||||
|
||||
import * as api 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('视频列表加载完成');
|
||||
}
|
||||
|
||||
const createTask = () => {};
|
||||
|
||||
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: 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: SvgDownloadIcon,
|
||||
title: '人次',
|
||||
totalTitle: '视频出现人次',
|
||||
totalValue: 0,
|
||||
value: selectedItem.value.v_a_count_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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
drawVideoProcess();
|
||||
});
|
||||
}
|
||||
function drawVideoProcess() {
|
||||
const data = selectedItem.value;
|
||||
player.value?.one('loadedmetadata', () => {
|
||||
// 计算起始时间戳
|
||||
const vStartTime = new Date(data.v_start_datetime).getTime();
|
||||
|
||||
// 获取进度条 DOM
|
||||
const progressControl = player.value?.controlBar?.progressControl?.el();
|
||||
if (!progressControl) return;
|
||||
|
||||
// 清除旧的自定义进度段
|
||||
progressControl
|
||||
.querySelectorAll('.custom-range')
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
const duration = player.value?.duration() || 1;
|
||||
|
||||
const areaData = Array.isArray(data.v_a_details.areaData)
|
||||
? data.v_a_details.areaData.map((actionGroup: any) => {
|
||||
return actionGroup.map((action: any) => {
|
||||
return { xAxis: action.xAxis, itemStyle: action.itemStyle };
|
||||
});
|
||||
})
|
||||
: []; // 默认值为空数组
|
||||
// 遍历区域数据,生成每个时间段
|
||||
for (const area of areaData) {
|
||||
const startMs = new Date(area[0].xAxis).getTime();
|
||||
const endMs = new Date(area[1].xAxis).getTime();
|
||||
|
||||
const startSec = (startMs - vStartTime) / 1000;
|
||||
const endSec = (endMs - vStartTime) / 1000;
|
||||
|
||||
if (startSec < 0 || endSec < 0 || startSec >= endSec) continue;
|
||||
|
||||
const startPct = (startSec / duration) * 100;
|
||||
const endPct = (endSec / duration) * 100;
|
||||
|
||||
const rangeDiv = document.createElement('div');
|
||||
rangeDiv.className = 'custom-range';
|
||||
rangeDiv.style.position = 'absolute';
|
||||
rangeDiv.style.left = `${startPct}%`;
|
||||
rangeDiv.style.width = `${endPct - startPct}%`;
|
||||
rangeDiv.style.height = '100%';
|
||||
rangeDiv.style.backgroundColor = area[0].itemStyle.color;
|
||||
rangeDiv.style.pointerEvents = 'none'; // 避免阻挡鼠标交互
|
||||
rangeDiv.style.zIndex = '2';
|
||||
|
||||
progressControl.append(rangeDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
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_start_datetime,
|
||||
文件名: 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();
|
||||
}
|
||||
}
|
||||
|
||||
function onListItemClick(video: any) {
|
||||
// 视频跳转到指定时间点
|
||||
const vStartTime =
|
||||
new Date(selectedItem.value.v_start_datetime).getTime() / 1000;
|
||||
const xAxisTimeStart = new Date(video.time).getTime() / 1000;
|
||||
const relativeTimeStart = xAxisTimeStart - vStartTime;
|
||||
|
||||
if (player.value) {
|
||||
const duration = player.value.duration() || 1; // 获取视频总时长,避免除以0
|
||||
if (relativeTimeStart >= 0 && relativeTimeStart <= duration) {
|
||||
player.value.currentTime(relativeTimeStart);
|
||||
} else {
|
||||
message.warn(
|
||||
`时间点超出视频范围,请选择 ${vStartTime} 秒到 秒之间的时间点`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
message.warn('请先选择左侧视频分析任务');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>正在开发中,敬请期待</h1>
|
||||
<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>
|
||||
|
||||
<!-- 右侧: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="mt-6 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">
|
||||
<!-- 左侧视频区域 -->
|
||||
<div class="flex-1 overflow-hidden rounded bg-black">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="video-js vjs-default-skin h-full w-full"
|
||||
preload="auto"
|
||||
controls
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- 右侧时间点列表 -->
|
||||
<div
|
||||
class="flex w-1/4 flex-col overflow-auto rounded-md border bg-white"
|
||||
>
|
||||
<!-- 列表标题 -->
|
||||
<div
|
||||
class="flex justify-between border-b p-3 text-sm font-medium text-gray-700"
|
||||
>
|
||||
<span>事件</span>
|
||||
<span>时间点</span>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(video, index) in detailList"
|
||||
:key="index"
|
||||
@click="onListItemClick(video)"
|
||||
class="flex cursor-pointer justify-between border-b p-3 text-sm hover:bg-gray-100"
|
||||
>
|
||||
<span>{{ video.action }}</span>
|
||||
<span>{{ video.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
@@ -26,7 +27,6 @@ interface RowType {
|
||||
deviceName: string;
|
||||
devicePort: number;
|
||||
}
|
||||
|
||||
const gridOptions: VxeGridProps<RowType> = {
|
||||
columns: [
|
||||
{ title: '序号', type: 'seq', width: 50 },
|
||||
@@ -47,14 +47,14 @@ const gridOptions: VxeGridProps<RowType> = {
|
||||
gt: 0,
|
||||
},
|
||||
// showOverflow: true, // 超出隐藏
|
||||
height: '500px',
|
||||
height: '750px',
|
||||
// keepSource: true,
|
||||
stripe: true, // 条纹
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async () => {
|
||||
query: async ({ page }, formValues) => {
|
||||
return {
|
||||
items: await refreshDeviceList(),
|
||||
items: await refreshDeviceList(formValues.deviceName),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -68,33 +68,28 @@ const gridOptions: VxeGridProps<RowType> = {
|
||||
};
|
||||
|
||||
// 筛选表单
|
||||
// const formOptions: VbenFormProps = {
|
||||
// // 默认展开
|
||||
// collapsed: true,
|
||||
// schema: [
|
||||
// {
|
||||
// component: 'Input',
|
||||
// componentProps: {
|
||||
// placeholder: '输入设备名',
|
||||
// },
|
||||
// fieldName: 'deviceName',
|
||||
// label: '筛选',
|
||||
// width: 300,
|
||||
// },
|
||||
// ],
|
||||
// // 控制表单是否显示折叠按钮
|
||||
// showCollapseButton: false,
|
||||
// submitButtonOptions: {
|
||||
// content: '查询',
|
||||
// },
|
||||
|
||||
// submitOnChange: true, // 是否在字段值改变时提交表单
|
||||
|
||||
// submitOnEnter: true, // 按下回车时是否提交表单
|
||||
// };
|
||||
const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
collapsed: true,
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '设备名',
|
||||
},
|
||||
fieldName: 'deviceName',
|
||||
label: '筛选',
|
||||
},
|
||||
],
|
||||
// 控制表单是否显示折叠按钮
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: false, // 是否显示默认操作按钮
|
||||
submitOnChange: true, // 是否在字段值改变时提交表单
|
||||
submitOnEnter: true, // 按下回车时是否提交表单
|
||||
};
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
// formOptions,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
separator: false,
|
||||
});
|
||||
@@ -103,7 +98,7 @@ const [Grid] = useVbenVxeGrid({
|
||||
* @param devicePort 设备端口号
|
||||
*/
|
||||
const handleConnect = async (devicePort: number) => {
|
||||
message.info(await connectDeviceByPort(devicePort));
|
||||
message.info(await connectDeviceByPort(devicePort.toString()));
|
||||
};
|
||||
const disconnectAll = async () => {
|
||||
message.info(await disConnectAll());
|
||||
@@ -131,7 +126,10 @@ const disconnectAll = async () => {
|
||||
</template>
|
||||
<template #default>
|
||||
<Card class="ml-2 h-full">
|
||||
<iframe src="http://171.212.101.199:8088" class="w-full"></iframe>
|
||||
<iframe
|
||||
src="http://171.212.101.199:8088"
|
||||
class="h-full w-full"
|
||||
></iframe>
|
||||
</Card>
|
||||
</template>
|
||||
</ColPage>
|
||||
|
||||
Reference in New Issue
Block a user