feat: DSH编排基建首版(server/tests 88用例/P2插件/8020端口/PORT-NOTE/MOBILE_APP)

This commit is contained in:
2026-09-12 14:20:02 +08:00
commit f6acd20325
128 changed files with 24471 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0f1115">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<title>dsh 同步</title>
<style>
:root {
--bg: #0f1115; --card: #1a1d24; --card2: #22262f; --line: #2c313c;
--text: #e8eaf0; --muted: #8b93a3; --acc: #4f8cff; --ok: #3fb96f; --warn: #e0a83c; --err: #e05a5a;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
header { padding: calc(14px + env(safe-area-inset-top)) 16px 10px; font-weight: 700; font-size: 20px; display: flex; align-items: center; justify-content: space-between; }
header .sub { font-size: 12px; color: var(--muted); font-weight: 400; }
main { padding: 0 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.card h3 { font-size: 14px; margin-bottom: 8px; word-break: break-all; }
.muted { color: var(--muted); font-size: 12px; }
.row { display: flex; gap: 8px; align-items: center; }
.grow { flex: 1; }
input, select, textarea { width: 100%; background: var(--card2); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; outline: none; }
input:focus, textarea:focus { border-color: var(--acc); }
textarea { min-height: 64px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
button { background: var(--acc); color: #fff; border: 0; border-radius: 10px; padding: 10px 16px; font: inherit; font-weight: 600; }
button.ghost { background: var(--card2); color: var(--text); border: 1px solid var(--line); }
button.danger { background: transparent; color: var(--err); border: 1px solid var(--err); }
button:active { opacity: .7; }
.tag { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 99px; background: var(--card2); color: var(--muted); margin-left: 6px; vertical-align: 2px; }
.tag.team { color: var(--warn); } .tag.user { color: var(--ok); } .tag.beta { color: var(--acc); }
nav { position: fixed; bottom: 0; left: 0; right: 0; display: flex; background: var(--card); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
nav button { flex: 1; background: none; color: var(--muted); font-size: 12px; font-weight: 500; padding: 10px 0 8px; border-radius: 0; }
nav button.on { color: var(--acc); }
nav button span { display: block; font-size: 19px; }
.toast { position: fixed; top: calc(14px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); background: var(--card2); border: 1px solid var(--line); color: var(--text); padding: 10px 18px; border-radius: 12px; font-size: 13px; z-index: 99; max-width: 88vw; }
.toast.err { border-color: var(--err); color: var(--err); }
pre.val { background: var(--card2); border-radius: 10px; padding: 10px; font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
#view-login { max-width: 420px; margin: 12vh auto 0; padding: 0 20px; }
#view-login h1 { text-align: center; font-size: 26px; margin-bottom: 4px; }
#view-login p { text-align: center; color: var(--muted); margin-bottom: 22px; }
.hidden { display: none !important; }
details summary { cursor: pointer; font-size: 13px; color: var(--acc); }
.qrbx { background: #fff; border-radius: 14px; padding: 10px; display: flex; justify-content: center; }
.qrbx img { width: min(230px, 72vw); height: auto; }
#scan-view { position: fixed; inset: 0; background: #000; z-index: 98; display: flex; flex-direction: column; }
#scan-view video { flex: 1; object-fit: cover; width: 100%; }
</style>
</head>
<body>
<div id="view-login">
<h1>dsh 同步</h1>
<p>连接你的 DeepSeek Harness 团队服务端</p>
<div class="card">
<div class="muted" style="margin-bottom:6px">服务端地址</div>
<input id="lg-url" placeholder="http://192.168.x.x:8000" autocapitalize="off" autocorrect="off">
<div style="height:12px"></div>
<div class="row" style="margin-bottom:12px">
<select id="lg-mode" class="grow">
<option value="invite">邀请码注册(新成员)</option>
<option value="token">已有 Token(直接粘贴)</option>
</select>
</div>
<div id="lg-invite-box">
<div class="muted" style="margin-bottom:6px">用户名</div>
<input id="lg-user" autocapitalize="off" autocorrect="off">
<div style="height:10px"></div>
<div class="muted" style="margin-bottom:6px">邀请码</div>
<input id="lg-invite" autocapitalize="off" autocorrect="off">
</div>
<div id="lg-token-box" class="hidden">
<div class="muted" style="margin-bottom:6px">Token(dsh_ 开头)</div>
<textarea id="lg-token" style="min-height:52px" autocapitalize="off" autocorrect="off"></textarea>
</div>
<div style="height:14px"></div>
<button id="lg-go" style="width:100%">连 接</button>
<div style="height:10px"></div>
<button id="lg-scan" class="ghost" style="width:100%">📷 扫码连接(对准服务端二维码)</button>
<div style="height:10px"></div>
<a href="/apk" style="display:block;width:100%;text-align:center;padding:9px;border:1px solid var(--line);border-radius:10px;color:var(--muted);text-decoration:none;font-size:13px">🤖 装安卓 App(地址免手输)</a>
</div>
</div>
<div id="view-main" class="hidden">
<header>dsh 同步 <span class="sub" id="hd-user"></span></header>
<main>
<section id="tab-settings">
<details class="card" id="connect-card"><summary>📱 连接手机(扫码免手输)</summary>
<div id="conn-body" class="muted" style="margin-top:10px">加载中…</div>
</details>
<div class="card"><div class="muted">生效配置 = 内置默认 &lt; 团队设置 &lt; 个人设置。长按/点击值可编辑。</div></div>
<div id="set-list"></div>
<details class="card"><summary>+ 新增设置项</summary>
<div style="height:10px"></div>
<input id="set-newkey" placeholder="key,如 model.temperature" autocapitalize="off">
<div style="height:8px"></div>
<textarea id="set-newval" placeholder='值(JSON 或普通文本),如 "dark" 或 0.7'></textarea>
<div style="height:8px"></div>
<div class="row">
<select id="set-newscope" class="grow"><option value="user">个人设置</option><option value="team">团队设置(仅 admin)</option></select>
<button id="set-add">保存</button>
</div>
</details>
</section>
<section id="tab-plugins" class="hidden">
<div id="plug-list"></div>
<details class="card" id="plug-upload-card"><summary>⬆ 发布新版本(仅 admin)</summary>
<div style="height:10px"></div>
<input type="file" id="plug-file" accept=".tar.gz,.tgz" style="color:var(--muted)">
<div style="height:10px"></div>
<button id="plug-up" style="width:100%">上传插件制品(tar.gz,内含 manifest.json)</button>
</details>
</section>
<section id="tab-admin" class="hidden">
<div class="card">
<h3>创建成员</h3>
<div class="row"><input id="ad-user" placeholder="用户名" class="grow" autocapitalize="off">
<select id="ad-role"><option value="member">member</option><option value="admin">admin</option></select></div>
<div style="height:8px"></div>
<button id="ad-create" style="width:100%">创建并发放 Token</button>
<pre id="ad-newtoken" class="val hidden" style="margin-top:8px"></pre>
</div>
<div class="card"><h3>审计日志</h3><div id="ad-audit" class="muted">加载中…</div></div>
</section>
<section id="tab-me" class="hidden">
<div class="card"><h3>我的身份</h3><pre id="me-info" class="val"></pre></div>
<div class="card"><h3>我的 Token</h3><div id="me-tokens"></div></div>
<div class="card"><button id="me-logout" class="danger" style="width:100%">退出登录(清除本机 Token)</button></div>
</section>
</main>
<nav>
<button data-t="settings" class="on"><span>⚙︎</span>设置</button>
<button data-t="plugins"><span>⬇</span>插件</button>
<button data-t="admin" id="nav-admin" class="hidden"><span>🛡</span>管理</button>
<button data-t="me"><span>👤</span>我的</button>
</nav>
</div>
<div id="scan-view" class="hidden">
<video id="scan-video" playsinline muted></video>
<div class="row" style="padding:12px"><button id="scan-cancel" class="grow" style="background:var(--card2);color:var(--text)">取消扫码</button></div>
</div>
<script>
"use strict";
const $ = id => document.getElementById(id);
const S = { url: localStorage.getItem("dsh_url") || "", token: localStorage.getItem("dsh_token") || "",
me: null, settings: null };
function toast(msg, err) {
const d = document.createElement("div");
d.className = "toast" + (err ? " err" : ""); d.textContent = msg;
document.body.appendChild(d); setTimeout(() => d.remove(), 2600);
}
async function api(method, path, body, extra) {
const opt = { method, headers: { Authorization: "Bearer " + S.token, ...(extra && extra.headers) } };
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
const resp = await fetch(S.url + path, opt);
if (resp.status === 401) { logout(); throw new Error("登录已失效,请重新连接"); }
if (resp.status === 304) return { status: 304 };
const text = await resp.text();
const data = text ? JSON.parse(text) : {};
if (!resp.ok) throw new Error(typeof data.detail === "object" ? JSON.stringify(data.detail) : (data.detail || ("HTTP " + resp.status)));
return { status: resp.status, data, headers: resp.headers };
}
const show = v => JSON.stringify(v);
/* ---------------- login ---------------- */
$("lg-mode").onchange = () => {
const inv = $("lg-mode").value === "invite";
$("lg-invite-box").classList.toggle("hidden", !inv);
$("lg-token-box").classList.toggle("hidden", inv);
};
$("lg-go").onclick = async () => {
S.url = $("lg-url").value.trim().replace(/\/+$/, "");
try {
if ($("lg-mode").value === "invite") {
const r = await fetch(S.url + "/v1/tokens", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: $("lg-user").value.trim(), invite_code: $("lg-invite").value.trim() }) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "HTTP " + r.status);
S.token = d.token;
} else {
S.token = $("lg-token").value.trim();
}
const me = await api("GET", "/v1/me");
S.me = me.data;
localStorage.setItem("dsh_url", S.url); localStorage.setItem("dsh_token", S.token);
enterMain();
} catch (e) { toast("连接失败:" + e.message, true); }
};
function logout() {
localStorage.removeItem("dsh_url"); localStorage.removeItem("dsh_token");
location.reload();
}
/* ---------------- main ---------------- */
async function enterMain() {
$("view-login").classList.add("hidden");
$("view-main").classList.remove("hidden");
$("hd-user").textContent = (S.me.username || "") + (S.me.role === "admin" ? " · admin" : "");
$("nav-admin").classList.toggle("hidden", S.me.role !== "admin");
$("connect-card").classList.toggle("hidden", S.me.role !== "admin");
await loadSettings(); await loadPlugins();
if (S.me.role === "admin") loadAdmin();
loadMe();
}
document.querySelectorAll("nav button").forEach(b => b.onclick = () => {
document.querySelectorAll("nav button").forEach(x => x.classList.toggle("on", x === b));
["settings", "plugins", "admin", "me"].forEach(t => $("tab-" + t).classList.toggle("hidden", t !== b.dataset.t));
if (b.dataset.t === "settings") loadSettings();
if (b.dataset.t === "plugins") loadPlugins();
if (b.dataset.t === "admin" && S.me.role === "admin") loadAdmin();
if (b.dataset.t === "me") loadMe();
});
/* ---------------- settings ---------------- */
async function loadSettings() {
try {
const r = await api("GET", "/v1/settings");
S.settings = r.data.settings;
renderSettings();
} catch (e) { toast(e.message, true); }
}
function renderSettings() {
const box = $("set-list"); box.innerHTML = "";
const keys = Object.keys(S.settings).sort();
if (!keys.length) { box.innerHTML = '<div class="card muted">暂无设置。团队设置由 admin 维护。</div>'; return; }
for (const k of keys) {
const it = S.settings[k];
const c = document.createElement("div"); c.className = "card";
c.innerHTML = `<h3>${k}<span class="tag ${it.scope}">${it.scope === "team" ? "团队" : "个人"} v${it.version}</span></h3>
<pre class="val">${show(it.value)}</pre>
<div class="row" style="margin-top:8px">
<button class="ghost grow" data-edit="${k}">编辑此值</button>
</div>`;
box.appendChild(c);
}
box.querySelectorAll("[data-edit]").forEach(b => b.onclick = () => editSetting(b.dataset.edit));
}
function editSetting(key) {
const it = S.settings[key];
const val = prompt(`修改 ${key}\n(JSON 或普通文本)`, show(it.value));
if (val === null) return;
let parsed; try { parsed = JSON.parse(val); } catch { parsed = val; }
const body = { value: parsed, base_version: it.version };
const scope = it.scope === "team" ? "team" : "user";
api("PUT", `/v1/settings/${scope}/${encodeURIComponent(key)}`, body)
.then(() => { toast("已保存"); loadSettings(); })
.catch(async e => {
if (String(e.message).includes("version conflict")) {
if (confirm(`「${key}」在服务器上已被他人更新。\n\n用你的值强制覆盖(last-writer-wins)?`)) {
api("PUT", `/v1/settings/${scope}/${encodeURIComponent(key)}`, { value: parsed })
.then(() => { toast("已强制覆盖"); loadSettings(); }).catch(err => toast(err.message, true));
}
} else toast(e.message, true);
});
}
$("set-add").onclick = async () => {
const key = $("set-newkey").value.trim(), raw = $("set-newval").value, scope = $("set-newscope").value;
if (!key) return toast("需要 key", true);
let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; }
try {
await api("PUT", `/v1/settings/${scope}/${encodeURIComponent(key)}`, { value: parsed, base_version: 0 });
toast("已新增"); $("set-newkey").value = ""; $("set-newval").value = ""; loadSettings();
} catch (e) {
if (String(e.message).includes("version conflict")) {
if (confirm("该项已存在,强制覆盖?"))
api("PUT", `/v1/settings/${scope}/${encodeURIComponent(key)}`, { value: parsed })
.then(() => { toast("已覆盖"); loadSettings(); }).catch(err => toast(err.message, true));
} else toast(e.message, true);
}
};
/* ---------------- plugins ---------------- */
async function loadPlugins() {
try {
const r = await api("GET", "/v1/plugins");
const box = $("plug-list"); box.innerHTML = "";
const ps = r.data.plugins;
if (!ps.length) { box.innerHTML = '<div class="card muted">还没有插件。admin 可在下方发布 tar.gz。</div>'; return; }
for (const p of ps) {
const c = document.createElement("div"); c.className = "card";
const latest = p.latest ? `<span class="tag">latest v${p.latest.version}</span>` : "";
const dl = p.latest ? `<a class="ghost" style="text-decoration:none;display:inline-block;color:var(--acc)" href="${S.url}/v1/plugins/${p.id}/${p.latest.version}/download">下载最新制品</a>` : "";
c.innerHTML = `<h3>${p.id}${latest}</h3><div class="muted">${p.description || ""}</div>
<div style="margin-top:8px">${p.versions.map(v =>
`<div class="row" style="margin-bottom:4px"><span class="grow" style="font-size:13px">v${v.version}<span class="tag ${v.channel}">${v.channel}</span></span>
${S.me.role === "admin" ? `<button class="danger" data-del="${p.id}|${v.version}">删</button>` : ""}</div>
<div class="muted" style="word-break:break-all;margin-bottom:6px">sha256 ${v.sha256.slice(0, 16)}… · ${(v.size_bytes/1024).toFixed(1)}KB</div>`).join("")}
${dl}</div>`;
box.appendChild(c);
}
box.querySelectorAll("[data-del]").forEach(b => b.onclick = async () => {
const [id, ver] = b.dataset.del.split("|");
if (!confirm(`删除插件 ${id} v${ver}?(不可恢复)`)) return;
try { await api("DELETE", `/v1/plugins/${id}/${ver}`); toast("已删除"); loadPlugins(); } catch (e) { toast(e.message, true); }
});
} catch (e) { toast(e.message, true); }
}
$("plug-up").onclick = async () => {
const f = $("plug-file").files[0];
if (!f) return toast("先选择 tar.gz 文件", true);
const fd = new FormData(); fd.append("file", f);
try {
const resp = await fetch(S.url + "/v1/plugins/" + (prompt("插件 id(manifest.id 需一致)") || ""), {
method: "POST", headers: { Authorization: "Bearer " + S.token }, body: fd });
const d = await resp.json();
if (!resp.ok) throw new Error(d.detail || "HTTP " + resp.status);
toast(`已发布 ${d.id} v${d.version}`); loadPlugins();
} catch (e) { toast(e.message, true); }
};
/* ---------------- admin ---------------- */
async function loadAdmin() {
try {
const r = await api("GET", "/v1/audit?limit=30");
$("ad-audit").innerHTML = r.data.events.map(e =>
`<div style="margin-bottom:6px;font-size:12px"><b>${e.action}</b> · ${e.created_at}<br><span class="muted">${show(e.detail)}</span></div>`).join("")
|| "暂无记录";
} catch (e) { $("ad-audit").textContent = e.message; }
}
$("ad-create").onclick = async () => {
try {
const r = await api("POST", "/v1/users", { username: $("ad-user").value.trim(), role: $("ad-role").value });
const pre = $("ad-newtoken"); pre.classList.remove("hidden");
pre.textContent = `${r.data.user.username} 的 Token(只显示一次,请立即复制):\n${r.data.token}`;
} catch (e) { toast(e.message, true); }
};
/* ---------------- me ---------------- */
async function loadMe() {
try {
const me = await api("GET", "/v1/me");
$("me-info").textContent = show(me.data) + "\n\n服务端: " + S.url;
const t = await api("GET", "/v1/tokens");
$("me-tokens").innerHTML = t.data.tokens.map(x =>
`<div class="row" style="margin-bottom:6px"><span class="grow muted" style="word-break:break-all">${x.name} · ${x.token_hash.slice(0,10)}…</span>
<button class="danger" data-rv="${x.token_hash}">撤销</button></div>`).join("") || '<span class="muted">无</span>';
$("me-tokens").querySelectorAll("[data-rv]").forEach(b => b.onclick = async () => {
if (!confirm("撤销该 Token?使用它的设备将立即失效。")) return;
try { await api("DELETE", "/v1/tokens/" + b.dataset.rv); toast("已撤销"); loadMe(); } catch (e) { toast(e.message, true); }
});
} catch (e) { toast(e.message, true); }
}
$("me-logout").onclick = logout;
/* ---------------- connect (scan-to-connect) ---------------- */
function applyConnectPayload(text) {
/* Accepts: deep link http(s)://host/app#invite=x, JSON {"url","invite"}, plain URL. */
text = (text || "").trim();
if (!text) return false;
const pickInvite = v => {
if (!v) return false;
$("lg-invite").value = v;
$("lg-mode").value = "invite"; $("lg-mode").onchange();
return true;
};
try {
const j = JSON.parse(text);
if (j && typeof j === "object" && (j.url || j.invite)) {
if (j.url) $("lg-url").value = String(j.url).replace(/\/+$/, "");
return pickInvite(j.invite) || !!j.url;
}
} catch { /* not JSON */ }
if (/^https?:\/\//i.test(text)) {
try {
const u = new URL(text);
$("lg-url").value = u.origin;
const inv = new URLSearchParams(u.hash.slice(1)).get("invite") || u.searchParams.get("invite");
pickInvite(inv);
return true;
} catch { return false; }
}
return false;
}
$("lg-scan").onclick = async () => {
if (!("BarcodeDetector" in window)) {
toast("此浏览器不支持应用内扫码:请用系统「相机」App 扫设置页里的二维码", true); return;
}
if (!window.isSecureContext) {
toast("HTTP 环境浏览器禁止调用摄像头:请用系统「相机」App 扫码,或走 HTTPS 部署", true); return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
} catch (e) { toast("无法打开摄像头:" + e.message, true); return; }
const video = $("scan-video");
$("scan-view").classList.remove("hidden");
video.srcObject = stream; await video.play();
const detector = new window.BarcodeDetector();
const stop = () => { clearInterval(timer); stream.getTracks().forEach(t => t.stop()); $("scan-view").classList.add("hidden"); };
$("scan-cancel").onclick = stop;
const timer = setInterval(async () => {
try {
const codes = await detector.detect(video);
if (codes.length && applyConnectPayload(codes[0].rawValue)) { stop(); toast("扫码成功,输入用户名即可连接"); }
} catch { /* transient decode errors are fine */ }
}, 300);
};
let connLoaded = false;
$("connect-card").addEventListener("toggle", () => {
if ($("connect-card").open && S.me.role === "admin") loadConnect();
});
async function loadConnect() {
if (connLoaded) return;
const box = $("conn-body");
try {
const info = await api("GET", "/v1/connect/info");
const urls = info.data.base_urls || [];
const invite = info.data.invite_code || "";
box.innerHTML = `
<div class="qrbx"><img id="conn-qr" alt="连接二维码"></div>
${invite ? `<div class="row" style="margin:10px 0"><span class="grow muted">邀请码 <b style="color:var(--text)">${invite}</b></span>
<button class="ghost" id="conn-copy">复制</button></div>` : ""}
<div class="muted" style="margin-bottom:6px">地址候选(多网卡时点选,二维码随之切换):</div>
<div id="conn-urls"></div>
<div class="muted" style="margin-top:10px">手机用「相机」App 对准二维码 → 自动打开本页并填好邀请码 → 输入用户名 → 连接。</div>`;
const showQr = async u => {
try {
const host = new URL(u).hostname;
const r = await fetch(S.url + "/v1/connect/qr?host=" + encodeURIComponent(host),
{ headers: { Authorization: "Bearer " + S.token } });
$("conn-qr").src = URL.createObjectURL(await r.blob());
box.querySelectorAll("[data-url]").forEach(b =>
b.style.borderColor = new URL(b.dataset.url).hostname === host ? "var(--acc)" : "var(--line)");
} catch (e) { toast(e.message, true); }
};
const ubox = box.querySelector("#conn-urls");
for (const u of urls) {
const b = document.createElement("button");
b.className = "ghost"; b.style.cssText = "width:100%;margin-bottom:6px;font-size:12px;word-break:break-all;text-align:left";
b.textContent = u; b.dataset.url = u;
b.onclick = () => showQr(u);
ubox.appendChild(b);
}
const cp = box.querySelector("#conn-copy");
if (cp) cp.onclick = async () => {
try { await navigator.clipboard.writeText(invite); toast("已复制"); }
catch { toast("复制失败(HTTP 环境限制),请手动长按选择", true); }
};
if (urls.length) await showQr(urls[0]);
connLoaded = true;
} catch (e) { box.textContent = "加载失败:" + e.message; }
}
/* ---------------- boot ---------------- */
(async function boot() {
if ("serviceWorker" in navigator && window.isSecureContext) {
/* offline shell + last-known API cache (HTTPS/localhost only; LAN HTTP skips) */
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
$("lg-url").value = S.url;
if (new URLSearchParams(location.hash.slice(1)).get("invite")) {
if (applyConnectPayload(location.href)) toast("已从二维码填入连接信息,输入用户名即可连接");
history.replaceState(null, "", location.pathname);
}
if (!$("lg-url").value) $("lg-url").value = location.origin;
if (!S.url || !S.token) return;
try {
const me = await api("GET", "/v1/me");
S.me = me.data; enterMain();
} catch { /* stay on login */ }
})();
</script>
</body>
</html>