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>
This diff is collapsed.
/**
* 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'
This diff is collapsed.
/** /**
* 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