完善前端vue项目

This commit is contained in:
BBIT-Kai
2025-09-05 09:33:55 +08:00
parent a4e5517e65
commit aa25f914ab
20 changed files with 43 additions and 1173 deletions
+1
View File
@@ -5,6 +5,7 @@ VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=http://localhost:8089/api
VITE_GLOB_API_URL_PY=http://localhost:13011/api
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
VITE_NITRO_MOCK=false
+1
View File
@@ -2,6 +2,7 @@ VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=http://s1.ronsunny.cn:8089/api
VITE_GLOB_API_URL_PY=http://s1.ronsunny.cn:13011/api
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
+3
View File
@@ -44,6 +44,9 @@
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"js-sha256": "^0.11.0",
"markdown-it": "^14.1.0",
"markdown-it-container": "^4.0.0",
"markdown-it-table": "^4.1.1",
"pinia": "catalog:",
"video.js": "^8.22.0",
"vue": "catalog:",
-2
View File
@@ -1,6 +1,4 @@
export * from './auth';
export * from './iva';
export * from './menu';
export * from './remote';
export * from './sca';
export * from './user';
-28
View File
@@ -1,28 +0,0 @@
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',
},
});
}
-19
View File
@@ -1,19 +0,0 @@
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',
},
});
}
+2
View File
@@ -1 +1,3 @@
export * from './core';
export * from './cv';
export * from './llm';
-12
View File
@@ -1,12 +0,0 @@
/** 用户信息 */
interface DeviceItem {
/**
* 设备名
*/
deviceName: string;
/**
* 设备端口
*/
devicePort: string;
}
export type { DeviceItem };
+8 -1
View File
@@ -19,7 +19,10 @@ import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const { apiURL, pyApiURL } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
@@ -110,4 +113,8 @@ export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const pyRequestClient = createRequestClient(pyApiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
@@ -9,6 +9,6 @@
"dashboard": {
"title": "常规",
"analytics": "分析页",
"workspace": "工作台"
"workspace": "首页"
}
}
@@ -1,77 +0,0 @@
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:baseline-view-in-ar',
keepAlive: false,
order: 2,
title: $t('ai.title'),
},
name: 'Ai',
path: '/ai',
children: [
{
name: 'IVA',
path: '/ai/iva',
meta: {
authority: ['iva'],
icon: 'mdi:video',
title: $t('ai.intelligence_video_analysis'),
keepAlive: true,
},
component: () => import('#/views/ai/iva/index.vue'),
},
{
name: 'SCA',
path: '/ai/sca',
meta: {
authority: ['sca'],
icon: 'mdi:ice-cream',
title: $t('ai.silkworm_cocoon_analysis'),
keepAlive: false,
},
component: () => import('#/views/ai/sca/index.vue'),
},
{
name: 'YSA',
path: '/ai/ysa',
meta: {
authority: ['ysa'],
icon: 'mdi:account-key-outline',
title: $t('ai.young_silkworm_analysis'),
keepAlive: false,
},
component: () => import('#/views/ai/ysa/index.vue'),
},
{
name: 'RAG',
path: '/ai/rag',
component: IFrameView,
meta: {
icon: 'mdi:wall-fire',
iframeSrc: 'http://s1.ronsunny.cn:13010/',
keepAlive: false,
title: '检索增强生成',
},
},
{
name: 'CVAT',
path: '/ai/cvat',
component: IFrameView,
meta: {
icon: 'mdi:abjad-arabic',
link: 'http://171.212.101.199:13013/',
keepAlive: true,
title: '标注平台入口',
},
},
],
},
];
export default routes;
@@ -17,7 +17,7 @@ const routes: RouteRecordRaw[] = [
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
icon: 'mdi:home',
title: $t('page.dashboard.workspace'),
},
},
@@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/remote/index.vue'),
meta: {
authority: ['remote'],
icon: 'mdi:home',
icon: 'carbon:workspace',
title: $t('remote.remote'),
},
},
@@ -1,159 +0,0 @@
<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/core/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>
@@ -1,499 +0,0 @@
<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>
@@ -1,97 +0,0 @@
<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/core/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>
@@ -1,270 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<template>
<div>
<h1>正在开发中敬请期待</h1>
</div>
</template>
@@ -15,9 +15,10 @@ export function useAppConfig(
? window._VBEN_ADMIN_PRO_APP_CONF_
: (env as VbenAdminProAppConfigRaw);
const { VITE_GLOB_API_URL } = config;
const { VITE_GLOB_API_URL, VITE_GLOB_API_URL_PY } = config;
return {
apiURL: VITE_GLOB_API_URL,
pyApiURL: VITE_GLOB_API_URL_PY,
};
}
+2
View File
@@ -9,10 +9,12 @@ declare module 'vue-router' {
export interface VbenAdminProAppConfigRaw {
VITE_GLOB_API_URL: string;
PY_VITE_GLOB_API_URL: string;
}
export interface ApplicationConfig {
apiURL: string;
pyApiURL: string;
}
declare global {
+21
View File
@@ -692,6 +692,15 @@ importers:
js-sha256:
specifier: ^0.11.0
version: 0.11.1
markdown-it:
specifier: ^14.1.0
version: 14.1.0
markdown-it-container:
specifier: ^4.0.0
version: 4.0.0
markdown-it-table:
specifier: ^4.1.1
version: 4.1.1
pinia:
specifier: ^3.0.2
version: 3.0.2(typescript@5.8.3)(vue@3.5.16(typescript@5.8.3))
@@ -7709,6 +7718,13 @@ packages:
mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
markdown-it-container@4.0.0:
resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
markdown-it-table@4.1.1:
resolution: {integrity: sha512-dzFHRwCe97sXD071Gw/LSIwgAcO2vNsT0BcPCmnrKhNW/W57Bnknl8EaC+j4hM3bAqus1CiVPcTQAvLkUfHLhw==}
engines: {node: '>12.6'}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -9522,6 +9538,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@@ -17521,6 +17538,10 @@ snapshots:
mark.js@8.11.1: {}
markdown-it-container@4.0.0: {}
markdown-it-table@4.1.1: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1