Add realtime gateway and simulator bridge

This commit is contained in:
2026-03-27 21:06:17 +08:00
parent 0703fd47a2
commit 2c0fd4c549
36 changed files with 6852 additions and 1 deletions

View File

@@ -73,6 +73,66 @@
</label>
</section>
<section class="group">
<div class="group__title">新网关桥接</div>
<div id="gatewayBridgeStatus" class="group__status">未启用</div>
<div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
<div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
<label class="field">
<span>桥接预设</span>
<select id="gatewayBridgePresetSelect">
<option value="">选择预设</option>
</select>
</label>
<label class="field">
<span>预设名称</span>
<input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
</label>
<div class="row">
<button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
<button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
</div>
<div class="row">
<button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
</div>
<label class="field field--check">
<input id="gatewayBridgeEnabledInput" type="checkbox">
<span>启用新网关桥接</span>
</label>
<label class="field">
<span>网关地址</span>
<input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
</label>
<label class="field">
<span>Producer Token / Channel Token</span>
<input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
</label>
<label class="field">
<span>Channel ID</span>
<input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
</label>
<label class="field">
<span>目标 Device ID</span>
<input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
</label>
<label class="field">
<span>目标 Group ID</span>
<input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
</label>
<label class="field">
<span>Source ID</span>
<input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
</label>
<label class="field">
<span>Source Mode</span>
<input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
</label>
<div class="row">
<button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
<button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
</div>
</section>
<section class="group">
<div class="group__title">心率模拟</div>
<div id="heartRateStatus" class="group__status">心率模拟待命</div>

View File

