482 lines
24 KiB
HTML
482 lines
24 KiB
HTML
<!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">生效配置 = 内置默认 < 团队设置 < 个人设置。长按/点击值可编辑。</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>
|