(function () {
  const DEFAULT_CENTER = [31.2304, 121.4737]
  const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
  const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
  const PROXY_BASE_URL = `${location.origin}/proxy?url=`

  function createSameOriginWsUrl(pathname) {
    const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
    const normalizedPath = String(pathname || '').startsWith('/') ? pathname : `/${pathname}`
    return `${wsProtocol}//${location.host}${normalizedPath}`
  }

  const GPS_WS_URL = createSameOriginWsUrl('/mock-gps')
  const HEART_RATE_WS_URL = createSameOriginWsUrl('/mock-hr')
  const DEBUG_LOG_WS_URL = createSameOriginWsUrl('/debug-log')
  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 SIM_CHANNEL_STORAGE_KEY = 'mock-gps-sim.channel-id'
  const MAX_DEBUG_LOG_LINES = 400

  function normalizeSimChannelId(rawValue) {
    const trimmed = String(rawValue || '').trim()
    return trimmed || 'default'
  }

  const map = L.map('map').setView(DEFAULT_CENTER, 16)
  let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
    maxZoom: 20,
    attribution: '&copy; OpenStreetMap',
  }).addTo(map)

  const liveMarker = L.circleMarker(DEFAULT_CENTER, {
    radius: 11,
    color: '#ffffff',
    weight: 3,
    fillColor: '#ff2f92',
    fillOpacity: 0.94,
  }).addTo(map)

  const pathLine = L.polyline([], {
    color: '#0ea5a4',
    weight: 4,
    opacity: 0.9,
  }).addTo(map)

  const courseLayer = L.layerGroup().addTo(map)
  const pathMarkers = []
  const pathPoints = []
  const state = {
    socket: null,
    heartRateSocket: null,
    debugSocket: null,
    connected: false,
    heartRateConnected: false,
    debugConnected: false,
    socketConnecting: false,
    heartRateSocketConnecting: false,
    debugSocketConnecting: false,
    streaming: false,
    heartRateStreaming: false,
    heartRateSampleMode: false,
    pathEditMode: false,
    playbackRunning: false,
    playbackTimer: 0,
    streamTimer: 0,
    heartRateStreamTimer: 0,
    lastSentText: '--',
    lastHeartRateSentText: '--',
    lastResourceDetailText: '尚未载入资源',
    lastTrackSourceText: '路径待命',
    simChannelId: 'default',
    currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
    headingDeg: 0,
    currentSegmentIndex: 0,
    currentSegmentProgress: 0,
    lastPlaybackAt: 0,
    heartRateSampleStartedAt: 0,
    loadedCourse: null,
    resourceLoading: false,
    bridgeEnabled: false,
    bridgeConnected: false,
    bridgeAuthenticated: false,
    bridgeTargetText: '--',
    bridgeLastStatusText: '--',
    bridgeConfigSaving: false,
    bridgePresets: [],
    debugLogEntries: [],
    debugLogScopeFilter: 'all',
    debugLogPanelMinimized: false,
  }

  const elements = {
    socketStatus: document.getElementById('socketStatus'),
    configUrlInput: document.getElementById('configUrlInput'),
    loadConfigBtn: document.getElementById('loadConfigBtn'),
    fitCourseBtn: document.getElementById('fitCourseBtn'),
    tileUrlInput: document.getElementById('tileUrlInput'),
    applyTilesBtn: document.getElementById('applyTilesBtn'),
    resetTilesBtn: document.getElementById('resetTilesBtn'),
    courseUrlInput: document.getElementById('courseUrlInput'),
    loadCourseBtn: document.getElementById('loadCourseBtn'),
    clearCourseBtn: document.getElementById('clearCourseBtn'),
    resourceStatus: document.getElementById('resourceStatus'),
    resourceDetail: document.getElementById('resourceDetail'),
    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'),
    sendHeartRateOnceBtn: document.getElementById('sendHeartRateOnceBtn'),
    startHeartRateStreamBtn: document.getElementById('startHeartRateStreamBtn'),
    stopHeartRateStreamBtn: document.getElementById('stopHeartRateStreamBtn'),
    applyHeartRatePresetBtn: document.getElementById('applyHeartRatePresetBtn'),
    toggleHeartRateSampleBtn: document.getElementById('toggleHeartRateSampleBtn'),
    heartRateInput: document.getElementById('heartRateInput'),
    heartRateHzSelect: document.getElementById('heartRateHzSelect'),
    heartRateSampleTemplateSelect: document.getElementById('heartRateSampleTemplateSelect'),
    trackFileInput: document.getElementById('trackFileInput'),
    importTrackBtn: document.getElementById('importTrackBtn'),
    connectBtn: document.getElementById('connectBtn'),
    simChannelIdInput: document.getElementById('simChannelIdInput'),
    sendOnceBtn: document.getElementById('sendOnceBtn'),
    streamBtn: document.getElementById('streamBtn'),
    stopStreamBtn: document.getElementById('stopStreamBtn'),
    togglePathModeBtn: document.getElementById('togglePathModeBtn'),
    clearPathBtn: document.getElementById('clearPathBtn'),
    fitPathBtn: document.getElementById('fitPathBtn'),
    playPathBtn: document.getElementById('playPathBtn'),
    pausePathBtn: document.getElementById('pausePathBtn'),
    hzSelect: document.getElementById('hzSelect'),
    accuracyInput: document.getElementById('accuracyInput'),
    speedInput: document.getElementById('speedInput'),
    loopPathInput: document.getElementById('loopPathInput'),
    pathHint: document.getElementById('pathHint'),
    latText: document.getElementById('latText'),
    lonText: document.getElementById('lonText'),
    headingText: document.getElementById('headingText'),
    pathCountText: document.getElementById('pathCountText'),
    log: document.getElementById('log'),
    debugLog: document.getElementById('debugLog'),
    debugLogMeta: document.getElementById('debugLogMeta'),
    clearDebugLogBtn: document.getElementById('clearDebugLogBtn'),
    debugLogScopeFilter: document.getElementById('debugLogScopeFilter'),
    floatingDebugLogPanel: document.getElementById('floatingDebugLogPanel'),
    toggleDebugLogPanelBtn: document.getElementById('toggleDebugLogPanelBtn'),
    topGpsStatus: document.getElementById('topGpsStatus'),
    topHrStatus: document.getElementById('topHrStatus'),
    topLoggerStatus: document.getElementById('topLoggerStatus'),
    topGatewayStatus: document.getElementById('topGatewayStatus'),
    summaryResourceText: document.getElementById('summaryResourceText'),
    summaryGpsSendText: document.getElementById('summaryGpsSendText'),
    summaryHrSendText: document.getElementById('summaryHrSendText'),
    summaryPathText: document.getElementById('summaryPathText'),
    summaryGatewayText: document.getElementById('summaryGatewayText'),
  }

  elements.configUrlInput.value = DEFAULT_CONFIG_URL
  applySimChannelId(loadSimChannelId(), false)

  function createTileLayer(urlTemplate, extraOptions) {
    return L.tileLayer(urlTemplate, Object.assign({
      maxZoom: 20,
      attribution: 'Custom Map',
    }, extraOptions || {}))
  }

  function log(message) {
    const time = new Date().toLocaleTimeString()
    elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
  }

  function loadSimChannelId() {
    try {
      return normalizeSimChannelId(window.localStorage.getItem(SIM_CHANNEL_STORAGE_KEY))
    } catch (_error) {
      return 'default'
    }
  }

  function saveSimChannelId(channelId) {
    try {
      window.localStorage.setItem(SIM_CHANNEL_STORAGE_KEY, normalizeSimChannelId(channelId))
    } catch (_error) {
      // noop
    }
  }

  function applySimChannelId(channelId, persist) {
    state.simChannelId = normalizeSimChannelId(channelId)
    if (elements.simChannelIdInput) {
      elements.simChannelIdInput.value = state.simChannelId
    }
    if (persist) {
      saveSimChannelId(state.simChannelId)
    }
    renderDebugLog()
  }

  function logDebug(entry) {
    if (!elements.debugLog) {
      return
    }

    const normalized = {
      timestamp: entry.timestamp || Date.now(),
      channelId: normalizeSimChannelId(entry.channelId),
      scope: String(entry.scope || 'app'),
      level: String(entry.level || 'info'),
      message: String(entry.message || ''),
      payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : null,
    }
    state.debugLogEntries.unshift(normalized)
    if (state.debugLogEntries.length > MAX_DEBUG_LOG_LINES) {
      state.debugLogEntries = state.debugLogEntries.slice(0, MAX_DEBUG_LOG_LINES)
    }
    renderDebugScopeOptions()
    renderDebugLog()
  }

  function clearDebugLog() {
    state.debugLogEntries = []
    renderDebugScopeOptions()
    renderDebugLog()
  }

  function renderDebugScopeOptions() {
    if (!elements.debugLogScopeFilter) {
      return
    }

    const staticOptions = ['all', 'logger', 'gps-logo', 'gps', 'heart-rate', 'track', 'compass', 'h5', 'content-card', 'gateway']
    const seenScopes = new Set(staticOptions)
    state.debugLogEntries.forEach((entry) => {
      if (entry.scope) {
        seenScopes.add(entry.scope)
      }
    })

    const options = Array.from(seenScopes)
    const currentValue = options.includes(state.debugLogScopeFilter) ? state.debugLogScopeFilter : 'all'
    elements.debugLogScopeFilter.innerHTML = options
      .map((scope) => `<option value="${scope}">${scope === 'all' ? '全部' : scope}</option>`)
      .join('')
    elements.debugLogScopeFilter.value = currentValue
    state.debugLogScopeFilter = currentValue
  }

  function renderDebugLog() {
    if (!elements.debugLog) {
      return
    }

    const filteredEntries = state.debugLogEntries.filter((entry) => {
      if (normalizeSimChannelId(entry.channelId) !== state.simChannelId) {
        return false
      }
      return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter
    })

    if (elements.debugLogMeta) {
      const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter
      elements.debugLogMeta.textContent = `通道 ${state.simChannelId} · ${scopeLabel} · ${filteredEntries.length} 条`
    }

    elements.debugLog.textContent = filteredEntries
      .map((entry) => {
        const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString()
        const level = String(entry.level || 'info').toUpperCase()
        const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : ''
        return `[${time}] [${entry.channelId}] [${entry.scope}] [${level}] ${entry.message}${payloadText}`
      })
      .join('\n')
  }

  function updateDebugLogPanelState() {
    if (!elements.floatingDebugLogPanel || !elements.toggleDebugLogPanelBtn) {
      return
    }
    elements.floatingDebugLogPanel.classList.toggle('is-minimized', state.debugLogPanelMinimized)
    elements.toggleDebugLogPanelBtn.textContent = state.debugLogPanelMinimized ? '展开' : '缩小'
  }

  function setResourceStatus(message, tone) {
    elements.resourceStatus.textContent = message
    elements.resourceStatus.className = 'hint'
    if (tone === 'ok') {
      elements.resourceStatus.classList.add('hint--ok')
    } else if (tone === 'warn') {
      elements.resourceStatus.classList.add('hint--warn')
    }
  }

  function updateReadout() {
    elements.latText.textContent = state.currentLatLng.lat.toFixed(6)
    elements.lonText.textContent = state.currentLatLng.lng.toFixed(6)
    elements.headingText.textContent = `${Math.round(state.headingDeg)}°`
    elements.pathCountText.textContent = String(pathPoints.length)
    liveMarker.setLatLng(state.currentLatLng)
  }

  function setSocketBadge(connected) {
    elements.socketStatus.textContent = connected ? '已连接' : '未连接'
    elements.socketStatus.className = connected ? 'badge badge--ok' : 'badge badge--muted'
  }

  function setConnectionValue(element, text, tone) {
    if (!element) {
      return
    }
    element.textContent = text
    element.classList.remove('is-ok', 'is-warn')
    if (tone === 'ok') {
      element.classList.add('is-ok')
    } else if (tone === 'warn') {
      element.classList.add('is-warn')
    }
  }

  function formatClockTime(timestamp) {
    if (!timestamp) {
      return '--'
    }

    return new Date(timestamp).toLocaleTimeString()
  }

  function updateUiState() {
    elements.connectBtn.textContent = state.connected ? '桥接已连接' : state.socketConnecting ? '连接中...' : '连接桥接'
    elements.connectBtn.classList.toggle('is-active', state.connected)
    elements.connectBtn.disabled = state.connected || state.socketConnecting

    elements.sendOnceBtn.disabled = !state.connected
    elements.streamBtn.textContent = state.streaming ? '发送中' : '开始连续发送'
    elements.streamBtn.classList.toggle('is-active', state.streaming)
    elements.streamBtn.disabled = !state.connected || state.streaming
    elements.stopStreamBtn.disabled = !state.streaming
    elements.sendHeartRateOnceBtn.disabled = !state.heartRateConnected
    elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
    elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
    elements.startHeartRateStreamBtn.disabled = !state.heartRateConnected || state.heartRateStreaming
    elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
    elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
    elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)

    elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
    elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
    elements.importTrackBtn.disabled = state.resourceLoading
    elements.clearPathBtn.textContent = pathPoints.length ? `清空路径 (${pathPoints.length})` : '清空路径'
    elements.clearPathBtn.disabled = pathPoints.length === 0
    elements.fitPathBtn.disabled = pathPoints.length < 2

    elements.playPathBtn.textContent = state.playbackRunning ? '回放中' : '开始回放'
    elements.playPathBtn.classList.toggle('is-active', state.playbackRunning)
    elements.playPathBtn.disabled = pathPoints.length < 2 || state.playbackRunning
    elements.pausePathBtn.disabled = !state.playbackRunning

    elements.fitCourseBtn.disabled = !state.loadedCourse
    elements.clearCourseBtn.disabled = !state.loadedCourse
    elements.loadConfigBtn.textContent = state.resourceLoading ? '载入中...' : '载入配置'
    elements.loadConfigBtn.disabled = state.resourceLoading
    elements.loadCourseBtn.textContent = state.resourceLoading ? '载入中...' : '载入控制点'
    elements.loadCourseBtn.disabled = state.resourceLoading
    elements.applyTilesBtn.disabled = state.resourceLoading
    elements.resetTilesBtn.disabled = state.resourceLoading
    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 连续发送`
    } else if (state.connected) {
      elements.realtimeStatus.textContent = '桥接已连接，待命中'
    } else if (state.socketConnecting) {
      elements.realtimeStatus.textContent = '桥接连接中'
    } else {
      elements.realtimeStatus.textContent = '桥接未连接'
    }

    if (state.heartRateConnected && state.heartRateStreaming) {
      elements.heartRateStatus.textContent = state.heartRateSampleMode
        ? `桥接已连接，正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
        : `桥接已连接，正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
    } else if (state.heartRateConnected) {
      elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
    } else if (state.heartRateSocketConnecting) {
      elements.heartRateStatus.textContent = '桥接连接中'
    } else {
      elements.heartRateStatus.textContent = '桥接未连接'
    }

    if (state.playbackRunning) {
      elements.playbackStatus.textContent = `路径回放中，速度 ${elements.speedInput.value} km/h`
    } else if (state.pathEditMode) {
      elements.playbackStatus.textContent = '路径编辑中，点击地图追加路径点'
    } else if (pathPoints.length >= 2) {
      elements.playbackStatus.textContent = `${state.lastTrackSourceText}，共 ${pathPoints.length} 个路径点`
    } else {
      elements.playbackStatus.textContent = '路径待命'
    }

    setConnectionValue(
      elements.topGpsStatus,
      state.connected ? (state.streaming ? '发送中' : '已连接') : state.socketConnecting ? '连接中' : '未连接',
      state.connected ? 'ok' : state.socketConnecting ? 'warn' : null
    )
    setConnectionValue(
      elements.topHrStatus,
      state.heartRateConnected ? (state.heartRateStreaming ? '发送中' : '已连接') : state.heartRateSocketConnecting ? '连接中' : '未连接',
      state.heartRateConnected ? 'ok' : state.heartRateSocketConnecting ? 'warn' : null
    )
    setConnectionValue(
      elements.topLoggerStatus,
      state.debugConnected ? '已连接' : state.debugSocketConnecting ? '连接中' : '未连接',
      state.debugConnected ? 'ok' : state.debugSocketConnecting ? 'warn' : null
    )
    setConnectionValue(
      elements.topGatewayStatus,
      !state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接',
      state.bridgeConnected && state.bridgeAuthenticated ? 'ok' : state.bridgeEnabled ? 'warn' : null
    )

    if (elements.summaryResourceText) {
      elements.summaryResourceText.textContent = state.resourceLoading ? '载入中' : state.loadedCourse ? '已载入' : '未载入'
    }
    if (elements.summaryGpsSendText) {
      elements.summaryGpsSendText.textContent = state.connected ? (state.streaming ? `${elements.hzSelect.value} Hz` : '待命') : '未连接'
    }
    if (elements.summaryHrSendText) {
      elements.summaryHrSendText.textContent = state.heartRateConnected ? (state.heartRateStreaming ? `${elements.heartRateHzSelect.value} Hz` : '待命') : '未连接'
    }
    if (elements.summaryPathText) {
      elements.summaryPathText.textContent = state.playbackRunning ? '回放中' : state.pathEditMode ? '编辑中' : pathPoints.length >= 2 ? `${pathPoints.length} 点` : '待命'
    }
    if (elements.summaryGatewayText) {
      elements.summaryGatewayText.textContent = !state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接'
    }
  }

  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
    }

    const socket = new WebSocket(GPS_WS_URL)
    state.socket = socket
    state.socketConnecting = true
    setSocketBadge(false)
    updateUiState()
    log(`连接 ${GPS_WS_URL}`)

    socket.addEventListener('open', () => {
      state.connected = true
      state.socketConnecting = false
      setSocketBadge(true)
      updateUiState()
      log('桥接已连接')
    })

    socket.addEventListener('close', () => {
      state.connected = false
      state.socketConnecting = false
      stopStream()
      setSocketBadge(false)
      updateUiState()
      log('桥接已断开')
    })

    socket.addEventListener('error', () => {
      state.connected = false
      state.socketConnecting = false
      stopStream()
      setSocketBadge(false)
      updateUiState()
      log('桥接连接失败')
    })
  }

  function connectHeartRateSocket() {
    if (state.heartRateSocket && (state.heartRateSocket.readyState === WebSocket.OPEN || state.heartRateSocket.readyState === WebSocket.CONNECTING)) {
      return
    }

    const socket = new WebSocket(HEART_RATE_WS_URL)
    state.heartRateSocket = socket
    state.heartRateSocketConnecting = true
    updateUiState()
    log(`连接心率模拟 ${HEART_RATE_WS_URL}`)

    socket.addEventListener('open', () => {
      state.heartRateConnected = true
      state.heartRateSocketConnecting = false
      updateUiState()
      log('心率模拟已连接')
    })

    socket.addEventListener('close', () => {
      state.heartRateConnected = false
      state.heartRateSocketConnecting = false
      state.heartRateSocket = null
      stopHeartRateStream()
      updateUiState()
      log('心率模拟已断开')
    })

    socket.addEventListener('error', () => {
      state.heartRateConnected = false
      state.heartRateSocketConnecting = false
      state.heartRateSocket = null
      stopHeartRateStream()
      updateUiState()
      log('心率模拟连接失败')
    })
  }

  function connectDebugSocket() {
    if (state.debugSocket && (state.debugSocket.readyState === WebSocket.OPEN || state.debugSocket.readyState === WebSocket.CONNECTING)) {
      return
    }

    const socket = new WebSocket(DEBUG_LOG_WS_URL)
    state.debugSocket = socket
    state.debugConnected = false
    state.debugSocketConnecting = true
    log(`连接日志通道 ${DEBUG_LOG_WS_URL}`)

    socket.addEventListener('message', (event) => {
      let parsed = null
      try {
        parsed = JSON.parse(String(event.data || ''))
      } catch (_error) {
        return
      }

      if (parsed && parsed.type === 'debug-log') {
        logDebug(parsed)
      }
    })

    socket.addEventListener('open', () => {
      state.debugSocketConnecting = false
      state.debugSocket = socket
      state.debugConnected = true
      log('日志通道已连接')
      updateUiState()
    })

    socket.addEventListener('close', () => {
      state.debugSocketConnecting = false
      state.debugSocket = null
      state.debugConnected = false
      log('日志通道已断开')
      updateUiState()
      window.setTimeout(connectDebugSocket, 1500)
    })

    socket.addEventListener('error', () => {
      state.debugSocketConnecting = false
      state.debugSocket = null
      state.debugConnected = false
      log('日志通道连接失败')
      updateUiState()
    })
  }

  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)}`
  }

  async function fetchJson(targetUrl) {
    const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
    if (!response.ok) {
      throw new Error(`载入失败: ${response.status} ${targetUrl}`)
    }

    const text = await response.text()
    return parseJsonWithFallback(text)
  }

  async function fetchText(targetUrl) {
    const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
    if (!response.ok) {
      throw new Error(`载入失败: ${response.status} ${targetUrl}`)
    }
    return response.text()
  }

  function parseJsonWithFallback(text) {
    try {
      return JSON.parse(text)
    } catch (_error) {
      const sanitized = text
        .replace(/,\s*"center"\s*:\s*\[[^\]]*\]\s*(?=[}\r\n])/g, '')
        .replace(/"center"\s*:\s*\[[^\]]*\]\s*,/g, '')
        .replace(/,\s*([}\]])/g, '$1')
      return JSON.parse(sanitized)
    }
  }

  function resolveUrl(baseUrl, relativePath) {
    const trimmed = String(relativePath || '').trim()
    if (!trimmed) {
      return ''
    }

    if (/^https?:\/\//i.test(trimmed)) {
      return trimmed
    }

    const url = new URL(baseUrl)
    if (trimmed.startsWith('/')) {
      return `${url.origin}${trimmed}`
    }

    const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
    return `${baseDir}${trimmed.replace(/^\.\//, '')}`
  }

  function joinUrl(rootUrl, relativePath) {
    const normalizedRoot = String(rootUrl || '').replace(/\/+$/, '')
    const normalizedPath = String(relativePath || '').replace(/^\/+/, '')
    return `${normalizedRoot}/${normalizedPath}`
  }

  function webMercatorToLatLng(x, y) {
    const lon = x / 20037508.34 * 180
    let lat = y / 20037508.34 * 180
    lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2)
    return L.latLng(lat, lon)
  }

  function applyTileTemplate(tileUrl, options) {
    const trimmed = String(tileUrl || '').trim()
    if (!trimmed) {
      throw new Error('瓦片模板不能为空')
    }

    if (tileLayer) {
      map.removeLayer(tileLayer)
    }

    tileLayer = createTileLayer(trimmed, options || {}).addTo(map)
    elements.tileUrlInput.value = trimmed
  }

  function fitBoundsFromMercator(bounds) {
    if (!Array.isArray(bounds) || bounds.length !== 4) {
      return
    }

    const southWest = webMercatorToLatLng(Number(bounds[0]), Number(bounds[1]))
    const northEast = webMercatorToLatLng(Number(bounds[2]), Number(bounds[3]))
    map.fitBounds(L.latLngBounds(southWest, northEast), { padding: [24, 24] })
  }

  function parseCoordinateTuple(rawValue) {
    const parts = rawValue.trim().split(',')
    if (parts.length < 2) {
      return null
    }

    const lon = Number(parts[0])
    const lat = Number(parts[1])
    if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
      return null
    }

    return { lat, lon }
  }

  function extractPointCoordinates(block) {
    const pointMatch = block.match(/<Point\b[\s\S]*?<coordinates>([\s\S]*?)<\/coordinates>[\s\S]*?<\/Point>/i)
    if (!pointMatch) {
      return null
    }

    const coordinateMatch = pointMatch[1].trim().match(/-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?)?/)
    return coordinateMatch ? parseCoordinateTuple(coordinateMatch[0]) : null
  }

  function decodeXmlEntities(text) {
    return text
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&apos;/g, "'")
      .replace(/&amp;/g, '&')
  }

  function stripXml(text) {
    return decodeXmlEntities(String(text || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
  }

  function extractTagText(block, tagName) {
    const match = block.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
    return match ? stripXml(match[1]) : ''
  }

  function normalizeCourseLabel(label) {
    return String(label || '').trim().replace(/\s+/g, ' ')
  }

  function inferExplicitKind(label, placemarkBlock) {
    const normalized = normalizeCourseLabel(label).toUpperCase().replace(/[^A-Z0-9]/g, '')
    const styleHint = String(placemarkBlock || '').toUpperCase()

    if (
      normalized === 'S'
      || normalized.startsWith('START')
      || /^S\d+$/.test(normalized)
      || styleHint.includes('START')
      || styleHint.includes('TRIANGLE')
    ) {
      return 'start'
    }

    if (
      normalized === 'F'
      || normalized === 'M'
      || normalized.startsWith('FINISH')
      || normalized.startsWith('GOAL')
      || /^F\d+$/.test(normalized)
      || styleHint.includes('FINISH')
      || styleHint.includes('GOAL')
    ) {
      return 'finish'
    }

    return null
  }

  function extractPlacemarkPoints(kmlText) {
    const placemarkBlocks = kmlText.match(/<Placemark\b[\s\S]*?<\/Placemark>/gi) || []
    const points = []

    placemarkBlocks.forEach((placemarkBlock) => {
      const point = extractPointCoordinates(placemarkBlock)
      if (!point) {
        return
      }

      const label = normalizeCourseLabel(extractTagText(placemarkBlock, 'name'))
      points.push({
        label,
        point,
        explicitKind: inferExplicitKind(label, placemarkBlock),
      })
    })

    return points
  }

  function classifyOrderedNodes(points) {
    if (!points.length) {
      return []
    }

    const startIndex = points.findIndex((point) => point.explicitKind === 'start')
    let finishIndex = -1
    for (let index = points.length - 1; index >= 0; index -= 1) {
      if (points[index].explicitKind === 'finish') {
        finishIndex = index
        break
      }
    }

    return points.map((point, index) => {
      let kind = point.explicitKind
      if (!kind) {
        if (startIndex === -1 && index === 0) {
          kind = 'start'
        } else if (finishIndex === -1 && points.length > 1 && index === points.length - 1) {
          kind = 'finish'
        } else {
          kind = 'control'
        }
      }

      return {
        label: point.label,
        point: point.point,
        kind,
      }
    })
  }

  function parseCourseKml(kmlText) {
    const points = extractPlacemarkPoints(kmlText)
    if (!points.length) {
      throw new Error('KML 中没有可用的 Point 控制点')
    }

    const nodes = classifyOrderedNodes(points)
    const starts = []
    const controls = []
    const finishes = []
    let controlSequence = 1

    nodes.forEach((node) => {
      if (node.kind === 'start') {
        starts.push({
          label: node.label || 'Start',
          point: node.point,
        })
        return
      }

      if (node.kind === 'finish') {
        finishes.push({
          label: node.label || 'Finish',
          point: node.point,
        })
        return
      }

      controls.push({
        label: node.label || String(controlSequence),
        sequence: controlSequence,
        point: node.point,
      })
      controlSequence += 1
    })

    return {
      title: extractTagText(kmlText, 'name') || 'Orienteering Course',
      starts,
      controls,
      finishes,
    }
  }

  function buildDivIcon(className, html, size) {
    return L.divIcon({
      className,
      html,
      iconSize: size,
      iconAnchor: [size[0] / 2, size[1] / 2],
    })
  }

  function setCurrentPosition(lat, lon) {
    state.currentLatLng = L.latLng(lat, lon)
    updateReadout()
  }

  function jumpToPoint(lat, lon, zoom) {
    setCurrentPosition(lat, lon)
    map.flyTo([lat, lon], zoom || Math.max(map.getZoom(), 18), {
      duration: 0.6,
    })
  }

  function buildJumpChip(label, point, className) {
    const button = document.createElement('button')
    button.type = 'button'
    button.className = `jump-chip ${className || ''}`.trim()
    button.textContent = label
    button.addEventListener('click', () => {
      jumpToPoint(point.lat, point.lon, 19)
      log(`跳转到 ${label}`)
    })
    return button
  }

  function refreshCourseJumpList(course) {
    elements.courseJumpList.innerHTML = ''
    if (!course) {
      return
    }

    course.starts.forEach((item) => {
      elements.courseJumpList.appendChild(buildJumpChip('开始点', item.point, 'jump-chip--start'))
    })
    course.controls.forEach((item) => {
      elements.courseJumpList.appendChild(buildJumpChip(String(item.sequence), item.point, ''))
    })
    course.finishes.forEach((item) => {
      elements.courseJumpList.appendChild(buildJumpChip('结束点', item.point, 'jump-chip--finish'))
    })
  }

  function renderCourse(course) {
    courseLayer.clearLayers()
    state.loadedCourse = course
    refreshCourseJumpList(course)

    course.starts.forEach((item) => {
      const marker = L.marker([item.point.lat, item.point.lon], {
        icon: buildDivIcon('course-marker', '<div class="course-marker__start"></div>', [36, 36]),
      })
      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
      marker.addTo(courseLayer)
    })

    course.controls.forEach((item) => {
      const marker = L.marker([item.point.lat, item.point.lon], {
        icon: buildDivIcon(
          'course-marker',
          `<div class="course-marker__control">${item.sequence}</div>`,
          [40, 40],
        ),
      })
      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
      marker.addTo(courseLayer)
    })

    course.finishes.forEach((item) => {
      const marker = L.marker([item.point.lat, item.point.lon], {
        icon: buildDivIcon('course-marker', '<div class="course-marker__finish"></div>', [40, 40]),
      })
      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
      marker.addTo(courseLayer)
    })

    fitCourseBounds()
    updateUiState()
  }

  function clearCourse() {
    state.loadedCourse = null
    courseLayer.clearLayers()
    refreshCourseJumpList(null)
    setResourceStatus('已清空控制点', 'warn')
    state.lastResourceDetailText = '已清空控制点'
    updateUiState()
  }

  function fitCourseBounds() {
    if (!state.loadedCourse) {
      return
    }

    const latLngs = []
    state.loadedCourse.starts.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
    state.loadedCourse.controls.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
    state.loadedCourse.finishes.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
    if (!latLngs.length) {
      return
    }

    map.fitBounds(L.latLngBounds(latLngs), { padding: [30, 30] })
  }

  async function loadCourseFromUrl(courseUrl, shouldFit) {
    const trimmed = String(courseUrl || '').trim()
    if (!trimmed) {
      throw new Error('KML 地址不能为空')
    }

    const kmlText = await fetchText(trimmed)
    const course = parseCourseKml(kmlText)
    renderCourse(course)
    elements.courseUrlInput.value = trimmed
    if (shouldFit !== false) {
      fitCourseBounds()
    }
    setResourceStatus(`已载入控制点: ${course.title}`, 'ok')
    state.lastResourceDetailText = `最近资源: 控制点 ${course.title} (${formatClockTime(Date.now())})`
    log(`已载入 KML: ${trimmed}`)
    updateUiState()
  }

  async function loadConfigResources() {
    const configUrl = String(elements.configUrlInput.value || '').trim()
    if (!configUrl) {
      setResourceStatus('请先填写 game.json 地址', 'warn')
      return
    }

    state.resourceLoading = true
    updateUiState()
    setResourceStatus('正在载入配置...', null)
    try {
      const config = await fetchJson(configUrl)
      let mapStatus = '未找到瓦片配置'
      if (config.map && config.mapmeta) {
        const mapRootUrl = resolveUrl(configUrl, config.map)
        const mapMetaUrl = resolveUrl(configUrl, config.mapmeta)
        const mapMeta = await fetchJson(mapMetaUrl)
        const tilePathTemplate = mapMeta.tilePathTemplate || `{z}/{x}/{y}.${mapMeta.tileFormat || 'png'}`
        const tileTemplateUrl = /^https?:\/\//i.test(tilePathTemplate)
          ? tilePathTemplate
          : joinUrl(mapRootUrl, tilePathTemplate)
        applyTileTemplate(tileTemplateUrl, {
          minZoom: Number.isFinite(mapMeta.minZoom) ? mapMeta.minZoom : 16,
          maxZoom: Number.isFinite(mapMeta.maxZoom) ? mapMeta.maxZoom : 20,
          attribution: 'Custom Map',
        })
        mapStatus = '已载入瓦片'
        if (Array.isArray(mapMeta.bounds) && mapMeta.bounds.length === 4) {
          fitBoundsFromMercator(mapMeta.bounds)
        }
      }

      let courseStatus = '未找到 KML 配置'
      if (config.course) {
        const courseUrl = resolveUrl(configUrl, config.course)
        elements.courseUrlInput.value = courseUrl
        await loadCourseFromUrl(courseUrl, false)
        courseStatus = '已载入控制点'
      }

      setResourceStatus(`配置已载入: ${mapStatus} / ${courseStatus}`, 'ok')
      state.lastResourceDetailText = `最近资源: 配置 ${formatClockTime(Date.now())}`
      log(`已载入配置: ${configUrl}`)
    } catch (error) {
      const message = error && error.message ? error.message : '未知错误'
      setResourceStatus(`配置载入失败: ${message}`, 'warn')
      log(`配置载入失败: ${message}`)
    } finally {
      state.resourceLoading = false
      updateUiState()
    }
  }

  function getAccuracy() {
    return Math.max(1, Number(elements.accuracyInput.value) || 6)
  }

  function getSpeedMps() {
    return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
  }

  function getHeartRateBpm() {
    return Math.max(40, Math.min(220, Math.round(Number(elements.heartRateInput.value) || 120)))
  }

  function getSampleHeartRateBpm() {
    const now = Date.now()
    if (!state.heartRateSampleStartedAt) {
      state.heartRateSampleStartedAt = now
    }

    const elapsedSeconds = (now - state.heartRateSampleStartedAt) / 1000
    const template = elements.heartRateSampleTemplateSelect.value || 'jog'

    let cycleSeconds = 360
    let bpm = 120
    const jitter = Math.sin(elapsedSeconds * 1.7) * 1.8 + Math.sin(elapsedSeconds * 0.47) * 1.2

    if (template === 'recovery') {
      cycleSeconds = 300
      const phase = elapsedSeconds % cycleSeconds
      if (phase < 80) {
        bpm = 82 + phase * 0.08
      } else if (phase < 190) {
        bpm = 89 + Math.sin((phase - 80) / 20) * 3
      } else {
        bpm = 90 - (phase - 190) * 0.06 + Math.sin((phase - 190) / 18) * 2
      }
    } else if (template === 'tempo') {
      cycleSeconds = 320
      const phase = elapsedSeconds % cycleSeconds
      if (phase < 50) {
        bpm = 102 + phase * 0.42
      } else if (phase < 230) {
        bpm = 124 + Math.sin((phase - 50) / 14) * 5 + Math.sin((phase - 50) / 36) * 3
      } else {
        bpm = 126 - (phase - 230) * 0.18 + Math.sin((phase - 230) / 12) * 3
      }
    } else if (template === 'interval') {
      cycleSeconds = 260
      const phase = elapsedSeconds % cycleSeconds
      if (phase < 40) {
        bpm = 100 + phase * 0.35
      } else {
        const wavePhase = phase - 40
        const intervalCycle = wavePhase % 44
        if (intervalCycle < 20) {
          bpm = 140 + intervalCycle * 1.2
        } else if (intervalCycle < 32) {
          bpm = 164 - (intervalCycle - 20) * 0.45
        } else {
          bpm = 158 - (intervalCycle - 32) * 2.7
        }
      }
    } else {
      const phase = elapsedSeconds % cycleSeconds
      if (phase < 60) {
        bpm = 96 + phase * 0.35
      } else if (phase < 150) {
        bpm = 118 + Math.sin((phase - 60) / 18) * 6
      } else if (phase < 240) {
        bpm = 138 + Math.sin((phase - 150) / 10) * 9
      } else if (phase < 300) {
        bpm = 158 + Math.sin((phase - 240) / 7) * 8
      } else {
        bpm = 124 - (phase - 300) * 0.22 + Math.sin((phase - 300) / 15) * 4
      }
    }

    const nextBpm = Math.max(72, Math.min(182, Math.round(bpm + jitter)))
    elements.heartRateInput.value = String(nextBpm)
    return nextBpm
  }

  function sendCurrentPoint() {
    if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
      log('未连接桥接，无法发送')
      return
    }

    const payload = {
      type: 'mock_gps',
      timestamp: Date.now(),
      channelId: state.simChannelId,
      lat: Number(state.currentLatLng.lat.toFixed(6)),
      lon: Number(state.currentLatLng.lng.toFixed(6)),
      accuracyMeters: getAccuracy(),
      speedMps: Number(getSpeedMps().toFixed(2)),
      headingDeg: Number(state.headingDeg.toFixed(1)),
    }
    state.socket.send(JSON.stringify(payload))
    state.lastSentText = `${formatClockTime(payload.timestamp)} @ ${payload.lat.toFixed(6)}, ${payload.lon.toFixed(6)}`
    updateUiState()
  }

  function sendCurrentHeartRate() {
    if (!state.heartRateSocket || state.heartRateSocket.readyState !== WebSocket.OPEN) {
      log('未连接心率模拟，无法发送心率')
      return
    }

    const payload = {
      type: 'mock_heart_rate',
      timestamp: Date.now(),
      channelId: state.simChannelId,
      bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
    }
    state.heartRateSocket.send(JSON.stringify(payload))
    state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
    updateUiState()
  }

  function startStream() {
    stopStream()
    state.streaming = true
    const intervalMs = Math.max(80, 1000 / (Number(elements.hzSelect.value) || 5))
    sendCurrentPoint()
    state.streamTimer = window.setInterval(sendCurrentPoint, intervalMs)
    updateUiState()
    log(`开始连续发送 (${Math.round(1000 / intervalMs)} Hz)`)
  }

  function stopStream() {
    state.streaming = false
    if (state.streamTimer) {
      window.clearInterval(state.streamTimer)
      state.streamTimer = 0
      log('已停止连续发送')
    }
    updateUiState()
  }

  function startHeartRateStream() {
    stopHeartRateStream()
    state.heartRateStreaming = true
    if (state.heartRateSampleMode && !state.heartRateSampleStartedAt) {
      state.heartRateSampleStartedAt = Date.now()
    }
    const intervalMs = Math.max(150, 1000 / (Number(elements.heartRateHzSelect.value) || 1))
    sendCurrentHeartRate()
    state.heartRateStreamTimer = window.setInterval(sendCurrentHeartRate, intervalMs)
    updateUiState()
    log(`开始连续发送心率 (${Math.round(1000 / intervalMs)} Hz)`)
  }

  function stopHeartRateStream() {
    state.heartRateStreaming = false
    if (state.heartRateStreamTimer) {
      window.clearInterval(state.heartRateStreamTimer)
      state.heartRateStreamTimer = 0
      log('已停止连续发送心率')
    }
    updateUiState()
  }

  function applyHeartRatePreset() {
    const sampleBpm = [88, 102, 118, 136, 154, 170]
    const current = getHeartRateBpm()
    let nextIndex = sampleBpm.findIndex((value) => value > current)
    if (nextIndex === -1) {
      nextIndex = 0
    }

    elements.heartRateInput.value = String(sampleBpm[nextIndex])
    log(`已应用心率分区样本: ${sampleBpm[nextIndex]} bpm`)
  }

  function toggleHeartRateSampleMode() {
    state.heartRateSampleMode = !state.heartRateSampleMode
    state.heartRateSampleStartedAt = state.heartRateSampleMode ? Date.now() : 0
    if (state.heartRateSampleMode) {
      const bpm = getSampleHeartRateBpm()
      log(`已开启真实心率样本 (${elements.heartRateSampleTemplateSelect.value || 'jog'}): ${bpm} bpm`)
    } else {
      log('已关闭真实心率样本')
    }
    updateUiState()
  }

  function syncPathLine() {
    pathLine.setLatLngs(pathPoints)
    elements.pathCountText.textContent = String(pathPoints.length)
    updateUiState()
  }

  function clearPathMarkers() {
    while (pathMarkers.length) {
      map.removeLayer(pathMarkers.pop())
    }
  }

  function refreshPathMarkers() {
    clearPathMarkers()
    pathPoints.forEach((point, index) => {
      const marker = L.circleMarker(point, {
        radius: 5,
        color: '#ffffff',
        weight: 2,
        fillColor: index === 0 ? '#0ea5a4' : '#0b625b',
        fillOpacity: 0.95,
      }).addTo(map)
      pathMarkers.push(marker)
    })
  }

  function addPathPoint(latlng) {
    pathPoints.push(L.latLng(latlng.lat, latlng.lng))
    state.lastTrackSourceText = '手工路径'
    syncPathLine()
    refreshPathMarkers()
  }

  function fitPathBounds() {
    if (pathPoints.length < 2) {
      return
    }

    map.fitBounds(L.latLngBounds(pathPoints), { padding: [30, 30] })
  }

  function replacePathPoints(nextPoints, sourceLabel) {
    pathPoints.splice(0, pathPoints.length)
    nextPoints.forEach((point) => {
      pathPoints.push(L.latLng(point.lat, point.lng))
    })
    state.lastTrackSourceText = sourceLabel
    stopPlayback()
    syncPathLine()
    refreshPathMarkers()
    if (pathPoints.length) {
      state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
      updateReadout()
    }
    if (pathPoints.length >= 2) {
      fitPathBounds()
    }
  }

  function parseGeoJsonTrack(rawValue) {
    const latLngs = []

    function pushLngLat(coords) {
      if (!Array.isArray(coords) || coords.length < 2) {
        return
      }
      const lng = Number(coords[0])
      const lat = Number(coords[1])
      if (Number.isFinite(lat) && Number.isFinite(lng)) {
        latLngs.push({ lat, lng })
      }
    }

    function walk(node) {
      if (!node || typeof node !== 'object') {
        return
      }

      if (node.type === 'FeatureCollection' && Array.isArray(node.features)) {
        node.features.forEach(walk)
        return
      }

      if (node.type === 'Feature' && node.geometry) {
        walk(node.geometry)
        return
      }

      if (node.type === 'LineString' && Array.isArray(node.coordinates)) {
        node.coordinates.forEach(pushLngLat)
        return
      }

      if (node.type === 'MultiLineString' && Array.isArray(node.coordinates)) {
        node.coordinates.forEach((line) => {
          if (Array.isArray(line)) {
            line.forEach(pushLngLat)
          }
        })
      }
    }

    if (Array.isArray(rawValue)) {
      rawValue.forEach((item) => {
        if (Array.isArray(item)) {
          pushLngLat(item)
          return
        }

        if (item && typeof item === 'object') {
          const lat = Number(item.lat)
          const lng = Number(item.lng !== undefined ? item.lng : item.lon)
          if (Number.isFinite(lat) && Number.isFinite(lng)) {
            latLngs.push({ lat, lng })
          }
        }
      })
      return latLngs
    }

    walk(rawValue)
    return latLngs
  }

  function parseGpxTrack(text) {
    const xml = new DOMParser().parseFromString(text, 'application/xml')
    const latLngs = []
    const trackPoints = Array.from(xml.querySelectorAll('trkpt'))
    const routePoints = trackPoints.length ? [] : Array.from(xml.querySelectorAll('rtept'))
    const nodes = trackPoints.length ? trackPoints : routePoints

    nodes.forEach((node) => {
      const lat = Number(node.getAttribute('lat'))
      const lng = Number(node.getAttribute('lon'))
      if (Number.isFinite(lat) && Number.isFinite(lng)) {
        latLngs.push({ lat, lng })
      }
    })

    return latLngs
  }

  function parseKmlTrack(text) {
    const xml = new DOMParser().parseFromString(text, 'application/xml')
    const latLngs = []
    const lineStrings = Array.from(xml.querySelectorAll('LineString coordinates'))

    lineStrings.forEach((node) => {
      String(node.textContent || '')
        .trim()
        .split(/\s+/)
        .forEach((tuple) => {
          const parsed = parseCoordinateTuple(tuple)
          if (parsed) {
            latLngs.push({ lat: parsed.lat, lng: parsed.lon })
          }
        })
    })

    return latLngs
  }

  function parseTrackFile(fileName, text) {
    const lowerName = String(fileName || '').toLowerCase()
    if (lowerName.endsWith('.gpx')) {
      return parseGpxTrack(text)
    }
    if (lowerName.endsWith('.kml')) {
      return parseKmlTrack(text)
    }
    if (lowerName.endsWith('.geojson') || lowerName.endsWith('.json')) {
      return parseGeoJsonTrack(parseJsonWithFallback(text))
    }
    if (text.includes('<gpx')) {
      return parseGpxTrack(text)
    }
    if (text.includes('<kml') || text.includes('<LineString')) {
      return parseKmlTrack(text)
    }
    return parseGeoJsonTrack(parseJsonWithFallback(text))
  }

  async function handleTrackFileSelected(file) {
    if (!file) {
      return
    }

    try {
      const text = await file.text()
      const latLngs = parseTrackFile(file.name, text)
      if (!latLngs || latLngs.length < 2) {
        throw new Error('轨迹文件中没有可回放的路径点')
      }

      replacePathPoints(latLngs, `轨迹文件 ${file.name}`)
      log(`已导入轨迹文件: ${file.name} (${latLngs.length} 点)`)
    } catch (error) {
      const message = error && error.message ? error.message : '轨迹文件导入失败'
      log(message)
      alert(message)
    } finally {
      elements.trackFileInput.value = ''
    }
  }

  function toRad(value) {
    return value * Math.PI / 180
  }

  function toDeg(value) {
    return value * 180 / Math.PI
  }

  function getDistanceMeters(from, to) {
    const earth = 6371000
    const lat1 = toRad(from.lat)
    const lat2 = toRad(to.lat)
    const dLat = lat2 - lat1
    const dLon = toRad(to.lng - from.lng)
    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
      + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
    return earth * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
  }

  function getHeadingDeg(from, to) {
    const lat1 = toRad(from.lat)
    const lat2 = toRad(to.lat)
    const dLon = toRad(to.lng - from.lng)
    const y = Math.sin(dLon) * Math.cos(lat2)
    const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
    const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360
    return bearing
  }

  function interpolateLatLng(from, to, t) {
    return L.latLng(
      from.lat + (to.lat - from.lat) * t,
      from.lng + (to.lng - from.lng) * t,
    )
  }

  function tickPlayback() {
    if (!state.playbackRunning || pathPoints.length < 2) {
      return
    }

    const now = performance.now()
    if (!state.lastPlaybackAt) {
      state.lastPlaybackAt = now
    }
    const deltaSeconds = (now - state.lastPlaybackAt) / 1000
    state.lastPlaybackAt = now

    let remainingTravel = getSpeedMps() * deltaSeconds
    while (remainingTravel > 0 && state.currentSegmentIndex < pathPoints.length - 1) {
      const from = pathPoints[state.currentSegmentIndex]
      const to = pathPoints[state.currentSegmentIndex + 1]
      const segmentDistance = getDistanceMeters(from, to)
      if (!segmentDistance) {
        state.currentSegmentIndex += 1
        state.currentSegmentProgress = 0
        continue
      }

      const remainingSegment = segmentDistance * (1 - state.currentSegmentProgress)
      if (remainingTravel >= remainingSegment) {
        remainingTravel -= remainingSegment
        state.currentSegmentIndex += 1
        state.currentSegmentProgress = 0
        state.currentLatLng = L.latLng(to.lat, to.lng)
        state.headingDeg = getHeadingDeg(from, to)
      } else {
        state.currentSegmentProgress += remainingTravel / segmentDistance
        state.currentLatLng = interpolateLatLng(from, to, state.currentSegmentProgress)
        state.headingDeg = getHeadingDeg(from, to)
        remainingTravel = 0
      }
    }

    if (state.currentSegmentIndex >= pathPoints.length - 1) {
      if (elements.loopPathInput.checked) {
        state.currentSegmentIndex = 0
        state.currentSegmentProgress = 0
        state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
      } else {
        stopPlayback()
      }
    }

    updateReadout()
    if (state.streaming) {
      sendCurrentPoint()
    }

    if (state.playbackRunning) {
      state.playbackTimer = window.requestAnimationFrame(tickPlayback)
    }
  }

  function startPlayback() {
    if (pathPoints.length < 2) {
      log('至少需要两个路径点')
      return
    }

    stopPlayback()
    state.playbackRunning = true
    state.currentSegmentIndex = 0
    state.currentSegmentProgress = 0
    state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
    state.lastPlaybackAt = 0
    updateReadout()
    updateUiState()
    log('开始路径回放')
    state.playbackTimer = window.requestAnimationFrame(tickPlayback)
  }

  function stopPlayback() {
    state.playbackRunning = false
    state.lastPlaybackAt = 0
    if (state.playbackTimer) {
      window.cancelAnimationFrame(state.playbackTimer)
      state.playbackTimer = 0
    }
    updateUiState()
  }

  map.on('click', (event) => {
    if (state.pathEditMode) {
      addPathPoint(event.latlng)
      return
    }

    setCurrentPosition(event.latlng.lat, event.latlng.lng)
  })

  liveMarker.on('mousedown', () => {
    map.dragging.disable()
  })

  map.on('mousemove', (event) => {
    if (event.originalEvent.buttons !== 1) {
      return
    }

    if (state.pathEditMode) {
      return
    }

    setCurrentPosition(event.latlng.lat, event.latlng.lng)
  })

  map.on('mouseup', () => {
    map.dragging.enable()
  })

  elements.connectBtn.addEventListener('click', connectSocket)
  if (elements.simChannelIdInput) {
    elements.simChannelIdInput.addEventListener('change', () => {
      applySimChannelId(elements.simChannelIdInput.value, true)
      log(`已切换模拟通道 ${state.simChannelId}`)
    })
  }
  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()
  })
  elements.trackFileInput.addEventListener('change', (event) => {
    const input = event.target
    const file = input && input.files && input.files[0] ? input.files[0] : null
    handleTrackFileSelected(file)
  })
  elements.loadConfigBtn.addEventListener('click', loadConfigResources)
  elements.fitCourseBtn.addEventListener('click', fitCourseBounds)
  elements.applyTilesBtn.addEventListener('click', () => {
    try {
      applyTileTemplate(elements.tileUrlInput.value, { attribution: 'Custom Map' })
      setResourceStatus('已应用自定义瓦片', 'ok')
      state.lastResourceDetailText = `最近资源: 自定义瓦片 ${formatClockTime(Date.now())}`
      updateUiState()
    } catch (error) {
      setResourceStatus(error && error.message ? error.message : '瓦片应用失败', 'warn')
    }
  })
  elements.resetTilesBtn.addEventListener('click', () => {
    applyTileTemplate(DEFAULT_TILE_URL, {
      maxZoom: 20,
      attribution: '&copy; OpenStreetMap',
    })
    setResourceStatus('已恢复 OSM 底图', 'ok')
    state.lastResourceDetailText = `最近资源: OSM 底图 ${formatClockTime(Date.now())}`
    updateUiState()
  })
  elements.loadCourseBtn.addEventListener('click', async () => {
    try {
      await loadCourseFromUrl(elements.courseUrlInput.value, true)
    } catch (error) {
      const message = error && error.message ? error.message : 'KML 载入失败'
      setResourceStatus(message, 'warn')
      log(message)
    }
  })
  elements.clearCourseBtn.addEventListener('click', clearCourse)
  elements.fitPathBtn.addEventListener('click', fitPathBounds)
  elements.sendOnceBtn.addEventListener('click', () => {
    sendCurrentPoint()
    log('已发送当前位置')
  })
  elements.streamBtn.addEventListener('click', startStream)
  elements.stopStreamBtn.addEventListener('click', stopStream)
  elements.sendHeartRateOnceBtn.addEventListener('click', () => {
    sendCurrentHeartRate()
    log('已发送当前心率')
  })
  elements.startHeartRateStreamBtn.addEventListener('click', startHeartRateStream)
  elements.stopHeartRateStreamBtn.addEventListener('click', stopHeartRateStream)
  elements.applyHeartRatePresetBtn.addEventListener('click', applyHeartRatePreset)
  elements.toggleHeartRateSampleBtn.addEventListener('click', toggleHeartRateSampleMode)
  elements.togglePathModeBtn.addEventListener('click', () => {
    state.pathEditMode = !state.pathEditMode
    elements.pathHint.textContent = state.pathEditMode
      ? '地图点击将按顺序追加路径点。'
      : '点击“开启路径编辑”后，在地图上逐点添加路径。'
    updateUiState()
  })
  elements.clearPathBtn.addEventListener('click', () => {
    pathPoints.splice(0, pathPoints.length)
    state.lastTrackSourceText = '路径待命'
    syncPathLine()
    clearPathMarkers()
    stopPlayback()
    log('已清空路径')
  })
  elements.playPathBtn.addEventListener('click', startPlayback)
  elements.pausePathBtn.addEventListener('click', () => {
    stopPlayback()
    log('已暂停回放')
  })
  if (elements.clearDebugLogBtn) {
    elements.clearDebugLogBtn.addEventListener('click', clearDebugLog)
  }
  if (elements.toggleDebugLogPanelBtn) {
    elements.toggleDebugLogPanelBtn.addEventListener('click', () => {
      state.debugLogPanelMinimized = !state.debugLogPanelMinimized
      updateDebugLogPanelState()
    })
  }
  if (elements.debugLogScopeFilter) {
    elements.debugLogScopeFilter.addEventListener('change', () => {
      state.debugLogScopeFilter = elements.debugLogScopeFilter.value || 'all'
      renderDebugLog()
    })
  }

  updateReadout()
  setSocketBadge(false)
  renderDebugScopeOptions()
  renderDebugLog()
  updateDebugLogPanelState()
  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()
  connectHeartRateSocket()
  connectDebugSocket()
})()
