Files
algorithm/frontend/src/views/AlgorithmsView.vue
2026-02-18 23:39:39 +08:00

1112 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="algorithm-showcase-container">
<!-- 左侧算法服务一览 -->
<div class="left-sidebar">
<div class="sidebar-header">
<h2>AI算法</h2>
<p>{{ algorithmStore.algorithms.length }}个算法</p>
</div>
<div class="algorithm-categories">
<div
v-for="(category, key) in categories"
:key="key"
class="category-item"
>
<div class="category-header">
<div class="category-icon" :style="{ backgroundColor: category.color }">
<component :is="category.icon" />
</div>
<h3>{{ category.name }}</h3>
<span class="algorithm-count">{{ getAlgorithmsByCategory(key).length }}个算法</span>
</div>
<div class="algorithm-list">
<div
v-for="algorithm in getAlgorithmsByCategory(key)"
:key="algorithm.id"
class="algorithm-item"
:class="{ active: selectedAlgorithm && selectedAlgorithm.id === algorithm.id }"
@click="selectAlgorithm(algorithm)"
>
<span class="algorithm-name">{{ algorithm.name }}</span>
<el-tag size="small" type="info">{{ getOutputTypeName(algorithm.output_type) }}</el-tag>
</div>
</div>
</div>
</div>
</div>
<!-- 中间内容区域 -->
<div class="main-content">
<!-- 视频/图片展示 -->
<div class="media-display">
<div v-if="!uploadedFile" class="media-placeholder">
<el-icon class="play-icon"><VideoPlay /></el-icon>
<p>请上传或选择演示素材</p>
<p class="support-text">支持视频和图片格式</p>
</div>
<div v-else-if="isImageFile" class="image-preview">
<img :src="uploadedFile.url" alt="上传图片" />
</div>
<div v-else-if="isVideoFile" class="video-preview">
<video controls :src="uploadedFile.url">
您的浏览器不支持视频播放
</video>
</div>
</div>
<!-- 素材上传 -->
<div class="upload-section">
<div class="section-header">
<h3>素材上传</h3>
<el-tag v-if="!userStore.isLoggedIn" type="warning" size="small">
<el-icon><Warning /></el-icon>
需要登录
</el-tag>
</div>
<div class="upload-tabs">
<el-tabs v-model="activeUploadTab">
<el-tab-pane label="视频数据" name="video">
<div class="upload-area">
<el-upload
class="upload-demo"
drag
action="#"
:auto-upload="false"
:on-change="handleFileChange"
:accept="'.mp4,.avi,.mov,.wmv'"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">点击或拖拽文件到此处上传</div>
<template #tip>
<div class="el-upload__tip">
支持 MP4AVIMOV 格式
</div>
</template>
</el-upload>
<div class="demo-videos">
<h4>演示视频</h4>
<div class="demo-video-list">
<div
v-for="(demo, index) in demoVideos"
:key="index"
class="demo-video-item"
@click="selectDemoVideo(demo)"
>
<div class="demo-video-thumbnail">
<el-icon class="play-icon"><VideoPlay /></el-icon>
</div>
<span>{{ demo.name }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="图片/图表" name="image">
<div class="upload-area">
<el-upload
class="upload-demo"
drag
action="#"
:auto-upload="false"
:on-change="handleFileChange"
:accept="'.jpg,.jpeg,.png,.gif'"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">点击或拖拽文件到此处上传</div>
<template #tip>
<div class="el-upload__tip">
支持 JPGPNGGIF 格式
</div>
</template>
</el-upload>
<div class="demo-images">
<h4>演示图片</h4>
<div class="demo-image-list">
<div
v-for="(demo, index) in demoImages"
:key="index"
class="demo-image-item"
@click="selectDemoImage(demo)"
>
<img :src="demo.url" :alt="demo.name" />
<span>{{ demo.name }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="标注工具" name="annotation">
<div class="annotation-tools">
<p>标注工具功能开发中...</p>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<!-- 算法执行 -->
<div class="algorithm-execution">
<div class="section-header">
<h3>算法执行</h3>
<el-tag v-if="!userStore.isLoggedIn" type="warning" size="small">
<el-icon><Warning /></el-icon>
需要登录
</el-tag>
</div>
<div class="execution-form">
<el-form :inline="true" class="execution-form">
<el-form-item label="选择算法模型">
<el-select
v-model="selectedModel"
placeholder="请选择算法..."
style="width: 200px"
>
<el-option
v-for="algorithm in algorithmStore.algorithms"
:key="algorithm.id"
:label="algorithm.name"
:value="algorithm.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="executeAlgorithm"
:loading="executing"
:disabled="!selectedAlgorithm || !uploadedFile"
>
<el-icon><VideoPlay /></el-icon>
运行算法
</el-button>
</el-form-item>
</el-form>
<div v-if="executionProgress > 0" class="execution-progress">
<el-progress
:percentage="executionProgress"
:status="executionStatus"
/>
<p class="progress-text">{{ executionMessage }}</p>
</div>
</div>
</div>
</div>
<!-- 右侧运行结果 -->
<div class="right-sidebar">
<div class="sidebar-header">
<h2>运行结果</h2>
</div>
<div v-if="!executionResult" class="result-placeholder">
<el-icon class="chart-icon"><DataAnalysis /></el-icon>
<p>等待算法执行</p>
<p class="placeholder-text">执行完成后将在此展示</p>
</div>
<div v-else class="result-content">
<div class="result-header">
<h3>{{ selectedAlgorithm && selectedAlgorithm.name || '算法' }} 执行结果</h3>
<span class="execution-time">
执行时间: {{ executionResult.response_time }}ms
</span>
</div>
<div class="result-details">
<el-descriptions :column="1" border>
<el-descriptions-item label="执行状态">
<el-tag :type="executionResult.status === 'success' ? 'success' : 'danger'">
{{ executionResult.status === 'success' ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="输出类型">
{{ getOutputTypeName(selectedAlgorithm && selectedAlgorithm.output_type || '') }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="result-display">
<h4>输出结果</h4>
<div v-if="isImageResult" class="result-image">
<img :src="executionResult.output_data.result?.image || executionResult.output_data.image" alt="结果图片" />
</div>
<div v-else-if="isVideoResult" class="result-video">
<video
controls
autoplay
loop
:src="getVideoResultUrl()"
@error="handleVideoError"
>
您的浏览器不支持视频播放
</video>
</div>
<div v-else-if="isTextResult" class="result-text">
<pre>{{ executionResult.output_data.text }}</pre>
</div>
<div v-else class="result-json">
<pre>{{ JSON.stringify(executionResult.output_data, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { useAlgorithmStore } from '../stores/algorithm'
import { useUserStore } from '../stores/user'
import {
VideoPlay,
Upload,
DataAnalysis,
Grid,
MagicStick,
ArrowUp,
Camera,
Star,
Warning
} from '@element-plus/icons-vue'
// 获取路由和存储
const router = useRouter()
const algorithmStore = useAlgorithmStore()
const userStore = useUserStore()
// 响应式数据
const selectedAlgorithm = ref<any>(null)
const uploadedFile = ref<any>(null)
const selectedModel = ref('')
const executing = ref(false)
const executionProgress = ref(0)
const executionStatus = ref('')
const executionMessage = ref('')
const executionResult = ref<any>(null)
const activeUploadTab = ref('video')
// WebSocket 连接
let wsConnection: WebSocket | null = null
const realtimeDetections = ref<any[]>([])
const isRealtimeMode = ref(false)
// 连接到 WebSocket 服务器
const connectWebSocket = () => {
const wsUrl = `ws://localhost:8766`
try {
wsConnection = new WebSocket(wsUrl)
wsConnection.onopen = () => {
console.log('[WS] Connected to WebSocket server')
isRealtimeMode.value = true
}
wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('[WS] Received:', data)
if (data.msg_type === 'frame') {
realtimeDetections.value.push(data)
// 保持最近100帧
if (realtimeDetections.value.length > 100) {
realtimeDetections.value.shift()
}
} else if (data.msg_type === 'alert') {
ElMessage.warning(`检测到异常: ${data.event_type}`)
}
} catch (e) {
console.error('[WS] Parse error:', e)
}
}
wsConnection.onerror = (error) => {
console.error('[WS] Error:', error)
}
wsConnection.onclose = () => {
console.log('[WS] Disconnected')
isRealtimeMode.value = false
}
} catch (e) {
console.error('[WS] Connection failed:', e)
}
}
// 断开 WebSocket 连接
const disconnectWebSocket = () => {
if (wsConnection) {
wsConnection.close()
wsConnection = null
}
isRealtimeMode.value = false
realtimeDetections.value = []
}
// 分类配置
const categories = {
computer_vision: {
name: '计算机视觉',
icon: Camera,
color: '#409EFF'
},
video_processing: {
name: '视频处理',
icon: VideoPlay,
color: '#67C23A'
},
nlp: {
name: '自然语言处理',
icon: Star,
color: '#E6A23C'
},
ml: {
name: '机器学习',
icon: MagicStick,
color: '#909399'
},
edge_computing: {
name: '边缘计算',
icon: Grid,
color: '#F56C6C'
},
medical: {
name: '医疗算法',
icon: DataAnalysis,
color: '#722ED1'
},
autonomous_driving: {
name: '自动驾驶算法',
icon: ArrowUp,
color: '#FAAD14'
}
}
// 演示视频
const demoVideos = [
{ id: 1, name: '街道交通监控', url: '' },
{ id: 2, name: '人群行为分析', url: '' }
]
// 演示图片
const demoImages = [
{ id: 1, name: '城市街景', url: '' },
{ id: 2, name: '人物肖像', url: '' }
]
// 输出类型映射
const outputTypes = {
image: '图片',
video: '视频',
text: '文本',
json: 'JSON',
audio: '音频'
}
// 计算属性
const getOutputTypeName = (type: string) => {
return outputTypes[type as keyof typeof outputTypes] || type
}
const getAlgorithmsByCategory = (category: string) => {
return algorithmStore.algorithms.filter(algorithm => algorithm.tech_category === category)
}
const isImageFile = computed(() => {
if (!uploadedFile.value) return false
return uploadedFile.value.type.startsWith('image/')
})
const isVideoFile = computed(() => {
if (!uploadedFile.value) return false
return uploadedFile.value.type.startsWith('video/')
})
const isImageResult = computed(() => {
if (!executionResult.value || !selectedAlgorithm.value) return false
const outputData = executionResult.value.output_data || {}
const result = outputData.result || outputData
// 检查是否有图片数据
if (result.image || result.image_url || result.output_image) return true
return false
})
const isVideoResult = computed(() => {
if (!executionResult.value || !selectedAlgorithm.value) return false
const outputData = executionResult.value.output_data || {}
const result = outputData.result || outputData
// 检查是否有视频数据
if (result.video || result.video_url || result.output_video) return true
return false
})
const isTextResult = computed(() => {
if (!executionResult.value || !selectedAlgorithm.value) return false
return selectedAlgorithm.value.output_type === 'text'
})
const getVideoResultUrl = () => {
if (!executionResult.value || !executionResult.value.output_data) return ''
const outputData = executionResult.value.output_data || {}
const result = outputData.result || outputData
// 检查是否是base64编码的视频数据
if (result.video && typeof result.video === 'string' && result.video.startsWith('data:')) {
return result.video
}
// 检查是否有video_url
if (result.video_url) {
console.log('[DEBUG] video_url 原始值:', result.video_url)
const url = result.video_url
console.log('[DEBUG] video_url 返回值:', url)
return url
}
// 检查是否有output_video
if (result.output_video) {
return result.output_video
}
return ''
}
const handleVideoError = (event: Event) => {
console.error('视频加载失败:', event)
ElMessage.error('视频加载失败,请检查算法输出格式')
}
// 方法
const selectAlgorithm = (algorithm: any) => {
selectedAlgorithm.value = algorithm
selectedModel.value = algorithm.id
}
const handleFileChange = async (file: any) => {
// 检查用户是否登录
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后再上传文件')
router.push('/login')
return
}
// 检查是否选择了算法
if (!selectedAlgorithm.value) {
ElMessage.warning('请先选择一个算法')
return
}
try {
// 创建临时URL用于预览
const url = URL.createObjectURL(file.raw)
// 上传文件到MinIO
const formData = new FormData()
formData.append('file', file.raw)
formData.append('algorithm_id', selectedAlgorithm.value.id)
// 调用后端API上传文件
const response = await axios.post('/api/v1/data/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
uploadedFile.value = {
name: file.name,
type: file.raw.type,
url: url,
file: file.raw,
uploaded: true,
filePath: response.data.file_path
}
ElMessage.success('文件上传成功')
} catch (error: any) {
console.error('文件上传失败:', error)
ElMessage.error(error.response?.data?.detail || '文件上传失败')
// 即使上传失败也使用临时URL
const url = URL.createObjectURL(file.raw)
uploadedFile.value = {
name: file.name,
type: file.raw.type,
url: url,
file: file.raw,
uploaded: false
}
}
}
const selectDemoVideo = (demo: any) => {
// 这里可以设置演示视频的URL
uploadedFile.value = {
name: demo.name,
type: 'video/mp4',
url: demo.url || '',
demo: true
}
}
const selectDemoImage = (demo: any) => {
// 这里可以设置演示图片的URL
uploadedFile.value = {
name: demo.name,
type: 'image/jpeg',
url: demo.url || '',
demo: true
}
}
const executeAlgorithm = async () => {
if (!selectedAlgorithm.value || !uploadedFile.value) {
ElMessage.warning('请先选择算法和上传文件')
return
}
executing.value = true
executionProgress.value = 0
executionStatus.value = 'success'
executionMessage.value = '正在执行算法...'
try {
// 获取算法的默认版本
const defaultVersion = selectedAlgorithm.value.versions?.find((v: any) => v.is_default)
if (!defaultVersion) {
ElMessage.error('该算法没有可用的版本')
executing.value = false
return
}
// 准备调用参数 - 使用文件路径或MinIO路径
const callRequest = {
algorithm_id: selectedAlgorithm.value.id,
version_id: defaultVersion.id,
input_data: {
video: uploadedFile.value.filePath || uploadedFile.value.url
},
params: {
ws_url: 'ws://localhost:8766'
}
}
// 调用算法API
executionProgress.value = 30
executionMessage.value = '正在调用算法...'
console.log('Calling algorithm API with:', callRequest)
const response = await axios.post('/api/v1/algorithms/call/public', callRequest)
console.log('Algorithm response:', response.data)
executionProgress.value = 80
executionMessage.value = '正在处理结果...'
// 设置执行结果
executionResult.value = {
id: response.data.id,
status: response.data.status,
output_data: response.data.output_data,
response_time: response.data.response_time,
error_message: response.data.error_message
}
executionProgress.value = 100
executionMessage.value = '算法执行完成!'
ElMessage.success('算法执行成功')
} catch (error: any) {
console.error('算法执行失败:', error)
executionStatus.value = 'exception'
executionMessage.value = '算法执行失败'
executionResult.value = {
id: 'error-' + Date.now(),
status: 'error',
output_data: {},
response_time: 0,
error_message: error.response?.data?.detail || '执行失败'
}
ElMessage.error(error.response?.data?.detail || '算法执行失败')
} finally {
executing.value = false
}
}
// 初始化
onMounted(async () => {
await algorithmStore.fetchAlgorithms()
// 默认选择第一个算法
if (algorithmStore.algorithms.length > 0) {
selectAlgorithm(algorithmStore.algorithms[0])
}
// 连接 WebSocket 服务器
connectWebSocket()
})
// 组件卸载时断开 WebSocket
onUnmounted(() => {
disconnectWebSocket()
})
</script>
<style scoped>
.algorithm-showcase-container {
display: flex;
height: 100vh;
background-color: #f5f7fa;
}
/* 左侧算法服务一览 */
.left-sidebar {
width: 300px;
background-color: #fff;
border-right: 1px solid #e4e7ed;
overflow-y: auto;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #e4e7ed;
}
.sidebar-header h2 {
font-size: 18px;
margin: 0 0 5px 0;
color: #303133;
}
.sidebar-header p {
font-size: 14px;
color: #909399;
margin: 0;
}
.algorithm-categories {
padding: 10px 0;
}
.category-item {
margin-bottom: 20px;
}
.category-header {
display: flex;
align-items: center;
padding: 0 20px 10px;
cursor: pointer;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
color: #fff;
}
.category-header h3 {
font-size: 14px;
margin: 0;
flex: 1;
color: #303133;
}
.algorithm-count {
font-size: 12px;
color: #909399;
}
.algorithm-list {
padding: 0 10px;
}
.algorithm-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
margin-bottom: 5px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.algorithm-item:hover {
background-color: #f5f7fa;
}
.algorithm-item.active {
background-color: #ecf5ff;
border-left: 3px solid #409EFF;
}
.algorithm-name {
font-size: 14px;
color: #303133;
flex: 1;
}
/* 中间内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px;
}
/* 视频/图片展示 */
.media-display {
flex: 1;
background-color: #1f2937;
border-radius: 8px;
margin-bottom: 20px;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.media-placeholder {
text-align: center;
color: #9ca3af;
}
.play-icon {
font-size: 64px;
margin-bottom: 20px;
color: #4b5563;
}
.media-placeholder p {
margin: 5px 0;
font-size: 16px;
}
.support-text {
font-size: 14px;
color: #6b7280;
}
.image-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.video-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.video-preview video {
max-width: 100%;
max-height: 100%;
}
/* 素材上传 */
.upload-section {
background-color: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-header h3 {
font-size: 16px;
margin: 0;
color: #303133;
}
.upload-area {
margin-top: 15px;
}
.demo-videos {
margin-top: 20px;
}
.demo-videos h4 {
font-size: 14px;
margin: 0 0 10px 0;
color: #606266;
}
.demo-video-list {
display: flex;
gap: 15px;
}
.demo-video-item {
flex: 1;
text-align: center;
cursor: pointer;
padding: 10px;
border-radius: 4px;
transition: all 0.3s;
}
.demo-video-item:hover {
background-color: #f5f7fa;
}
.demo-video-thumbnail {
width: 100%;
height: 100px;
background-color: #f5f7fa;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.demo-video-thumbnail .play-icon {
font-size: 24px;
color: #909399;
}
.demo-images {
margin-top: 20px;
}
.demo-images h4 {
font-size: 14px;
margin: 0 0 10px 0;
color: #606266;
}
.demo-image-list {
display: flex;
gap: 15px;
}
.demo-image-item {
flex: 1;
text-align: center;
cursor: pointer;
padding: 10px;
border-radius: 4px;
transition: all 0.3s;
}
.demo-image-item:hover {
background-color: #f5f7fa;
}
.demo-image-item img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
/* 算法执行 */
.algorithm-execution {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.execution-form {
margin-bottom: 15px;
}
.execution-progress {
margin-top: 15px;
}
.progress-text {
font-size: 14px;
color: #606266;
margin-top: 5px;
text-align: center;
}
/* 右侧运行结果 */
.right-sidebar {
width: 400px;
background-color: #fff;
border-left: 1px solid #e4e7ed;
overflow-y: auto;
}
.right-sidebar .sidebar-header {
padding: 20px;
border-bottom: 1px solid #e4e7ed;
}
.right-sidebar .sidebar-header h2 {
font-size: 18px;
margin: 0;
color: #303133;
}
.result-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
color: #909399;
}
.chart-icon {
font-size: 64px;
margin-bottom: 20px;
color: #dcdfe6;
}
.result-placeholder p {
margin: 5px 0;
font-size: 16px;
}
.placeholder-text {
font-size: 14px;
color: #c0c4cc;
}
.result-content {
padding: 20px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.result-header h3 {
font-size: 16px;
margin: 0;
color: #303133;
}
.execution-time {
font-size: 12px;
color: #909399;
}
.result-details {
margin-bottom: 20px;
}
.result-display {
margin-top: 20px;
}
.result-display h4 {
font-size: 14px;
margin: 0 0 15px 0;
color: #606266;
}
.result-image img,
.result-video video {
width: 100%;
border-radius: 4px;
}
.result-text pre,
.result-json pre {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
border: 1px solid #e4e7ed;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
}
/* 响应式设计 */
@media (max-width: 1600px) {
.left-sidebar {
width: 250px;
}
.right-sidebar {
width: 350px;
}
}
@media (max-width: 1400px) {
.left-sidebar {
width: 200px;
}
.right-sidebar {
width: 300px;
}
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>