Commit b0973fec authored by 崔婷婷's avatar 崔婷婷

UI改版

parent 38039a32
<template> <template>
<div class="dashboard"> <div class="dashboard">
<!-- ======== 顶部信息栏 ======== --> <!-- ======== 1. 基础信息 ======== -->
<header class="header"> <header class="header">
<div class="header-left"> <div class="header-row">
<span class="meta-item">批次号:<em>{{ batch.batch_id }}</em></span> <span>成型线编号:<em>{{ fmt(workstation.line) }}</em></span>
<span class="meta-item">产品型号:<em>{{ batch.product_type }}</em></span> <span>工位编号:<em>{{ fmt(workstation.workstation_id) }}</em></span>
<span class="meta-item">系列:<em>{{ batch.series }}</em></span> <span>员工编号:<em>{{ fmt(operator.operator_id) }}</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> </div>
<h1 class="title">飞天兔AI行为引导系统</h1> <h1 class="title">飞天兔AI行为引导系统</h1>
<div class="header-right"> <div class="header-row 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>
<!-- ======== 系统状态栏 ======== --> <!-- ======== 2. 系统监控 ======== -->
<div class="system-bar"> <div class="sys-bar">
<span class="sys-item">FPS:<em>{{ system.fps }}</em></span> <span>FPS:<em>{{ fmt(system.fps) }}</em></span>
<span class="sys-item">推理耗时:<em>{{ system.inference_ms }}ms</em></span> <span>推理耗时:<em>{{ fmtUnit(system.inference_ms, 'ms') }}</em></span>
<span class="sys-item">模型版本:<em>{{ system.model_version }}</em></span> <span>模型版本:<em>{{ fmt(system.model_version) }}</em></span>
<span class="sys-item"> <span>摄像头:<i class="dot" :class="{ on: system.camera_connected }"></i><em>{{ system.camera_connected ? '已连接' : '未连接' }}</em></span>
摄像头:<i class="cam-dot" :class="{ on: system.camera_connected }"></i> <span>系统状态:<i class="dot" :class="connDotClass"></i><em :class="connTextClass">{{ connStatusText }}</em></span>
<em>{{ system.camera_connected ? '已连接' : '断开' }}</em> <span v-if="wsReconnecting" class="warn">重连中 {{ wsReconnectAttempt }}/{{ wsReconnectMax === Infinity ? '∞' : wsReconnectMax }}…</span>
</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> </div>
<!-- ======== 主区域 (6:4) ======== -->
<main class="main"> <main class="main">
<!-- ======== 左侧:视频画面 ======== --> <!-- ===== 左列 (6) ===== -->
<section class="panel video-panel"> <div class="left-col">
<div class="video-wrap"> <!-- 3. 实时画面 -->
<img :src="videoUrl" class="video-stream" /> <section class="panel video-panel">
<span class="video-label">MJPEG 实时画面</span> <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>
</section>
<!-- 4. 动作卡片 2行×4列 -->
<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>
</section> </div>
<!-- ======== 右侧:SOP 工序列表 + 状态环 + 统计 ======== --> <!-- ===== 右列 (4) ===== -->
<section class="info-panel"> <div class="right-col">
<div class="panel steps-card"> <!-- 工序明细表 -->
<div class="card-header"> <div class="panel table-panel">
<div class="table-hdr">
<span>工序明细</span> <span>工序明细</span>
<span class="progress-count">{{ completedCount }}/{{ displaySteps.length }}</span> <span class="hdr-cnt">{{ doneCount }}/{{ FIXED.length }}</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<div class="steps-header"> <div class="t-row t-head">
<span>NO</span> <span>NO</span><span>工序</span><span>用时</span><span>状态</span>
<span>工序</span>
<span>进度</span>
<span>状态</span>
</div> </div>
<div class="steps-body"> <div class="t-body">
<div <div v-for="card in actionCards" :key="card.step_index" class="t-row" :class="{ 'row-skip': card.anomaly_type === 'skip_missing' }">
v-for="s in displaySteps" <span>{{ card.step_index }}</span>
:key="s.step_index" <span>{{ card.step_name }}<i v-if="card.anomaly_type==='skip_missing'" class="skip-icon" title="跳步缺失">⚠</i></span>
class="step-row" <span class="t-frames">{{ card.frames }}</span>
:class="{ 'row-skip': s.anomaly_type === 'skip_missing' }" <span class="r-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status) }}</span>
>
<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> </div>
</div> </div>
<div class="panel status-card"> <!-- 5. 进度 + 统计 -->
<div class="ring" :style="ringStyle"> <div class="panel prog-panel">
<div class="ring-inner"> <div class="prog-top">
<span :class="ringMainClass">{{ ringMainText }}</span> <div class="prog-left-group">
<span class="ring-sub">{{ ringSubText }}</span> <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> </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>
<div class="stat-grid"> <!-- 6. 工位统计 -->
<div class="panel stat-card"> <div class="stats-cards">
<span class="stat-label">通过率</span> <div class="stat-card sc-op"><span class="sc-value">{{ opCompliance }}</span><span class="sc-label">操作合规</span></div>
<span class="stat-value accent">{{ stats.pass_rate_pct }}%</span> <div class="stat-card sc-time"><span class="sc-value">{{ timeCompliance }}</span><span class="sc-label">时间合规</span></div>
</div> <div class="stat-card sc-avg"><span class="sc-value">{{ avgTime }}</span><span class="sc-label">平均质检用时</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> </div>
</section> </div>
</main> </main>
<!-- 跳步/错误 Toast --> <Teleport to="body"><div v-if="pageLoading" class="loading-mask"><span>加载中…</span></div></Teleport>
<Teleport to="body"> <Teleport to="body">
<div v-if="alertMsg" class="alert-toast" :class="alertMsg.level" @click="alertMsg = null"> <div v-if="alertMsg" class="alert-toast" :class="alertMsg.level" @click="alertMsg=null"><span>{{ alertMsg.text }}</span></div>
<span>{{ alertMsg.text }}</span>
</div>
</Teleport> </Teleport>
</div> </div>
</template> </template>
...@@ -121,727 +121,328 @@ ...@@ -121,727 +121,328 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { connect } from '../ws/index.js' import { connect } from '../ws/index.js'
// ==================== 数据模型(v2) ==================== 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 workstation = reactive({ workstation_id: '-', name: '-', area: '-', line: '-' }) 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 operator = reactive({ operator_id: '-', name: '-' })
const batch = reactive({ batch_id: '-', product_type: '-', series: '-', started_at: '-' })
// 2.1 工序配置
const stepConfigs = ref([])
// 2.4/2.5 产品 const productElapsed = ref(null)
const productId = ref('-') const timeOvertime = ref(false)
const productResult = ref(null) // 'pass' | 'fail' | null const actionsDone = ref(null)
const productEndType = ref(null) // 'flow_complete' | 'timeout' | 'material_change' | 'manual_reset' | null
const productDuration = ref(0)
const productStartedAt = ref('')
const currentStepIndex = ref(1)
// 工序状态 → stepStates[step_index] = { status, anomaly_type, best_confidence } const stepConfigs = ref([]) // 后端 step_configs(含 pass_duration,用于计算总目标用时)
const stepStates = reactive({}) const stepStates = reactive({})
const cardStates = reactive({})
// 3.3 step_progress → 当前 active 工序实时的帧进度 const frameProgress = reactive({}) // idx → { consecutive_frames, target_frames }
const activeProgress = reactive({ step_index: 0, consecutive_frames: 0, target_frames: 0, best_confidence: 0 })
// 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 pageLoading = ref(true)
const wsDisconnected = ref(false)
const wsReconnecting = ref(false) const wsReconnecting = ref(false)
const wsReconnectAttempt = ref(0) const wsReconnectAttempt = ref(0)
const wsReconnectMax = ref(Infinity) const wsReconnectMax = ref(0)
const videoUrl = computed(() => import.meta.env.VITE_VIDEO_URL || 'http://127.0.0.1:8080/stream/video')
// 告警
const alertMsg = ref(null) const alertMsg = ref(null)
const sysErrorMsg = ref('') // 后端 error 消息,如 CAMERA_DISCONNECTED
// 时钟
const currentTime = ref('') const currentTime = ref('')
let clockTimer = null let clockTimer = null
let loadTimeout = null
// ==================== 计算属性 ==================== const videoUrl = computed(() => import.meta.env.VITE_VIDEO_URL || 'http://127.0.0.1:8080/stream/video')
// 合并 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 { const FIXED = [
step_index: cfg.step_index, { step_index:1, step_name:'看号码' }, { step_index:2, step_name:'摸内里' },
step_code: cfg.step_code, { step_index:3, step_name:'看内侧' }, { step_index:4, step_name:'看外侧' },
step_name: cfg.step_name, { step_index:5, step_name:'看后跟高低' }, { step_index:6, step_name:'比鞋头大小' },
status: state.status, { step_index:7, step_name:'看大底' }, { step_index:8, step_name:'查开胶' },
anomaly_type: state.anomaly_type || 'none', ]
framesText,
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 currentStepName = computed(() => { const doneCount= computed(() => actionCards.value.filter(s=>s.status==='completed'||s.status==='incomplete').length)
const cs = displaySteps.value.find(s => s.status === 'active')
return cs ? cs.step_name : '-'
})
const completedCount = computed(() => function fmt(v) { return (v==null||v==='')?'--':v }
displaySteps.value.filter(s => s.status === 'completed' || s.status === 'incomplete').length function fmtNum(v) { return (v==null||v==='')?'--':Number(v) } // 整数/0 原样,null/空→'--'
) function fmtUnit(v,u){ return (v==null||v==='')?'--':`${v}${u}` }
// 格式化 started_at → HH:MM:SS const totalTargetDuration = computed(() => {
const fmtProductTime = computed(() => { if (!stepConfigs.value.length) return null
if (!productStartedAt.value) return '-' return stepConfigs.value.reduce((s, sc) => s + (sc.pass_duration || 0), 0)
const m = productStartedAt.value.match(/T(\d{2}:\d{2}:\d{2})/)
return m ? m[1] : '-'
}) })
const elapsedText = computed(() => {
// 状态环 if (productElapsed.value == null || totalTargetDuration.value == null) return '--'
const ringMainText = computed(() => { const num = Number(productElapsed.value).toFixed(1)
if (productEndType.value) return productResult.value === 'pass' ? '通过' : '不通过' return `${num}/${totalTargetDuration.value.toFixed(1)} 秒`
if (activeProgress.step_index > 0) return '检测中'
return '待机'
}) })
const actionsDoneText= computed(() => actionsDone.value==null?'--':`${Math.min(8,Math.max(0,actionsDone.value))}/8`)
const ringSubText = computed(() => { const ringPct = computed(() => {
if (productEndType.value) return endTypeLabel.value if (actionsDone.value == null) return 0
if (activeProgress.target_frames > 0) { return Math.min(100, (actionsDone.value / 8) * 100)
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 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 resultLabel = computed(() => { const opCompliance = computed(()=>_t.value?`${stats.operation_compliant??0}/${_t.value}(${((stats.operation_compliant??0)/_t.value*100).toFixed(1)}%)`:'--')
if (productResult.value === 'pass') return '通过' const timeCompliance= computed(()=>_t.value?`${stats.time_compliant??0}/${_t.value}(${((stats.time_compliant??0)/_t.value*100).toFixed(1)}%)`:'--')
if (productResult.value === 'fail') return '不通过' const avgTime = computed(()=>(_t.value&&stats.avg_duration!=null)?`${Number(stats.avg_duration).toFixed(1)} 秒`:'--')
return '-'
})
const resultClass = computed(() => {
if (productResult.value === 'pass') return 'text-pass'
if (productResult.value === 'fail') return 'text-fail'
return ''
})
const endTypeLabel = computed(() => { const connStatusText = computed(()=>{if(wsDisconnected.value)return'数据连接异常';if(sysErrorMsg.value)return sysErrorMsg.value;return'运行正常'})
const map = { flow_complete: '流程结束', timeout: '超时结束', material_change: '物料切换', manual_reset: '手动复位' } const connDotClass = computed(()=>(wsDisconnected.value||!!sysErrorMsg.value)?'off':'on')
return map[productEndType.value] || '-' const connTextClass = computed(()=>(wsDisconnected.value||!!sysErrorMsg.value)?'text-warn':'')
})
const endTypeClass = computed(() => productEndType.value === 'timeout' ? 'text-fail' : '')
// UI 帮助函数 function onCardImgErr(card) { const prev=cardStates[card.step_index]||{}; cardStates[card.step_index]={...prev,imageError:true} }
function statusLabel(s) { function cardCls(s) { return {'c-a':s==='active','c-d':s==='completed'||s==='completed_active','c-f':s==='incomplete'} }
const map = { pending: '待检', active: '进行中', completed: '已完成', incomplete: '未完成' } function statusLabel(s){ const m={pending:'待检',active:'进行中',completed:'通过',completed_active:'通过',incomplete:'已检未达标'}; return m[s]||s }
return map[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 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() { const d=new Date(),p=n=>String(n).padStart(2,'0'); currentTime.value=`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` }
function updateClock() {
const d = new Date()
const pd = n => String(n).padStart(2, '0')
currentTime.value = `${pd(d.getHours())}:${pd(d.getMinutes())}:${pd(d.getSeconds())}`
}
// ==================== WebSocket 消息处理 ==================== 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) { function handleSnapshot(msg) {
const d = msg.data const d=msg.data
if (d.workstation) Object.assign(workstation, d.workstation) sysErrorMsg.value=''
if (d.operator) Object.assign(operator, d.operator) if(d.workstation) Object.assign(workstation,d.workstation)
if (d.batch) Object.assign(batch, d.batch) if(d.operator) Object.assign(operator,d.operator)
if (d.step_configs) stepConfigs.value = d.step_configs if(d.stats) Object.assign(stats,d.stats)
if (d.stats) { if(d.system) Object.assign(system,d.system)
Object.assign(stats, d.stats) if(d.step_configs) stepConfigs.value = d.step_configs
stats.pass_rate_pct = Math.round(stats.pass_rate * 100) if(d.current_product) {
}
if (d.system) Object.assign(system, d.system)
if (d.current_product) {
applyProduct(d.current_product) applyProduct(d.current_product)
productEndType.value = d.current_product.end_type || null } else if(d.last_product) {
productResult.value = d.current_product.result || null applyLastProduct(d.last_product)
productDuration.value = d.current_product.duration || 0 } else {
productElapsed.value=null; timeOvertime.value=false; actionsDone.value=null; clearCards()
} }
pageLoading.value=false
} }
function applyProduct(p) {
function applyProduct(product) { productElapsed.value=p.duration??null; timeOvertime.value=p.time_status==='timeout'||p.time_status==='已超时'; actionsDone.value=p.step_count_passed??0
productId.value = product.product_id || '-' clearCards()
productStartedAt.value = product.started_at || '' 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} })
currentStepIndex.value = product.current_step_index || 1 }
function applyLastProduct(p) {
Object.keys(stepStates).forEach(k => delete stepStates[k]) // 仅展示上一双的卡片状态和最终 X/8,本双用时清空
if (product.steps) { productElapsed.value=null; timeOvertime.value=false
product.steps.forEach(s => { clearCards()
stepStates[s.step_index] = { 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} })
status: s.status, if(p.step_count_passed!=null) actionsDone.value=p.step_count_passed
anomaly_type: s.anomaly_type || 'none',
best_confidence: s.best_confidence || 0,
}
})
}
} }
// 3.3 product_start
function handleProductStart(msg) { function handleProductStart(msg) {
const d = msg.data const d=msg.data
productId.value = d.product_id productElapsed.value=0; timeOvertime.value=false; actionsDone.value=0; clearCards()
productStartedAt.value = d.started_at stepStates[d.step_index||1]={status:'active', anomaly_type:'none'}
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) { function handleStepConfirmed(msg) {
const d = msg.data const d=msg.data
// 当前工序 → completed/incomplete stepStates[d.step_index]={status:d.status, anomaly_type:d.anomaly_type||'none'}
stepStates[d.step_index] = { if(d.end_image_url) cardStates[d.step_index]={imageUrl:d.end_image_url,imageError:false}
status: d.status, // 清除当前步骤的帧进度
anomaly_type: d.anomaly_type || 'none', delete frameProgress[d.step_index]
best_confidence: d.best_confidence || 0, if(d.next_step_index) {
stepStates[d.next_step_index]={status:'active', anomaly_type:'none'}
delete frameProgress[d.next_step_index]
} }
// 注意:不再根据卡片状态自行计算 X,actionsDone 仅从后端 step_progress / product_progress 消息获取
if (d.next_step_index) { }
currentStepIndex.value = d.next_step_index function handleTimeUpdate(msg) {
// 下一道工序 → active const d=msg.data
stepStates[d.next_step_index] = { if(d.elapsed!=null) productElapsed.value = d.elapsed
status: 'active', if(d.time_status!=null) timeOvertime.value = d.time_status==='timeout' || d.time_status==='已超时'
anomaly_type: 'none', if(d.actions_done!=null) actionsDone.value = d.actions_done
best_confidence: 0, // per-step frame progress (from step_progress)
if(d.step_index!=null && d.consecutive_frames!=null) {
const cur = stepStates[d.step_index]
// 防御:step_progress 先于 step_confirmed 到达时,标记为 active
// 但已完成/未完成的步骤不再覆盖状态和帧数据(后端不应再推送,防竞态)
if(!cur || cur.status === 'pending') {
stepStates[d.step_index] = { status:'active', anomaly_type:'none' }
}
if(!cur || cur.status === 'active' || cur.status === 'pending') {
frameProgress[d.step_index] = { consecutive_frames:d.consecutive_frames, target_frames:d.target_frames ?? 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) { 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} })}
// 优先用后端返回的 step_count_passed,其次用 actions_done,否则保留当前值
if(d.step_count_passed!=null) actionsDone.value=d.step_count_passed
}
function handleStatsUpdate(msg) { Object.assign(stats,msg.data) }
function handleSystemStatus(msg) { Object.assign(system,msg.data) }
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 const d = msg.data
productEndType.value = d.end_type // 跳步告警:标记被跳过的步骤为 incomplete+skip_missing
productResult.value = d.result if (d.skipped_steps) {
productDuration.value = d.duration || 0 d.skipped_steps.forEach(idx => {
stepStates[idx] = { status:'incomplete', anomaly_type:'skip_missing' }
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 }) // 更新 detected_step → active
} if (d.detected_step_index) {
stepStates[d.detected_step_index] = { status:'active', anomaly_type:'none' }
// 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(() => {
updateClock()
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(() => {
clearInterval(clockTimer)
if (window.__qcWs) {
window.__qcWs.close()
delete window.__qcWs
} }
// 弹出告警 toast
alertMsg.value = { text: d.message || '检测到跳步', level: 'warn' }
setTimeout(()=>{ if(alertMsg.value && alertMsg.value.text === (d.message||'检测到跳步')) alertMsg.value = null }, 5000)
}
let wsApi = null
onMounted(()=>{
updateClock(); clockTimer=setInterval(updateClock,1000)
// 5 秒后仍未收到 snapshot,取消遮罩防止无限 loading
loadTimeout = setTimeout(()=>{ pageLoading.value=false }, 5000)
wsApi = connect()
.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',handleStatsUpdate)
.on('system_status',handleSystemStatus)
.on('skip_detected', onWsSkip)
.on('error', onWsError)
.on('open',()=>{ wsDisconnected.value=false; wsReconnecting.value=false; sysErrorMsg.value='' })
.on('reconnecting',d=>{ wsReconnecting.value=true; wsReconnectAttempt.value=d.attempt; wsReconnectMax.value=d.max })
.on('close',()=>wsDisconnected.value=true)
}) })
onBeforeUnmount(()=>{ clearInterval(clockTimer); clearTimeout(loadTimeout); if(wsApi) wsApi.close() })
</script> </script>
<style scoped> <style scoped>
.dashboard { .dashboard {
--bg: #0a1128; --bg:#060e1f; --pbg:rgba(8,20,44,0.92); --bd:rgba(56,189,248,0.1);
--panel-bg: rgba(13, 27, 58, 0.65); --ac:#38bdf8; --tx:#dfe7f2; --dim:#5a7094; --wn:#f59e0b; --er:#f87171; --ok:#34d399;
--border: rgba(56, 189, 248, 0.18); height:100vh; min-height:600px; overflow:hidden; background:var(--bg); color:var(--tx); padding:clamp(4px,0.5vw,10px) clamp(6px,0.6vw,14px); display:flex; flex-direction:column; gap:clamp(3px,0.3vw,6px);
--accent: #38bdf8; }
--accent-strong: #22d3ee;
--warn: #f59e0b; /* ===== header ===== */
--danger: #f87171; .header { display:flex; align-items:center; justify-content:space-between; background:var(--pbg); border:1px solid var(--bd); border-radius:6px; padding:clamp(6px,0.6vw,12px) clamp(12px,1.2vw,20px); flex-shrink:0; }
--success: #34d399; .title { font-size:clamp(18px,1.6vw,30px); font-weight:700; letter-spacing:2px; color:#fff; text-shadow:0 0 8px rgba(56,189,248,.4); white-space:nowrap; }
--text: #e5eefc; .header-row { display:flex; gap:clamp(10px,1vw,18px); font-size:clamp(12px,0.9vw,17px); color:var(--dim); align-items:center; }
--text-dim: #7f92b8; .header-row.right { font-size:clamp(13px,0.95vw,18px); }
.header-row em { color:var(--tx); font-style:normal; margin-left:2px; }
height: 100vh;
min-height: 600px; /* ===== sys bar ===== */
overflow: hidden; .sys-bar { display:flex; align-items:center; gap:clamp(12px,1.2vw,20px); padding:clamp(3px,0.3vw,6px) clamp(12px,1.2vw,20px); font-size:clamp(11px,0.85vw,15px); color:var(--dim); flex-shrink:0; }
background: radial-gradient(circle at 20% 0%, #10224a 0%, var(--bg) 55%); .sys-bar em { color:var(--tx); font-style:normal; margin-left:2px; }
color: var(--text); .dot { display:inline-block; width:5px; height:5px; border-radius:50%; margin-right:2px; background:var(--er); }
padding: clamp(6px, 1vw, 12px); .dot.on { background:var(--ok); box-shadow:0 0 4px var(--ok); }
display: flex; .dot.off { background:var(--er); }
flex-direction: column; .text-warn { color:var(--er) !important; }
gap: 6px; .sys-bar .warn { color:var(--wn); }
}
/* ===== main - 6:4 ===== */
/* ======== 顶部信息栏 ======== */ .main { flex:1; min-height:0; display:grid; grid-template-columns:6fr 4fr; gap:clamp(4px,0.4vw,8px); }
.header { .panel { background:var(--pbg); border:1px solid var(--bd); border-radius:6px; overflow:hidden; }
position: relative;
display: flex; /* ---- left col ---- */
align-items: center; .left-col { display:flex; flex-direction:column; gap:clamp(3px,0.3vw,6px); min-height:0; }
justify-content: space-between; .video-panel { flex:1; padding:2px; display:flex; min-height:0; }
flex-wrap: nowrap; .video-wrap { flex:1; border-radius:4px; overflow:hidden; background:#000; display:flex; align-items:center; justify-content:center; }
gap: 8px; .video-stream { width:100%; height:100%; object-fit:contain; display:block; }
background: linear-gradient(180deg, rgba(21, 42, 84, 0.9), rgba(13, 27, 58, 0.6)); .video-err { color:var(--dim); }
border: 1px solid var(--border); .video-err span { font-size:clamp(13px,1.1vw,20px); letter-spacing:1px; }
border-radius: 10px;
padding: clamp(8px, 1vw, 14px) clamp(14px, 1.4vw, 24px); /* cards 2×4 */
} .cards-grid { display:grid; grid-template-columns:repeat(8,1fr); gap:clamp(3px,0.3vw,6px); min-height:0; }
.action-card { aspect-ratio:1; 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; }
.title { .action-card.c-a { border-color:rgba(245,158,11,.5); }
position: absolute; .action-card.c-d { border-color:rgba(52,211,153,.35); }
left: 50%; .action-card.c-f { border-color:rgba(248,113,113,.45); }
transform: translateX(-50%); .card-img { flex:1; background:#000; display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; }
font-size: clamp(18px, 1.6vw, 30px); .card-img img { width:100%; height:100%; object-fit:cover; display:block; }
font-weight: 700; .card-live-feed { width:100%; height:100%; object-fit:cover; display:block; }
letter-spacing: 2px; .live { font-size:clamp(9px,0.7vw,12px); color:var(--ok); letter-spacing:1px; animation:pulse 1.4s infinite; }
color: #fff; @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
text-shadow: 0 0 12px rgba(56, 189, 248, 0.5); .nopic { font-size:clamp(10px,0.8vw,14px); color:rgba(255,255,255,.15); }
white-space: nowrap; .card-foot { display:flex; align-items:center; gap:clamp(2px,0.2vw,4px); padding:clamp(3px,0.3vw,6px) clamp(4px,0.4vw,8px); flex-shrink:0; }
pointer-events: none; .c-no { display:inline-flex; align-items:center; justify-content:center; width:clamp(14px,1.2vw,18px); height:clamp(14px,1.2vw,18px); border-radius:3px; background:rgba(56,189,248,.15); color:var(--ac); font-weight:700; font-size:clamp(9px,0.7vw,12px); flex-shrink:0; }
} .c-name { flex:1; color:var(--tx); font-size:clamp(10px,0.8vw,14px); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.c-badge { padding:0 clamp(3px,0.3vw,5px); border-radius:2px; font-size:clamp(9px,0.7vw,11px); font-weight:600; white-space:nowrap; }
.header-left,
.header-right { /* ---- right col ---- */
display: flex; .right-col { display:flex; flex-direction:column; gap:clamp(3px,0.3vw,6px); min-height:0; }
gap: clamp(10px, 1.2vw, 18px);
flex-wrap: nowrap; /* table */
font-size: clamp(11px, 0.85vw, 16px); .table-panel { flex:6; display:flex; flex-direction:column; min-height:0; }
color: var(--text-dim); .table-hdr { display:flex; justify-content:space-between; align-items:center; padding:clamp(6px,0.5vw,10px) clamp(8px,0.7vw,14px); font-size:clamp(13px,1vw,19px); font-weight:600; border-bottom:1px solid var(--bd); flex-shrink:0; }
align-items: center; .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(2px,0.2vw,4px); font-size:clamp(11px,0.9vw,18px); text-align:center; flex:1; padding:0 clamp(4px,0.4vw,8px); border-bottom:1px solid rgba(56,189,248,.05); }
.meta-item { .t-head { color:var(--dim); font-weight:500; flex:none; height:clamp(26px,2.5vh,38px); border-bottom:1px solid var(--bd); font-size:clamp(11px,0.9vw,16px); }
white-space: nowrap; .t-body { flex:1; display:flex; flex-direction:column; }
} .r-badge { display:inline-block; padding:0 clamp(3px,0.3vw,6px); border-radius:3px; font-size:clamp(10px,0.75vw,14px); font-weight:600; }
.t-frames { font-variant-numeric:tabular-nums; white-space:nowrap; color:var(--tx); }
.meta-item em { .row-skip { background:rgba(245,158,11,0.06); }
color: var(--text); .skip-icon { font-style:normal; color:var(--wn); margin-left:3px; font-size:clamp(9px,0.7vw,12px); }
font-style: normal; .badge-w{background:rgba(90,112,148,.15);color:var(--dim)} .badge-a{background:rgba(245,158,11,.18);color:var(--wn)}
margin-left: 3px; .badge-p{background:rgba(52,211,153,.18);color:var(--ok)} .badge-f{background:rgba(248,113,113,.18);color:var(--er)}
}
/* progress */
/* ======== 系统状态栏 ======== */ .prog-panel { flex:2; display:flex; flex-direction:column; justify-content:center; padding:clamp(6px,0.6vw,14px); }
.system-bar { .prog-top { display:flex; align-items:center; justify-content:space-evenly; flex:1; gap:clamp(16px,1.6vw,32px); }
display: flex; .prog-left-group { display:flex; align-items:center; gap:clamp(6px,0.6vw,12px); }
align-items: center; .prog-ring-wrap { display:flex; flex-direction:column; align-items:center; gap:clamp(3px,0.3vw,6px); flex-shrink:0; }
gap: clamp(14px, 1.5vw, 24px); .prog-ring { width:clamp(90px,8vw,130px); height:clamp(90px,8vw,130px); border-radius:50%; display:flex; align-items:center; justify-content:center; }
padding: 3px 18px; .prog-ring-inner { width:clamp(68px,6vw,100px); height:clamp(68px,6vw,100px); border-radius:50%; background:var(--bg); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0; }
font-size: clamp(11px, 0.85vw, 15px); .ring-num { font-size:clamp(22px,2vw,36px); font-weight:700; color:var(--ac); line-height:1; }
color: var(--text-dim); .ring-label-cut { font-size:clamp(9px,0.8vw,13px); color:var(--dim); line-height:1; }
flex-shrink: 0; .ring-label-below { font-size:clamp(11px,0.9vw,15px); color:var(--dim); line-height:1; }
} .prog-stat { display:flex; flex-direction:column; align-items:center; gap:clamp(3px,0.3vw,6px); }
.ps-value { font-size:clamp(32px,3vw,52px); font-weight:700; color:var(--ac); line-height:1; }
.system-bar em { .ps-label { font-size:clamp(13px,1vw,18px); color:var(--dim); }
color: var(--text); .p-warn { font-size:clamp(10px,0.8vw,13px); color:var(--er); }
font-style: normal;
margin-left: 3px; /* stats cards */
} .stats-cards { flex:2; display:grid; grid-template-columns:repeat(3,1fr); gap:clamp(6px,0.5vw,10px); }
.stat-card { background:rgba(0,0,0,.25); border:1px solid var(--bd); border-radius:6px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:clamp(4px,0.4vw,8px); padding:clamp(8px,0.8vw,16px); }
.sys-item.warn { .sc-label { font-size:clamp(13px,1vw,18px); color:var(--dim); text-align:center; }
color: var(--warn); .sc-value { font-size:clamp(20px,1.8vw,32px); font-weight:700; color:var(--tx); text-align:center; }
} .sc-op .sc-value { color:var(--ok); }
.sys-item.warn em { .sc-time .sc-value { color:var(--wn); }
color: var(--warn); .sc-avg .sc-value { color:var(--ac); }
}
/* toast */
.cam-dot { .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 .3s ease; }
display: inline-block; .alert-toast.warn { background:rgba(245,158,11,.95); color:#1e1e1e; }
width: 7px; .alert-toast.error { background:rgba(248,113,113,.95); color:#fff; }
height: 7px; @keyframes slideDown { from{opacity:0;transform:translateX(-50%) translateY(-20px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
border-radius: 50%;
background: var(--danger); /* loading */
margin-right: 3px; .loading-mask { position:fixed; inset:0; z-index:3000; background:var(--bg); display:flex; align-items:center; justify-content:center; font-size:clamp(16px,1.4vw,24px); color:var(--dim); letter-spacing:2px; }
}
.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;
bottom: 8px;
right: 12px;
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 1px;
pointer-events: none;
}
/* ======== 工序明细表 ======== */
.info-panel {
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;
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;
grid-template-columns: 1fr 1fr;
gap: clamp(4px, 0.5vw, 8px);
}
.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>
...@@ -41,6 +41,7 @@ export function connect() { ...@@ -41,6 +41,7 @@ export function connect() {
}, },
close() { close() {
clearInterval(pingTimer) clearInterval(pingTimer)
clearTimeout(reconnectTimer)
reconnectCount = -1 // 阻止重连 reconnectCount = -1 // 阻止重连
if (socket) socket.close() if (socket) socket.close()
}, },
......
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