@@ -4,6 +4,13 @@
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
const WS_URL = `ws://${location.hostname}:17865/mock-gps`
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
const LEGACY_GATEWAY_BRIDGE_URLS = new Set([
'ws://127.0.0.1:8080/ws',
'ws://localhost:8080/ws',
])
const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
const map = L.map('map').setView(DEFAULT_CENTER, 16)
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
@@ -52,6 +59,13 @@
heartRateSampleStartedAt: 0,
loadedCourse: null,
resourceLoading: false,
bridgeEnabled: false,
bridgeConnected: false,
bridgeAuthenticated: false,
bridgeTargetText: '--',
bridgeLastStatusText: '--',
bridgeConfigSaving: false,
bridgePresets: [],
}
const elements = {
@@ -70,6 +84,24 @@
courseJumpList: document.getElementById('courseJumpList'),
realtimeStatus: document.getElementById('realtimeStatus'),
lastSendStatus: document.getElementById('lastSendStatus'),
gatewayBridgeStatus: document.getElementById('gatewayBridgeStatus'),
gatewayBridgeTarget: document.getElementById('gatewayBridgeTarget'),
gatewayBridgeLast: document.getElementById('gatewayBridgeLast'),
gatewayBridgePresetSelect: document.getElementById('gatewayBridgePresetSelect'),
gatewayBridgePresetNameInput: document.getElementById('gatewayBridgePresetNameInput'),
applyGatewayBridgePresetBtn: document.getElementById('applyGatewayBridgePresetBtn'),
saveGatewayBridgePresetBtn: document.getElementById('saveGatewayBridgePresetBtn'),
deleteGatewayBridgePresetBtn: document.getElementById('deleteGatewayBridgePresetBtn'),
gatewayBridgeEnabledInput: document.getElementById('gatewayBridgeEnabledInput'),
gatewayBridgeUrlInput: document.getElementById('gatewayBridgeUrlInput'),
gatewayBridgeTokenInput: document.getElementById('gatewayBridgeTokenInput'),
gatewayBridgeChannelIdInput: document.getElementById('gatewayBridgeChannelIdInput'),
gatewayBridgeDeviceIdInput: document.getElementById('gatewayBridgeDeviceIdInput'),
gatewayBridgeGroupIdInput: document.getElementById('gatewayBridgeGroupIdInput'),
gatewayBridgeSourceIdInput: document.getElementById('gatewayBridgeSourceIdInput'),
gatewayBridgeSourceModeInput: document.getElementById('gatewayBridgeSourceModeInput'),
applyGatewayBridgeConfigBtn: document.getElementById('applyGatewayBridgeConfigBtn'),
reloadGatewayBridgeConfigBtn: document.getElementById('reloadGatewayBridgeConfigBtn'),
playbackStatus: document.getElementById('playbackStatus'),
heartRateStatus: document.getElementById('heartRateStatus'),
lastHeartRateStatus: document.getElementById('lastHeartRateStatus'),
@@ -190,6 +222,23 @@
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
elements.resourceDetail.textContent = state.lastResourceDetailText
elements.gatewayBridgeTarget.textContent = `目标设备: ${state.bridgeTargetText}`
elements.gatewayBridgeLast.textContent = `最近状态: ${state.bridgeLastStatusText}`
elements.applyGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.reloadGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.applyGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
elements.saveGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving
elements.deleteGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
if (!state.bridgeEnabled) {
elements.gatewayBridgeStatus.textContent = '未启用'
} else if (state.bridgeConnected && state.bridgeAuthenticated) {
elements.gatewayBridgeStatus.textContent = '已连接并已认证'
} else if (state.bridgeConnected) {
elements.gatewayBridgeStatus.textContent = '已连接,等待认证'
} else {
elements.gatewayBridgeStatus.textContent = '已启用,未连接'
}
if (state.connected && state.streaming) {
elements.realtimeStatus.textContent = `桥接已连接,正在以 ${elements.hzSelect.value} Hz 连续发送`
@@ -224,6 +273,204 @@
}
}
function bridgeConfigFromServerPayload(payload) {
const config = payload && payload.config ? payload.config : {}
return {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
}
}
function normalizeGatewayBridgeUrl(value) {
const next = String(value || '').trim()
if (!next) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
if (LEGACY_GATEWAY_BRIDGE_URLS.has(next)) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
return next
}
function getBridgeConfigDraft() {
try {
const raw = window.localStorage.getItem(BRIDGE_CONFIG_STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return {
enabled: Boolean(parsed.enabled),
url: normalizeGatewayBridgeUrl(typeof parsed.url === 'string' ? parsed.url : ''),
token: typeof parsed.token === 'string' ? parsed.token : '',
channelId: typeof parsed.channelId === 'string' ? parsed.channelId : '',
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : '',
groupId: typeof parsed.groupId === 'string' ? parsed.groupId : '',
sourceId: typeof parsed.sourceId === 'string' ? parsed.sourceId : '',
sourceMode: typeof parsed.sourceMode === 'string' ? parsed.sourceMode : 'mock',
}
} catch (_error) {
return null
}
}
function loadBridgePresets() {
try {
const raw = window.localStorage.getItem(BRIDGE_PRESETS_STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((item) => {
const config = item && item.config ? item.config : {}
return {
name: item && typeof item.name === 'string' ? item.name.trim() : '',
config: {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
},
}
})
.filter((item) => item.name)
} catch (_error) {
return []
}
}
function saveBridgePresets() {
try {
window.localStorage.setItem(BRIDGE_PRESETS_STORAGE_KEY, JSON.stringify(state.bridgePresets))
} catch (_error) {
// noop
}
}
function renderBridgePresetOptions(selectedName) {
const currentValue = typeof selectedName === 'string'
? selectedName
: elements.gatewayBridgePresetSelect.value
elements.gatewayBridgePresetSelect.innerHTML = '<option value="">选择预设</option>'
state.bridgePresets.forEach((preset) => {
const option = document.createElement('option')
option.value = preset.name
option.textContent = preset.name
if (preset.name === currentValue) {
option.selected = true
}
elements.gatewayBridgePresetSelect.appendChild(option)
})
}
function saveBridgeConfigDraft(config) {
try {
window.localStorage.setItem(BRIDGE_CONFIG_STORAGE_KEY, JSON.stringify({
...config,
url: normalizeGatewayBridgeUrl(config && config.url),
}))
} catch (_error) {
// noop
}
}
function fillBridgeConfigForm(config) {
elements.gatewayBridgeEnabledInput.checked = Boolean(config.enabled)
elements.gatewayBridgeUrlInput.value = normalizeGatewayBridgeUrl(config && config.url)
elements.gatewayBridgeTokenInput.value = config.token || ''
elements.gatewayBridgeChannelIdInput.value = config.channelId || ''
elements.gatewayBridgeDeviceIdInput.value = config.deviceId || ''
elements.gatewayBridgeGroupIdInput.value = config.groupId || ''
elements.gatewayBridgeSourceIdInput.value = config.sourceId || ''
elements.gatewayBridgeSourceModeInput.value = config.sourceMode || 'mock'
}
function readBridgeConfigForm() {
return {
enabled: elements.gatewayBridgeEnabledInput.checked,
url: normalizeGatewayBridgeUrl(elements.gatewayBridgeUrlInput.value),
token: String(elements.gatewayBridgeTokenInput.value || '').trim(),
channelId: String(elements.gatewayBridgeChannelIdInput.value || '').trim(),
deviceId: String(elements.gatewayBridgeDeviceIdInput.value || '').trim(),
groupId: String(elements.gatewayBridgeGroupIdInput.value || '').trim(),
sourceId: String(elements.gatewayBridgeSourceIdInput.value || '').trim(),
sourceMode: String(elements.gatewayBridgeSourceModeInput.value || '').trim() || 'mock',
}
}
function selectedBridgePreset() {
const name = String(elements.gatewayBridgePresetSelect.value || '').trim()
if (!name) {
return null
}
return state.bridgePresets.find((item) => item.name === name) || null
}
function applyBridgePresetToForm() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
fillBridgeConfigForm(preset.config)
elements.gatewayBridgePresetNameInput.value = preset.name
saveBridgeConfigDraft(preset.config)
updateUiState()
log(`已载入桥接预设: ${preset.name}`)
}
function saveCurrentBridgePreset() {
const name = String(elements.gatewayBridgePresetNameInput.value || '').trim()
if (!name) {
log('请先输入预设名称')
return
}
const config = readBridgeConfigForm()
const nextPreset = { name, config }
const existingIndex = state.bridgePresets.findIndex((item) => item.name === name)
if (existingIndex >= 0) {
state.bridgePresets.splice(existingIndex, 1, nextPreset)
} else {
state.bridgePresets.push(nextPreset)
state.bridgePresets.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}
saveBridgePresets()
renderBridgePresetOptions(name)
log(`已保存桥接预设: ${name}`)
updateUiState()
}
function deleteSelectedBridgePreset() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
state.bridgePresets = state.bridgePresets.filter((item) => item.name !== preset.name)
saveBridgePresets()
renderBridgePresetOptions('')
if (elements.gatewayBridgePresetNameInput.value.trim() === preset.name) {
elements.gatewayBridgePresetNameInput.value = ''
}
log(`已删除桥接预设: ${preset.name}`)
updateUiState()
}
function connectSocket() {
if (state.socket && (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING)) {
return
@@ -265,6 +512,100 @@
})
}
async function refreshGatewayBridgeStatus() {
try {
const response = await fetch('/bridge-status', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const status = await response.json()
state.bridgeEnabled = Boolean(status.enabled)
state.bridgeConnected = Boolean(status.connected)
state.bridgeAuthenticated = Boolean(status.authenticated)
if (status.channelId) {
state.bridgeTargetText = `${status.channelId}${status.deviceId ? ` / ${status.deviceId}` : ''}${status.groupId ? ` / ${status.groupId}` : ''}`
} else {
state.bridgeTargetText = status.deviceId
? `${status.deviceId}${status.groupId ? ` / ${status.groupId}` : ''}`
: '--'
}
state.bridgeLastStatusText = status.lastError
? `错误: ${status.lastError}`
: status.lastSentAt
? `${status.lastSentTopic || 'unknown'} @ ${formatClockTime(status.lastSentAt)}`
: '待命'
updateUiState()
} catch (_error) {
state.bridgeEnabled = false
state.bridgeConnected = false
state.bridgeAuthenticated = false
state.bridgeTargetText = '--'
state.bridgeLastStatusText = '状态读取失败'
updateUiState()
}
}
async function loadGatewayBridgeConfig(options) {
const preserveForm = Boolean(options && options.preserveForm)
const response = await fetch('/bridge-config', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`桥接配置读取失败: HTTP ${response.status}`)
}
const payload = await response.json()
if (!preserveForm) {
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
}
return payload
}
async function applyGatewayBridgeConfig() {
const config = readBridgeConfigForm()
if (!config.url) {
log('桥接配置缺少网关地址')
return
}
if (!config.deviceId) {
log('桥接配置缺少目标 Device ID')
return
}
if (!config.sourceId) {
log('桥接配置缺少 Source ID')
return
}
state.bridgeConfigSaving = true
updateUiState()
try {
const response = await fetch('/bridge-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : `HTTP ${response.status}`)
}
saveBridgeConfigDraft(config)
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
await refreshGatewayBridgeStatus()
log(`已应用新网关桥接配置 -> ${config.deviceId}`)
} catch (error) {
log(error && error.message ? error.message : '桥接配置应用失败')
} finally {
state.bridgeConfigSaving = false
updateUiState()
}
}
function proxyUrl(targetUrl) {
return `${PROXY_BASE_URL}${encodeURIComponent(targetUrl)}`
}
@@ -1257,6 +1598,24 @@
})
elements.connectBtn.addEventListener('click', connectSocket)
elements.applyGatewayBridgePresetBtn.addEventListener('click', applyBridgePresetToForm)
elements.saveGatewayBridgePresetBtn.addEventListener('click', saveCurrentBridgePreset)
elements.deleteGatewayBridgePresetBtn.addEventListener('click', deleteSelectedBridgePreset)
elements.gatewayBridgePresetSelect.addEventListener('change', () => {
const preset = selectedBridgePreset()
elements.gatewayBridgePresetNameInput.value = preset ? preset.name : ''
updateUiState()
})
elements.applyGatewayBridgeConfigBtn.addEventListener('click', applyGatewayBridgeConfig)
elements.reloadGatewayBridgeConfigBtn.addEventListener('click', async () => {
try {
await loadGatewayBridgeConfig()
await refreshGatewayBridgeStatus()
log('已重新读取桥接配置')
} catch (error) {
log(error && error.message ? error.message : '桥接配置读取失败')
}
})
elements.importTrackBtn.addEventListener('click', () => {
elements.trackFileInput.click()
})
@@ -1335,6 +1694,25 @@
updateReadout()
setSocketBadge(false)
setResourceStatus('支持直接载入 game.json也支持单独填瓦片模板和 KML 地址。', null)
state.bridgePresets = loadBridgePresets()
renderBridgePresetOptions('')
updateUiState()
const draftBridgeConfig = getBridgeConfigDraft()
if (draftBridgeConfig) {
fillBridgeConfigForm(draftBridgeConfig)
}
loadGatewayBridgeConfig({ preserveForm: Boolean(draftBridgeConfig) })
.then(async () => {
if (draftBridgeConfig) {
log('已恢复上次桥接配置草稿,可直接点“应用桥接配置”')
}
await refreshGatewayBridgeStatus()
})
.catch((error) => {
log(error && error.message ? error.message : '桥接配置读取失败')
refreshGatewayBridgeStatus()
})
refreshGatewayBridgeStatus()
window.setInterval(refreshGatewayBridgeStatus, 3000)
connectSocket()
})()