优化菜单布局,新增智能体模块
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './iva';
|
||||||
|
export * from './sca';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传视频分析任务
|
||||||
|
*/
|
||||||
|
export async function createVideoTask(formData: FormData) {
|
||||||
|
return requestClient.post('/iva/createVideoTask', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已分析的图片列表
|
||||||
|
*/
|
||||||
|
export async function refreshImageList(name = '') {
|
||||||
|
return requestClient.get('/sca/getImageList', { params: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片分析任务
|
||||||
|
*/
|
||||||
|
export async function createImageTask(formData: FormData) {
|
||||||
|
return requestClient.post('/sca/createImageTask', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { pyRequestClient } from '#/api/request';
|
||||||
|
/**
|
||||||
|
* 获取AI列表
|
||||||
|
*/
|
||||||
|
export async function getAIList() {
|
||||||
|
return pyRequestClient.get('/llm/aiList');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对话列表
|
||||||
|
*/
|
||||||
|
export async function getSessions() {
|
||||||
|
return pyRequestClient.get('/llm/sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天记录
|
||||||
|
*/
|
||||||
|
export async function getHistory(sessionId: string) {
|
||||||
|
return pyRequestClient.get('/llm/history', { params: { sessionId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天
|
||||||
|
*/
|
||||||
|
export async function chat(
|
||||||
|
aiId: string,
|
||||||
|
sessionId: null | string,
|
||||||
|
userInput: string,
|
||||||
|
) {
|
||||||
|
return pyRequestClient.post('/llm/chat', {
|
||||||
|
aiId,
|
||||||
|
sessionId,
|
||||||
|
userInput,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './bot';
|
||||||
|
export * from './report';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
icon: 'ic:round-remove-red-eye',
|
||||||
|
authority: ['iva', 'sca', 'ysa'],
|
||||||
|
keepAlive: false,
|
||||||
|
order: 2,
|
||||||
|
title: $t('计算机视觉'),
|
||||||
|
},
|
||||||
|
name: 'Ai',
|
||||||
|
path: '/cv',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'IVA',
|
||||||
|
path: '/cv/iva',
|
||||||
|
meta: {
|
||||||
|
authority: ['iva'],
|
||||||
|
icon: 'mdi:video',
|
||||||
|
title: $t('ai.intelligence_video_analysis'),
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
component: () => import('#/views/cv/iva/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SCA',
|
||||||
|
path: '/cv/sca',
|
||||||
|
meta: {
|
||||||
|
authority: ['sca'],
|
||||||
|
icon: 'mdi:ice-cream',
|
||||||
|
title: $t('ai.silkworm_cocoon_analysis'),
|
||||||
|
keepAlive: false,
|
||||||
|
},
|
||||||
|
component: () => import('#/views/cv/sca/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'YSA',
|
||||||
|
path: '/cv/ysa',
|
||||||
|
meta: {
|
||||||
|
authority: ['ysa'],
|
||||||
|
icon: 'mdi:waveform',
|
||||||
|
title: $t('ai.young_silkworm_analysis'),
|
||||||
|
keepAlive: false,
|
||||||
|
},
|
||||||
|
component: () => import('#/views/cv/ysa/index.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
icon: 'ic:baseline-view-in-ar',
|
||||||
|
authority: ['bot', 'report'],
|
||||||
|
keepAlive: false,
|
||||||
|
order: 2,
|
||||||
|
title: $t('大语言模型'),
|
||||||
|
},
|
||||||
|
name: 'Llm',
|
||||||
|
path: '/llm',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Bot',
|
||||||
|
path: '/llm/bot',
|
||||||
|
meta: {
|
||||||
|
authority: ['bot'],
|
||||||
|
icon: 'mdi:face-woman-shimmer',
|
||||||
|
title: $t('智能体对话'),
|
||||||
|
keepAlive: true,
|
||||||
|
},
|
||||||
|
component: () => import('#/views/llm/bot/index.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SDP',
|
||||||
|
path: '/llm/report',
|
||||||
|
meta: {
|
||||||
|
authority: ['report'],
|
||||||
|
icon: 'mdi:hoop-house',
|
||||||
|
title: $t('智农观数阁'),
|
||||||
|
keepAlive: false,
|
||||||
|
},
|
||||||
|
component: () => import('#/views/llm/report/index.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
icon: 'ic:round-handyman',
|
||||||
|
keepAlive: false,
|
||||||
|
authority: ['user'],
|
||||||
|
order: 2,
|
||||||
|
title: $t('其他平台'),
|
||||||
|
},
|
||||||
|
name: 'Out',
|
||||||
|
path: '/out',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'RAG',
|
||||||
|
path: '/out/rag',
|
||||||
|
component: IFrameView,
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:wall-fire',
|
||||||
|
iframeSrc: 'http://s1.ronsunny.cn:13010/',
|
||||||
|
keepAlive: false,
|
||||||
|
title: '检索增强生成',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CVAT',
|
||||||
|
path: '/out/cvat',
|
||||||
|
component: IFrameView,
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:abjad-arabic',
|
||||||
|
link: 'http://171.212.101.199:13013/',
|
||||||
|
keepAlive: true,
|
||||||
|
title: '标注平台入口',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Form, Input, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { createVideoTask } from '#/api/cv/iva';
|
||||||
|
|
||||||
|
const projectName = ref('');
|
||||||
|
const year = ref<null | number>(null);
|
||||||
|
const month = ref<null | number>(null);
|
||||||
|
const day = ref<null | number>(null);
|
||||||
|
const hours = ref<null | number>(null);
|
||||||
|
const minutes = ref<null | number>(null);
|
||||||
|
const seconds = ref<null | number>(null);
|
||||||
|
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 (!projectName.value || !selectedFile.value) {
|
||||||
|
message.warning('请填写项目名并选择视频文件');
|
||||||
|
}
|
||||||
|
uploadFile();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile.value);
|
||||||
|
formData.append('projectName', projectName.value);
|
||||||
|
formData.append(
|
||||||
|
'projectDatetime',
|
||||||
|
`${year.value ? year.value.toString() : '2025'}-${
|
||||||
|
month.value ? month.value.toString().padStart(2, '0') : '01'
|
||||||
|
}-${day.value ? day.value.toString().padStart(2, '0') : '01'} ${
|
||||||
|
hours.value ? hours.value.toString().padStart(2, '0') : '00'
|
||||||
|
}:${minutes.value ? minutes.value.toString().padStart(2, '0') : '00'}:${
|
||||||
|
seconds.value ? seconds.value.toString().padStart(2, '0') : '00'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
await createVideoTask(formData).then(() => {
|
||||||
|
message.success('任务创建成功,正在处理视频,请稍后刷新列表查看');
|
||||||
|
modalApi.close();
|
||||||
|
// 清空表单
|
||||||
|
projectName.value = '';
|
||||||
|
year.value = null;
|
||||||
|
month.value = null;
|
||||||
|
day.value = null;
|
||||||
|
hours.value = null;
|
||||||
|
minutes.value = null;
|
||||||
|
seconds.value = null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="项目名*" required>
|
||||||
|
<Input v-model:value="projectName" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="日期">
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="year"
|
||||||
|
:min="1900"
|
||||||
|
:max="2100"
|
||||||
|
placeholder="年"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="month"
|
||||||
|
:min="1"
|
||||||
|
:max="12"
|
||||||
|
placeholder="月"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="day"
|
||||||
|
:min="1"
|
||||||
|
:max="31"
|
||||||
|
placeholder="日"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="时间">
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="hours"
|
||||||
|
:min="0"
|
||||||
|
:max="23"
|
||||||
|
placeholder="小时"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="minutes"
|
||||||
|
:min="0"
|
||||||
|
:max="59"
|
||||||
|
placeholder="分钟"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="seconds"
|
||||||
|
:min="0"
|
||||||
|
:max="59"
|
||||||
|
placeholder="秒"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<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, useVbenModal } 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 CreateVideoTaskModal from './CreateVideoTaskModal.vue';
|
||||||
|
|
||||||
|
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 [BaseModal, baseModalApi] = useVbenModal({
|
||||||
|
// 连接抽离的组件
|
||||||
|
connectedComponent: CreateVideoTaskModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTask() {
|
||||||
|
baseModalApi.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: 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 class="flex h-full w-full flex-col">
|
||||||
|
<BaseModal />
|
||||||
|
<CreateVideoTaskModal />
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Form, Input, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { createVideoTask } from '#/api/cv/iva';
|
||||||
|
|
||||||
|
const projectName = ref('');
|
||||||
|
const year = ref<null | number>(null);
|
||||||
|
const month = ref<null | number>(null);
|
||||||
|
const day = ref<null | number>(null);
|
||||||
|
const hours = ref<null | number>(null);
|
||||||
|
const minutes = ref<null | number>(null);
|
||||||
|
const seconds = ref<null | number>(null);
|
||||||
|
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 (!projectName.value || !selectedFile.value) {
|
||||||
|
message.warning('请填写项目名并选择蚕茧图片');
|
||||||
|
}
|
||||||
|
uploadFile();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile.value!);
|
||||||
|
formData.append('projectName', projectName.value);
|
||||||
|
await createVideoTask(formData).then(() => {
|
||||||
|
message.success('任务创建成功,正在处理图像,请稍后刷新列表查看');
|
||||||
|
modalApi.close();
|
||||||
|
// 清空表单
|
||||||
|
projectName.value = '';
|
||||||
|
year.value = null;
|
||||||
|
month.value = null;
|
||||||
|
day.value = null;
|
||||||
|
hours.value = null;
|
||||||
|
minutes.value = null;
|
||||||
|
seconds.value = null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="项目名*" required>
|
||||||
|
<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="image/*"
|
||||||
|
ref="fileInputRef"
|
||||||
|
@change="handleFileChange"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||||
|
|
||||||
|
import { onActivated, onDeactivated, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||||
|
|
||||||
|
import { Button, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import * as api from '#/api';
|
||||||
|
|
||||||
|
import CreateYSATaskModal from './CreateYSATaskModal.vue';
|
||||||
|
|
||||||
|
const list = ref<any[]>([]);
|
||||||
|
const error = ref<null | string>(null);
|
||||||
|
const filterKeyword = ref('');
|
||||||
|
const selectedItem = ref<any>(null);
|
||||||
|
async function loadList() {
|
||||||
|
error.value = null;
|
||||||
|
const res = await api.refreshImageList(filterKeyword.value);
|
||||||
|
list.value = res || [];
|
||||||
|
}
|
||||||
|
function refreshList() {
|
||||||
|
filterKeyword.value = '';
|
||||||
|
loadList();
|
||||||
|
message.success('列表加载完成');
|
||||||
|
}
|
||||||
|
const [BaseModal, baseModalApi] = useVbenModal({
|
||||||
|
// 连接抽离的组件
|
||||||
|
connectedComponent: CreateYSATaskModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTask() {
|
||||||
|
baseModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectItem(item: any) {
|
||||||
|
selectedItem.value = item;
|
||||||
|
refreshLineChart();
|
||||||
|
}
|
||||||
|
// 监听关键词变化,调用防抖接口
|
||||||
|
watch(filterKeyword, () => {
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
watch(selectedItem, () => {});
|
||||||
|
onMounted(() => {
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
|
||||||
|
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.name,
|
||||||
|
项目上传时间: data.upload_datetime,
|
||||||
|
文件名: data.file_name,
|
||||||
|
文件大小: `${data.size} MB`,
|
||||||
|
分辨率: data.resolution,
|
||||||
|
最高置信度: data.max_confidence,
|
||||||
|
最低置信度: data.min_confidence,
|
||||||
|
平均置信度: data.average_confidence,
|
||||||
|
分析时长: data.processing_time,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderEcharts1({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
data: Object.keys(data.other_info),
|
||||||
|
axisTick: {
|
||||||
|
alignWithLabel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '数量',
|
||||||
|
type: 'bar',
|
||||||
|
barWidth: '30%',
|
||||||
|
data: Object.values(data.other_info).map(Number),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderEcharts2({
|
||||||
|
legend: { top: '5%', left: 'center' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
animationDelay() {
|
||||||
|
return Math.random() * 100;
|
||||||
|
},
|
||||||
|
animationEasing: 'exponentialInOut',
|
||||||
|
animationType: 'scale',
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||||
|
data: Object.entries(data.other_info).map(([key, val]) => ({
|
||||||
|
name: key,
|
||||||
|
value: Number(val),
|
||||||
|
})),
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
position: 'center',
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
padAngle: 5,
|
||||||
|
name: '蚕茧分类',
|
||||||
|
radius: ['10%', '70%'],
|
||||||
|
type: 'pie',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
// 离开路由时清理状态
|
||||||
|
selectedItem.value = null;
|
||||||
|
showInfoStr.value = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
// 回来的时候重新刷新一次列表
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
<BaseModal />
|
||||||
|
<CreateYSATaskModal />
|
||||||
|
<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.id === selectedItem?.id }"
|
||||||
|
>
|
||||||
|
<div class="text-base font-medium">{{ item.name }}</div>
|
||||||
|
<div class="text-sm text-gray-400">{{ item.upload_datetime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:Tab 内容区 -->
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden p-6">
|
||||||
|
<div
|
||||||
|
v-if="!selectedItem"
|
||||||
|
class="flex h-full items-center justify-center text-gray-400"
|
||||||
|
>
|
||||||
|
请先选择左侧列表中的分析任务
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div 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="flex-1 rounded border bg-white p-4">
|
||||||
|
<EchartsUI ref="chartRef2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧 -->
|
||||||
|
<div class="flex flex-1 flex-col gap-4">
|
||||||
|
<!-- 上:左右两个图片显示 -->
|
||||||
|
<div class="flex flex-1 gap-4">
|
||||||
|
<!-- 左图 -->
|
||||||
|
<div
|
||||||
|
class="flex flex-1 items-center justify-center rounded border bg-white p-4"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="selectedItem?.image_pre"
|
||||||
|
alt="左图"
|
||||||
|
class="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 右图 -->
|
||||||
|
<div
|
||||||
|
class="flex flex-1 items-center justify-center rounded border bg-white p-4"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="selectedItem?.image_after"
|
||||||
|
alt="右图"
|
||||||
|
class="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 下:柱状图区域 -->
|
||||||
|
<div class="flex-1 rounded border bg-white p-4">
|
||||||
|
<EchartsUI ref="chartRef1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>正在开发中,敬请期待</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import { markdownItTable } from 'markdown-it-table';
|
||||||
|
|
||||||
|
import * as api from '#/api';
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
}).use(markdownItTable);
|
||||||
|
|
||||||
|
// 输入框内容
|
||||||
|
const inputMessage = ref('');
|
||||||
|
|
||||||
|
// 对话列表
|
||||||
|
const conversations = ref<{ id: string; title: string; updatedAt: string }[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
// AI列表
|
||||||
|
const aiOptions = ref<{ id: string; name: string; welcomeWords?: string }[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前选中的AI
|
||||||
|
const selectedAI = ref<null | {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
welcomeWords?: string;
|
||||||
|
}>(null);
|
||||||
|
// 当前选中的对话
|
||||||
|
const currentSession = ref<null | string>(null);
|
||||||
|
|
||||||
|
// 聊天记录
|
||||||
|
const messages = reactive<{ content: string; sender: 'ai' | 'user' }[]>([]);
|
||||||
|
|
||||||
|
// 计算第一次聊天状态
|
||||||
|
const isInitial = computed(() => messages.length === 0);
|
||||||
|
|
||||||
|
function handleEnter(e: KeyboardEvent) {
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
// Ctrl + Enter 换行
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
const start = target.selectionStart;
|
||||||
|
const end = target.selectionEnd;
|
||||||
|
target.value = `${target.value.slice(0, Math.max(0, start))}\n${target.value.slice(Math.max(0, end))}`;
|
||||||
|
target.selectionStart = target.selectionEnd = start + 1;
|
||||||
|
inputMessage.value = target.value;
|
||||||
|
} else {
|
||||||
|
// Enter 发送
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(e: Event) {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
if (!target) return;
|
||||||
|
target.style.height = 'auto';
|
||||||
|
target.style.height = `${target.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = document.querySelector('#chat-container');
|
||||||
|
if (container)
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟接口获取对话列表
|
||||||
|
async function getSessions() {
|
||||||
|
conversations.value = await api.getSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟接口获取聊天记录
|
||||||
|
async function fetchMessages(conversationId: string) {
|
||||||
|
messages.splice(0);
|
||||||
|
const msg = await api.getHistory(conversationId);
|
||||||
|
messages.push(...msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击对话加载聊天记录
|
||||||
|
async function loadConversation(id: string) {
|
||||||
|
currentSession.value = id;
|
||||||
|
await fetchMessages(id);
|
||||||
|
nextTick(() => scrollToBottom());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
async function sendMessage() {
|
||||||
|
const content = inputMessage.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
messages.push({ type: 'human', content });
|
||||||
|
inputMessage.value = '';
|
||||||
|
|
||||||
|
nextTick(() => scrollToBottom());
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const answer = await api.chat(
|
||||||
|
selectedAI.value?.id || '',
|
||||||
|
currentSession.value,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
currentSession.value = answer.sessionId;
|
||||||
|
if (answer.isNewSession) {
|
||||||
|
conversations.value.unshift({
|
||||||
|
id: answer.sessionId,
|
||||||
|
updatedAt: formatDate(new Date()),
|
||||||
|
title: answer.sessionName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages.push({ type: 'ai', content: answer.content });
|
||||||
|
nextTick(() => scrollToBottom());
|
||||||
|
// 焦点回到输入框
|
||||||
|
const textarea = document.querySelector(
|
||||||
|
'textarea[placeholder="输入消息开始聊天..."], textarea[placeholder="输入消息..."]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建聊天
|
||||||
|
function createNewConversation() {
|
||||||
|
currentSession.value = null;
|
||||||
|
messages.splice(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取AI列表
|
||||||
|
const fetchOptions = async () => {
|
||||||
|
try {
|
||||||
|
aiOptions.value = await api.getAIList();
|
||||||
|
if (aiOptions.value.length > 0 && aiOptions.value[0] !== undefined) {
|
||||||
|
selectedAI.value = aiOptions.value[0]!;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取下拉选项失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||||
|
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 页面加载时调用接口
|
||||||
|
onMounted(() => {
|
||||||
|
fetchOptions();
|
||||||
|
getSessions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-[90dvh] bg-gray-50">
|
||||||
|
<!-- 左侧对话列表 -->
|
||||||
|
<div
|
||||||
|
class="flex h-full w-64 flex-shrink-0 flex-col border-r border-gray-200 bg-white"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2 border-b border-gray-200 p-4">
|
||||||
|
<button
|
||||||
|
@click="createNewConversation"
|
||||||
|
class="flex items-center rounded bg-blue-500 px-3 py-1 text-white transition-colors hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-1 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
新聊天
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 下拉框 -->
|
||||||
|
<select
|
||||||
|
v-model="selectedAI"
|
||||||
|
class="flex-1 rounded border border-gray-300 p-1"
|
||||||
|
>
|
||||||
|
<option v-for="item in aiOptions" :key="item.id" :value="item">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<ul class="flex-1 divide-y divide-gray-200 overflow-y-auto">
|
||||||
|
<li
|
||||||
|
v-for="conv in conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
@click="loadConversation(conv.id)"
|
||||||
|
class="cursor-pointer p-4 transition-colors hover:bg-gray-50"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100 font-semibold': currentSession === conv.id,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="truncate">{{ conv.title }}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400">{{ conv.updatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧聊天区 -->
|
||||||
|
<div class="relative flex flex-1 flex-col">
|
||||||
|
<!-- 聊天记录 -->
|
||||||
|
<div
|
||||||
|
id="chat-container"
|
||||||
|
class="flex flex-1 flex-col space-y-4 overflow-y-auto p-4 transition-all duration-300"
|
||||||
|
:class="{
|
||||||
|
'items-center justify-center': isInitial,
|
||||||
|
'justify-start': !isInitial,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="isInitial">
|
||||||
|
<div class="flex w-full flex-col items-center space-y-4">
|
||||||
|
<!-- 欢迎词 -->
|
||||||
|
<div class="text-center text-lg text-gray-700">
|
||||||
|
{{ selectedAI?.welcomeWords }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<textarea
|
||||||
|
v-model="inputMessage"
|
||||||
|
placeholder="输入消息开始聊天..."
|
||||||
|
rows="1"
|
||||||
|
class="w-1/2 resize-none overflow-hidden rounded-lg border border-gray-300 p-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
|
@keydown.enter.prevent="handleEnter($event)"
|
||||||
|
@input="autoResize($event)"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex w-full flex-col items-center space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(msg, idx) in messages"
|
||||||
|
:key="idx"
|
||||||
|
class="animate-fadeIn flex w-full transition-opacity duration-500"
|
||||||
|
:class="msg.type === 'ai' ? 'justify-start' : 'justify-end'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="prose inline-block max-w-[80%] break-words rounded-lg p-3"
|
||||||
|
:class="
|
||||||
|
msg.type === 'ai'
|
||||||
|
? 'bg-blue-100 text-gray-800'
|
||||||
|
: 'bg-gray-200 text-gray-800'
|
||||||
|
"
|
||||||
|
v-html="md.render(msg.content)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入框固定底部 -->
|
||||||
|
<div v-if="!isInitial" class="flex border-t border-gray-200 bg-white p-4">
|
||||||
|
<textarea
|
||||||
|
v-model="inputMessage"
|
||||||
|
@keyup.enter="sendMessage"
|
||||||
|
rows="1"
|
||||||
|
placeholder="输入消息..."
|
||||||
|
class="flex-1 resize-none overflow-hidden rounded-md border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
|
@input="
|
||||||
|
$event.target.style.height = 'auto';
|
||||||
|
$event.target.style.height = `${$event.target.scrollHeight}px`;
|
||||||
|
"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
@click="sendMessage"
|
||||||
|
class="ml-2 rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.3s forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>正在开发中,敬请期待</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
services:
|
||||||
|
vue:
|
||||||
|
container_name: ce_vue
|
||||||
|
image: ce-vue:latest
|
||||||
|
ports:
|
||||||
|
- "8090:8090" # 映射到宿主机的 8090 端口
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- ce_network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: ce-pybackend
|
||||||
|
image: ce-pybackend:latest
|
||||||
|
ports:
|
||||||
|
- "13011:13011"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- ce_network
|
||||||
|
depends_on:
|
||||||
|
- vue
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ce_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ktor
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: 123456
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- ce_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.2.3-alpine
|
||||||
|
container_name: ce_redis
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- ce_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-03-12T18-04-18Z
|
||||||
|
container_name: ce_minio
|
||||||
|
environment:
|
||||||
|
MINIO_ACCESS_KEY: minioadmin
|
||||||
|
MINIO_SECRET_KEY: minioadmin
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
networks:
|
||||||
|
- ce_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ollama:
|
||||||
|
# image: ollama/ollama
|
||||||
|
# container_name: ce_ollama
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:7
|
||||||
|
# - capabilities: [gpu] # 启用 GPU 支持
|
||||||
|
# volumes:
|
||||||
|
# - ollama_data:/root/.ollama # 持久化 ollama 数据
|
||||||
|
# ports:
|
||||||
|
# - "11434:11434" # 暴露 ollama 的端口
|
||||||
|
# networks:
|
||||||
|
# - ce_network
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
# 定义数据卷
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
|
ollama_data: # 定义 ollama 的数据卷
|
||||||
|
|
||||||
|
# 定义网络
|
||||||
|
networks:
|
||||||
|
ce_network:
|
||||||
|
driver: bridge
|
||||||
Reference in New Issue
Block a user