fix:联调算法

zlx
zhoulexin 1 day ago
parent 69259d9de7
commit 8ff1b05f22

33
package-lock.json generated

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.18.1", "axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.9.7", "element-plus": "^2.9.7",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"vue": "^3.5.38", "vue": "^3.5.38",
@ -1287,6 +1288,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.14.2", "version": "2.14.2",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
@ -2169,7 +2186,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2842,6 +2858,21 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.18.1", "axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.9.7", "element-plus": "^2.9.7",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"vue": "^3.5.38", "vue": "^3.5.38",

@ -14,6 +14,9 @@ importers:
axios: axios:
specifier: ^1.18.1 specifier: ^1.18.1
version: 1.18.1 version: 1.18.1
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus: element-plus:
specifier: ^2.9.7 specifier: ^2.9.7
version: 2.14.2(vue@3.5.38) version: 2.14.2(vue@3.5.38)
@ -479,6 +482,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
element-plus@2.14.2: element-plus@2.14.2:
resolution: {integrity: sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==} resolution: {integrity: sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==}
peerDependencies: peerDependencies:
@ -844,6 +850,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -959,6 +968,9 @@ packages:
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots: snapshots:
'@antfu/utils@0.7.10': {} '@antfu/utils@0.7.10': {}
@ -1345,6 +1357,11 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
element-plus@2.14.2(vue@3.5.38): element-plus@2.14.2(vue@3.5.38):
dependencies: dependencies:
'@ctrl/tinycolor': 4.2.0 '@ctrl/tinycolor': 4.2.0
@ -1694,6 +1711,8 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
tslib@2.3.0: {}
tslib@2.8.1: tslib@2.8.1:
optional: true optional: true
@ -1794,3 +1813,7 @@ snapshots:
'@vue/shared': 3.5.38 '@vue/shared': 3.5.38
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0

@ -0,0 +1,4 @@
const fileHttp = {
ptApi: '10.23.22.43:8001'
}
export default fileHttp

