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

对应后端多通道前端接口文档

parent 46d5f76b
# 前端唯一环境变量:后端 API 基础地址
# 默认值为空,表示使用当前页面同源地址
# 生产环境推荐前后端同源部署,无需设置此变量
VITE_API_BASE_URL=
# Mock 模式:设为 true 时使用本地模拟数据,无需启动后端
# 开发调试时使用,生产部署时不要设置
VITE_USE_MOCK=true
...@@ -10,7 +10,9 @@ lerna-debug.log* ...@@ -10,7 +10,9 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local .env
.env.local
.env.*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
......
...@@ -6,43 +6,89 @@ ...@@ -6,43 +6,89 @@
<div class="header-right">{{ currentTime }}</div> <div class="header-right">{{ currentTime }}</div>
</header> </header>
<!-- 双工位面板 --> <!-- 加载中 -->
<main class="main"> <main v-if="loading" class="main loading-main">
<WorkstationPanel <span class="loading-text">正在发现检测通道…</span>
:ws-url="wsUrl1" </main>
:video-url="videoUrl1"
/> <!-- 通道错误 -->
<main v-else-if="errorMsg" class="main loading-main">
<span class="loading-text error-text">{{ errorMsg }}</span>
</main>
<!-- 动态通道面板:N 个通道 = N 个面板 -->
<main v-else class="main" :style="mainGridStyle">
<WorkstationPanel <WorkstationPanel
:ws-url="wsUrl2" v-for="ch in channels"
:video-url="videoUrl2" :key="ch.channel_id"
:channel="ch"
/> />
</main> </main>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import WorkstationPanel from './WorkstationPanel.vue' import WorkstationPanel from './WorkstationPanel.vue'
const wsUrl1 = const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
import.meta.env.VITE_WS_URL_STATION_1 || const API_BASE = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/+$/, '')
import.meta.env.VITE_WS_URL ||
'ws://127.0.0.1:8080/ws?station=1' const channels = ref([])
const loading = ref(true)
const errorMsg = ref('')
const wsUrl2 = // 获取通道列表,只展示 enabled=true 的通道,按 order 排序
import.meta.env.VITE_WS_URL_STATION_2 || async function fetchChannels() {
import.meta.env.VITE_WS_URL || loading.value = true
'ws://127.0.0.1:8080/ws?station=2' errorMsg.value = ''
const videoUrl1 = if (USE_MOCK) {
import.meta.env.VITE_VIDEO_URL_STATION_1 || // Mock 模式:动态导入本地数据(生产构建时整个 mock 模块会被 tree-shaking 移除)
import.meta.env.VITE_VIDEO_URL || console.log('[QcDashboard] Mock 模式,使用本地通道数据')
'http://127.0.0.1:8080/stream/video?station=1' const { mockChannelsResponse } = await import('../mock/index.js')
channels.value = (mockChannelsResponse.channels || [])
.filter(c => c.enabled === true)
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
loading.value = false
return
}
// 真实模式:请求后端
try {
const resp = await fetch(`${API_BASE}/api/v1/channels`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
try {
const data = JSON.parse(text)
channels.value = (data.channels || [])
.filter(c => c.enabled === true)
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
if (channels.value.length === 0) {
errorMsg.value = '未发现可用检测通道'
}
} catch (parseErr) {
if (text.trim().startsWith('<!doctype') || text.trim().startsWith('<')) {
console.error('[QcDashboard] 收到 HTML 而非 JSON —— 请确认后端已启动且 Vite 代理已配置')
}
throw parseErr
}
} catch (e) {
console.error('[QcDashboard] 获取通道列表失败', e)
if (e.message?.includes('JSON')) {
errorMsg.value = '无法连接后端服务:收到非 JSON 响应。请确认 Vite 代理已配置(vite.config.js → server.proxy),且后端服务已启动'
} else {
errorMsg.value = `无法连接后端服务:${e.message}`
}
} finally {
loading.value = false
}
}
const videoUrl2 = // 动态网格:1 列 / 2 列 / N 列
import.meta.env.VITE_VIDEO_URL_STATION_2 || const mainGridStyle = computed(() => ({
import.meta.env.VITE_VIDEO_URL || gridTemplateColumns: `repeat(${channels.value.length || 1}, 1fr)`,
'http://127.0.0.1:8080/stream/video' }))
const currentTime = ref('') const currentTime = ref('')
let clockTimer = null let clockTimer = null
...@@ -56,6 +102,7 @@ function updateClock() { ...@@ -56,6 +102,7 @@ function updateClock() {
onMounted(() => { onMounted(() => {
updateClock() updateClock()
clockTimer = setInterval(updateClock, 1000) clockTimer = setInterval(updateClock, 1000)
fetchChannels()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
...@@ -109,12 +156,24 @@ onBeforeUnmount(() => { ...@@ -109,12 +156,24 @@ onBeforeUnmount(() => {
color: var(--dim); color: var(--dim);
} }
/* ===== main: 双工位并排 ===== */ /* ===== main: 动态 N 工位并排 ===== */
.main { .main {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: 1fr 1fr;
gap: 0; gap: 0;
} }
.loading-main {
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-size: clamp(14px, 1.1vw, 20px);
color: var(--dim);
letter-spacing: 2px;
}
.error-text {
color: #f87171;
}
</style> </style>
...@@ -2,40 +2,49 @@ ...@@ -2,40 +2,49 @@
<div class="panel"> <div class="panel">
<!-- 迷你信息栏 + 系统状态 --> <!-- 迷你信息栏 + 系统状态 -->
<div class="top-bar"> <div class="top-bar">
<span>成型线:<em>{{ fmt(workstation.line) }}</em></span> <span>线别:<em>{{ fmt(workstation.area) }}{{ fmt(workstation.line) }}</em></span>
<span>工位:<em>{{ fmt(workstation.workstation_id) }}</em></span> <span>工位:<em>{{ fmt(workstation.station_id) }}</em></span>
<span>员工:<em>{{ fmt(operator.operator_id) }}</em></span> <span>员工:<em>{{ fmt(operator.operator_id) }}</em></span>
<span class="sep"></span> <span class="sep"></span>
<span>FPS:<em>{{ fmt(system.fps) }}</em></span> <span>FPS:<em>{{ fmtNum(system.inference_fps) }}</em></span>
<span>推理:<em>{{ fmtUnit(system.inference_ms, 'ms') }}</em></span> <span>推理:<em>{{ fmtUnit(system.inference_ms, 'ms') }}</em></span>
<span>模型:<em>{{ fmt(system.model_version) }}</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="{ on: system.camera_connected }"></i></span>
<span>状态:<i class="dot" :class="connDotClass"></i><em :class="connTextClass">{{ connStatusText }}</em></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> <span v-if="wsReconnecting" class="warn">重连 {{ wsReconnectAttempt }}…</span>
</div> </div>
<!-- 视频画面 --> <!-- 视频画面:每个面板只有 1 个主视频 <img> -->
<div class="video-panel"> <div class="video-panel">
<div v-if="system.camera_connected && !wsDisconnected" class="video-wrap"> <div v-if="system.camera_connected && !wsDisconnected" class="video-wrap">
<img :src="videoUrl" class="video-stream" /> <img :src="videoUrl" class="video-stream" alt="实时检测画面" />
</div> </div>
<div v-else-if="!system.camera_connected" class="video-wrap video-err"><span>摄像头未连接</span></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 v-else class="video-wrap video-err"><span>数据连接异常</span></div>
</div> </div>
<!-- 动作卡片 4列×2行 --> <!-- 动作卡片:step_configs.length 列,自适应 -->
<div class="cards-grid"> <div class="cards-grid" :style="cardsGridStyle">
<div v-for="card in actionCards" :key="card.step_index" class="action-card" :class="cardCls(card.status)"> <div
v-for="card in actionCards"
:key="card.step_index"
class="action-card"
:class="cardCls(card.status)"
>
<div class="card-img"> <div class="card-img">
<img v-if="card.showLive" :src="videoUrl" class="card-live-feed" /> <!-- 只使用静态截图,不复用视频 URL -->
<img v-else-if="card.imageUrl && !card.imageError" :src="card.imageUrl" @error="onCardImgErr(card)" /> <img
<span v-else-if="card.imageError || card.status === 'incomplete'" class="nopic">图片暂不可用</span> v-if="card.imageUrl && !card.imageError"
:src="card.imageUrl"
@error="onCardImgErr(card)"
/>
<span v-else-if="card.imageError || (card.done && !card.imageUrl)" class="nopic">截图暂不可用</span>
<span v-else class="nopic">待检</span> <span v-else class="nopic">待检</span>
</div> </div>
<div class="card-foot"> <div class="card-foot">
<span class="c-no">{{ card.step_index }}</span> <span class="c-no">{{ card.display_order ?? card.step_index }}</span>
<span class="c-name">{{ card.step_name }}</span> <span class="c-name">{{ card.step_name }}</span>
<span class="c-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status) }}</span> <span class="c-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status, card.anomaly_type) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -46,18 +55,28 @@ ...@@ -46,18 +55,28 @@
<div class="table-panel"> <div class="table-panel">
<div class="table-hdr"> <div class="table-hdr">
<span>工序明细</span> <span>工序明细</span>
<span class="hdr-cnt">{{ doneCount }}/{{ FIXED.length }}</span> <span class="hdr-cnt">{{ doneCount }}/{{ mergedStepConfigs.length }}</span>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<div class="t-row t-head"> <div class="t-row t-head">
<span>NO</span><span>工序</span><span>用时</span><span>状态</span> <span>NO</span><span>工序</span><span>用时</span><span>状态</span>
</div> </div>
<div class="t-body"> <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' }"> <div
<span>{{ card.step_index }}</span> v-for="card in actionCards"
<span>{{ card.step_name }}<i v-if="card.anomaly_type==='skip_missing'" class="skip-icon" title="跳步缺失">⚠</i></span> :key="card.step_index"
<span class="t-frames">{{ card.frames }}</span> class="t-row"
<span class="r-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status) }}</span> :class="{ 'row-skip': card.anomaly_type === 'skip_missing' }"
>
<span>{{ card.display_order ?? card.step_index }}</span>
<span>
{{ card.step_name }}
<i v-if="card.anomaly_type === 'skip_missing'" class="skip-icon" title="跳步缺失">⚠</i>
<i v-else-if="card.anomaly_type === 'ai_misrecognition'" class="ai-icon" title="AI误识别">🤖</i>
</span>
<!-- 用时列唯一展示数据:duration_seconds -->
<span class="t-duration">{{ fmtDuration(card) }}</span>
<span class="r-badge" :class="badgeCls(card.status)">{{ statusLabel(card.status, card.anomaly_type) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -89,41 +108,85 @@ ...@@ -89,41 +108,85 @@
</div> </div>
<div class="stats-cards"> <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-op">
<div class="stat-card sc-time"><span class="sc-value">{{ timeCompliance }}</span><span class="sc-label">时间合规</span></div> <span class="sc-value">{{ opCompliance }}</span>
<div class="stat-card sc-avg"><span class="sc-value">{{ avgTime }}</span><span class="sc-label">平均质检用时</span></div> <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> </div>
</div> </div>
<!-- loading / alert --> <!-- loading / alert -->
<div v-if="pageLoading" class="loading-mask"><span>加载中…</span></div> <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
v-if="alertMsg"
class="alert-toast"
:class="alertMsg.level"
@click="alertMsg = null"
><span>{{ alertMsg.text }}</span></div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { connect } from '../ws/index.js' import { connect } from '../ws/index.js'
const props = defineProps({ const props = defineProps({
wsUrl: { type: String, default: '' }, channel: {
videoUrl: { type: String, default: '' }, type: Object,
required: true,
},
}) })
const workstation = reactive({ workstation_id:'', line:'', area:'' }) // ── 展示用静态数据(来自 channel descriptor) ──
const operator = reactive({ operator_id:'' }) const workstation = reactive({ station_id: '', name: '', area: '', line: '' })
const system = reactive({ fps:'', inference_ms:'', model_version:'', model_loaded:false, camera_connected:false }) const camera = reactive({ camera_id: '', display_name: '', 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 operator = reactive({ operator_id: '', name: '' })
const batch = reactive({ batch_id: '', product_type: '', series: '', started_at: '' })
const system = reactive({
status: '',
camera_connected: false,
model_loaded: false,
capture_fps: null,
inference_fps: null,
inference_ms: null,
queue_delay_ms: null,
dropped_frames: null,
model_version: '',
last_frame_at: null,
last_error: null,
})
const stats = reactive({
total_products: 0,
passed_products: 0,
failed_products: 0,
pass_rate: 0,
avg_duration_seconds: null,
operation_compliant_products: 0,
time_compliant_products: 0,
skip_missing_count: 0,
ai_misrecognition_count: 0,
})
const productElapsed = ref(null) const productElapsed = ref(null)
const timeOvertime = ref(false) const timeOvertime = ref(false)
const actionsDone = ref(null) const actionsDone = ref(null)
const totalActions = ref(0)
const timeLimitSeconds = ref(null)
const stepConfigs = ref([]) const stepConfigs = ref([])
const stepStates = reactive({}) const stepStates = reactive({}) // { [step_index]: { status, anomaly_type, done, duration_seconds, ... } }
const cardStates = reactive({}) const cardImages = reactive({}) // { [step_index]: { imageUrl, imageError } }
const frameProgress = reactive({})
const pageLoading = ref(true) const pageLoading = ref(true)
const wsDisconnected = ref(false) const wsDisconnected = ref(false)
...@@ -134,200 +197,494 @@ const alertMsg = ref(null) ...@@ -134,200 +197,494 @@ const alertMsg = ref(null)
const sysErrorMsg = ref('') const sysErrorMsg = ref('')
let loadTimeout = null let loadTimeout = null
// ── 通道连接标识 ──
const channelId = computed(() => props.channel?.channel_id || '')
// ── 当前产品 ID(用于增量消息校验)──
let currentProductId = null
// ── WebSocket / 视频 URL 解析 ──
function resolveWsUrl(path) {
const url = new URL(path, window.location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.href
}
function resolveHttpUrl(path) {
const url = new URL(path, window.location.origin)
return url.href
}
const videoUrl = computed(() => {
if (props.channel?.urls?.video) return resolveHttpUrl(props.channel.urls.video)
return ''
})
const wsUrl = computed(() => {
if (props.channel?.urls?.websocket) return resolveWsUrl(props.channel.urls.websocket)
return ''
})
// ── 固定工序兜底(step_configs 为空时使用)──
const FIXED = [ const FIXED = [
{ step_index:1, step_name:'看号码' }, { step_index:2, step_name:'摸内里' }, { step_index: 1, display_order: 1, step_code: 'STEP1', step_name: '看号码', target_duration_seconds: 0.45 },
{ step_index:3, step_name:'看内侧' }, { step_index:4, step_name:'看外侧' }, { step_index: 2, display_order: 2, step_code: 'STEP2', step_name: '摸内里', target_duration_seconds: 0.45 },
{ step_index:5, step_name:'看后跟高低' }, { step_index:6, step_name:'比鞋头大小' }, { step_index: 3, display_order: 3, step_code: 'STEP3', step_name: '看内侧', target_duration_seconds: 0.45 },
{ step_index:7, step_name:'看大底' }, { step_index:8, step_name:'查开胶' }, { step_index: 4, display_order: 4, step_code: 'STEP4', step_name: '看外侧', target_duration_seconds: 0.45 },
{ step_index: 5, display_order: 5, step_code: 'STEP5', step_name: '看后跟高低', target_duration_seconds: 0.45 },
{ step_index: 6, display_order: 6, step_code: 'STEP6', step_name: '比鞋头大小', target_duration_seconds: 0.45 },
{ step_index: 7, display_order: 7, step_code: 'STEP7', step_name: '看大底', target_duration_seconds: 0.45 },
{ step_index: 8, display_order: 8, step_code: 'STEP8', step_name: '查开胶', target_duration_seconds: 0.45 },
] ]
const actionCards = computed(() => FIXED.map(f => { // ── 合并 stepConfigs 与 FIXED ──
const s = stepStates[f.step_index] || { status:'pending' } const mergedStepConfigs = computed(() => {
const c = cardStates[f.step_index] || { imageUrl:null, imageError:false } if (stepConfigs.value.length) return stepConfigs.value
const fp = frameProgress[f.step_index] || { consecutive_frames:0, target_frames:0 } return FIXED
let frames = '--' })
let showLive = false
if (s.status === 'active') { // ── 卡片网格列数:由 step_configs.length 决定 ──
showLive = true const cardsGridStyle = computed(() => ({
if (fp.target_frames > 0) frames = `${fp.consecutive_frames}/${fp.target_frames}` gridTemplateColumns: `repeat(${mergedStepConfigs.value.length || 8}, 1fr)`,
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) // ── 动作卡片列表 ──
const actionCards = computed(() =>
mergedStepConfigs.value.map(sc => {
const idx = sc.step_index
const s = stepStates[idx] || { status: 'pending', anomaly_type: 'none', done: false, duration_seconds: null }
const img = cardImages[idx] || { imageUrl: null, imageError: false }
const targetDuration = sc.target_duration_seconds ?? null
return {
...sc,
status: s.status,
anomaly_type: s.anomaly_type || 'none',
done: s.done,
duration_seconds: s.duration_seconds ?? null,
target_duration_seconds: targetDuration,
imageUrl: img.imageUrl,
imageError: img.imageError || false,
}
})
)
const doneCount = computed(() =>
actionCards.value.filter(s => s.done).length
)
function fmt(v) { return (v==null||v==='')?'--':v } // ── 格式化 ──
function fmtNum(v) { return (v==null||v==='')?'--':Number(v) } function fmt(v) {
function fmtUnit(v,u){ return (v==null||v==='')?'--':`${v}${u}` } if (v == null || v === '') return '--'
if (typeof v === 'number') return v.toFixed(1)
return v
}
function fmtNum(v) {
if (v == null || v === '') return '--'
const n = Number(v)
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
function fmtUnit(v, u) {
if (v == null || v === '') return '--'
return `${Number(v).toFixed(1)}${u}`
}
function fmtDuration(card) {
if (card.duration_seconds != null) return `${Number(card.duration_seconds).toFixed(1)}s`
return '--'
}
const totalTargetDuration = computed(() => { // ── 耗时展示 ──
if (!stepConfigs.value.length) return null
return stepConfigs.value.reduce((s, sc) => s + (sc.pass_duration || 0), 0)
})
const elapsedText = computed(() => { const elapsedText = computed(() => {
if (productElapsed.value == null || totalTargetDuration.value == null) return '--' if (productElapsed.value == null) return '--'
const num = Number(productElapsed.value).toFixed(1) const num = Number(productElapsed.value).toFixed(1)
return `${num}/${totalTargetDuration.value.toFixed(1)} 秒` const limit = timeLimitSeconds.value != null ? `/${Number(timeLimitSeconds.value).toFixed(1)}` : ''
return `${num}${limit} 秒`
}) })
const actionsDoneText = computed(() => actionsDone.value==null?'--':`${Math.min(8,Math.max(0,actionsDone.value))}/8`)
// ── 动作进度环 ──
const totalStepCount = computed(() => mergedStepConfigs.value.length)
const actionsDoneText = computed(() => {
if (actionsDone.value == null) return '--'
return `${Math.min(totalStepCount.value, Math.max(0, actionsDone.value))}/${totalStepCount.value}`
})
const ringPct = computed(() => { const ringPct = computed(() => {
if (actionsDone.value == null) return 0 if (actionsDone.value == null) return 0
return Math.min(100, (actionsDone.value / 8) * 100) return Math.min(100, (actionsDone.value / totalStepCount.value) * 100)
}) })
const ringStyle = computed(() => ({ const ringStyle = computed(() => ({
background: `conic-gradient(var(--ok) ${ringPct.value}%, rgba(255,255,255,0.06) ${ringPct.value}%)`, background: `conic-gradient(var(--ok) ${ringPct.value}%, rgba(255,255,255,0.06) ${ringPct.value}%)`,
})) }))
// ── 统计卡 ──
const _t = computed(() => stats.total_products || 0) 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 opCompliance = computed(() =>
const timeCompliance = computed(() => _t.value ? `${stats.time_compliant??0}/${_t.value}(${((stats.time_compliant??0)/_t.value*100).toFixed(1)}%)` : '--') _t.value
const avgTime = computed(() => (_t.value && stats.avg_duration!=null) ? `${Number(stats.avg_duration).toFixed(1)} 秒` : '--') ? `${stats.operation_compliant_products ?? 0}/${_t.value}(${((stats.operation_compliant_products ?? 0) / _t.value * 100).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 timeCompliance = computed(() =>
const connTextClass = computed(() => (wsDisconnected.value || !!sysErrorMsg.value) ? 'text-warn' : '') _t.value
? `${stats.time_compliant_products ?? 0}/${_t.value}(${((stats.time_compliant_products ?? 0) / _t.value * 100).toFixed(1)}%)`
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 } const avgTime = computed(() =>
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' } _t.value && stats.avg_duration_seconds != null
? `${Number(stats.avg_duration_seconds).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 cardCls(s) {
const m = { active: 'c-a', pass: 'c-d', checked: 'c-c', fail: 'c-f' }
return m[s] || ''
}
// 状态标签:pending=待检, active=进行中, checked=已检, pass=通过, fail=失败
function statusLabel(s, anomaly) {
const m = {
pending: '待检',
active: '进行中',
checked: '已检',
pass: '通过',
fail: anomaly === 'skip_missing' ? '跳步' : anomaly === 'ai_misrecognition' ? '误判' : '失败',
}
return m[s] || s
}
function badgeCls(s) {
if (s === 'active') return 'badge-a'
if (s === 'pass') return 'badge-p'
if (s === 'checked') return 'badge-c'
if (s === 'fail') return 'badge-f'
return 'badge-w'
}
function onCardImgErr(card) {
cardImages[card.step_index] = { ...(cardImages[card.step_index] || {}), imageError: true }
}
// ── 清理卡片状态 ──
function clearCards() {
Object.keys(stepStates).forEach(k => delete stepStates[k])
Object.keys(cardImages).forEach(k => delete cardImages[k])
}
// ── WebSocket 序号校验 ──
let lastSequence = null
let serverInstanceId = null
/**
* 校验消息合法性:
* 1. channel_id 必须匹配
* 2. server_instance_id 变化 → 重置序号并等待新 snapshot
* 3. sequence <= lastSequence → 重复/迟到,丢弃
* 4. sequence > lastSequence + 1 → 丢消息,请求快照
*/
function validateAndUpdateSeq(msg) {
// channel_id 不匹配 → 丢弃并记录错误
if (msg.channel_id && msg.channel_id !== channelId.value) {
console.error(`[WS] channel_id 不匹配: 收到 ${msg.channel_id}, 期望 ${channelId.value}`)
return false
}
// server_instance_id 变化 → 重置
if (msg.server_instance_id && msg.server_instance_id !== serverInstanceId) {
serverInstanceId = msg.server_instance_id
lastSequence = null
}
const seq = msg.sequence
if (seq == null) return true // 无序号则放过
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]) } if (lastSequence != null && seq <= lastSequence) {
// 重复或迟到消息
return false
}
if (lastSequence != null && seq > lastSequence + 1) {
// 序号缺口,请求快照
sendMsg({
type: 'request_snapshot',
request_id: `front-${Date.now()}`,
data: { reason: 'sequence_gap' },
})
}
lastSequence = seq
return true
}
// ===== WS handlers ===== // ── WS handlers ──
function handleSnapshot(msg) { function handleSnapshot(msg) {
const d = msg.data const d = msg.data
sysErrorMsg.value = '' sysErrorMsg.value = ''
if (d.workstation) Object.assign(workstation, d.workstation)
if (d.operator) Object.assign(operator, d.operator) // 整体替换(文档 13.1:不得只做浅层增量合并)
if (d.stats) Object.assign(stats, d.stats) resetReactive(workstation, (d.workstation || {}))
if (d.system) Object.assign(system, d.system) resetReactive(camera, (d.camera || {}))
if (d.step_configs) stepConfigs.value = d.step_configs resetReactive(operator, (d.operator || {}))
resetReactive(batch, (d.batch || {}))
resetReactive(stats, (d.stats || {}))
resetReactive(system, (d.system || {}))
stepConfigs.value = d.step_configs || []
if (d.current_product) { if (d.current_product) {
applyProduct(d.current_product) currentProductId = d.current_product.product_id || null
applyCurrentProduct(d.current_product)
} else if (d.last_product) { } else if (d.last_product) {
currentProductId = null
applyLastProduct(d.last_product) applyLastProduct(d.last_product)
} else { } else {
productElapsed.value = null; timeOvertime.value = false; actionsDone.value = null; clearCards() currentProductId = null
productElapsed.value = null
timeOvertime.value = false
actionsDone.value = null
totalActions.value = 0
timeLimitSeconds.value = null
clearCards()
} }
pageLoading.value = false 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 /** 先清空再赋值,实现整体替换 */
function resetReactive(target, source) {
Object.keys(target).forEach(k => delete target[k])
Object.assign(target, source)
}
function applyCurrentProduct(p) {
productElapsed.value = p.elapsed_seconds ?? null
timeOvertime.value = p.time_status === 'timeout'
actionsDone.value = p.actions_done ?? null
totalActions.value = p.total_actions ?? mergedStepConfigs.value.length
timeLimitSeconds.value = p.time_limit_seconds ?? null
clearCards() 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.steps) {
p.steps.forEach(s => {
stepStates[s.step_index] = {
status: s.status,
anomaly_type: s.anomaly_type || 'none',
done: s.done,
duration_seconds: s.duration_seconds ?? null,
}
if (s.image_url) {
cardImages[s.step_index] = { imageUrl: s.image_url, imageError: false }
}
})
}
} }
function applyLastProduct(p) { function applyLastProduct(p) {
productElapsed.value = null; timeOvertime.value = false productElapsed.value = null
timeOvertime.value = false
timeLimitSeconds.value = null
clearCards() 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.steps) {
if (p.step_count_passed!=null) actionsDone.value = p.step_count_passed p.steps.forEach(s => {
stepStates[s.step_index] = {
status: s.status,
anomaly_type: s.anomaly_type || 'none',
done: s.done,
duration_seconds: s.duration_seconds ?? null,
}
if (s.image_url) {
cardImages[s.step_index] = { imageUrl: s.image_url, imageError: false }
}
})
}
if (p.actions_done != null) actionsDone.value = p.actions_done
if (p.total_actions != null) totalActions.value = p.total_actions
} }
function handleProductStart(msg) { function handleProductStart(msg) {
const d = msg.data const d = msg.data
productElapsed.value = 0; timeOvertime.value = false; actionsDone.value = 0; clearCards() currentProductId = d.product_id || null
stepStates[d.step_index||1] = {status:'active', anomaly_type:'none'} productElapsed.value = d.elapsed_seconds ?? 0
timeOvertime.value = d.time_status === 'timeout'
actionsDone.value = d.actions_done ?? 0
totalActions.value = d.total_actions ?? mergedStepConfigs.value.length
timeLimitSeconds.value = d.time_limit_seconds ?? null
clearCards()
}
function handleProductProgress(msg) {
const d = msg.data
if (!checkProductId(d.product_id)) return
if (d.elapsed_seconds != null) productElapsed.value = d.elapsed_seconds
if (d.time_status != null) timeOvertime.value = d.time_status === 'timeout'
if (d.actions_done != null) actionsDone.value = d.actions_done
if (d.total_actions != null) totalActions.value = d.total_actions
} }
function handleStepConfirmed(msg) {
function handleStepProgress(msg) {
const d = msg.data const d = msg.data
stepStates[d.step_index] = {status:d.status, anomaly_type:d.anomaly_type||'none'} if (!checkProductId(d.product_id)) return
if (d.end_image_url) cardStates[d.step_index] = {imageUrl:d.end_image_url, imageError:false} if (d.actions_done != null) actionsDone.value = d.actions_done
delete frameProgress[d.step_index] if (d.total_actions != null) totalActions.value = d.total_actions
if (d.next_step_index) { // step_progress 不含 anomaly_type,只更新 status / done / duration_seconds
stepStates[d.next_step_index] = {status:'active', anomaly_type:'none'} if (d.step_index != null) {
delete frameProgress[d.next_step_index] const existing = stepStates[d.step_index]
stepStates[d.step_index] = {
status: d.status,
anomaly_type: existing?.anomaly_type || 'none',
done: d.done,
duration_seconds: d.duration_seconds ?? (existing?.duration_seconds ?? null),
}
} }
} }
let _lastTimeUpdateKey = ''
function handleTimeUpdate(msg) { function handleStepConfirmed(msg) {
const d = msg.data const d = msg.data
// 防重复:同一帧数据可能同时触发 step_progress 和 product_progress,只处理一次 if (!checkProductId(d.product_id)) return
const key = `${msg.type ?? ''}|${d.step_index ?? ''}|${d.consecutive_frames ?? ''}|${d.elapsed ?? ''}` if (d.actions_done != null) actionsDone.value = d.actions_done
if (key === _lastTimeUpdateKey) return if (d.total_actions != null) totalActions.value = d.total_actions
_lastTimeUpdateKey = key if (d.step_index != null) {
stepStates[d.step_index] = {
if (d.elapsed!=null) productElapsed.value = d.elapsed status: d.status,
if (d.time_status!=null) timeOvertime.value = d.time_status==='timeout' || d.time_status==='已超时' anomaly_type: d.anomaly_type || 'none',
if (d.actions_done!=null) actionsDone.value = d.actions_done done: d.done,
if (d.step_index!=null && d.consecutive_frames!=null) { duration_seconds: d.duration_seconds ?? null,
const cur = stepStates[d.step_index]
if (!cur || cur.status === 'pending') {
stepStates[d.step_index] = { status:'active', anomaly_type:'none' }
} }
// 重新读取,避免在 stepStates 被写入后仍使用旧的 cur 引用 if (d.image_url) {
const fresh = stepStates[d.step_index] cardImages[d.step_index] = { imageUrl: d.image_url, imageError: false }
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) { function handleProductEnd(msg) {
const d = msg.data const d = msg.data
productElapsed.value = null; timeOvertime.value = false if (!checkProductId(d.product_id)) return
Object.keys(frameProgress).forEach(k => delete frameProgress[k]) currentProductId = null
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} })} productElapsed.value = null
if (d.step_count_passed!=null) actionsDone.value = d.step_count_passed timeOvertime.value = false
timeLimitSeconds.value = null
if (d.steps) {
d.steps.forEach(s => {
stepStates[s.step_index] = {
status: s.status,
anomaly_type: s.anomaly_type || 'none',
done: s.done,
duration_seconds: s.duration_seconds ?? null,
}
if (s.image_url) {
cardImages[s.step_index] = { imageUrl: s.image_url, imageError: false }
}
})
}
if (d.actions_done != null) actionsDone.value = d.actions_done
if (d.total_actions != null) totalActions.value = d.total_actions
}
/**
* 校验 product_id(文档 15):
* 增量消息 product_id 与当前产品不一致时,不应用且请求快照。
*/
function checkProductId(productId) {
if (productId == null) return true // 无 product_id 则放过(兼容)
if (productId !== currentProductId) {
console.warn(`[WS] product_id 不匹配: 收到 ${productId}, 期望 ${currentProductId},请求快照`)
sendMsg({
type: 'request_snapshot',
request_id: `front-${Date.now()}`,
data: { reason: 'product_mismatch' },
})
return false
}
return true
} }
function onWsError(msg) {
const text = (msg.data && msg.data.message) || msg.message || '连接异常' function handleStatsUpdate(msg) {
Object.assign(stats, msg.data)
}
function handleSystemStatus(msg) {
Object.assign(system, msg.data)
}
function handleError(msg) {
const d = msg.data
const text = (d && d.message) || msg.message || '连接异常'
sysErrorMsg.value = text sysErrorMsg.value = text
console.warn('[WS Error]', text) console.warn('[WS Error]', text)
} }
function onWsSkip(msg) {
const d = msg.data // ── 客户端消息发送 ──
if (d.skipped_steps) { function sendMsg(obj) {
d.skipped_steps.forEach(idx => { if (wsApi) wsApi.send(obj)
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)
} }
// ── WebSocket 生命周期 ──
let wsApi = null let wsApi = null
let wsGeneration = 0 let wsGeneration = 0
function connectWs() { async function connectWs() {
const gen = ++wsGeneration // 防止旧 socket 事件干扰新连接状态 const gen = ++wsGeneration
if (wsApi) wsApi.close() if (wsApi) wsApi.close()
wsApi = connect(props.wsUrl || undefined)
.on('snapshot', msg => { clearTimeout(loadTimeout); handleSnapshot(msg) }) // Mock 模式(仅开发时启用,生产构建时整个分支被 tree-shaking 移除)
.on('product_start', handleProductStart) if (import.meta.env.VITE_USE_MOCK === 'true') {
.on('step_confirmed', handleStepConfirmed) console.log(`[WS] Mock 模式: channel=${props.channel?.channel_id}`)
.on('step_progress', handleTimeUpdate) const { createMockWs } = await import('../mock/index.js')
.on('product_progress', handleTimeUpdate) wsApi = createMockWs(props.channel)
.on('product_end', handleProductEnd) bindWsHandlers(wsApi, gen)
.on('stats_update', msg => Object.assign(stats, msg.data)) return
.on('system_status', msg => Object.assign(system, msg.data)) }
.on('skip_detected', onWsSkip)
.on('error', msg => { if (gen !== wsGeneration) return; onWsError(msg) }) const targetUrl = wsUrl.value
.on('open', () => { if (gen !== wsGeneration) return; wsDisconnected.value = false; wsReconnecting.value = false; sysErrorMsg.value = '' }) if (!targetUrl) {
.on('reconnecting', d => { if (gen !== wsGeneration) return; wsReconnecting.value = true; wsReconnectAttempt.value = d.attempt; wsReconnectMax.value = d.max }) console.warn('[WS] 未配置 WebSocket URL,跳过连接')
.on('close', () => { if (gen !== wsGeneration) return; wsDisconnected.value = true }) return
}
wsApi = connect(targetUrl)
bindWsHandlers(wsApi, gen)
}
function bindWsHandlers(wsApi, gen) {
wsApi
.on('snapshot', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleSnapshot(msg) })
.on('product_start', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleProductStart(msg) })
.on('product_progress', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleProductProgress(msg) })
.on('step_progress', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleStepProgress(msg) })
.on('step_confirmed', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleStepConfirmed(msg) })
.on('product_end', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleProductEnd(msg) })
.on('stats_update', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleStatsUpdate(msg) })
.on('system_status', msg => { if (gen !== wsGeneration) return; if (!validateAndUpdateSeq(msg)) return; handleSystemStatus(msg) })
.on('error', msg => { if (gen !== wsGeneration) return; handleError(msg) })
// command_result / pong 仅记录日志
.on('command_result', msg => { console.log('[WS] command_result', msg.data) })
.on('pong', msg => { /* 心跳响应,无需处理 */ })
.on('open', () => {
if (gen !== wsGeneration) return
wsDisconnected.value = false
wsReconnecting.value = false
sysErrorMsg.value = ''
lastSequence = null // 新连接重置序号,等待 snapshot
})
.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
wsReconnecting.value = false
})
} }
onMounted(() => { onMounted(() => {
loadTimeout = setTimeout(() => { pageLoading.value = false }, 5000) // 从 channel descriptor 初始化静态信息
connectWs() if (props.channel) {
}) if (props.channel.workstation) Object.assign(workstation, props.channel.workstation)
if (props.channel.camera) Object.assign(camera, props.channel.camera)
// wsUrl 变化时重连 }
watch(() => props.wsUrl, () => {
pageLoading.value = true
clearTimeout(loadTimeout)
loadTimeout = setTimeout(() => { pageLoading.value = false }, 5000) loadTimeout = setTimeout(() => { pageLoading.value = false }, 5000)
connectWs() connectWs()
}) })
...@@ -375,9 +732,9 @@ onBeforeUnmount(() => { ...@@ -375,9 +732,9 @@ onBeforeUnmount(() => {
.video-err { color: var(--dim); } .video-err { color: var(--dim); }
.video-err span { font-size: clamp(11px, 0.9vw, 16px); letter-spacing: 1px; } .video-err span { font-size: clamp(11px, 0.9vw, 16px); letter-spacing: 1px; }
/* ===== cards 4×2 ===== */ /* ===== cards: 动态列数 ===== */
.cards-grid { .cards-grid {
flex-shrink: 0; display: grid; grid-template-columns: repeat(8, 1fr); flex-shrink: 0; display: grid;
gap: clamp(2px, 0.25vw, 4px); gap: clamp(2px, 0.25vw, 4px);
} }
.action-card { .action-card {
...@@ -387,13 +744,13 @@ onBeforeUnmount(() => { ...@@ -387,13 +744,13 @@ onBeforeUnmount(() => {
} }
.action-card.c-a { border-color: rgba(245,158,11,.5); } .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-d { border-color: rgba(52,211,153,.35); }
.action-card.c-c { border-color: rgba(245,158,11,.35); }
.action-card.c-f { border-color: rgba(248,113,113,.45); } .action-card.c-f { border-color: rgba(248,113,113,.45); }
.card-img { .card-img {
flex: 1; background: #000; display: flex; align-items: center; justify-content: center; flex: 1; background: #000; display: flex; align-items: center; justify-content: center;
overflow: hidden; min-height: 0; overflow: hidden; min-height: 0;
} }
.card-img img { width: 100%; height: 100%; object-fit: cover; display: block; } .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); } .nopic { font-size: clamp(9px, 0.65vw, 12px); color: rgba(255,255,255,.15); }
.card-foot { .card-foot {
display: flex; align-items: center; gap: clamp(2px, 0.15vw, 3px); display: flex; align-items: center; gap: clamp(2px, 0.15vw, 3px);
...@@ -442,13 +799,15 @@ onBeforeUnmount(() => { ...@@ -442,13 +799,15 @@ onBeforeUnmount(() => {
font-size: clamp(10px, 0.75vw, 13px); font-size: clamp(10px, 0.75vw, 13px);
} }
.t-body { flex: 1; display: flex; flex-direction: column; } .t-body { flex: 1; display: flex; flex-direction: column; }
.t-duration { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--tx); }
.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; } .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); } .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); } .skip-icon { font-style: normal; color: var(--wn); margin-left: 2px; font-size: clamp(8px, 0.6vw, 11px); }
.ai-icon { font-style: normal; margin-left: 2px; font-size: clamp(8px, 0.6vw, 11px); }
.badge-w { background: rgba(90,112,148,.15); color: var(--dim); } .badge-w { background: rgba(90,112,148,.15); color: var(--dim); }
.badge-a { background: rgba(245,158,11,.18); color: var(--wn); } .badge-a { background: rgba(245,158,11,.18); color: var(--wn); }
.badge-p { background: rgba(52,211,153,.18); color: var(--ok); } .badge-p { background: rgba(52,211,153,.18); color: var(--ok); }
.badge-c { background: rgba(245,158,11,.18); color: var(--wn); }
.badge-f { background: rgba(248,113,113,.18); color: var(--er); } .badge-f { background: rgba(248,113,113,.18); color: var(--er); }
/* progress + stats column */ /* progress + stats column */
......
/**
* Mock 通道列表数据,对应 GET /api/v1/channels 响应
*/
export const mockChannelsResponse = {
api_version: '1.0',
server_instance_id: 'mock-srv-001',
generated_at: new Date().toISOString(),
channels: [
{
channel_id: 'channel-a',
display_name: 'A区B线A工位',
order: 1,
enabled: true,
status: 'online',
workstation: {
station_id: 'ARBE-A',
name: 'A区B线A工位',
area: 'ARBE',
line: 'A',
},
camera: {
camera_id: 'CAM-A',
display_name: 'A工位主摄像头',
connected: true,
},
urls: {
state: '/api/v1/channels/channel-a/state',
websocket: '/api/v1/ws/channels/channel-a',
video: '/api/v1/cameras/CAM-A/stream.mjpeg',
health: '/api/v1/channels/channel-a/health',
},
},
{
channel_id: 'channel-b',
display_name: 'A区B线B工位',
order: 2,
enabled: true,
status: 'online',
workstation: {
station_id: 'ARBE-B',
name: 'A区B线B工位',
area: 'ARBE',
line: 'B',
},
camera: {
camera_id: 'CAM-B',
display_name: 'B工位主摄像头',
connected: true,
},
urls: {
state: '/api/v1/channels/channel-b/state',
websocket: '/api/v1/ws/channels/channel-b',
video: '/api/v1/cameras/CAM-B/stream.mjpeg',
health: '/api/v1/channels/channel-b/health',
},
},
],
}
export { mockChannelsResponse } from './channels.js'
export { createMockWs } from './ws-mock.js'
/**
* Mock WebSocket —— 模拟后端 WebSocket 协议 v1.0
*
* 模拟完整的产品周期:
* 连接 → snapshot → system_status → product_start → step_progress/step_confirmed
* → product_end → stats_update → 循环
*/
// ── 固定工序配置 ──
const STEP_CONFIGS = [
{ step_index: 1, display_order: 1, step_code: 'STEP1', step_name: '看号码', target_duration_seconds: 0.45 },
{ step_index: 2, display_order: 2, step_code: 'STEP2', step_name: '摸内里', target_duration_seconds: 0.50 },
{ step_index: 3, display_order: 3, step_code: 'STEP3', step_name: '看内侧', target_duration_seconds: 0.40 },
{ step_index: 4, display_order: 4, step_code: 'STEP4', step_name: '看外侧', target_duration_seconds: 0.40 },
{ step_index: 5, display_order: 5, step_code: 'STEP5', step_name: '看后跟高低', target_duration_seconds: 0.55 },
{ step_index: 6, display_order: 6, step_code: 'STEP6', step_name: '比鞋头大小', target_duration_seconds: 0.50 },
{ step_index: 7, display_order: 7, step_code: 'STEP7', step_name: '看大底', target_duration_seconds: 0.45 },
{ step_index: 8, display_order: 8, step_code: 'STEP8', step_name: '查开胶', target_duration_seconds: 0.60 },
]
// ── 初始统计 ──
const INITIAL_STATS = {
total_products: 255,
passed_products: 250,
failed_products: 5,
pass_rate: 0.9804,
avg_duration_seconds: 24.6,
operation_compliant_products: 252,
time_compliant_products: 248,
skip_missing_count: 3,
ai_misrecognition_count: 12,
}
function nowISO() {
const d = new Date()
const off = -d.getTimezoneOffset()
const sign = off >= 0 ? '+' : '-'
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0')
return (
d.getFullYear() + '-' +
pad(d.getMonth() + 1) + '-' +
pad(d.getDate()) + 'T' +
pad(d.getHours()) + ':' +
pad(d.getMinutes()) + ':' +
pad(d.getSeconds()) + '.' +
String(d.getMilliseconds()).padStart(3, '0') +
sign + pad(off / 60) + ':' + pad(off % 60)
)
}
/**
* 创建 Mock WebSocket 实例
*
* @param {Object} channel — 通道描述符(来自通道列表)
* @returns {Object} 与 ws/index.js connect() 相同的 API
*/
export function createMockWs(channel) {
const ch = channel || {}
const channelId = ch.channel_id || 'channel-a'
const stationId = ch.workstation?.station_id || 'ARBE-A'
const cameraId = ch.camera?.camera_id || 'CAM-A'
const handlers = {}
let sequence = 0
let productSeq = 0
let connected = false
let closed = false
const timers = []
function nextSeq() {
return ++sequence
}
function envelope(type, data) {
return {
api_version: '1.0',
type,
server_instance_id: 'mock-srv-001',
channel_id: channelId,
station_id: stationId,
camera_id: cameraId,
sequence: nextSeq(),
timestamp: nowISO(),
data,
}
}
/** 按 msg.type 分发(与 ws/index.js 行为一致) */
function dispatch(msg) {
const type = msg && msg.type
if (type && handlers[type]) {
fireByKey(type, msg)
}
fireByKey('*', msg)
}
function fireByKey(key, msg) {
const fns = handlers[key] || []
fns.forEach(fn => {
try { fn(msg) } catch {}
})
}
// ── 构建 snapshot ──
function buildSnapshot() {
return envelope('snapshot', {
channel: {
channel_id: channelId,
display_name: ch.display_name || 'Mock 工位',
enabled: true,
status: 'online',
},
workstation: {
station_id: stationId,
name: ch.workstation?.name || 'Mock 工位',
area: ch.workstation?.area || 'AREA',
line: ch.workstation?.line || 'X',
},
camera: {
camera_id: cameraId,
display_name: ch.camera?.display_name || '主摄像头',
},
operator: {
operator_id: '4827',
name: '张三',
},
batch: {
batch_id: 'C20260709-035',
product_type: 'AUREL-AZ',
series: 'BAR',
started_at: '2026-07-21T08:00:00+08:00',
},
step_configs: STEP_CONFIGS,
current_product: null,
last_product: null,
stats: { ...INITIAL_STATS },
system: {
status: 'online',
camera_connected: true,
model_loaded: true,
capture_fps: 30.0,
inference_fps: 8.2,
inference_ms: 42.6,
queue_delay_ms: 3.4,
dropped_frames: 128,
model_version: 'shoes-v1.engine',
last_frame_at: nowISO(),
last_error: null,
},
})
}
// ── 产品周期模拟 ──
function runProductCycle() {
if (closed || !connected) return
const pid = `prod-mock-${++productSeq}`
const startTime = nowISO()
const TIME_LIMIT = 24.0
const STEP_COUNT = STEP_CONFIGS.length
// product_start
dispatch(envelope('product_start', {
product_id: pid,
started_at: startTime,
elapsed_seconds: 0.0,
time_limit_seconds: TIME_LIMIT,
time_status: 'normal',
actions_done: 0,
total_actions: STEP_COUNT,
}))
// 逐个步骤推进
let stepIdx = 0
let elapsed = 0
const stepInterval = setInterval(() => {
if (closed || !connected) {
clearInterval(stepInterval)
return
}
const step = STEP_CONFIGS[stepIdx]
const targetDur = step.target_duration_seconds
elapsed += targetDur
const isLast = stepIdx === STEP_COUNT - 1
// step_progress(不含 anomaly_type)
dispatch(envelope('step_progress', {
product_id: pid,
step_index: step.step_index,
status: 'active',
done: true,
duration_seconds: targetDur,
target_duration_seconds: targetDur,
actions_done: stepIdx + 1,
total_actions: STEP_COUNT,
}))
// step_confirmed
dispatch(envelope('step_confirmed', {
product_id: pid,
step_index: step.step_index,
display_order: step.display_order,
step_code: step.step_code,
step_name: step.step_name,
status: 'pass',
anomaly_type: 'none',
done: true,
duration_seconds: targetDur,
target_duration_seconds: targetDur,
completed_at: nowISO(),
actions_done: stepIdx + 1,
total_actions: STEP_COUNT,
image_url: null,
}))
// product_progress
const timeStatus = elapsed > TIME_LIMIT ? 'timeout' : 'normal'
dispatch(envelope('product_progress', {
product_id: pid,
elapsed_seconds: elapsed,
time_limit_seconds: TIME_LIMIT,
time_status: timeStatus,
actions_done: stepIdx + 1,
total_actions: STEP_COUNT,
}))
stepIdx++
if (isLast) {
clearInterval(stepInterval)
// product_end
dispatch(envelope('product_end', {
product_id: pid,
ended_at: nowISO(),
duration_seconds: elapsed,
time_limit_seconds: TIME_LIMIT,
time_status: 'normal',
end_type: 'flow_complete',
result: 'pass',
actions_done: STEP_COUNT,
total_actions: STEP_COUNT,
steps: STEP_CONFIGS.map(s => ({
step_index: s.step_index,
display_order: s.display_order,
step_code: s.step_code,
step_name: s.step_name,
status: 'pass',
anomaly_type: 'none',
done: true,
duration_seconds: s.target_duration_seconds,
target_duration_seconds: s.target_duration_seconds,
completed_at: nowISO(),
best_confidence: 0.93,
image_url: null,
})),
}))
// stats_update
const newStats = { ...INITIAL_STATS, total_products: INITIAL_STATS.total_products + productSeq }
dispatch(envelope('stats_update', newStats))
// 3 秒后开始下一双
timers.push(setTimeout(() => runProductCycle(), 3000))
}
}, 1200) // 每步间隔 1.2s
}
// ── system_status 定时推送(1Hz)──
let sysTimer = null
function startSystemStatus() {
sysTimer = setInterval(() => {
if (closed || !connected) return
dispatch(envelope('system_status', {
status: 'online',
camera_connected: true,
model_loaded: true,
capture_fps: 30.0,
inference_fps: 7.5 + Math.random() * 2,
inference_ms: 40 + Math.random() * 10,
queue_delay_ms: 2 + Math.random() * 5,
dropped_frames: 128,
model_version: 'shoes-v1.engine',
last_frame_at: nowISO(),
last_error: null,
}))
}, 1000)
}
// ── 对外 API ──
const api = {
on(type, fn) {
if (!handlers[type]) handlers[type] = []
handlers[type].push(fn)
return api
},
off(type, fn) {
if (!handlers[type]) return api
handlers[type] = handlers[type].filter(f => f !== fn)
return api
},
send(obj) {
if (closed) return
const t = obj?.type
if (t === 'ping') {
// 响应 pong
const pong = envelope('pong', {
request_id: obj.request_id || '',
server_time: nowISO(),
})
// 延迟一点点响应
timers.push(setTimeout(() => {
dispatch(pong)
}, 50))
} else if (t === 'request_snapshot') {
timers.push(setTimeout(() => {
const snap = buildSnapshot()
snap.sequence = nextSeq()
snap.timestamp = nowISO()
dispatch(snap)
}, 100))
} else if (t === 'reset_product') {
dispatch(envelope('command_result', {
request_id: obj.request_id || '',
command: 'reset_product',
accepted: true,
message: '复位请求已接受',
}))
// 模拟 product_end
dispatch(envelope('product_end', {
product_id: obj.data?.product_id || `prod-mock-${productSeq}`,
ended_at: nowISO(),
duration_seconds: 0,
time_limit_seconds: 24.0,
time_status: 'normal',
end_type: 'manual_reset',
result: null,
actions_done: 0,
total_actions: STEP_CONFIGS.length,
steps: [],
}))
}
},
close() {
closed = true
connected = false
timers.forEach(t => clearTimeout(t))
timers.forEach(t => clearInterval(t))
timers.length = 0
if (sysTimer) clearInterval(sysTimer)
},
}
// ── 启动模拟 ──
// 模拟异步连接
const connectTimer = setTimeout(() => {
connected = true
fireByKey('open', {})
// 先发 snapshot
dispatch(buildSnapshot())
// 启动 system_status
startSystemStatus()
// 1.5 秒后开始产品循环
timers.push(setTimeout(() => runProductCycle(), 1500))
}, 300)
timers.push(connectTimer)
return api
}
/** /**
* WebSocket 客户端 * WebSocket 客户端
* *
* 连接 ws://127.0.0.1:8080/ws 接收实时推送。 * 对应后端多通道前端接口文档 v1.0。
* URL 由调用方从通道列表的 urls.websocket 获取并通过 resolveWsUrl() 解析后传入。
* *
* 用法: * 用法:
* import { connect } from './ws/index.js' * import { connect } from './ws/index.js'
* const ws = connect() * const ws = connect('ws://127.0.0.1:8080/api/v1/ws/channels/channel-a')
* ws.on('snapshot', data => { ... }) * ws.on('snapshot', msg => { ... })
* ws.on('step_progress', data => { ... }) * ws.on('step_confirmed', msg => { ... })
* ws.send({ type: 'ping' }) * ws.send({ type: 'ping', request_id: 'front-001', data: { client_time: '...' } })
* ws.send({ type: 'reset_product', reason: 'operator_request' }) * ws.send({ type: 'request_snapshot', request_id: 'front-002', data: { reason: 'sequence_gap' } })
*
* 环境变量:
* VITE_WS_URL — WebSocket 地址,默认 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 = 无限重连
let _pingSeq = 0
export function connect(url) { export function connect(url) {
const wsUrl = url || import.meta.env.VITE_WS_URL || 'ws://127.0.0.1:8080/ws' const wsUrl = url || ''
const handlers = {} const handlers = {}
...@@ -59,13 +59,39 @@ export function connect(url) { ...@@ -59,13 +59,39 @@ export function connect(url) {
} }
function createSocket() { function createSocket() {
if (!wsUrl) {
console.warn('[WS] URL 为空,跳过连接')
return
}
socket = new WebSocket(wsUrl) socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectCount = 0 reconnectCount = 0
fire('open', {}) fire('open', {})
// 心跳:按文档格式发送 ping,建议间隔 10 秒
pingTimer = setInterval(() => { pingTimer = setInterval(() => {
api.send({ type: 'ping' }) const reqId = `ping-${++_pingSeq}-${Date.now()}`
// 本地时区 ISO 8601,如 2026-07-21T15:30:00.000+08:00
const now = new Date()
const off = -now.getTimezoneOffset()
const sign = off >= 0 ? '+' : '-'
const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0')
const localISO =
now.getFullYear() + '-' +
pad(now.getMonth() + 1) + '-' +
pad(now.getDate()) + 'T' +
pad(now.getHours()) + ':' +
pad(now.getMinutes()) + ':' +
pad(now.getSeconds()) + '.' +
String(now.getMilliseconds()).padStart(3, '0') +
sign + pad(off / 60) + ':' + pad(off % 60)
api.send({
type: 'ping',
request_id: reqId,
data: {
client_time: localISO,
},
})
}, PING_INTERVAL) }, PING_INTERVAL)
}) })
......
...@@ -4,4 +4,12 @@ import vue from '@vitejs/plugin-vue' ...@@ -4,4 +4,12 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080', // 后端地址,按实际情况修改
changeOrigin: true,
},
},
},
}) })
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