Files

482 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>