Ctrl K
ComunidadGuías…

desktop/electron/server-picker/index.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mao · 服务器设置</title>
  <style>
    :root {
      --bg: #f5f5f7;
      --surface: #ffffff;
      --ink: #1d1d1f;
      --muted: #6e6e73;
      --line: #e5e5ea;
      --line-strong: #d5d5da;
      --primary: #0066cc;
      --primary-hover: #0055aa;
      --primary-soft: rgba(0, 102, 204, 0.08);
      --danger: #c62828;
      --danger-soft: rgba(198, 40, 40, 0.08);
      --warn: #b26a00;
      --warn-soft: rgba(230, 145, 20, 0.12);
      --ok: #2e7d32;
      --radius: 12px;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      background: var(--bg);
      color: var(--ink);
      font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
      padding: 20px;
    }
    .card {
      max-width: 520px;
      margin: 0 auto;
      background: var(--surface);
      border: 1px solid var(--line);
      border-radius: var(--radius);
      padding: 24px 24px 20px;
      box-shadow: 0 8px 28px rgba(0, 0, 0, 0.06);
    }
    h1 {
      margin: 0 0 6px;
      font-size: 19px;
      font-weight: 600;
    }
    .sub {
      margin: 0 0 18px;
      color: var(--muted);
      font-size: 13px;
      line-height: 1.5;
    }

    /* ---------- 当前连接状态卡 ---------- */
    .status {
      border: 1px solid var(--line);
      border-radius: 10px;
      padding: 14px 14px 12px;
      background: #fbfbfc;
    }
    .status-head {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .dot {
      flex-shrink: 0;
      width: 9px;
      height: 9px;
      border-radius: 50%;
      background: #c7c7cc;
    }
    .status.checking .dot { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
    .status.ok .dot { background: var(--ok); }
    .status.error .dot { background: var(--danger); }
    @keyframes pulse { 50% { opacity: 0.35; } }
    .status-label { font-size: 13px; font-weight: 600; }
    .status-head .spacer { flex: 1; }
    .status-url {
      margin-top: 8px;
      font-size: 15px;
      font-weight: 600;
      word-break: break-all;
      line-height: 1.4;
    }
    .status-url.empty { color: var(--muted); font-weight: 500; }
    .status-detail {
      margin-top: 4px;
      font-size: 12px;
      color: var(--muted);
      line-height: 1.5;
      word-break: break-all;
    }
    .status-warn {
      margin-top: 8px;
      font-size: 12px;
      color: var(--warn);
      line-height: 1.5;
    }
    .status-actions { margin-top: 10px; display: flex; gap: 8px; }

    /* ---------- 历史列表 ---------- */
    .history { margin-top: 18px; }
    .history-title {
      font-size: 13px;
      font-weight: 600;
      margin-bottom: 8px;
    }
    .hist-row {
      display: flex;
      align-items: center;
      gap: 10px;
      padding: 9px 10px;
      border: 1px solid var(--line);
      border-radius: 10px;
      margin-bottom: 8px;
      background: var(--surface);
    }
    .hist-row:hover { border-color: var(--line-strong); }
    .hist-row.current { background: var(--primary-soft); border-color: rgba(0, 102, 204, 0.25); }
    .hist-avatar {
      flex-shrink: 0;
      width: 30px;
      height: 30px;
      border-radius: 8px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 13px;
      font-weight: 700;
      color: #fff;
    }
    .hist-info { flex: 1; min-width: 0; }
    .hist-host {
      font-size: 13px;
      font-weight: 600;
      display: flex;
      align-items: center;
      gap: 6px;
      min-width: 0;
    }
    .hist-host .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .http-tag {
      flex-shrink: 0;
      font-size: 10px;
      font-weight: 600;
      color: var(--warn);
      background: var(--warn-soft);
      border-radius: 4px;
      padding: 0 4px;
      line-height: 16px;
    }
    .hist-sub {
      margin-top: 2px;
      font-size: 12px;
      color: var(--muted);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .hist-ops { flex-shrink: 0; display: flex; gap: 6px; }
    .current-badge {
      flex-shrink: 0;
      font-size: 11px;
      color: var(--primary);
      border: 1px solid rgba(0, 102, 204, 0.4);
      border-radius: 999px;
      padding: 2px 8px;
    }

    /* ---------- 手动表单(折叠) ---------- */
    .manual { margin-top: 16px; }
    .manual-toggle {
      width: 100%;
      display: flex;
      align-items: center;
      gap: 6px;
      background: none;
      border: none;
      padding: 6px 2px;
      font-size: 13px;
      font-weight: 500;
      color: var(--primary);
      cursor: pointer;
    }
    .manual-toggle:hover { text-decoration: underline; }
    .manual-body { margin-top: 10px; }
    label {
      display: block;
      font-size: 13px;
      font-weight: 500;
      margin-bottom: 6px;
    }
    input[type="text"] {
      width: 100%;
      height: 40px;
      padding: 0 12px;
      border: 1px solid var(--line-strong);
      border-radius: 8px;
      font-size: 14px;
      outline: none;
      background: #fff;
    }
    input[type="text"]:focus {
      border-color: var(--primary);
      box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.12);
    }
    .hint {
      margin-top: 8px;
      font-size: 12px;
      color: var(--muted);
      line-height: 1.5;
      min-height: 18px;
    }
    .hint .preview { color: var(--ink); font-weight: 500; }
    .row {
      margin-top: 14px;
      display: flex;
      align-items: flex-start;
      gap: 8px;
      font-size: 13px;
    }
    .row input { margin-top: 2px; }
    .actions {
      margin-top: 18px;
      display: flex;
      gap: 10px;
    }
    button {
      height: 38px;
      padding: 0 16px;
      border-radius: 8px;
      border: 1px solid transparent;
      font-size: 14px;
      cursor: pointer;
      font-family: inherit;
    }
    button.primary {
      flex: 1;
      background: var(--primary);
      color: #fff;
    }
    button.primary:hover { background: var(--primary-hover); }
    button.primary:disabled {
      opacity: 0.55;
      cursor: not-allowed;
    }
    button.ghost {
      background: transparent;
      border-color: var(--line-strong);
      color: var(--ink);
    }
    button.ghost:hover { border-color: var(--muted); }
    button.ghost:disabled { opacity: 0.55; cursor: not-allowed; }
    button.mini {
      height: 28px;
      padding: 0 10px;
      font-size: 12px;
      border-radius: 6px;
      background: transparent;
      border-color: var(--line-strong);
      color: var(--ink);
    }
    button.mini:hover { border-color: var(--primary); color: var(--primary); }
    button.mini.danger:hover, button.mini.danger-confirm {
      border-color: var(--danger);
      color: var(--danger);
      background: var(--danger-soft);
    }
    button.mini:disabled { opacity: 0.55; cursor: not-allowed; }
    .msg {
      margin-top: 12px;
      min-height: 20px;
      font-size: 13px;
      line-height: 1.45;
    }
    .msg.error { color: var(--danger); }
    .msg.ok { color: var(--ok); }

    /* ---------- 确认弹窗 ---------- */
    .overlay {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.3);
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 24px;
      z-index: 10;
    }
    .dialog {
      width: 100%;
      max-width: 360px;
      background: var(--surface);
      border-radius: var(--radius);
      padding: 20px;
      box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
    }
    .dialog-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
    .dialog-body { font-size: 13px; color: var(--ink); line-height: 1.6; word-break: break-all; }
    .dialog-body .target { font-weight: 600; }
    .dialog-actions { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
    .dialog-actions button { height: 32px; font-size: 13px; }
    .dialog-actions button.danger { background: var(--danger); color: #fff; }
    .dialog-actions button.danger:hover { background: #a51f1f; }
  </style>
</head>
<body>
  <div class="card">
    <h1>连接 Mao 服务器</h1>
    <p class="sub">填写要连接的站点地址。同一客户端可切换不同私有化部署,无需重新打包。</p>

    <!-- 当前连接状态 -->
    <div class="status" id="statusCard">
      <div class="status-head">
        <span class="dot"></span>
        <span class="status-label" id="statusLabel">读取中…</span>
        <span class="spacer"></span>
        <button class="mini" id="recheckBtn" type="button">重新检测</button>
      </div>
      <div class="status-url empty" id="statusUrl"></div>
      <div class="status-detail" id="statusDetail"></div>
      <div class="status-warn" id="statusWarn" hidden></div>
    </div>

    <!-- 连接过的服务器:一键切换 -->
    <div class="history" id="historyWrap" hidden>
      <div class="history-title">连接过的服务器</div>
      <div id="historyList"></div>
    </div>

    <!-- 手动连接 -->
    <div class="manual">
      <button class="manual-toggle" id="manualToggle" type="button" aria-expanded="false">
        <span id="manualToggleIcon">▸</span><span id="manualToggleText">连接到其他服务器…</span>
      </button>
      <div class="manual-body" id="manualBody" hidden>
        <label for="serverUrl">服务器地址</label>
        <input id="serverUrl" type="text" spellcheck="false" autocomplete="off"
               placeholder="https://mao.example.com" />
        <div class="hint" id="urlHint">支持站点根、<code>/api</code> 或 <code>/api/v1</code>,将自动归一化为站点根。</div>

        <div class="row">
          <input id="allowHttp" type="checkbox" />
          <label for="allowHttp" style="margin:0;font-weight:400">允许 HTTP(仅内网自建环境,有安全风险)</label>
        </div>

        <div class="actions">
          <button class="primary" id="saveBtn" type="button">连接并保存</button>
          <button class="ghost" id="probeBtn" type="button">仅检测</button>
        </div>
      </div>
    </div>

    <div class="msg" id="msg" aria-live="polite"></div>
  </div>

  <script>
    const $ = (id) => document.getElementById(id)
    const hasApi = () => typeof window !== 'undefined' && window.electronAPI && window.electronAPI.serverConfig

    function setMsg(text, type) {
      const el = $('msg')
      el.textContent = text || ''
      el.className = 'msg' + (type ? ' ' + type : '')
    }

    /** 与主进程 serverHostKey 同口径:host 或 host_port */
    function hostKeyOf(url) {
      try {
        const parsed = new URL(String(url || ''))
        const host = parsed.hostname || 'default'
        return parsed.port ? `${host}_${parsed.port}` : host
      } catch {
        return 'default'
      }
    }

    function hostOf(url) {
      try {
        return new URL(String(url || '')).hostname || String(url || '')
      } catch {
        return String(url || '')
      }
    }

    /** 相对时间:刚刚 / N 分钟前 / N 小时前 / N 天前 / 昨天 HH:mm / MM-DD HH:mm / YYYY-MM-DD */
    function formatRelative(iso) {
      if (!iso) return ''
      const then = new Date(iso).getTime()
      if (!Number.isFinite(then)) return ''
      const diff = Date.now() - then
      if (diff < 0) return ''
      const min = 60000
      if (diff < min) return '刚刚'
      if (diff < 60 * min) return `${Math.floor(diff / min)} 分钟前`
      if (diff < 24 * 60 * min) return `${Math.floor(diff / (60 * min))} 小时前`
      if (diff < 7 * 24 * 60 * min) return `${Math.floor(diff / (24 * 60 * min))} 天前`
      const pad = (n) => String(n).padStart(2, '0')
      const d = new Date(then)
      const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
      const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0)
      if (then >= dayStart.getTime() - 24 * 60 * 60 * min) return `昨天 ${hhmm}`
      if (d.getFullYear() === new Date().getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
    }

    /** 预览用归一化(权威归一化在主进程 serverConfig.cjs) */
    function previewNormalize(raw) {
      const s = String(raw || '').trim().replace(/\s+/g, '')
      if (!s) return null
      const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s) ? s : `https://${s}`
    try {
      const parsed = new URL(candidate)
      if (parsed.username || parsed.password) return null
      let pathname = (parsed.pathname || '/').replace(/\/+$/, '')
        pathname = pathname.replace(/\/api\/v1$/i, '').replace(/\/api$/i, '')
        return `${parsed.origin}${pathname === '/' ? '' : pathname}`
      } catch {
        return null
      }
    }

    /** 通用确认弹窗(替代 window.confirm) */
    function showConfirm({ title, bodyHtml, confirmText = '确认', danger = false }) {
      return new Promise((resolve) => {
        const overlay = document.createElement('div')
        overlay.className = 'overlay'
        const dialog = document.createElement('div')
        dialog.className = 'dialog'
        dialog.setAttribute('role', 'dialog')
        dialog.setAttribute('aria-modal', 'true')
        const t = document.createElement('div')
        t.className = 'dialog-title'
        t.textContent = title
        const b = document.createElement('div')
        b.className = 'dialog-body'
        b.innerHTML = bodyHtml
        const actions = document.createElement('div')
        actions.className = 'dialog-actions'
        const cancel = document.createElement('button')
        cancel.className = 'ghost'
        cancel.type = 'button'
        cancel.textContent = '取消'
        const ok = document.createElement('button')
        ok.className = danger ? 'danger' : 'primary'
        ok.type = 'button'
        ok.textContent = confirmText
        const done = (val) => {
          overlay.remove()
          document.removeEventListener('keydown', onKey, true)
          resolve(val)
        }
        const onKey = (e) => {
          if (e.key === 'Escape') { e.stopPropagation(); done(false) }
          if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); done(true) }
        }
        cancel.addEventListener('click', () => done(false))
        ok.addEventListener('click', () => done(true))
        overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) done(false) })
        document.addEventListener('keydown', onKey, true)
        actions.appendChild(cancel)
        actions.appendChild(ok)
        dialog.appendChild(t)
        dialog.appendChild(b)
        dialog.appendChild(actions)
        overlay.appendChild(dialog)
        document.body.appendChild(overlay)
        ok.focus()
      })
    }

    let currentBaseUrl = ''
    let currentHostKey = ''
    let currentUpdateFeedMode = 'follow-site'
    let history = []
    let probing = false
    let statusMeta = ''
    let statusProbeDetail = ''

    function renderStatusDetail() {
      $('statusDetail').textContent = [statusMeta, statusProbeDetail].filter(Boolean).join(' · ')
    }

    function setStatus(state, label, url) {
      const card = $('statusCard')
      card.className = 'status' + (state ? ' ' + state : '')
      $('statusLabel').textContent = label
      $('statusUrl').textContent = url || ''
      $('statusUrl').classList.toggle('empty', !url)
      renderStatusDetail()
    }

    function renderHistory() {
      const wrap = $('historyWrap')
      const list = $('historyList')
      list.textContent = ''
      if (!history.length) { wrap.hidden = true; return }
      wrap.hidden = false
      history.forEach((entry) => {
        const isCurrent = entry.serverBaseUrl === currentBaseUrl
        const row = document.createElement('div')
        row.className = 'hist-row' + (isCurrent ? ' current' : '')

        const avatar = document.createElement('div')
        avatar.className = 'hist-avatar'
        avatar.textContent = (hostOf(entry.serverBaseUrl)[0] || '?').toUpperCase()
        const palette = ['#0066cc', '#6c5ce7', '#0097a7', '#2e7d32', '#b26a00', '#c62828', '#5c6bc0']
        avatar.style.background = palette[hostOf(entry.serverBaseUrl).charCodeAt(0) % palette.length]

        const info = document.createElement('div')
        info.className = 'hist-info'
        const host = document.createElement('div')
        host.className = 'hist-host'
        const name = document.createElement('span')
        name.className = 'name'
        name.textContent = hostOf(entry.serverBaseUrl)
        host.appendChild(name)
        if (entry.allowHttp) {
          const tag = document.createElement('span')
          tag.className = 'http-tag'
          tag.textContent = 'HTTP'
          host.appendChild(tag)
        }
        const sub = document.createElement('div')
        sub.className = 'hist-sub'
        const rel = formatRelative(entry.lastConnectedAt)
        sub.textContent = rel ? `${entry.serverBaseUrl} · ${rel}` : entry.serverBaseUrl
        sub.title = entry.serverBaseUrl
        info.appendChild(host)
        info.appendChild(sub)

        const ops = document.createElement('div')
        ops.className = 'hist-ops'
        if (isCurrent) {
          const badge = document.createElement('span')
          badge.className = 'current-badge'
          badge.textContent = '当前'
          ops.appendChild(badge)
        } else {
          const sw = document.createElement('button')
          sw.className = 'mini'
          sw.type = 'button'
          sw.textContent = '切换'
          sw.addEventListener('click', () => switchTo(entry, sw))

          const del = document.createElement('button')
          del.className = 'mini danger'
          del.type = 'button'
          del.textContent = '删除'
          let confirming = false
          let revertTimer = null
          const revert = () => {
            confirming = false
            clearTimeout(revertTimer)
            del.textContent = '删除'
            del.disabled = false
          }
          del.addEventListener('click', async () => {
            if (!hasApi()) return
            if (!confirming) {
              confirming = true
              del.textContent = '确认删除'
              del.disabled = false
              revertTimer = setTimeout(revert, 3000)
              return
            }
            clearTimeout(revertTimer)
            del.disabled = true
            try {
              const result = await window.electronAPI.serverConfig.removeHistory(entry.serverBaseUrl)
              if (result && result.ok) {
                history = Array.isArray(result.history) ? result.history : history.filter((e) => e.serverBaseUrl !== entry.serverBaseUrl)
                renderHistory()
              } else {
                setMsg(result?.error || '删除失败', 'error')
                revert()
              }
            } catch (e) {
              setMsg(e?.message || '删除失败', 'error')
              revert()
            }
          })
          ops.appendChild(sw)
          ops.appendChild(del)
        }

        row.appendChild(avatar)
        row.appendChild(info)
        row.appendChild(ops)
        list.appendChild(row)
      })
    }

    async function probeCurrent() {
      if (!hasApi() || probing) return
      probing = true
      $('recheckBtn').disabled = true
      setStatus('checking', '正在检测…', currentBaseUrl)
      statusProbeDetail = ''
      renderStatusDetail()
      try {
        const result = await window.electronAPI.serverConfig.probe(currentBaseUrl, {
          allowHttp: /^http:\/\//i.test(currentBaseUrl),
        })
        if (result.ok) {
          statusProbeDetail = result.detail || '站点可达'
          setStatus('ok', '已连接', currentBaseUrl)
        } else {
          statusProbeDetail = result.error || '检测失败'
          setStatus('error', '无法连接', currentBaseUrl)
        }
      } catch (e) {
        statusProbeDetail = e?.message || '检测失败'
        setStatus('error', '无法连接', currentBaseUrl)
      } finally {
        probing = false
        $('recheckBtn').disabled = false
      }
    }

    /** 历史一键切换:先探测,host 变化需确认,随后保存并重载 */
    async function switchTo(entry, btn) {
      if (!hasApi()) return
      const target = entry.serverBaseUrl
      btn.disabled = true
      setMsg('正在检测…')
      try {
        const probe = await window.electronAPI.serverConfig.probe(target, { allowHttp: entry.allowHttp === true })
        if (!probe.ok) {
          setMsg(probe.error || '服务器检测失败', 'error')
          btn.disabled = false
          return
        }
        if (hostKeyOf(target) !== currentHostKey) {
          const ok = await showConfirm({
            title: '切换服务器',
            bodyHtml: `将切换到 <span class="target">${target}</span>。<br/>切换会清除本机登录态,LOCAL 任务数据按服务器隔离,需要重新登录。确认继续?`,
            confirmText: '切换',
            danger: true,
          })
          if (!ok) { btn.disabled = false; setMsg(''); return }
        }
        setMsg('正在切换…')
        const result = await window.electronAPI.serverConfig.set({
          serverBaseUrl: target,
          allowHttp: entry.allowHttp === true,
          updateFeedMode: currentUpdateFeedMode,
        })
        if (result && result.ok) {
          setMsg(`已切换 ${result.serverBaseUrl},窗口即将关闭…`, 'ok')
          setTimeout(() => {
            try { window.electronAPI.serverConfig.closePicker?.() } catch {}
          }, 800)
        } else {
          setMsg(result?.error || '切换失败', 'error')
          btn.disabled = false
        }
      } catch (e) {
        setMsg(e?.message || '切换失败', 'error')
        btn.disabled = false
      }
    }

    async function probeOnly() {
      if (!hasApi()) return
      const url = $('serverUrl').value
      const allowHttp = $('allowHttp').checked
      setMsg('正在检测…')
      $('probeBtn').disabled = true
      try {
        const result = await window.electronAPI.serverConfig.probe(url, { allowHttp })
        if (result.ok) {
          setMsg(`检测通过:${result.baseUrl}${result.detail ? '(' + result.detail + ')' : ''}`, 'ok')
        } else {
          setMsg(result.error || '检测失败', 'error')
        }
      } catch (e) {
        setMsg(e?.message || '检测失败', 'error')
      } finally {
        $('probeBtn').disabled = false
      }
    }

    async function saveAndConnect() {
      if (!hasApi()) return
      const url = $('serverUrl').value
      const allowHttp = $('allowHttp').checked
      if (allowHttp && /^http:\/\//i.test(url.trim())) {
        const ok = await showConfirm({
          title: '使用不安全的连接',
          bodyHtml: `将使用 HTTP(明文)连接 <span class="target">${url.trim()}</span>,仅建议内网自建环境使用。确认继续?`,
          confirmText: '继续',
          danger: true,
        })
        if (!ok) return
      }
      setMsg('正在连接…')
      $('saveBtn').disabled = true
      try {
        const result = await window.electronAPI.serverConfig.set({
          serverBaseUrl: url,
          allowHttp,
          updateFeedMode: currentUpdateFeedMode,
        })
        if (result && result.ok) {
          setMsg(`已连接 ${result.serverBaseUrl},窗口即将关闭…`, 'ok')
          setTimeout(() => {
            try {
              window.electronAPI.serverConfig.closePicker?.()
            } catch {}
          }, 800)
        } else {
          setMsg(result?.error || '保存失败', 'error')
          $('saveBtn').disabled = false
        }
      } catch (e) {
        setMsg(e?.message || '保存失败', 'error')
        $('saveBtn').disabled = false
      }
    }

    function renderUrlHint() {
      const hint = $('urlHint')
      const raw = $('serverUrl').value
      if (!raw.trim()) {
        hint.innerHTML = '支持站点根、<code>/api</code> 或 <code>/api/v1</code>,将自动归一化为站点根。'
        return
      }
      const preview = previewNormalize(raw)
      if (preview) {
        hint.innerHTML = `将连接 <span class="preview">${preview}</span>`
      } else {
        hint.textContent = '地址格式无法识别,请检查后重试'
      }
    }

    function setManualOpen(open) {
      $('manualBody').hidden = !open
      $('manualToggleIcon').textContent = open ? '▾' : '▸'
      $('manualToggleText').textContent = open ? '收起' : (currentBaseUrl ? '连接到其他服务器…' : '填写服务器地址…')
      $('manualToggle').setAttribute('aria-expanded', String(open))
    }

    async function loadCurrent() {
      if (!hasApi()) {
        statusMeta = ''
        statusProbeDetail = '未检测到桌面客户端桥接,请从 Mao 桌面应用打开本页'
        setStatus('error', '无法读取配置', '')
        setManualOpen(true)
        return
      }
      try {
        const info = await window.electronAPI.serverConfig.get()
        $('serverUrl').value = info.serverBaseUrl || ''
        $('allowHttp').checked = !!info.allowHttp
        currentBaseUrl = info.serverBaseUrl || ''
        currentHostKey = info.hostKey || hostKeyOf(currentBaseUrl)
        currentUpdateFeedMode = info.updateFeedMode || 'follow-site'
        history = Array.isArray(info.history) ? info.history : []
        renderHistory()
        renderUrlHint()

        if (!info.configured) {
          statusMeta = ''
          statusProbeDetail = '请在下方填写要连接的站点地址'
          setStatus('', '尚未配置服务器', '')
          $('statusWarn').hidden = true
          setManualOpen(true)
          return
        }

        const parts = []
        if (info.appVersion) parts.push('壳版本 ' + info.appVersion)
        if (currentHostKey) parts.push('命名空间 ' + currentHostKey)
        statusMeta = parts.join(' · ')
        statusProbeDetail = ''
        setStatus('', '读取中…', currentBaseUrl)
        if (info.envOverride) {
          $('statusWarn').textContent = '当前地址由环境变量 MAO_DESKTOP_SERVER_URL 锁定,界面修改不生效'
          $('statusWarn').hidden = false
        }
        setManualOpen(false)
        probeCurrent()
      } catch (e) {
        statusMeta = ''
        statusProbeDetail = e?.message || ''
        setStatus('error', '读取配置失败', '')
        setManualOpen(true)
      }
    }

    $('recheckBtn').addEventListener('click', probeCurrent)
    $('probeBtn').addEventListener('click', probeOnly)
    $('saveBtn').addEventListener('click', saveAndConnect)
    $('manualToggle').addEventListener('click', () => setManualOpen($('manualBody').hidden))
    $('serverUrl').addEventListener('input', () => {
      const cleaned = $('serverUrl').value.replace(/\s+/g, '')
      if (cleaned !== $('serverUrl').value) $('serverUrl').value = cleaned
      renderUrlHint()
    })
    $('serverUrl').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') saveAndConnect()
    })
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && hasApi()) {
        try { window.electronAPI.serverConfig.closePicker?.() } catch {}
      }
    })
    loadCurrent()
  </script>
</body>
</html>