@ -15,6 +15,7 @@ class WebSocketClient {
this.heartbeatTimeout = options.heartbeatTimeout || 5000 // 心跳超时时间ms this.heartbeatTimeout = options.heartbeatTimeout || 5000 // 心跳超时时间ms
this.autoReconnect = options.autoReconnect !== false // 是否自动重连,默认 true this.autoReconnect = options.autoReconnect !== false // 是否自动重连,默认 true
this.debug = options.debug || false // 调试模式 this.debug = options.debug || false // 调试模式
this.binaryType = options.binaryType || 'arraybuffer' // 二进制数据类型
this.ws = null // WebSocket 实例 this.ws = null // WebSocket 实例
this.reconnectTimes = 0 // 当前重连次数 this.reconnectTimes = 0 // 当前重连次数
@ -80,6 +81,7 @@ class WebSocketClient {
this.ws.onclose = this._onClose this.ws.onclose = this._onClose
this.ws.onerror = this._onError this.ws.onerror = this._onError
this.ws.onmessage = this._onMessage this.ws.onmessage = this._onMessage
this.ws.binaryType = this.binaryType
} catch (err) { } catch (err) {
console.error('[WebSocket] 创建连接失败:', err) console.error('[WebSocket] 创建连接失败:', err)
this._emit('error', err) this._emit('error', err)
@ -143,6 +145,18 @@ class WebSocketClient {
return false return false
} }
/**
* 发送二进制数据ArrayBuffer用于文件切片上传等场景
* @param {ArrayBuffer} buffer - 二进制数据
*/
sendBinary(buffer) {
if (this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buffer)
return true
}
return false
}
// ==================== 事件监听 ==================== // ==================== 事件监听 ====================
on(event, callback) { on(event, callback) {
@ -173,7 +187,7 @@ class WebSocketClient {
this._emit('open', event) this._emit('open', event)
// 发送认证信息(带 token // 发送认证信息(带 token
this._sendAuthMessage() // this._sendAuthMessage()
// 发送待发送队列中的消息 // 发送待发送队列中的消息
this._flushPendingMessages() this._flushPendingMessages()
@ -202,9 +216,14 @@ class WebSocketClient {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
this._log('收到消息:', data) this._log('收到消息:', data)
// 任何服务端消息都视为心跳信号,重置超时检测
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
// 处理心跳回复 // 处理心跳回复
if (data.type === 'pong') { if (data.type === 'pong') {
this._handlePong()
return return
} }
@ -216,7 +235,11 @@ class WebSocketClient {
this._emit('message', data) this._emit('message', data)
} catch { } catch {
// 非 JSON 格式,透传原始消息 // 非 JSON 格式如二进制文件chunk接收也重置心跳
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
this._emit('message', event.data) this._emit('message', event.data)
} }
} }
@ -242,6 +265,9 @@ class WebSocketClient {
_startHeartbeat() { _startHeartbeat() {
this._clearHeartbeat() this._clearHeartbeat()
// heartbeatInterval 为 0 或非正值时关闭主动心跳
if (!this.heartbeatInterval || this.heartbeatInterval <= 0) return
// 定时发送 ping // 定时发送 ping
this.heartbeatTimer = setInterval(() => { this.heartbeatTimer = setInterval(() => {
if (this.isConnected && this.ws) { if (this.isConnected && this.ws) {
@ -259,15 +285,6 @@ class WebSocketClient {
}, this.heartbeatInterval) }, this.heartbeatInterval)
} }
_handlePong() {
this._log('收到心跳 pong')
// 清除超时检测
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
}
_clearHeartbeat() { _clearHeartbeat() {
if (this.heartbeatTimer) { if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer) clearInterval(this.heartbeatTimer)
@ -282,7 +299,7 @@ class WebSocketClient {
// ==================== 重连机制 ==================== // ==================== 重连机制 ====================
_tryReconnect() { _tryReconnect() {
if (this.isDestroyed) return if (this.isDestroyed || !this.autoReconnect) return
if (this.reconnectTimes >= this.maxReconnectTimes) { if (this.reconnectTimes >= this.maxReconnectTimes) {
this._log('已达到最大重连次数,停止重连') this._log('已达到最大重连次数,停止重连')

@ -0,0 +1,173 @@
<template>
<div class="charts-panel">
<div class="chart-box">
<div ref="pieChartRef" class="chart"></div>
</div>
<div class="chart-box">
<div ref="barChartRef" class="chart"></div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts/core'
import { PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, MarkLineComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, MarkLineComponent, CanvasRenderer])
const props = defineProps({
processStats: { type: Object, default: () => ({ completed: 0, incomplete: 0 }) },
counts: { type: Array, default: () => [] }
})
const pieChartRef = ref(null)
const barChartRef = ref(null)
let pieInstance = null
let barInstance = null
function initCharts() {
if (pieChartRef.value) {
pieInstance = echarts.init(pieChartRef.value)
}
if (barChartRef.value) {
barInstance = echarts.init(barChartRef.value)
}
}
function updatePieChart() {
if (!pieInstance) return
const { completed, incomplete } = props.processStats
pieInstance.setOption({
title: { text: '流程完成状态', left: 'center', textStyle: { fontSize: 14 } },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 25 },
series: [{
type: 'pie',
radius: ['30%', '60%'],
center: ['50%', '50%'],
data: [
{ name: '已完成', value: completed, itemStyle: { color: '#67c23a' } },
{ name: '未完成', value: incomplete, itemStyle: { color: '#e6a23c' } }
],
label: { show: true, formatter: '{b}\n{c}个' }
}]
})
}
function lightenColor(hex, amount) {
const num = parseInt(hex.slice(1), 16)
const r = Math.min(255, (num >> 16) + Math.round((255 - (num >> 16)) * amount))
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round((255 - ((num >> 8) & 0xff)) * amount))
const b = Math.min(255, (num & 0xff) + Math.round((255 - (num & 0xff)) * amount))
return `rgb(${r},${g},${b})`
}
function updateBarChart() {
if (!barInstance) return
const names = props.counts.map(item => item.name)
const colors = ['#ff4500', '#FFA500', '#C0C0C0', '#32CD32', '#00CED1', '#4169E1', '#9932CC', '#FF1493', '#8B4513']
//
const actualData = props.counts.map((item, idx) => ({
value: item.count,
itemStyle: { color: colors[idx % colors.length], borderRadius: [0, 0, 4, 4] }
}))
// 9 -
const remainData = props.counts.map((item, idx) => ({
value: Math.max(0, 9 - item.count),
itemStyle: { color: lightenColor(colors[idx % colors.length], 0.7), borderRadius: [4, 4, 0, 0] }
}))
barInstance.setOption({
title: { text: '检测计数', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
formatter(params) {
const actual = params.find(p => p.seriesName === '实际值')
if (!actual) return ''
return `${actual.name}<br/>${actual.marker} 检测数: ${actual.value}/9`
}
},
grid: { left: '5%', right: '5%', bottom: '5%', top: '15%', containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { rotate: 30, fontSize: 12 } },
yAxis: {
type: 'value',
minInterval: 1,
max: 9,
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
},
series: [
{
type: 'bar',
name: '实际值',
stack: 'total',
barWidth: 22,
data: actualData,
markLine: {
silent: true,
symbol: 'none',
lineStyle: { color: '#e6a23c', type: 'dashed', width: 2 },
label: {
formatter: '上限: 9',
color: '#e6a23c',
fontSize: 12,
position: 'insideEndTop'
},
data: [{ yAxis: 9 }]
}
},
{
type: 'bar',
name: '',
stack: 'total',
barWidth: 22,
data: remainData
}
]
})
}
watch(() => ({ ...props.processStats }), updatePieChart, { deep: true })
watch(() => [...props.counts], updateBarChart, { deep: true })
onMounted(() => {
nextTick(() => {
initCharts()
updatePieChart()
updateBarChart()
})
})
onUnmounted(() => {
pieInstance?.dispose()
barInstance?.dispose()
})
</script>
<style lang="scss" scoped>
.charts-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
.chart-box {
flex: 1;
background: $bg-white;
border-radius: $radius-md;
box-shadow: $shadow-sm;
overflow: hidden;
min-height: 0;
.chart {
width: 100%;
height: 100%;
min-height: 200px;
}
}
}
</style>

@ -0,0 +1,46 @@
<template>
<div class="control-buttons">
<el-button
:type="streamRunning ? 'danger' : 'primary'"
size="default"
@click="$emit('toggleStream')"
:disabled="streamAddressEmpty || (algorithmRunning && !streamRunning)"
>
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
</el-button>
<el-button
:type="algorithmRunning ? 'danger' : 'success'"
size="default"
@click="$emit('toggleAlgorithm')"
:disabled="offlineVideoMissing"
>
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
</el-button>
</div>
</template>
<script setup>
defineProps({
streamRunning: Boolean,
algorithmRunning: Boolean,
streamAddressEmpty: Boolean,
offlineVideoMissing: Boolean
})
defineEmits(['toggleStream', 'toggleAlgorithm'])
</script>
<style lang="scss" scoped>
.control-buttons {
display: flex;
gap: 12px;
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.el-button {
flex: 1;
}
}
</style>

@ -0,0 +1,64 @@
<template>
<div class="counts-card" v-if="hasCounts">
<div class="counts-header">检测计数</div>
<div class="counts-body">
<div class="count-item" v-for="(item, index) in counts" :key="index">
<span class="count-label">{{ item.name }}</span>
<span class="count-value">{{ item.count }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
counts: { type: Array, default: () => [] }
})
const hasCounts = computed(() => props.counts.length > 0)
</script>
<style lang="scss" scoped>
.counts-card {
background: $bg-white;
border-radius: $radius-md;
box-shadow: $shadow-sm;
overflow: hidden;
.counts-header {
padding: 10px 16px;
background: #e3f2fd;
font-size: 14px;
font-weight: 600;
color: #1565c0;
}
.counts-body {
padding: 10px 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
.count-item {
flex: 1 1 calc(50% - 6px);
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: #f5f5f5;
border-radius: 6px;
font-size: 13px;
.count-label {
color: $text-secondary;
}
.count-value {
font-weight: 700;
color: $primary-color;
}
}
}
}
</style>

@ -0,0 +1,66 @@
<template>
<div class="video-bottom-controls" v-if="visible">
<div class="detect-progress">
<div class="progress-info">
<span>{{ statusText || '检测中...' }}</span>
<span class="progress-pct">{{ progress.toFixed(1) }}%</span>
</div>
<el-progress :percentage="Math.round(progress)" :stroke-width="8" />
<div class="progress-actions">
<el-button size="small" :type="paused ? 'warning' : 'info'" @click="$emit('togglePause')" :disabled="ending">
<el-icon :size="14"><component :is="paused ? 'VideoPlay' : 'VideoPause'" /></el-icon>
{{ paused ? '恢复处理' : '暂停处理' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
visible: Boolean,
progress: { type: Number, default: 0 },
statusText: String,
paused: Boolean,
ending: Boolean
})
defineEmits(['togglePause'])
</script>
<style lang="scss" scoped>
.video-bottom-controls {
display: flex;
justify-content: center;
gap: 16px;
padding: 8px 0;
.detect-progress {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: $text-secondary;
.progress-pct {
color: $primary-color;
font-weight: 600;
}
}
.progress-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
}
</style>

@ -0,0 +1,103 @@
<template>
<div class="log-section">
<div class="log-header">流程日志</div>
<div class="log-body" ref="logBodyRef">
<div
v-for="(log, index) in logs"
:key="index"
class="log-item"
:class="'log-' + log.type"
>
<span class="log-time">{{ log.time }}</span>
<div class="log-msg">{{ log.message }}</div>
</div>
<div v-if="logs.length === 0" class="log-empty"></div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
logs: { type: Array, default: () => [] }
})
const logBodyRef = ref(null)
watch(() => props.logs.length, () => {
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
})
</script>
<style lang="scss" scoped>
.log-section {
flex: 1;
display: flex;
flex-direction: column;
background: #fffacd;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-sm;
min-height: 0;
.log-header {
padding: 10px 16px;
background: #fff176;
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.log-body {
flex: 1;
padding: 10px 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
.log-item {
width: 200%;
padding: 4px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
&:last-child {
border-bottom: none;
}
.log-time {
color: #666;
margin-right: 8px;
}
.log-msg {
color: $text-primary;
white-space: pre-wrap;
}
&.log-success .log-msg {
color: $success-color;
}
&.log-error .log-msg {
color: $danger-color;
}
&.log-warning .log-msg {
color: $warning-color;
}
}
.log-empty {
text-align: center;
color: #999;
padding: 20px 0;
}
}
}
</style>

@ -0,0 +1,104 @@
<template>
<div class="video-container">
<!-- 实时流模式 -->
<WebRtcPlayer
v-if="videoMode === 'live'"
:src="webrtcUrl"
@connection-status="$emit('connectionChange', $event)"
/>
<!-- 离线视频模式 -->
<div v-else-if="videoMode === 'offline'" class="offline-player">
<video
ref="offlineVideoRef"
:src="offlineVideoUrl"
class="video-element"
controls
></video>
</div>
<!-- 空状态 -->
<div v-else class="empty-video">
<el-icon :size="48"><VideoCamera /></el-icon>
<p class="empty-title">暂无视频画面</p>
<p class="empty-hint">点击上方切换加载实时流或上传离线视频</p>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
const props = defineProps({
videoMode: String,
webrtcUrl: String,
offlineVideoUrl: String,
videoAutoplayTrigger: Number
})
defineEmits(['connectionChange'])
const offlineVideoRef = ref(null)
//
watch(() => props.videoAutoplayTrigger, () => {
if (props.videoMode === 'offline') {
nextTick(() => offlineVideoRef.value?.play())
}
})
</script>
<style lang="scss" scoped>
.video-container {
flex: 1;
background: #1a1a2e;
border-radius: $radius-md;
overflow: hidden;
position: relative;
min-height: 300px;
:deep(.webrtc-wrapper) {
width: 100%;
height: 100%;
}
.offline-player {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
.video-element {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
object-fit: contain;
background: #000;
}
}
.empty-video {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
.empty-title {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
}
.empty-hint {
margin-top: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.3);
}
}
}
</style>

@ -0,0 +1,76 @@
<template>
<div class="video-toolbar">
<div class="stream-info">
<span class="label">实时视频流:</span>
<el-input
:model-value="streamAddress"
size="small"
placeholder="输入地址如: 10.23.22.xx"
class="stream-input"
@update:model-value="$emit('update:streamAddress', $event)"
/>
<el-button type="primary" size="small" @click="$emit('switchStream')" :disabled="disabled">切换</el-button>
</div>
<div class="upload-area">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="video/*"
:on-change="handleUpload"
:disabled="disabled"
>
<el-button size="small">
<el-icon :size="14"><Upload /></el-icon>
离线视频
</el-button>
</el-upload>
</div>
</div>
</template>
<script setup>
const props = defineProps({
streamAddress: String,
disabled: Boolean
})
const emit = defineEmits(['update:streamAddress', 'switchStream', 'uploadVideo'])
function handleUpload(file) {
emit('uploadVideo', file)
}
</script>
<style lang="scss" scoped>
.video-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg-white;
padding: 12px 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.stream-info {
display: flex;
align-items: center;
gap: 8px;
.label {
font-size: 13px;
color: $text-secondary;
white-space: nowrap;
}
.stream-input {
width: 180px;
}
}
.upload-area {
:deep(.el-upload) {
display: block;
}
}
}
</style>

@ -1,167 +1,76 @@
<template> <template>
<div class="home-page"> <div class="home-page">
<!-- 左侧视频区 -->
<div class="left-section"> <div class="left-section">
<!-- 顶部工具栏 --> <ControlButtons
<div class="video-toolbar"> :stream-running="streamRunning"
<div class="stream-info"> :algorithm-running="algorithmRunning"
<span class="label">实时视频流:</span> :stream-address-empty="!streamAddress"
<el-input :offline-video-missing="!offlineVideoUrl"
v-model="streamAddress" @toggle-stream="toggleStream"
size="small" @toggle-algorithm="toggleAlgorithm"
placeholder="输入地址如: 10.23.22.xx" />
class="stream-input"
/>
<el-button type="primary" size="small" @click="switchStream"></el-button>
</div>
<div class="upload-area">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="video/*"
:on-change="handleVideoUpload"
>
<el-button size="small">
<el-icon :size="14"><Upload /></el-icon>
离线视频
</el-button>
</el-upload>
</div>
</div>
<!-- 视频画面 -->
<div class="video-container">
<!-- 实时流模式 -->
<WebRtcPlayer
v-if="videoMode === 'live'"
ref="webrtcPlayerRef"
:src="webrtcUrl"
@connection-status="onConnectionChange"
/>
<!-- 离线视频模式 -->
<div v-else-if="videoMode === 'offline'" class="offline-player">
<video
ref="offlineVideoRef"
:src="offlineVideoUrl"
class="video-element"
@loadedmetadata="onVideoLoaded"
@timeupdate="onTimeUpdate"
@ended="onVideoEnded"
></video>
<!-- 进度条 -->
<div class="video-controls" v-if="videoDuration > 0">
<div class="progress-wrapper">
<div class="progress-track" @click="seekVideo">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="time-display">{{ formatTime(currentVideoTime) }} / {{ formatTime(videoDuration) }}</span>
</div>
</div>
</div>
<!-- 空状态默认展示 --> <div class="stats-panel">
<div v-else class="empty-video"> <div v-for="item in detectCounts" :key="item.name" class="stat-box">
<el-icon :size="48"><VideoCamera /></el-icon> <div class="stat-label">{{ item.name }}</div>
<p class="empty-title">暂无视频画面</p> <div class="stat-value">{{ item.count }}</div>
<p class="empty-hint">点击上方切换加载实时流或上传离线视频</p>
</div> </div>
</div> </div>
<!-- 底部控制按钮离线视频时显示 --> <FlowLog :logs="flowLogs" />
<div class="video-bottom-controls" v-if="videoMode === 'offline' && offlineVideoUrl"> </div>
<el-button type="primary" size="small" @click="togglePlay"> <div class="center-section">
<el-icon :size="14"><component :is="isPlaying ? 'VideoPause' : 'VideoPlay'" /></el-icon> <VideoToolbar
{{ isPlaying ? '暂停' : '播放' }} :stream-address="streamAddress"
</el-button> :disabled="algorithmRunning || streamRunning"
</div> @update:stream-address="streamAddress = $event"
@switch-stream="switchStream"
@upload-video="handleVideoUpload"
/>
<VideoPlayer
:video-mode="videoMode"
:webrtc-url="webrtcUrl"
:offline-video-url="offlineVideoUrl"
:video-autoplay-trigger="videoAutoplayTrigger"
@connection-change="onConnectionChange"
/>
<DetectionProgress
:visible="videoMode === 'offline' && !!offlineVideoUrl && algorithmRunning"
:progress="detectProgress"
:status-text="detectStatusText"
:paused="paused"
:ending="ending"
@toggle-pause="togglePause"
/>
</div> </div>
<!-- 右侧控制面板 -->
<div class="right-section"> <div class="right-section">
<!-- 顶部功能按钮 --> <ChartsPanel :process-stats="processStats" :counts="detectCounts" />
<div class="control-buttons">
<el-button
:type="streamRunning ? 'danger' : 'primary'"
size="default"
@click="toggleStream"
>
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
</el-button>
<el-button
:type="algorithmRunning ? 'danger' : 'success'"
size="default"
@click="toggleAlgorithm"
>
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
</el-button>
</div>
<!-- 配件信息卡片 -->
<div class="parts-card">
<div class="parts-row">
<span class="parts-label">配件名称:</span>
<span class="parts-value">充电线充电头隔板信封...</span>
</div>
<div class="parts-row">
<span class="parts-label">已检查配件数量:</span>
<span class="parts-value">
充电线{{ partsCount.chargerLine }}充电头{{ partsCount.chargerHead }}隔板{{ partsCount.divider }}信封{{ partsCount.envelope }}
</span>
</div>
</div>
<!-- 统计面板 -->
<div class="stats-panel">
<div class="stat-box">
<div class="stat-label">已完成的流程数量</div>
<div class="stat-value success">{{ completedCount }}</div>
</div>
<div class="stat-box">
<div class="stat-label">未完成的流程数量</div>
<div class="stat-value warning">{{ uncompletedCount }}</div>
</div>
<div class="stat-box detail-box" @click="showCompletedDetail">
<div class="stat-label">查看流程详情</div>
<div class="stat-value detail-arrow">
<el-icon :size="22"><ArrowRight /></el-icon>
</div>
</div>
</div>
<!-- 流程日志区域 -->
<div class="log-section">
<div class="log-header">流程日志</div>
<div class="log-body" ref="logBodyRef">
<div
v-for="(log, index) in flowLogs"
:key="index"
class="log-item"
:class="'log-' + log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
<div v-if="flowLogs.length === 0" class="log-empty"></div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
import WebSocketClient from '@/utils/websocket' import WebSocketClient from '@/utils/websocket'
import VideoToolbar from './components/VideoToolbar.vue'
import VideoPlayer from './components/VideoPlayer.vue'
import DetectionProgress from './components/DetectionProgress.vue'
import ControlButtons from './components/ControlButtons.vue'
import FlowLog from './components/FlowLog.vue'
import ChartsPanel from './components/ChartsPanel.vue'
import fileHttp from '@/utils/fileHttp.js'
// ============ ============ // ============ ============
const videoMode = ref('idle') // 'idle' | 'live' | 'offline' const videoMode = ref('idle')
const streamAddress = ref('10.23.22.xx') const streamAddress = ref('')
const webrtcUrl = computed(() => { const webrtcUrl = computed(() => {
return import.meta.env.VITE_WEBRTC_URL || `http://${streamAddress.value}/stream` return import.meta.env.VITE_WEBRTC_URL || `http://${streamAddress.value}/stream`
}) })
const streamRunning = ref(false) const streamRunning = ref(false)
const webrtcPlayerRef = ref(null)
function switchStream() { function switchStream() {
if (!streamAddress.value) { if (!streamAddress.value) {
@ -175,6 +84,14 @@ function switchStream() {
function toggleStream() { function toggleStream() {
streamRunning.value = !streamRunning.value streamRunning.value = !streamRunning.value
if (streamRunning.value) { if (streamRunning.value) {
// 线
if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value)
offlineVideoUrl.value = ''
detectionFile.value = null
resetDetectionState()
addLog('已清空离线视频文件,如需检测请重新上传', 'warning')
}
videoMode.value = 'live' videoMode.value = 'live'
ElMessage.success('视频流已启动') ElMessage.success('视频流已启动')
} else { } else {
@ -188,188 +105,285 @@ function onConnectionChange(status) {
} }
// ============ 线 ============ // ============ 线 ============
const offlineVideoRef = ref(null)
const offlineVideoUrl = ref('') const offlineVideoUrl = ref('')
const videoDuration = ref(0) const detectionFile = ref(null)
const currentVideoTime = ref(0)
const isPlaying = ref(false)
const progressPercent = computed(() => {
if (videoDuration.value === 0) return 0
return (currentVideoTime.value / videoDuration.value) * 100
})
function handleVideoUpload(file) { function handleVideoUpload(file) {
if (offlineVideoUrl.value) { if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value) URL.revokeObjectURL(offlineVideoUrl.value)
} }
offlineVideoUrl.value = URL.createObjectURL(file.raw) offlineVideoUrl.value = URL.createObjectURL(file.raw)
detectionFile.value = file.raw
videoMode.value = 'offline' videoMode.value = 'offline'
isPlaying.value = false resetDetectionState()
ElMessage.success('离线视频已加载') ElMessage.success('离线视频已加载')
} }
function onVideoLoaded() { // ============ 线WebSocket ============
if (offlineVideoRef.value) { const WS_URL = `ws://${fileHttp.ptApi}/ws/detect`
videoDuration.value = offlineVideoRef.value.duration const HTTP_BASE = `http://${fileHttp.ptApi}`
}
} const detectProgress = ref(0)
const detectStatusText = ref('')
function onTimeUpdate() {
if (offlineVideoRef.value) { const DEFAULT_COUNTS = [
currentVideoTime.value = offlineVideoRef.value.currentTime { name: '附件包白板', count: 0 },
} { name: '充电头', count: 0 },
{ name: '充电线1', count: 0 },
{ name: '充电线2', count: 0 },
{ name: '附件包', count: 0 },
{ name: '产品1', count: 0 },
{ name: '隔板', count: 0 },
{ name: '信封', count: 0 },
{ name: '胶带', count: 0 }
]
const detectCounts = reactive(DEFAULT_COUNTS.map(c => ({ ...c })))
const processStats = reactive({ completed: 0, incomplete: 0 })
function mergeCounts(newCounts) {
newCounts.forEach(newItem => {
const existing = detectCounts.find(item => item.name === newItem.name)
if (existing) {
existing.count = newItem.count
}
})
} }
function onVideoEnded() { function resetDetectionState() {
isPlaying.value = false detectProgress.value = 0
detectStatusText.value = ''
detectCounts.forEach(item => { item.count = 0 })
processStats.completed = 0
processStats.incomplete = 0
} }
function togglePlay() { async function sendFileInChunks(socket, file) {
if (!offlineVideoRef.value) return const chunkSize = 1024 * 1024
if (isPlaying.value) { let offset = 0
offlineVideoRef.value.pause() while (offset < file.size) {
isPlaying.value = false const chunk = file.slice(offset, offset + chunkSize)
} else { const buffer = await chunk.arrayBuffer()
offlineVideoRef.value.play() socket.sendBinary(buffer)
isPlaying.value = true offset += chunkSize
} }
} }
function seekVideo(e) {
if (!offlineVideoRef.value || videoDuration.value === 0) return
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
offlineVideoRef.value.currentTime = ratio * videoDuration.value
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ============ ============ // ============ ============
const algorithmRunning = ref(false) const algorithmRunning = ref(false)
const paused = ref(false)
const ending = ref(false)
let wsClient = null let wsClient = null
const videoAutoplayTrigger = ref(0)
function toggleAlgorithm() { function toggleAlgorithm() {
if (algorithmRunning.value && videoMode.value === 'offline' && detectionFile.value) {
endDetection()
return
}
algorithmRunning.value = !algorithmRunning.value algorithmRunning.value = !algorithmRunning.value
if (algorithmRunning.value) { if (algorithmRunning.value) {
// WebSocket paused.value = false
wsClient = new WebSocketClient({ ending.value = false
url: 'ws://10.21.221.41:8000/ws/detect', flowLogs.value = []
debug: true,
autoReconnect: true if (videoMode.value === 'offline' && detectionFile.value) {
}) resetDetectionState()
detectStatusText.value = '正在连接 WebSocket...'
wsClient.on('open', () => { addLog('开始连接算法检测服务', 'info')
ElMessage.success('算法已启动') //
addLog('WebSocket 算法连接成功', 'info') videoAutoplayTrigger.value++
console.log('[WebSocket] 算法连接成功')
}) wsClient = new WebSocketClient({
url: WS_URL,
wsClient.on('message', (data) => { binaryType: 'arraybuffer',
console.log('[WebSocket] 收到算法检测数据:', data) autoReconnect: false,
addLog('收到检测数据: ' + JSON.stringify(data), 'info') heartbeatInterval: 0,
}) heartbeatTimeout: 0,
debug: false
wsClient.on('close', (event) => { })
console.log('[WebSocket] 连接关闭:', event.code, event.reason)
addLog('算法连接已关闭', 'warning') wsClient.on('open', () => {
}) detectStatusText.value = '已连接,正在打开算法开关...'
addLog('WebSocket 已连接,发送算法开关', 'info')
wsClient.on('error', (err) => { wsClient.send({ type: 'switch', enabled: true })
console.error('[WebSocket] 连接出错:', err) })
addLog('算法连接出错', 'error')
}) wsClient.on('message', async (msg) => {
switch (msg.type) {
wsClient.on('reconnect', ({ times, maxTimes }) => { case 'ready':
console.log(`[WebSocket] 第 ${times}/${maxTimes} 次重连中...`) addLog('服务端 ready', 'info')
addLog(`${times}/${maxTimes} 次重连中...`, 'warning') break
})
case 'switch':
wsClient.on('heartbeat', () => { addLog('算法开关已开启', 'success')
console.warn('[WebSocket] 心跳超时,即将重连') wsClient.send({
addLog('算法连接心跳超时', 'error') type: 'start',
}) filename: detectionFile.value.name,
options: {
wsClient.connect() conf: 0.6, iou: 0.45, skip: 5, imgsz: 640, max_frames: 0, save_video: true
}
})
break
case 'upload_started':
detectStatusText.value = '正在上传视频...'
addLog('开始上传视频文件', 'info')
await sendFileInChunks(wsClient, detectionFile.value)
wsClient.send({ type: 'end' })
addLog('视频上传完成,等待服务端处理', 'info')
break
case 'processing_started':
detectStatusText.value = '服务端正在处理视频...'
addLog('检测开始', 'success')
break
case 'pause':
paused.value = Boolean(msg.paused)
addLog(msg.message || (paused.value ? '检测已暂停' : '检测已恢复'), 'warning')
break
case 'progress':
detectProgress.value = Number(msg.progress || 0)
detectStatusText.value = `处理中 (${msg.frame || 0}/${msg.total_frames || 0})`
break
case 'counts':
mergeCounts(msg.counts || [])
break
case 'log':
addLog(msg.message || '', 'info')
break
case 'stopping':
ending.value = true
detectStatusText.value = msg.message || '正在结束检测...'
addLog(msg.message || '已请求结束检测', 'warning')
break
case 'cancelled':
ending.value = true
detectStatusText.value = msg.message || '检测已结束,正在生成当前结果...'
addLog(msg.message || '检测已结束', 'warning')
break
case 'done':
const videoUrl = HTTP_BASE.replace(/\/$/, '') + msg.output_video_url
if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value)
}
offlineVideoUrl.value = videoUrl
videoMode.value = 'offline'
detectStatusText.value = '检测完成'
detectProgress.value = 100
addLog('检测完成,视频已更新', 'success')
//
const processes = msg.processes || []
processStats.completed = processes.filter(p => p.is_complete).length
processStats.incomplete = processes.filter(p => !p.is_complete).length
//
mergeCounts(msg.counts || [])
ElMessage.success('视频检测完成')
algorithmRunning.value = false
paused.value = false
ending.value = false
wsClient.close()
videoAutoplayTrigger.value++
break
case 'error':
detectStatusText.value = '检测出错'
addLog('服务端错误: ' + (msg.message || ''), 'error')
ElMessage.error(msg.message || '服务端处理出错')
algorithmRunning.value = false
paused.value = false
ending.value = false
wsClient.close()
break
}
})
wsClient.on('close', () => {
addLog('连接关闭', 'warning')
algorithmRunning.value = false
paused.value = false
ending.value = false
})
wsClient.on('error', () => {
detectStatusText.value = '连接失败'
addLog('WebSocket 连接失败,请检查服务地址', 'error')
ElMessage.error('WebSocket 连接失败')
algorithmRunning.value = false
paused.value = false
ending.value = false
})
wsClient.connect()
} else if (videoMode.value === 'offline' && !detectionFile.value) {
ElMessage.warning('请先上传离线视频')
algorithmRunning.value = false
} else {
wsClient = new WebSocketClient({
url: WS_URL, debug: true, autoReconnect: true
})
wsClient.on('open', () => {
ElMessage.success('算法已启动')
addLog('算法连接成功', 'info')
})
wsClient.on('message', (data) => {
console.log('[WebSocket] 收到算法数据:', data)
})
wsClient.on('close', () => addLog('算法连接已关闭', 'warning'))
wsClient.on('error', () => addLog('算法连接出错', 'error'))
wsClient.connect()
}
} else { } else {
// WebSocket
if (wsClient) { if (wsClient) {
wsClient.destroy() wsClient.destroy()
wsClient = null wsClient = null
} }
ElMessage.info('算法已关闭') ElMessage.info('算法已关闭')
addLog('算法已关闭', 'info') addLog('算法已关闭', 'info')
console.log('[WebSocket] 算法连接已主动关闭')
} }
} }
// ============ ============ function togglePause() {
const partsCount = reactive({ if (!wsClient || wsClient.getReadyState() !== WebSocket.OPEN) return
chargerLine: 30, paused.value = !paused.value
chargerHead: 15, wsClient.send({ type: 'pause', paused: paused.value })
divider: 0, addLog(paused.value ? '发送暂停请求...' : '发送恢复请求...', 'info')
envelope: 0 }
})
function endDetection() {
if (!wsClient || wsClient.getReadyState() !== WebSocket.OPEN || ending.value) return
ending.value = true
detectStatusText.value = '已发送结束请求,等待服务端生成结果...'
addLog('前端请求结束检测', 'warning')
wsClient.send({ type: 'stop' })
}
// ============ ============ // ============ ============
const flowLogs = ref([ const flowLogs = ref([])
{ time: '10:05:12', message: '开始检测流程', type: 'info' },
{ time: '10:05:15', message: '检测到产品A', type: 'success' },
{ time: '10:05:18', message: '检测到产品B', type: 'success' },
{ time: '10:05:22', message: '检测隔板...', type: 'warning' },
{ time: '10:05:25', message: '隔板未检测到', type: 'error' },
])
const logBodyRef = ref(null)
function addLog(message, type = 'info') { function addLog(message, type = 'info') {
const now = new Date() const now = new Date()
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
flowLogs.value.push({ time, message, type }) flowLogs.value.push({ time, message, type })
// if (flowLogs.value.length > 200) {
if (flowLogs.value.length > 100) {
flowLogs.value.shift() flowLogs.value.shift()
} }
//
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
} }
// ============ ============ // ============ ============
const completedCount = ref(12)
const uncompletedCount = ref(3)
function showCompletedDetail() {
ElMessage.info('查看已完成流程详情')
}
// ============ ============
let logTimer = null
onMounted(() => { onMounted(() => {
// addLog('页面已加载,可上传离线视频或连接实时流', 'info')
logTimer = setInterval(() => {
if (algorithmRunning.value && Math.random() > 0.7) {
const msgs = [
{ msg: '检测到充电线', type: 'success' },
{ msg: '检测到充电头', type: 'success' },
{ msg: '正在识别产品...', type: 'info' },
{ msg: '流程完成', type: 'success' }
]
const item = msgs[Math.floor(Math.random() * msgs.length)]
addLog(item.msg, item.type)
}
}, 3000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (logTimer) clearInterval(logTimer)
if (offlineVideoUrl.value) { if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value) URL.revokeObjectURL(offlineVideoUrl.value)
} }
@ -378,15 +392,6 @@ onUnmounted(() => {
wsClient = null wsClient = null
} }
}) })
//
watch(flowLogs, () => {
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
}, { deep: true })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -397,313 +402,50 @@ watch(flowLogs, () => {
padding: 0; padding: 0;
} }
// .left-section, .right-section{
.left-section { width: 500px;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
min-height: 0;
}
.video-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg-white;
padding: 12px 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.stream-info {
display: flex;
align-items: center;
gap: 8px;
.label {
font-size: 13px;
color: $text-secondary;
white-space: nowrap;
}
.stream-input {
width: 180px;
}
}
.upload-area {
:deep(.el-upload) {
display: block;
}
}
}
.video-container {
flex: 1;
background: #1a1a2e;
border-radius: $radius-md;
overflow: hidden;
position: relative;
min-height: 300px;
:deep(.webrtc-wrapper) {
width: 100%;
height: 100%;
}
.offline-player {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
.video-element {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
object-fit: contain;
background: #000;
}
.video-controls {
padding: 8px 16px;
background: rgba(0, 0, 0, 0.8);
.progress-wrapper {
display: flex;
align-items: center;
gap: 12px;
.progress-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
&:hover {
height: 6px;
}
.progress-fill {
height: 100%;
background: $primary-color;
border-radius: 2px;
transition: width 0.1s linear;
}
}
.time-display {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
}
}
.empty-video {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
.empty-title {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
}
.empty-hint {
margin-top: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.3);
}
}
}
.video-bottom-controls {
display: flex;
justify-content: center;
gap: 16px;
padding: 8px 0;
}
//
.right-section {
width: 400px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.left-section{
.control-buttons { width: 400px;
display: flex;
gap: 12px;
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.el-button {
flex: 1;
}
}
.parts-card {
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.parts-row {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.6;
&:not(:last-child) {
margin-bottom: 8px;
}
.parts-label {
color: $text-secondary;
white-space: nowrap;
}
.parts-value {
color: $text-primary;
font-weight: 500;
}
}
} }
.center-section{
.log-section {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #fffacd; gap: 12px;
border-radius: $radius-md; min-width: 0;
overflow: hidden;
box-shadow: $shadow-sm;
min-height: 0; min-height: 0;
.log-header {
padding: 10px 16px;
background: #fff176;
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.log-body {
flex: 1;
padding: 10px 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
.log-item {
padding: 4px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
&:last-child {
border-bottom: none;
}
.log-time {
color: #666;
margin-right: 8px;
}
.log-msg {
color: $text-primary;
}
&.log-success .log-msg {
color: $success-color;
}
&.log-error .log-msg {
color: $danger-color;
}
&.log-warning .log-msg {
color: $warning-color;
}
}
.log-empty {
text-align: center;
color: #999;
padding: 20px 0;
}
}
} }
.stats-panel { .stats-panel {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: 12px; gap: 6px;
.stat-box { .stat-box {
flex: 0 0 calc(33.33% - 4px);
background: $bg-white; background: $bg-white;
padding: 10px 16px; padding: 6px 4px;
border-radius: $radius-md; border-radius: $radius-md;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
text-align: center; text-align: center;
min-width: 120px;
.stat-label { .stat-label {
font-size: 12px; font-size: 14px;
color: $text-secondary; color: $text-secondary;
margin-bottom: 4px; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.stat-value { .stat-value {
font-size: 22px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: $primary-color;
&.success {
color: $success-color;
}
&.warning {
color: $warning-color;
}
}
&.detail-box {
cursor: pointer;
transition: $transition;
.detail-arrow {
color: $primary-color;
transition: transform 0.2s;
}
&:hover {
background: #f8f9ff;
box-shadow: 0 2px 8px rgba(45, 90, 240, 0.1);
.detail-arrow {
transform: translateX(4px);
}
}
&:active {
background: #f0f2ff;
}
} }
} }
} }

@ -0,0 +1,431 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOP WebSocket 前端测试</title>
<style>
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
padding: 20px;
margin-bottom: 16px;
}
h1 {
margin: 0 0 16px;
font-size: 24px;
}
label {
display: block;
margin: 10px 0 6px;
font-weight: 600;
}
input, button {
font-size: 14px;
}
input[type="text"], input[type="number"] {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
}
input[type="file"] {
width: 100%;
padding: 10px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 16px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
color: #fff;
background: #2563eb;
}
button:disabled {
cursor: not-allowed;
background: #9ca3af;
}
.danger {
background: #dc2626;
}
.status {
padding: 10px 12px;
border-radius: 8px;
background: #eef2ff;
color: #3730a3;
margin-bottom: 12px;
white-space: pre-wrap;
}
.progress-wrap {
height: 16px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #22c55e, #16a34a);
transition: width 0.2s;
}
.counts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.count-item {
padding: 10px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
justify-content: space-between;
}
.log {
height: 280px;
overflow: auto;
background: #111827;
color: #d1d5db;
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
video {
width: 100%;
max-height: 520px;
background: #000;
border-radius: 8px;
}
a {
color: #2563eb;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>SOP 视频检测 WebSocket 前端测试</h1>
<div id="status" class="status">未连接</div>
<label>WebSocket 地址</label>
<input id="wsUrl" type="text" value="ws://10.21.221.41:8000/ws/detect" />
<label>HTTP 服务地址,用于播放结果视频</label>
<input id="httpBase" type="text" value="http://10.21.221.41:8000" />
<label>选择要上传的视频</label>
<input id="videoFile" type="file" accept="video/*" />
<div class="grid">
<div>
<label>conf</label>
<input id="conf" type="number" value="0.5" min="0" max="1" step="0.01" />
</div>
<div>
<label>iou</label>
<input id="iou" type="number" value="0.45" min="0" max="1" step="0.01" />
</div>
<div>
<label>skip</label>
<input id="skip" type="number" value="5" min="1" step="1" />
</div>
<div>
<label>max_frames0 表示完整视频</label>
<input id="maxFrames" type="number" value="0" min="0" step="1" />
</div>
</div>
<div class="buttons">
<button id="startBtn">上传并开始检测</button>
<button id="pauseBtn" disabled>暂停处理</button>
<button id="endBtn" class="danger" disabled>结束检测</button>
<button id="stopBtn" class="danger" disabled>关闭连接</button>
</div>
</div>
<div class="card">
<h2>实时进度</h2>
<div class="progress-wrap"><div id="progressBar" class="progress-bar"></div></div>
<p id="progressText">0%</p>
</div>
<div class="card">
<h2>实时计数</h2>
<div id="counts" class="counts">暂无计数</div>
</div>
<div class="card">
<h2>实时日志</h2>
<div id="log" class="log"></div>
</div>
<div class="card">
<h2>处理后视频</h2>
<video id="resultVideo" controls></video>
<p id="resultLinks"></p>
</div>
</div>
<script>
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const countsEl = document.getElementById("counts");
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const resultVideo = document.getElementById("resultVideo");
const resultLinks = document.getElementById("resultLinks");
const startBtn = document.getElementById("startBtn");
const pauseBtn = document.getElementById("pauseBtn");
const endBtn = document.getElementById("endBtn");
const stopBtn = document.getElementById("stopBtn");
let ws = null;
let paused = false;
let ending = false;
function setStatus(text) {
statusEl.textContent = text;
}
function appendLog(text) {
logEl.textContent += text + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function renderCounts(counts) {
const entries = Object.entries(counts || {});
if (!entries.length) {
countsEl.textContent = "暂无计数";
return;
}
countsEl.innerHTML = entries.map(([name, value]) => (
`<div class="count-item"><span>${name}</span><strong>${value}</strong></div>`
)).join("");
}
function renderProgress(msg) {
const progress = Number(msg.progress || 0);
progressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
progressText.textContent = `${progress.toFixed(1)}% | ${msg.frame || 0}/${msg.total_frames || 0}`;
}
async function sendFileInChunks(socket, file) {
const chunkSize = 1024 * 1024;
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
const buffer = await chunk.arrayBuffer();
socket.send(buffer);
offset += chunkSize;
}
}
startBtn.onclick = async () => {
const file = document.getElementById("videoFile").files[0];
const wsUrl = document.getElementById("wsUrl").value.trim();
if (!file) {
alert("请先选择视频文件");
return;
}
if (!wsUrl.startsWith("ws://") && !wsUrl.startsWith("wss://")) {
alert("WebSocket 地址必须以 ws:// 或 wss:// 开头");
return;
}
logEl.textContent = "";
resultLinks.innerHTML = "";
resultVideo.removeAttribute("src");
resultVideo.load();
renderCounts({});
renderProgress({ progress: 0, frame: 0, total_frames: 0 });
startBtn.disabled = true;
pauseBtn.disabled = true;
pauseBtn.textContent = "暂停处理";
paused = false;
ending = false;
endBtn.disabled = true;
stopBtn.disabled = false;
setStatus("正在连接 WebSocket...");
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接,正在打开算法开关...");
ws.send(JSON.stringify({ type: "switch", enabled: true }));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ready") {
appendLog("服务端 ready");
return;
}
if (msg.type === "switch") {
appendLog("算法开关已开启");
ws.send(JSON.stringify({
type: "start",
filename: file.name,
options: {
conf: Number(document.getElementById("conf").value),
iou: Number(document.getElementById("iou").value),
skip: Number(document.getElementById("skip").value),
imgsz: 640,
max_frames: Number(document.getElementById("maxFrames").value),
save_video: true
}
}));
return;
}
if (msg.type === "upload_started") {
setStatus("正在上传视频...");
appendLog("开始上传视频");
await sendFileInChunks(ws, file);
ws.send(JSON.stringify({ type: "end" }));
appendLog("视频上传完成,等待服务端处理");
return;
}
if (msg.type === "processing_started") {
setStatus("服务端正在处理视频...");
appendLog("检测开始");
pauseBtn.disabled = false;
endBtn.disabled = false;
return;
}
if (msg.type === "stopping") {
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus(msg.message || "正在结束检测...");
appendLog(msg.message || "已请求结束检测");
return;
}
if (msg.type === "cancelled") {
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus(msg.message || "检测已结束,正在生成当前结果...");
appendLog(msg.message || "检测已结束");
return;
}
if (msg.type === "pause") {
paused = Boolean(msg.paused);
pauseBtn.textContent = paused ? "恢复处理" : "暂停处理";
setStatus(paused ? "检测已暂停,服务仍在运行" : "服务端正在处理视频...");
appendLog(msg.message || (paused ? "检测已暂停" : "检测已恢复"));
return;
}
if (msg.type === "progress") {
renderProgress(msg);
return;
}
if (msg.type === "counts") {
renderCounts(msg.counts);
return;
}
if (msg.type === "log") {
appendLog(msg.message || "");
return;
}
if (msg.type === "done") {
const httpBase = document.getElementById("httpBase").value.replace(/\/$/, "");
const videoUrl = httpBase + msg.output_video_url;
const reportUrl = httpBase + msg.report_url;
setStatus("检测完成");
pauseBtn.disabled = true;
endBtn.disabled = true;
renderCounts(msg.counts || {});
resultVideo.src = videoUrl;
resultLinks.innerHTML = `结果视频:<a href="${videoUrl}" target="_blank">${videoUrl}</a><br>` +
`检测报告:<a href="${reportUrl}" target="_blank">${reportUrl}</a>`;
appendLog("检测完成");
ws.close();
return;
}
if (msg.type === "error") {
setStatus("服务端错误:" + msg.message);
appendLog("错误:" + msg.message);
pauseBtn.disabled = true;
endBtn.disabled = true;
ws.close();
}
};
ws.onerror = () => {
setStatus("WebSocket 连接失败,请检查服务地址、端口、防火墙和服务是否以 0.0.0.0 启动");
};
ws.onclose = () => {
startBtn.disabled = false;
pauseBtn.disabled = true;
pauseBtn.textContent = "暂停处理";
endBtn.disabled = true;
stopBtn.disabled = true;
};
};
pauseBtn.onclick = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
paused = !paused;
ws.send(JSON.stringify({ type: "pause", paused }));
pauseBtn.textContent = paused ? "恢复处理" : "暂停处理";
};
endBtn.onclick = () => {
if (!ws || ws.readyState !== WebSocket.OPEN || ending) return;
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus("已发送结束检测请求,等待服务端生成当前结果...");
appendLog("前端请求结束检测");
ws.send(JSON.stringify({ type: "stop" }));
};
stopBtn.onclick = () => {
if (ws) ws.close();
setStatus("连接已关闭");
};
</script>
</body>
</html>

@ -0,0 +1,368 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOP WebSocket 前端测试</title>
<style>
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
padding: 20px;
margin-bottom: 16px;
}
h1 {
margin: 0 0 16px;
font-size: 24px;
}
label {
display: block;
margin: 10px 0 6px;
font-weight: 600;
}
input, button {
font-size: 14px;
}
input[type="text"], input[type="number"] {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
}
input[type="file"] {
width: 100%;
padding: 10px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 16px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
color: #fff;
background: #2563eb;
}
button:disabled {
cursor: not-allowed;
background: #9ca3af;
}
.danger {
background: #dc2626;
}
.status {
padding: 10px 12px;
border-radius: 8px;
background: #eef2ff;
color: #3730a3;
margin-bottom: 12px;
white-space: pre-wrap;
}
.progress-wrap {
height: 16px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #22c55e, #16a34a);
transition: width 0.2s;
}
.counts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.count-item {
padding: 10px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
justify-content: space-between;
}
.log {
height: 280px;
overflow: auto;
background: #111827;
color: #d1d5db;
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
video {
width: 100%;
max-height: 520px;
background: #000;
border-radius: 8px;
}
a {
color: #2563eb;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>SOP 视频检测 WebSocket 前端测试</h1>
<div id="status" class="status">未连接</div>
<label>WebSocket 地址</label>
<input id="wsUrl" type="text" value="ws://10.21.221.41:8000/ws/detect" />
<label>HTTP 服务地址,用于播放结果视频</label>
<input id="httpBase" type="text" value="http://10.21.221.41:8000" />
<label>选择要上传的视频</label>
<input id="videoFile" type="file" accept="video/*" />
<div class="grid">
<div>
<label>conf</label>
<input id="conf" type="number" value="0.5" min="0" max="1" step="0.01" />
</div>
<div>
<label>iou</label>
<input id="iou" type="number" value="0.45" min="0" max="1" step="0.01" />
</div>
<div>
<label>skip</label>
<input id="skip" type="number" value="5" min="1" step="1" />
</div>
<div>
<label>max_frames0 表示完整视频</label>
<input id="maxFrames" type="number" value="0" min="0" step="1" />
</div>
</div>
<div class="buttons">
<button id="startBtn">上传并开始检测</button>
<button id="stopBtn" class="danger" disabled>关闭连接</button>
</div>
</div>
<div class="card">
<h2>实时进度</h2>
<div class="progress-wrap"><div id="progressBar" class="progress-bar"></div></div>
<p id="progressText">0%</p>
</div>
<div class="card">
<h2>实时计数</h2>
<div id="counts" class="counts">暂无计数</div>
</div>
<div class="card">
<h2>实时日志</h2>
<div id="log" class="log"></div>
</div>
<div class="card">
<h2>处理后视频</h2>
<video id="resultVideo" controls></video>
<p id="resultLinks"></p>
</div>
</div>
<script>
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const countsEl = document.getElementById("counts");
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const resultVideo = document.getElementById("resultVideo");
const resultLinks = document.getElementById("resultLinks");
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
let ws = null;
function setStatus(text) {
statusEl.textContent = text;
}
function appendLog(text) {
logEl.textContent += text + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function renderCounts(counts) {
const entries = Object.entries(counts || {});
if (!entries.length) {
countsEl.textContent = "暂无计数";
return;
}
countsEl.innerHTML = entries.map(([name, value]) => (
`<div class="count-item"><span>${name}</span><strong>${value}</strong></div>`
)).join("");
}
function renderProgress(msg) {
const progress = Number(msg.progress || 0);
progressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
progressText.textContent = `${progress.toFixed(1)}% | ${msg.frame || 0}/${msg.total_frames || 0}`;
}
async function sendFileInChunks(socket, file) {
const chunkSize = 1024 * 1024;
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
const buffer = await chunk.arrayBuffer();
socket.send(buffer);
offset += chunkSize;
}
}
startBtn.onclick = async () => {
const file = document.getElementById("videoFile").files[0];
const wsUrl = document.getElementById("wsUrl").value.trim();
if (!file) {
alert("请先选择视频文件");
return;
}
if (!wsUrl.startsWith("ws://") && !wsUrl.startsWith("wss://")) {
alert("WebSocket 地址必须以 ws:// 或 wss:// 开头");
return;
}
logEl.textContent = "";
resultLinks.innerHTML = "";
resultVideo.removeAttribute("src");
resultVideo.load();
renderCounts({});
renderProgress({ progress: 0, frame: 0, total_frames: 0 });
startBtn.disabled = true;
stopBtn.disabled = false;
setStatus("正在连接 WebSocket...");
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接,正在打开算法开关...");
ws.send(JSON.stringify({ type: "switch", enabled: true }));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ready") {
appendLog("服务端 ready");
return;
}
if (msg.type === "switch") {
appendLog("算法开关已开启");
ws.send(JSON.stringify({
type: "start",
filename: file.name,
options: {
conf: Number(document.getElementById("conf").value),
iou: Number(document.getElementById("iou").value),
skip: Number(document.getElementById("skip").value),
imgsz: 640,
max_frames: Number(document.getElementById("maxFrames").value),
save_video: true
}
}));
return;
}
if (msg.type === "upload_started") {
setStatus("正在上传视频...");
appendLog("开始上传视频");
await sendFileInChunks(ws, file);
ws.send(JSON.stringify({ type: "end" }));
appendLog("视频上传完成,等待服务端处理");
return;
}
if (msg.type === "processing_started") {
setStatus("服务端正在处理视频...");
appendLog("检测开始");
return;
}
if (msg.type === "progress") {
renderProgress(msg);
return;
}
if (msg.type === "counts") {
renderCounts(msg.counts);
return;
}
if (msg.type === "log") {
appendLog(msg.message || "");
return;
}
if (msg.type === "done") {
const httpBase = document.getElementById("httpBase").value.replace(/\/$/, "");
const videoUrl = httpBase + msg.output_video_url;
const reportUrl = httpBase + msg.report_url;
setStatus("检测完成");
renderCounts(msg.counts || {});
resultVideo.src = videoUrl;
resultLinks.innerHTML = `结果视频:<a href="${videoUrl}" target="_blank">${videoUrl}</a><br>` +
`检测报告:<a href="${reportUrl}" target="_blank">${reportUrl}</a>`;
appendLog("检测完成");
ws.close();
return;
}
if (msg.type === "error") {
setStatus("服务端错误:" + msg.message);
appendLog("错误:" + msg.message);
ws.close();
}
};
ws.onerror = () => {
setStatus("WebSocket 连接失败,请检查服务地址、端口、防火墙和服务是否以 0.0.0.0 启动");
};
ws.onclose = () => {
startBtn.disabled = false;
stopBtn.disabled = true;
};
};
stopBtn.onclick = () => {
if (ws) ws.close();
setStatus("连接已关闭");
};
</script>
</body>
</html>
Loading…
Cancel
Save