Commit 46d5f76b authored by 崔婷婷's avatar 崔婷婷

v2

parent 38039a32
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>工厂生产看板</title> <title>工厂生产看板</title>
</head> </head>
......
<template> <template>
<div class="dashboard"> <div class="dashboard">
<!-- ======== 顶部信息栏 ======== --> <!-- 全局标题栏 -->
<header class="header"> <header class="header">
<div class="header-left">
<span class="meta-item">批次号:<em>{{ batch.batch_id }}</em></span>
<span class="meta-item">产品型号:<em>{{ batch.product_type }}</em></span>
<span class="meta-item">系列:<em>{{ batch.series }}</em></span>
<span class="meta-item">工位:<em>{{ workstation.workstation_id }}</em></span>
<span class="meta-item">员工ID:<em>{{ operator.operator_id }}</em></span>
<span class="meta-item">开始时间:<em>{{ fmtProductTime }}</em></span>
</div>
<h1 class="title">飞天兔AI行为引导系统</h1> <h1 class="title">飞天兔AI行为引导系统</h1>
<div class="header-right"> <div class="header-right">{{ currentTime }}</div>
<span class="meta-item">当前工序:<em>{{ currentStepName }}</em></span>
<span class="meta-item">{{ currentTime }}</span>
<span class="meta-item">产品序号:<em>{{ productId }}</em></span>
</div>
</header> </header>
<!-- ======== 系统状态栏 ======== --> <!-- 双工位面板 -->
<div class="system-bar">
<span class="sys-item">FPS:<em>{{ system.fps }}</em></span>
<span class="sys-item">推理耗时:<em>{{ system.inference_ms }}ms</em></span>
<span class="sys-item">模型版本:<em>{{ system.model_version }}</em></span>
<span class="sys-item">
摄像头:<i class="cam-dot" :class="{ on: system.camera_connected }"></i>
<em>{{ system.camera_connected ? '已连接' : '断开' }}</em>
</span>
<span v-if="wsReconnecting" class="sys-item warn">重连中 {{ wsReconnectAttempt }}/{{ isFinite(wsReconnectMax) ? wsReconnectMax : '∞' }}…</span>
<span v-if="stats.skip_missing_count > 0" class="sys-item warn">跳步违规:<em>{{ stats.skip_missing_count }}</em> 次</span>
</div>
<main class="main"> <main class="main">
<!-- ======== 左侧:视频画面 ======== --> <WorkstationPanel
<section class="panel video-panel"> :ws-url="wsUrl1"
<div class="video-wrap"> :video-url="videoUrl1"
<img :src="videoUrl" class="video-stream" /> />
<span class="video-label">MJPEG 实时画面</span> <WorkstationPanel
</div> :ws-url="wsUrl2"
</section> :video-url="videoUrl2"
/>
<!-- ======== 右侧:SOP 工序列表 + 状态环 + 统计 ======== -->
<section class="info-panel">
<div class="panel steps-card">
<div class="card-header">
<span>工序明细</span>
<span class="progress-count">{{ completedCount }}/{{ displaySteps.length }}</span>
</div>
<div class="table-wrap">
<div class="steps-header">
<span>NO</span>
<span>工序</span>
<span>进度</span>
<span>状态</span>
</div>
<div class="steps-body">
<div
v-for="s in displaySteps"
:key="s.step_index"
class="step-row"
:class="{ 'row-skip': s.anomaly_type === 'skip_missing' }"
>
<span>{{ s.step_index }}</span>
<span>
{{ s.step_name }}
<i v-if="s.anomaly_type === 'skip_missing'" class="skip-icon" title="跳步缺失">⚠</i>
</span>
<span class="duration-cell">{{ s.framesText }}</span>
<span class="badge" :class="statusBadgeClass(s.status)">{{ statusLabel(s.status) }}</span>
</div>
</div>
</div>
</div>
<div class="panel status-card">
<div class="ring" :style="ringStyle">
<div class="ring-inner">
<span :class="ringMainClass">{{ ringMainText }}</span>
<span class="ring-sub">{{ ringSubText }}</span>
</div>
</div>
<ul class="status-detail">
<li><span>整双结果</span><em :class="resultClass">{{ resultLabel }}</em></li>
<li><span>结束类型</span><em :class="endTypeClass">{{ endTypeLabel }}</em></li>
<li><span>产品耗时</span><em>{{ productDuration.toFixed(1) }}s</em></li>
</ul>
</div>
<div class="stat-grid">
<div class="panel stat-card">
<span class="stat-label">通过率</span>
<span class="stat-value accent">{{ stats.pass_rate_pct }}%</span>
</div>
<div class="panel stat-card">
<span class="stat-label">总数</span>
<span class="stat-value accent">{{ stats.total_products }}</span>
</div>
<div class="panel stat-card">
<span class="stat-label">平均耗时</span>
<span class="stat-value">{{ stats.avg_duration }}s</span>
</div>
<div class="panel stat-card">
<span class="stat-label">跳步次数</span>
<span class="stat-value warn">{{ stats.skip_missing_count }}</span>
</div>
</div>
</section>
</main> </main>
<!-- 跳步/错误 Toast -->
<Teleport to="body">
<div v-if="alertMsg" class="alert-toast" :class="alertMsg.level" @click="alertMsg = null">
<span>{{ alertMsg.text }}</span>
</div>
</Teleport>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { connect } from '../ws/index.js' import WorkstationPanel from './WorkstationPanel.vue'
// ==================== 数据模型(v2) ====================
// 工位 / 操作员 / 批次
const workstation = reactive({ workstation_id: '-', name: '-', area: '-', line: '-' })
const operator = reactive({ operator_id: '-', name: '-' })
const batch = reactive({ batch_id: '-', product_type: '-', series: '-', started_at: '-' })
// 2.1 工序配置 const wsUrl1 =
const stepConfigs = ref([]) import.meta.env.VITE_WS_URL_STATION_1 ||
import.meta.env.VITE_WS_URL ||
'ws://127.0.0.1:8080/ws?station=1'
// 2.4/2.5 产品 const wsUrl2 =
const productId = ref('-') import.meta.env.VITE_WS_URL_STATION_2 ||
const productResult = ref(null) // 'pass' | 'fail' | null import.meta.env.VITE_WS_URL ||
const productEndType = ref(null) // 'flow_complete' | 'timeout' | 'material_change' | 'manual_reset' | null 'ws://127.0.0.1:8080/ws?station=2'
const productDuration = ref(0)
const productStartedAt = ref('')
const currentStepIndex = ref(1)
// 工序状态 → stepStates[step_index] = { status, anomaly_type, best_confidence } const videoUrl1 =
const stepStates = reactive({}) import.meta.env.VITE_VIDEO_URL_STATION_1 ||
import.meta.env.VITE_VIDEO_URL ||
'http://127.0.0.1:8080/stream/video?station=1'
// 3.3 step_progress → 当前 active 工序实时的帧进度 const videoUrl2 =
const activeProgress = reactive({ step_index: 0, consecutive_frames: 0, target_frames: 0, best_confidence: 0 }) import.meta.env.VITE_VIDEO_URL_STATION_2 ||
import.meta.env.VITE_VIDEO_URL ||
'http://127.0.0.1:8080/stream/video'
// 2.6 统计
const stats = reactive({
total_products: 0, passed_products: 0, failed_products: 0,
pass_rate: 0, pass_rate_pct: 0, avg_duration: 0,
skip_missing_count: 0, ai_misrecognition_count: 0,
})
// 2.7 系统状态
const system = reactive({
fps: 0, inference_ms: 0, model_loaded: false,
model_version: '-', camera_connected: false,
})
// WebSocket
const wsReconnecting = ref(false)
const wsReconnectAttempt = ref(0)
const wsReconnectMax = ref(Infinity)
const videoUrl = computed(() => import.meta.env.VITE_VIDEO_URL || 'http://127.0.0.1:8080/stream/video')
// 告警
const alertMsg = ref(null)
// 时钟
const currentTime = ref('') const currentTime = ref('')
let clockTimer = null let clockTimer = null
// ==================== 计算属性 ====================
// 合并 step_configs + stepStates + activeProgress → 展示行
const displaySteps = computed(() => stepConfigs.value.map(cfg => {
const state = stepStates[cfg.step_index] || { status: 'pending', anomaly_type: 'none' }
const isActive = state.status === 'active'
const isCompleted = state.status === 'completed' || state.status === 'incomplete'
// 帧进度文本
let framesText = '-'
if (isActive && activeProgress.target_frames > 0) {
framesText = `${activeProgress.consecutive_frames}/${activeProgress.target_frames}`
} else if (isActive) {
framesText = '...'
} else if (isCompleted) {
framesText = '已完成'
}
return {
step_index: cfg.step_index,
step_code: cfg.step_code,
step_name: cfg.step_name,
status: state.status,
anomaly_type: state.anomaly_type || 'none',
framesText,
}
}))
const currentStepName = computed(() => {
const cs = displaySteps.value.find(s => s.status === 'active')
return cs ? cs.step_name : '-'
})
const completedCount = computed(() =>
displaySteps.value.filter(s => s.status === 'completed' || s.status === 'incomplete').length
)
// 格式化 started_at → HH:MM:SS
const fmtProductTime = computed(() => {
if (!productStartedAt.value) return '-'
const m = productStartedAt.value.match(/T(\d{2}:\d{2}:\d{2})/)
return m ? m[1] : '-'
})
// 状态环
const ringMainText = computed(() => {
if (productEndType.value) return productResult.value === 'pass' ? '通过' : '不通过'
if (activeProgress.step_index > 0) return '检测中'
return '待机'
})
const ringSubText = computed(() => {
if (productEndType.value) return endTypeLabel.value
if (activeProgress.target_frames > 0) {
return `${Math.round((activeProgress.consecutive_frames / activeProgress.target_frames) * 100)}%`
}
return ''
})
const ringMainClass = computed(() => {
if (!productEndType.value) return ''
return productResult.value === 'pass' ? 'ring-pass' : 'ring-fail'
})
const ringStyle = computed(() => {
if (productEndType.value) {
const color = productResult.value === 'pass' ? '#34d399' : '#f87171'
return { background: color }
}
if (activeProgress.target_frames > 0) {
const pct = Math.min(100, (activeProgress.consecutive_frames / activeProgress.target_frames) * 100)
return { background: `conic-gradient(#3ddc84 ${pct}%, rgba(255,255,255,0.08) ${pct}%)` }
}
return { background: 'rgba(255,255,255,0.08)' }
})
// 结果 / 结束类型中文
const resultLabel = computed(() => {
if (productResult.value === 'pass') return '通过'
if (productResult.value === 'fail') return '不通过'
return '-'
})
const resultClass = computed(() => {
if (productResult.value === 'pass') return 'text-pass'
if (productResult.value === 'fail') return 'text-fail'
return ''
})
const endTypeLabel = computed(() => {
const map = { flow_complete: '流程结束', timeout: '超时结束', material_change: '物料切换', manual_reset: '手动复位' }
return map[productEndType.value] || '-'
})
const endTypeClass = computed(() => productEndType.value === 'timeout' ? 'text-fail' : '')
// UI 帮助函数
function statusLabel(s) {
const map = { pending: '待检', active: '进行中', completed: '已完成', incomplete: '未完成' }
return map[s] || s
}
function statusBadgeClass(s) {
if (s === 'active') return 'badge-progress'
if (s === 'completed') return 'badge-pass'
if (s === 'incomplete') return 'badge-fail'
return 'badge-wait'
}
// ==================== 时钟 ====================
function updateClock() { function updateClock() {
const d = new Date() const d = new Date()
const pd = n => String(n).padStart(2, '0') const p = n => String(n).padStart(2, '0')
currentTime.value = `${pd(d.getHours())}:${pd(d.getMinutes())}:${pd(d.getSeconds())}` currentTime.value = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
} }
// ==================== WebSocket 消息处理 ====================
function handleSnapshot(msg) {
const d = msg.data
if (d.workstation) Object.assign(workstation, d.workstation)
if (d.operator) Object.assign(operator, d.operator)
if (d.batch) Object.assign(batch, d.batch)
if (d.step_configs) stepConfigs.value = d.step_configs
if (d.stats) {
Object.assign(stats, d.stats)
stats.pass_rate_pct = Math.round(stats.pass_rate * 100)
}
if (d.system) Object.assign(system, d.system)
if (d.current_product) {
applyProduct(d.current_product)
productEndType.value = d.current_product.end_type || null
productResult.value = d.current_product.result || null
productDuration.value = d.current_product.duration || 0
}
}
function applyProduct(product) {
productId.value = product.product_id || '-'
productStartedAt.value = product.started_at || ''
currentStepIndex.value = product.current_step_index || 1
Object.keys(stepStates).forEach(k => delete stepStates[k])
if (product.steps) {
product.steps.forEach(s => {
stepStates[s.step_index] = {
status: s.status,
anomaly_type: s.anomaly_type || 'none',
best_confidence: s.best_confidence || 0,
}
})
}
}
// 3.3 product_start
function handleProductStart(msg) {
const d = msg.data
productId.value = d.product_id
productStartedAt.value = d.started_at
productEndType.value = null
productResult.value = null
productDuration.value = 0
currentStepIndex.value = d.step_index || 1
Object.assign(activeProgress, { step_index: 0, consecutive_frames: 0, target_frames: 0, best_confidence: 0 })
Object.keys(stepStates).forEach(k => delete stepStates[k])
stepConfigs.value.forEach(sc => {
const isInitial = sc.step_index === (d.step_index || 1)
stepStates[sc.step_index] = {
status: isInitial ? 'active' : 'pending',
anomaly_type: 'none',
}
})
}
// 3.3 step_confirmed
function handleStepConfirmed(msg) {
const d = msg.data
// 当前工序 → completed/incomplete
stepStates[d.step_index] = {
status: d.status,
anomaly_type: d.anomaly_type || 'none',
best_confidence: d.best_confidence || 0,
}
if (d.next_step_index) {
currentStepIndex.value = d.next_step_index
// 下一道工序 → active
stepStates[d.next_step_index] = {
status: 'active',
anomaly_type: 'none',
best_confidence: 0,
}
Object.assign(activeProgress, {
step_index: d.next_step_index,
consecutive_frames: 0,
target_frames: 0,
best_confidence: 0,
})
} else {
// 最后一道,清进度
Object.assign(activeProgress, { step_index: 0, consecutive_frames: 0, target_frames: 0, best_confidence: 0 })
}
}
// 3.3 step_progress
function handleStepProgress(msg) {
const d = msg.data
// 确保工序状态为 active(防御:step_confirmed 可能尚未到达)
if (stepStates[d.step_index] && stepStates[d.step_index].status !== 'active') {
stepStates[d.step_index] = { status: 'active', anomaly_type: 'none', best_confidence: 0 }
}
Object.assign(activeProgress, {
step_index: d.step_index,
consecutive_frames: d.consecutive_frames,
target_frames: d.target_frames,
best_confidence: d.best_confidence,
})
}
// 3.3 product_end
function handleProductEnd(msg) {
const d = msg.data
productEndType.value = d.end_type
productResult.value = d.result
productDuration.value = d.duration || 0
if (d.steps && d.steps.length) {
d.steps.forEach(s => {
stepStates[s.step_index] = {
status: s.status,
anomaly_type: s.anomaly_type || 'none',
best_confidence: s.best_confidence || 0,
}
})
}
Object.assign(activeProgress, { step_index: 0, consecutive_frames: 0, target_frames: 0, best_confidence: 0 })
}
// 3.3 stats_update
function handleStatsUpdate(msg) {
const d = msg.data
Object.assign(stats, d)
stats.pass_rate_pct = Math.round(d.pass_rate * 100)
}
// 3.3 system_status
function handleSystemStatus(msg) {
Object.assign(system, msg.data)
}
// 3.3 skip_detected
function handleSkipDetected(msg) {
alertMsg.value = { text: msg.data.message, level: 'warn' }
setTimeout(() => { if (alertMsg.value && alertMsg.value.text === msg.data.message) alertMsg.value = null }, 5000)
}
// 3.3 error
function handleError(msg) {
if (msg.data && msg.data.code) {
alertMsg.value = { text: `[${msg.data.code}] ${msg.data.message}`, level: 'error' }
} else {
alertMsg.value = { text: msg.message || 'WebSocket 连接异常', level: 'error' }
}
setTimeout(() => { alertMsg.value = null }, 8000)
}
// ==================== 生命周期 ====================
onMounted(() => { onMounted(() => {
updateClock() updateClock()
clockTimer = setInterval(updateClock, 1000) clockTimer = setInterval(updateClock, 1000)
const ws = connect()
.on('snapshot', handleSnapshot)
.on('product_start', handleProductStart)
.on('step_confirmed', handleStepConfirmed)
.on('step_progress', handleStepProgress)
.on('skip_detected', handleSkipDetected)
.on('product_end', handleProductEnd)
.on('stats_update', handleStatsUpdate)
.on('system_status', handleSystemStatus)
.on('error', handleError)
.on('reconnecting', (data) => {
wsReconnecting.value = true
wsReconnectAttempt.value = data.attempt
wsReconnectMax.value = data.max
})
.on('open', () => {
wsReconnecting.value = false
wsReconnectAttempt.value = 0
})
window.__qcWs = ws
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(clockTimer) clearInterval(clockTimer)
if (window.__qcWs) {
window.__qcWs.close()
delete window.__qcWs
}
}) })
</script> </script>
<style scoped> <style scoped>
.dashboard { .dashboard {
--bg: #0a1128; --bg: #060e1f;
--panel-bg: rgba(13, 27, 58, 0.65); --pbg: rgba(8, 20, 44, 0.92);
--border: rgba(56, 189, 248, 0.18); --bd: rgba(56, 189, 248, 0.1);
--accent: #38bdf8; --ac: #38bdf8;
--accent-strong: #22d3ee; --tx: #dfe7f2;
--warn: #f59e0b; --dim: #5a7094;
--danger: #f87171;
--success: #34d399;
--text: #e5eefc;
--text-dim: #7f92b8;
height: 100vh; height: 100vh;
min-height: 600px; min-height: 600px;
overflow: hidden; overflow: hidden;
background: radial-gradient(circle at 20% 0%, #10224a 0%, var(--bg) 55%); background: var(--bg);
color: var(--text); color: var(--tx);
padding: clamp(6px, 1vw, 12px); padding: 2px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 2px;
} }
/* ======== 顶部信息栏 ======== */ /* ===== header ===== */
.header { .header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
flex-wrap: nowrap; background: var(--pbg);
gap: 8px; border: 1px solid var(--bd);
background: linear-gradient(180deg, rgba(21, 42, 84, 0.9), rgba(13, 27, 58, 0.6)); border-radius: 6px;
border: 1px solid var(--border); padding: 4px 14px;
border-radius: 10px; flex-shrink: 0;
padding: clamp(8px, 1vw, 14px) clamp(14px, 1.4vw, 24px); position: relative;
} }
.title { .title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: clamp(18px, 1.6vw, 30px); font-size: clamp(18px, 1.6vw, 30px);
font-weight: 700; font-weight: 700;
letter-spacing: 2px; letter-spacing: 2px;
color: #fff; color: #fff;
text-shadow: 0 0 12px rgba(56, 189, 248, 0.5); text-shadow: 0 0 8px rgba(56, 189, 248, 0.4);
white-space: nowrap; white-space: nowrap;
pointer-events: none;
} }
.header-left,
.header-right { .header-right {
display: flex;
gap: clamp(10px, 1.2vw, 18px);
flex-wrap: nowrap;
font-size: clamp(11px, 0.85vw, 16px);
color: var(--text-dim);
align-items: center;
}
.meta-item {
white-space: nowrap;
}
.meta-item em {
color: var(--text);
font-style: normal;
margin-left: 3px;
}
/* ======== 系统状态栏 ======== */
.system-bar {
display: flex;
align-items: center;
gap: clamp(14px, 1.5vw, 24px);
padding: 3px 18px;
font-size: clamp(11px, 0.85vw, 15px);
color: var(--text-dim);
flex-shrink: 0;
}
.system-bar em {
color: var(--text);
font-style: normal;
margin-left: 3px;
}
.sys-item.warn {
color: var(--warn);
}
.sys-item.warn em {
color: var(--warn);
}
.cam-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--danger);
margin-right: 3px;
}
.cam-dot.on {
background: var(--success);
box-shadow: 0 0 5px var(--success);
}
/* ======== 主区域 ======== */
.main {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 2.4fr) minmax(290px, 1fr);
gap: 8px;
min-height: 0;
}
@media (max-width: 1100px) {
.main { grid-template-columns: 1fr; }
}
.panel {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 10px;
}
/* ======== 视频面板 ======== */
.video-panel {
display: flex;
flex-direction: column;
padding: 8px;
}
.video-wrap {
position: relative;
flex: 1;
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #000;
}
.video-stream {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-label {
position: absolute; position: absolute;
bottom: 8px; right: clamp(12px, 1.2vw, 20px);
right: 12px; font-size: clamp(13px, 0.95vw, 18px);
font-size: 11px; color: var(--dim);
color: rgba(255, 255, 255, 0.45);
letter-spacing: 1px;
pointer-events: none;
} }
/* ======== 工序明细表 ======== */ /* ===== main: 双工位并排 ===== */
.info-panel { .main {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.steps-card {
flex: 12;
padding: clamp(8px, 0.9vw, 14px);
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: clamp(13px, 1vw, 19px);
font-weight: 600;
margin-bottom: 4px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.progress-count { color: var(--accent-strong); }
.table-wrap {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
}
.steps-header,
.step-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: center;
gap: 2px;
font-size: clamp(11px, 0.9vw, 18px);
text-align: center;
}
.steps-header {
color: var(--text-dim);
font-weight: 500;
padding: 0 4px;
height: clamp(28px, 3vh, 44px);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: clamp(12px, 0.9vw, 17px);
line-height: 1;
}
.steps-body {
flex: 1;
display: flex;
flex-direction: column;
}
.step-row {
flex: 1;
padding: 0 4px;
border-bottom: 1px solid rgba(56, 189, 248, 0.08);
min-height: 0;
}
.step-row.row-skip {
background: rgba(245, 158, 11, 0.06);
}
.skip-icon {
font-style: normal;
color: var(--warn);
margin-left: 3px;
font-size: 12px;
}
.duration-cell {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ======== Badge ======== */
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
text-align: center;
white-space: nowrap;
font-size: clamp(10px, 0.7vw, 14px);
}
.badge-progress { background: rgba(245, 158, 11, 0.18); color: var(--warn); }
.badge-wait { background: rgba(127, 146, 184, 0.15); color: var(--text-dim); }
.badge-pass { background: rgba(52, 211, 153, 0.18); color: var(--success); }
.badge-fail { background: rgba(248, 113, 113, 0.18); color: var(--danger); }
/* ======== 状态卡片 ======== */
.status-card {
flex: 1;
display: flex;
align-items: center;
gap: clamp(14px, 1.8vw, 24px);
padding: clamp(6px, 0.7vw, 10px) clamp(8px, 1vw, 14px);
}
.ring {
width: clamp(70px, 8vw, 110px);
height: clamp(70px, 8vw, 110px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.4s;
}
.ring-inner {
width: clamp(54px, 6.2vw, 86px);
height: clamp(54px, 6.2vw, 86px);
border-radius: 50%;
background: #0b1730;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: clamp(12px, 0.9vw, 17px);
}
.ring-inner span:first-child { font-weight: 700; }
.ring-pass { color: var(--success); }
.ring-fail { color: var(--danger); }
.ring-sub { color: var(--text-dim); font-size: clamp(9px, 0.65vw, 12px); }
.status-detail {
list-style: none;
display: flex;
flex-direction: column;
gap: clamp(6px, 0.8vw, 12px);
font-size: clamp(14px, 1vw, 20px);
}
.status-detail li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.status-detail span { color: var(--text-dim); }
.text-pass { color: var(--success) !important; }
.text-fail { color: var(--danger) !important; }
/* ======== 统计卡片 ======== */
.stat-grid {
flex: 2;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: clamp(4px, 0.5vw, 8px); gap: 0;
}
.stat-card {
padding: clamp(4px, 0.6vw, 10px) clamp(6px, 0.8vw, 14px);
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(3px, 0.4vw, 6px);
}
.stat-label { font-size: clamp(11px, 1.2vw, 14px); color: var(--text-dim); }
.stat-value { font-size: clamp(20px, 2.2vw, 28px); font-weight: 700; }
.stat-value.accent { color: var(--accent-strong); }
.stat-value.warn { color: var(--warn); }
/* ======== 告警 Toast ======== */
.alert-toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
animation: slideDown 0.3s ease;
}
.alert-toast.warn {
background: rgba(245, 158, 11, 0.95);
color: #1e1e1e;
}
.alert-toast.error {
background: rgba(248, 113, 113, 0.95);
color: #fff;
}
@keyframes slideDown {
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@media (max-width: 640px) {
.header { flex-direction: column; align-items: flex-start; }
} }
</style> </style>
<template>
<div class="panel">
<!-- 迷你信息栏 + 系统状态 -->
<div class="top-bar">
<span>成型线:<em>{{ fmt(workstation.line) }}</em></span>
<span>工位:<em>{{ fmt(workstation.workstation_id) }}</em></span>
<span>员工:<em>{{ fmt(operator.operator_id) }}</em></span>
<span class="sep"></span>
<span>FPS:<em>{{ fmt(system.fps) }}</em></span>
<span>推理:<em>{{ fmtUnit(system.inference_ms, 'ms') }}</em></span>
<span>模型:<em>{{ fmt(system.model_version) }}</em></span>
<span>摄像头:<i class="dot" :class="{ on: system.camera_connected }"></i></span>
<span>状态:<i class="dot" :class="connDotClass"></i><em :class="connTextClass">{{ connStatusText }}</em></span>
<span v-if="wsReconnecting" class="warn">重连 {{ wsReconnectAttempt }}/{{ wsReconnectMax === Infinity ? '∞' : wsReconnectMax }}…</span>
</div>
<!-- 视频画面 -->
<div class="video-panel">
<div v-if="system.camera_connected && !wsDisconnected" class="video-wrap">
<img :src="videoUrl" class="video-stream" />
</div>
<div v-else-if="!system.camera_connected" class="video-wrap video-err"><span>摄像头未连接</span></div>
<div v-else class="video-wrap video-err"><span>数据连接异常</span></div>
</div>
<!-- 动作卡片 4列×2行 -->
<div class="cards-grid">
<div v-for="card in actionCards" :key="card.step_index" class="action-card" :class="cardCls(card.status)">
<div class="card-img">
<img v-if="card.showLive" :src="videoUrl" class="card-live-feed" />
<img v-else-if="card.imageUrl && !card.imageError" :src="card.imageUrl" @error="onCardImgErr(card)" />
<span v-else-if="card.imageError || card.status === 'incomplete'" class="nopic">图片暂不可用</span>
<span v-else class="nopic">待检</span>
</div>
<div class="card-foot">
<span class="c-no">{{ card.step_index }}</span>
<span class="c-name">{{ card.step_name }}</span>
<span class="c-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status) }}</span>
</div>
</div>
</div>
<!-- 底部:工序表 + 进度统计 -->
<div class="bottom-row">
<!-- 工序明细表 -->
<div class="table-panel">
<div class="table-hdr">
<span>工序明细</span>
<span class="hdr-cnt">{{ doneCount }}/{{ FIXED.length }}</span>
</div>
<div class="table-wrap">
<div class="t-row t-head">
<span>NO</span><span>工序</span><span>用时</span><span>状态</span>
</div>
<div class="t-body">
<div v-for="card in actionCards" :key="card.step_index" class="t-row" :class="{ 'row-skip': card.anomaly_type === 'skip_missing' }">
<span>{{ card.step_index }}</span>
<span>{{ card.step_name }}<i v-if="card.anomaly_type==='skip_missing'" class="skip-icon" title="跳步缺失">⚠</i></span>
<span class="t-frames">{{ card.frames }}</span>
<span class="r-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status) }}</span>
</div>
</div>
</div>
</div>
<!-- 进度 + 统计 -->
<div class="prog-stats-col">
<div class="prog-panel">
<div class="prog-main-group">
<div class="prog-ring-wrap">
<div class="prog-ring" :style="ringStyle">
<div class="prog-ring-inner">
<span class="ring-num">{{ actionsDoneText }}</span>
<span class="ring-label-cut">已做</span>
</div>
</div>
<span class="ring-label-below">已做动作数</span>
</div>
<div class="prog-stat">
<span class="ps-value">{{ elapsedText }}</span>
<span class="ps-label">本双已用时间</span>
<span v-if="timeOvertime" class="p-warn">时间已超标</span>
</div>
</div>
<div class="prog-stat">
<span class="ps-value">{{ fmtNum(stats.total_products) }}</span>
<span class="ps-label">质检总双数</span>
</div>
</div>
<div class="stats-cards">
<div class="stat-card sc-op"><span class="sc-value">{{ opCompliance }}</span><span class="sc-label">操作合规</span></div>
<div class="stat-card sc-time"><span class="sc-value">{{ timeCompliance }}</span><span class="sc-label">时间合规</span></div>
<div class="stat-card sc-avg"><span class="sc-value">{{ avgTime }}</span><span class="sc-label">平均质检用时</span></div>
</div>
</div>
</div>
<!-- loading / alert -->
<div v-if="pageLoading" class="loading-mask"><span>加载中…</span></div>
<div v-if="alertMsg" class="alert-toast" :class="alertMsg.level" @click="alertMsg=null"><span>{{ alertMsg.text }}</span></div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { connect } from '../ws/index.js'
const props = defineProps({
wsUrl: { type: String, default: '' },
videoUrl: { type: String, default: '' },
})
const workstation = reactive({ workstation_id:'', line:'', area:'' })
const operator = reactive({ operator_id:'' })
const system = reactive({ fps:'', inference_ms:'', model_version:'', model_loaded:false, camera_connected:false })
const stats = reactive({ total_products:0, passed_products:0, failed_products:0, pass_rate:0, avg_duration:0, skip_missing_count:0, operation_compliant:0, time_compliant:0 })
const productElapsed = ref(null)
const timeOvertime = ref(false)
const actionsDone = ref(null)
const stepConfigs = ref([])
const stepStates = reactive({})
const cardStates = reactive({})
const frameProgress = reactive({})
const pageLoading = ref(true)
const wsDisconnected = ref(false)
const wsReconnecting = ref(false)
const wsReconnectAttempt = ref(0)
const wsReconnectMax = ref(0)
const alertMsg = ref(null)
const sysErrorMsg = ref('')
let loadTimeout = null
const FIXED = [
{ step_index:1, step_name:'看号码' }, { step_index:2, step_name:'摸内里' },
{ step_index:3, step_name:'看内侧' }, { step_index:4, step_name:'看外侧' },
{ step_index:5, step_name:'看后跟高低' }, { step_index:6, step_name:'比鞋头大小' },
{ step_index:7, step_name:'看大底' }, { step_index:8, step_name:'查开胶' },
]
const actionCards = computed(() => FIXED.map(f => {
const s = stepStates[f.step_index] || { status:'pending' }
const c = cardStates[f.step_index] || { imageUrl:null, imageError:false }
const fp = frameProgress[f.step_index] || { consecutive_frames:0, target_frames:0 }
let frames = '--'
let showLive = false
if (s.status === 'active') {
showLive = true
if (fp.target_frames > 0) frames = `${fp.consecutive_frames}/${fp.target_frames}`
else frames = '...'
} else if (s.status === 'completed_active') {
showLive = true
frames = '已完成'
} else if (s.status === 'completed') {
showLive = !c.imageUrl || c.imageError
frames = '已完成'
} else if (s.status === 'incomplete') {
frames = '已完成'
}
return { ...f, status:s.status, anomaly_type:s.anomaly_type||'none', imageUrl:c.imageUrl, imageError:c.imageError||false, frames, showLive }
}))
const doneCount = computed(() => actionCards.value.filter(s => s.status==='completed' || s.status==='incomplete').length)
function fmt(v) { return (v==null||v==='')?'--':v }
function fmtNum(v) { return (v==null||v==='')?'--':Number(v) }
function fmtUnit(v,u){ return (v==null||v==='')?'--':`${v}${u}` }
const totalTargetDuration = computed(() => {
if (!stepConfigs.value.length) return null
return stepConfigs.value.reduce((s, sc) => s + (sc.pass_duration || 0), 0)
})
const elapsedText = computed(() => {
if (productElapsed.value == null || totalTargetDuration.value == null) return '--'
const num = Number(productElapsed.value).toFixed(1)
return `${num}/${totalTargetDuration.value.toFixed(1)} 秒`
})
const actionsDoneText = computed(() => actionsDone.value==null?'--':`${Math.min(8,Math.max(0,actionsDone.value))}/8`)
const ringPct = computed(() => {
if (actionsDone.value == null) return 0
return Math.min(100, (actionsDone.value / 8) * 100)
})
const ringStyle = computed(() => ({
background: `conic-gradient(var(--ok) ${ringPct.value}%, rgba(255,255,255,0.06) ${ringPct.value}%)`,
}))
const _t = computed(() => stats.total_products || 0)
const opCompliance = computed(() => _t.value ? `${stats.operation_compliant??0}/${_t.value}(${((stats.operation_compliant??0)/_t.value*100).toFixed(1)}%)` : '--')
const timeCompliance = computed(() => _t.value ? `${stats.time_compliant??0}/${_t.value}(${((stats.time_compliant??0)/_t.value*100).toFixed(1)}%)` : '--')
const avgTime = computed(() => (_t.value && stats.avg_duration!=null) ? `${Number(stats.avg_duration).toFixed(1)} 秒` : '--')
const connStatusText = computed(() => { if(wsDisconnected.value) return '数据连接异常'; if(sysErrorMsg.value) return sysErrorMsg.value; return '运行正常' })
const connDotClass = computed(() => (wsDisconnected.value || !!sysErrorMsg.value) ? 'off' : 'on')
const connTextClass = computed(() => (wsDisconnected.value || !!sysErrorMsg.value) ? 'text-warn' : '')
function onCardImgErr(card) { const prev = cardStates[card.step_index]||{}; cardStates[card.step_index] = {...prev, imageError:true} }
function cardCls(s) { return {'c-a':s==='active','c-d':s==='completed'||s==='completed_active','c-f':s==='incomplete'} }
function statusLabel(s) { const m={pending:'待检',active:'进行中',completed:'通过',completed_active:'通过',incomplete:'已检未达标'}; return m[s]||s }
function badgeCls(s) { if(s==='active')return'badge-a';if(s==='completed'||s==='completed_active')return'badge-p';if(s==='incomplete')return'badge-f';return'badge-w' }
function clearCards() { Object.keys(stepStates).forEach(k => delete stepStates[k]); Object.keys(cardStates).forEach(k => delete cardStates[k]); Object.keys(frameProgress).forEach(k => delete frameProgress[k]) }
// ===== WS handlers =====
function handleSnapshot(msg) {
const d = msg.data
sysErrorMsg.value = ''
if (d.workstation) Object.assign(workstation, d.workstation)
if (d.operator) Object.assign(operator, d.operator)
if (d.stats) Object.assign(stats, d.stats)
if (d.system) Object.assign(system, d.system)
if (d.step_configs) stepConfigs.value = d.step_configs
if (d.current_product) {
applyProduct(d.current_product)
} else if (d.last_product) {
applyLastProduct(d.last_product)
} else {
productElapsed.value = null; timeOvertime.value = false; actionsDone.value = null; clearCards()
}
pageLoading.value = false
}
function applyProduct(p) {
productElapsed.value = p.duration??null; timeOvertime.value = p.time_status==='timeout'||p.time_status==='已超时'; actionsDone.value = p.step_count_passed??0
clearCards()
if (p.steps) p.steps.forEach(s => { stepStates[s.step_index] = {status:s.status, anomaly_type:s.anomaly_type||'none'}; if (s.end_image_url) cardStates[s.step_index] = {imageUrl:s.end_image_url, imageError:false} })
}
function applyLastProduct(p) {
productElapsed.value = null; timeOvertime.value = false
clearCards()
if (p.steps) p.steps.forEach(s => { stepStates[s.step_index] = {status:s.status, anomaly_type:s.anomaly_type||'none'}; if (s.end_image_url) cardStates[s.step_index] = {imageUrl:s.end_image_url, imageError:false} })
if (p.step_count_passed!=null) actionsDone.value = p.step_count_passed
}
function handleProductStart(msg) {
const d = msg.data
productElapsed.value = 0; timeOvertime.value = false; actionsDone.value = 0; clearCards()
stepStates[d.step_index||1] = {status:'active', anomaly_type:'none'}
}
function handleStepConfirmed(msg) {
const d = msg.data
stepStates[d.step_index] = {status:d.status, anomaly_type:d.anomaly_type||'none'}
if (d.end_image_url) cardStates[d.step_index] = {imageUrl:d.end_image_url, imageError:false}
delete frameProgress[d.step_index]
if (d.next_step_index) {
stepStates[d.next_step_index] = {status:'active', anomaly_type:'none'}
delete frameProgress[d.next_step_index]
}
}
let _lastTimeUpdateKey = ''
function handleTimeUpdate(msg) {
const d = msg.data
// 防重复:同一帧数据可能同时触发 step_progress 和 product_progress,只处理一次
const key = `${msg.type ?? ''}|${d.step_index ?? ''}|${d.consecutive_frames ?? ''}|${d.elapsed ?? ''}`
if (key === _lastTimeUpdateKey) return
_lastTimeUpdateKey = key
if (d.elapsed!=null) productElapsed.value = d.elapsed
if (d.time_status!=null) timeOvertime.value = d.time_status==='timeout' || d.time_status==='已超时'
if (d.actions_done!=null) actionsDone.value = d.actions_done
if (d.step_index!=null && d.consecutive_frames!=null) {
const cur = stepStates[d.step_index]
if (!cur || cur.status === 'pending') {
stepStates[d.step_index] = { status:'active', anomaly_type:'none' }
}
// 重新读取,避免在 stepStates 被写入后仍使用旧的 cur 引用
const fresh = stepStates[d.step_index]
if (fresh && (fresh.status === 'active' || fresh.status === 'pending')) {
frameProgress[d.step_index] = { consecutive_frames:d.consecutive_frames, target_frames:d.target_frames ?? 0 }
}
}
}
function handleProductEnd(msg) {
const d = msg.data
productElapsed.value = null; timeOvertime.value = false
Object.keys(frameProgress).forEach(k => delete frameProgress[k])
if (d.steps) { d.steps.forEach(s => { stepStates[s.step_index] = {status:s.status, anomaly_type:s.anomaly_type||'none'}; if (s.end_image_url) cardStates[s.step_index] = {imageUrl:s.end_image_url, imageError:false} })}
if (d.step_count_passed!=null) actionsDone.value = d.step_count_passed
}
function onWsError(msg) {
const text = (msg.data && msg.data.message) || msg.message || '连接异常'
sysErrorMsg.value = text
console.warn('[WS Error]', text)
}
function onWsSkip(msg) {
const d = msg.data
if (d.skipped_steps) {
d.skipped_steps.forEach(idx => {
stepStates[idx] = { status:'incomplete', anomaly_type:'skip_missing' }
})
}
if (d.detected_step_index) {
stepStates[d.detected_step_index] = { status:'active', anomaly_type:'none' }
}
alertMsg.value = { text: d.message || '检测到跳步', level: 'warn' }
setTimeout(() => { if (alertMsg.value && alertMsg.value.text === (d.message||'检测到跳步')) alertMsg.value = null }, 5000)
}
let wsApi = null
let wsGeneration = 0
function connectWs() {
const gen = ++wsGeneration // 防止旧 socket 事件干扰新连接状态
if (wsApi) wsApi.close()
wsApi = connect(props.wsUrl || undefined)
.on('snapshot', msg => { clearTimeout(loadTimeout); handleSnapshot(msg) })
.on('product_start', handleProductStart)
.on('step_confirmed', handleStepConfirmed)
.on('step_progress', handleTimeUpdate)
.on('product_progress', handleTimeUpdate)
.on('product_end', handleProductEnd)
.on('stats_update', msg => Object.assign(stats, msg.data))
.on('system_status', msg => Object.assign(system, msg.data))
.on('skip_detected', onWsSkip)
.on('error', msg => { if (gen !== wsGeneration) return; onWsError(msg) })
.on('open', () => { if (gen !== wsGeneration) return; wsDisconnected.value = false; wsReconnecting.value = false; sysErrorMsg.value = '' })
.on('reconnecting', d => { if (gen !== wsGeneration) return; wsReconnecting.value = true; wsReconnectAttempt.value = d.attempt; wsReconnectMax.value = d.max })
.on('close', () => { if (gen !== wsGeneration) return; wsDisconnected.value = true })
}
onMounted(() => {
loadTimeout = setTimeout(() => { pageLoading.value = false }, 5000)
connectWs()
})
// wsUrl 变化时重连
watch(() => props.wsUrl, () => {
pageLoading.value = true
clearTimeout(loadTimeout)
loadTimeout = setTimeout(() => { pageLoading.value = false }, 5000)
connectWs()
})
onBeforeUnmount(() => {
clearTimeout(loadTimeout)
if (wsApi) wsApi.close()
})
</script>
<style scoped>
.panel {
--bg: #060e1f; --pbg: rgba(8,20,44,0.92); --bd: rgba(56,189,248,0.1);
--ac: #38bdf8; --tx: #dfe7f2; --dim: #5a7094; --wn: #f59e0b; --er: #f87171; --ok: #34d399;
height: 100%; display: flex; flex-direction: column; gap: 2px;
background: var(--bg); color: var(--tx); padding: 2px;
position: relative; overflow: hidden;
}
/* ===== top bar ===== */
.top-bar {
flex-shrink: 0; background: var(--pbg); border: 1px solid var(--bd);
border-radius: 4px; padding: 2px 6px;
display: flex; flex-wrap: wrap; align-items: center; gap: clamp(2px, 0.2vw, 4px);
font-size: clamp(11px, 0.8vw, 14px); color: var(--dim);
}
.top-bar em { color: var(--tx); font-style: normal; margin-left: 2px; }
.sep { width: 1px; height: 12px; background: var(--bd); margin: 0 clamp(4px, 0.4vw, 8px); flex-shrink: 0; }
.dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; margin-right: 2px; background: var(--er); }
.dot.on { background: var(--ok); box-shadow: 0 0 4px var(--ok); }
.dot.off { background: var(--er); }
.text-warn { color: var(--er) !important; }
.warn { color: var(--wn); }
/* ===== video ===== */
.video-panel {
flex: 5; min-height: 0; background: var(--pbg); border: 1px solid var(--bd);
border-radius: 4px; padding: 2px; display: flex;
}
.video-wrap {
flex: 1; border-radius: 3px; overflow: hidden; background: #000;
display: flex; align-items: center; justify-content: center;
}
.video-stream { width: 100%; height: 100%; object-fit: contain; display: block; }
.video-err { color: var(--dim); }
.video-err span { font-size: clamp(11px, 0.9vw, 16px); letter-spacing: 1px; }
/* ===== cards 4×2 ===== */
.cards-grid {
flex-shrink: 0; display: grid; grid-template-columns: repeat(8, 1fr);
gap: clamp(2px, 0.25vw, 4px);
}
.action-card {
height: clamp(44px, 8vh, 80px); display: flex; flex-direction: column;
background: rgba(0,0,0,.3); border: 1px solid var(--bd);
border-radius: 4px; overflow: hidden; transition: border-color .25s;
}
.action-card.c-a { border-color: rgba(245,158,11,.5); }
.action-card.c-d { border-color: rgba(52,211,153,.35); }
.action-card.c-f { border-color: rgba(248,113,113,.45); }
.card-img {
flex: 1; background: #000; display: flex; align-items: center; justify-content: center;
overflow: hidden; min-height: 0;
}
.card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card-live-feed { width: 100%; height: 100%; object-fit: cover; display: block; }
.nopic { font-size: clamp(9px, 0.65vw, 12px); color: rgba(255,255,255,.15); }
.card-foot {
display: flex; align-items: center; gap: clamp(2px, 0.15vw, 3px);
padding: clamp(2px, 0.25vw, 4px) clamp(3px, 0.3vw, 6px); flex-shrink: 0;
}
.c-no {
display: inline-flex; align-items: center; justify-content: center;
width: clamp(14px, 1.1vw, 18px); height: clamp(14px, 1.1vw, 18px);
border-radius: 3px; background: rgba(56,189,248,.15);
color: var(--ac); font-weight: 700; font-size: clamp(9px, 0.65vw, 12px); flex-shrink: 0;
}
.c-name {
flex: 1; color: var(--tx); font-size: clamp(9px, 0.7vw, 12px);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.c-badge { padding: 0 clamp(2px, 0.2vw, 4px); border-radius: 2px; font-size: clamp(8px, 0.6vw, 10px); font-weight: 600; white-space: nowrap; }
/* ===== bottom row ===== */
.bottom-row {
flex: 4; display: grid; grid-template-columns: 5fr 4fr;
gap: clamp(2px, 0.25vw, 4px); min-height: 0;
}
/* table */
.table-panel {
background: var(--pbg); border: 1px solid var(--bd); border-radius: 4px;
display: flex; flex-direction: column; overflow: hidden;
}
.table-hdr {
display: flex; justify-content: space-between; align-items: center;
padding: clamp(4px, 0.4vw, 8px) clamp(6px, 0.5vw, 10px);
font-size: clamp(11px, 0.85vw, 16px); font-weight: 600;
border-bottom: 1px solid var(--bd); flex-shrink: 0;
}
.hdr-cnt { color: var(--ac); }
.table-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.t-row {
display: grid; grid-template-columns: repeat(4, 1fr); align-items: center;
gap: clamp(1px, 0.15vw, 3px); font-size: clamp(10px, 0.75vw, 14px);
text-align: center; flex: 1; padding: 0 clamp(3px, 0.3vw, 6px);
border-bottom: 1px solid rgba(56,189,248,.05);
}
.t-head {
color: var(--dim); font-weight: 500; flex: none;
height: clamp(22px, 2.2vh, 32px); border-bottom: 1px solid var(--bd);
font-size: clamp(10px, 0.75vw, 13px);
}
.t-body { flex: 1; display: flex; flex-direction: column; }
.r-badge { display: inline-block; padding: 0 clamp(2px, 0.25vw, 5px); border-radius: 3px; font-size: clamp(9px, 0.65vw, 12px); font-weight: 600; }
.t-frames { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--tx); }
.row-skip { background: rgba(245,158,11,0.06); }
.skip-icon { font-style: normal; color: var(--wn); margin-left: 2px; font-size: clamp(8px, 0.6vw, 11px); }
.badge-w { background: rgba(90,112,148,.15); color: var(--dim); }
.badge-a { background: rgba(245,158,11,.18); color: var(--wn); }
.badge-p { background: rgba(52,211,153,.18); color: var(--ok); }
.badge-f { background: rgba(248,113,113,.18); color: var(--er); }
/* progress + stats column */
.prog-stats-col {
display: flex; flex-direction: column; gap: clamp(2px, 0.25vw, 4px);
}
/* progress */
.prog-panel {
flex: 1.2; background: var(--pbg); border: 1px solid var(--bd);
border-radius: 4px; display: flex; align-items: center; justify-content: space-evenly;
padding: clamp(4px, 0.4vw, 10px); gap: clamp(8px, 0.8vw, 16px);
}
.prog-main-group {
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.prog-ring-wrap {
display: flex; flex-direction: column; align-items: center;
gap: clamp(2px, 0.2vw, 4px); flex-shrink: 0;
}
.prog-ring {
width: clamp(46px, 4.5vw, 80px); height: clamp(46px, 4.5vw, 80px);
border-radius: 50%; display: flex; align-items: center; justify-content: center;
}
.prog-ring-inner {
width: clamp(34px, 3.3vw, 58px); height: clamp(34px, 3.3vw, 58px);
border-radius: 50%; background: var(--bg);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0;
}
.ring-num { font-size: clamp(14px, 1.2vw, 22px); font-weight: 700; color: var(--ac); line-height: 1; }
.ring-label-cut { font-size: clamp(8px, 0.6vw, 11px); color: var(--dim); line-height: 1; }
.ring-label-below { font-size: clamp(9px, 0.75vw, 13px); color: var(--dim); line-height: 1; white-space: nowrap; }
.prog-main-group .prog-stat { display: flex; flex-direction: column; align-items: center; gap: clamp(1px, 0.15vw, 3px); }
.prog-stat { display: flex; flex-direction: column; align-items: center; gap: clamp(2px, 0.2vw, 4px); }
.ps-value { font-size: clamp(16px, 1.5vw, 28px); font-weight: 700; color: var(--ac); line-height: 1; }
.ps-label { font-size: clamp(9px, 0.7vw, 12px); color: var(--dim); }
.p-warn { font-size: clamp(8px, 0.55vw, 10px); color: var(--er); }
/* stats cards */
.stats-cards {
flex: 1; display: grid; grid-template-columns: repeat(3, 1fr);
gap: clamp(3px, 0.3vw, 6px);
min-height: 0;
}
.stat-card {
background: rgba(0,0,0,.25); border: 1px solid var(--bd); border-radius: 4px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: clamp(2px, 0.25vw, 4px); padding: clamp(4px, 0.5vw, 10px);
}
.sc-label { font-size: clamp(10px, 0.75vw, 14px); color: var(--dim); text-align: center; }
.sc-value { font-size: clamp(14px, 1.3vw, 24px); font-weight: 700; color: var(--tx); text-align: center; }
.sc-op .sc-value { color: var(--ok); }
.sc-time .sc-value { color: var(--wn); }
.sc-avg .sc-value { color: var(--ac); }
/* toast */
.alert-toast {
position: absolute; top: 8px; left: 50%; transform: translateX(-50%); z-index: 2000;
padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 600;
cursor: pointer; animation: slideDown .3s ease;
}
.alert-toast.warn { background: rgba(245,158,11,.95); color: #1e1e1e; }
.alert-toast.error { background: rgba(248,113,113,.95); color: #fff; }
@keyframes slideDown { from { opacity: 0; transform: translateX(-50%) translateY(-16px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
/* loading */
.loading-mask {
position: absolute; inset: 0; z-index: 3000; background: var(--bg);
display: flex; align-items: center; justify-content: center;
font-size: clamp(14px, 1.1vw, 20px); color: var(--dim); letter-spacing: 2px;
}
</style>
...@@ -15,12 +15,13 @@ ...@@ -15,12 +15,13 @@
* VITE_WS_URL — WebSocket 地址,默认 ws://127.0.0.1:8080/ws * VITE_WS_URL — WebSocket 地址,默认 ws://127.0.0.1:8080/ws
*/ */
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://127.0.0.1:8080/ws'
const RECONNECT_DELAY = 2000 // 断线重连间隔(ms) const RECONNECT_DELAY = 2000 // 断线重连间隔(ms)
const PING_INTERVAL = 10000 // 心跳间隔(ms) const PING_INTERVAL = 10000 // 心跳间隔(ms)
const MAX_RECONNECTS = 0 // 0 = 无限重连 const MAX_RECONNECTS = 0 // 0 = 无限重连
export function connect() { export function connect(url) {
const wsUrl = url || import.meta.env.VITE_WS_URL || 'ws://127.0.0.1:8080/ws'
const handlers = {} const handlers = {}
const api = { const api = {
...@@ -41,6 +42,7 @@ export function connect() { ...@@ -41,6 +42,7 @@ export function connect() {
}, },
close() { close() {
clearInterval(pingTimer) clearInterval(pingTimer)
clearTimeout(reconnectTimer)
reconnectCount = -1 // 阻止重连 reconnectCount = -1 // 阻止重连
if (socket) socket.close() if (socket) socket.close()
}, },
...@@ -57,7 +59,7 @@ export function connect() { ...@@ -57,7 +59,7 @@ export function connect() {
} }
function createSocket() { function createSocket() {
socket = new WebSocket(WS_URL) socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectCount = 0 reconnectCount = 0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment