/* dsfree2api web console */
(function () {
"use strict";
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
const view = $("#view");
const TITLES = {
dashboard: "仪表盘", models: "模型与站点", keys: "API Key", proxy: "代理线路",
turnstile: "Turnstile", playground: "对话调试", logs: "实时日志", settings: "设置"
};
let overviewTimer = null;
let logStop = null;
let lastOverview = null;
let pxMode = "basic"; // 代理线路页签:basic=基础线路 pool=代理池,重渲染后保留
/* ── helpers ─────────────────────────────────────────────── */
function toast(msg, kind) {
const t = $("#toast");
t.textContent = msg;
t.className = "toast " + (kind || "");
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.add("hidden"), 3200);
}
async function api(path, opts) {
opts = opts || {};
const init = { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: {} };
if (opts.signal) init.signal = opts.signal;
if (opts.body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
}
const res = await fetch(path, init);
let data = null;
try { data = await res.json(); } catch (e) { data = null; }
if (res.status === 401 && !path.endsWith("/login")) {
showLogin();
throw new Error("unauthorized");
}
if (!res.ok) {
const msg = (data && (data.error || data.detail)) || res.statusText;
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
}
return data;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
const fmtInt = n => (n || 0).toLocaleString("en-US");
const fmtPct = (a, b) => b ? (a * 100 / b).toFixed(1) + "%" : "0%";
const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(2) + "s" : Math.round(ms) + "ms";
const fmtBytes = n => n > 1048576 ? (n / 1048576).toFixed(1) + "MB" : n > 1024 ? (n / 1024).toFixed(1) + "KB" : n + "B";
const timeStr = t => new Date(t).toLocaleTimeString("zh-CN", { hour12: false });
function switchHTML(id, checked) {
// id 可能含模型名/站点码等配置里的任意字符,必须转义,否则闭合属性后
// 会把后面的 checked/span 拆成攻击者可控的标记。
return ``;
}
/* ── auth ────────────────────────────────────────────────── */
async function ensureSession() {
try {
const s = await api("/api/session");
if (s.authenticated) { $("#login").classList.add("hidden"); return true; }
} catch (e) { /* ignore */ }
showLogin();
return false;
}
function showLogin() {
$("#login").classList.remove("hidden");
setTimeout(() => $("#login-password").focus(), 50);
}
async function doLogin() {
const pw = $("#login-password").value;
if (!pw) return;
try {
await api("/api/login", { method: "POST", body: { password: pw } });
$("#login-error").textContent = "";
$("#login").classList.add("hidden");
$("#login-password").value = "";
route();
} catch (e) {
$("#login-error").textContent = e.message === "invalid password" ? "密码错误" : e.message;
}
}
/* ── router ──────────────────────────────────────────────── */
function route() {
if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; }
if (logStop) { logStop(); logStop = null; }
const hash = location.hash.replace(/^#\/?/, "") || "dashboard";
const page = (hash.split("?")[0] || "dashboard");
$$("#nav a").forEach(a => a.classList.toggle("active", a.dataset.page === page));
$("#page-title").textContent = TITLES[page] || page;
view.innerHTML = '
加载中…
';
const fn = PAGES[page] || PAGES.dashboard;
fn().catch(e => { view.innerHTML = `加载失败:${esc(e.message)}
`; });
}
/* ── overview poll ───────────────────────────────────────── */
async function fetchOverview() {
const ov = await api("/api/overview");
lastOverview = ov;
renderBadges(ov);
return ov;
}
function renderBadges(ov) {
const st = (ov.turnstile.status || []).filter(s => s.valid).length;
const total = (ov.sites || []).filter(s => s.enabled).length;
$("#sys-version").textContent = "v" + (ov.system.version || "?");
$("#sys-badges").innerHTML = `
运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m
${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)}
Turnstile ${st}/${total}
在线 ${fmtInt(ov.metrics.in_flight)}`;
}
/* ── pages ───────────────────────────────────────────────── */
const PAGES = {};
PAGES.dashboard = async () => {
view.innerHTML = `
`;
$("#btn-invalidate").onclick = async () => { await api("/api/actions", { body: { action: "invalidate" } }); toast("已清除 nonce 与 cookie 缓存", "ok"); };
$("#btn-reset").onclick = async () => { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("统计已清空", "ok"); refresh(); };
async function refresh() {
let ov;
try { ov = await fetchOverview(); } catch (e) { return; }
const m = ov.metrics;
$("#s-req").textContent = fmtInt(m.requests);
$("#s-rps").textContent = `${m.rps.toFixed(2)} req/s · 流式 ${fmtInt(m.streams)} · 运行中 ${fmtInt(m.in_flight)}`;
$("#s-err").textContent = fmtInt(m.errors);
$("#s-err").className = "stat-value " + (m.errors ? "err" : "ok");
$("#s-errpct").textContent = `错误率 ${fmtPct(m.errors, m.requests)} · 空响应 ${fmtInt(m.empty)} · 拒绝 ${fmtInt(m.unauthorized)} · 限流 ${fmtInt(m.rate_limited)}`;
$("#s-tok").textContent = fmtInt(m.prompt_tokens + m.completion_tokens);
$("#s-toksub").textContent = `输入 ${fmtInt(m.prompt_tokens)} · 输出 ${fmtInt(m.completion_tokens)}`;
$("#s-lat").textContent = m.latency.count ? `${fmtMs(m.latency.p50)} / ${fmtMs(m.latency.p95)}` : "–";
$("#s-latsub").textContent = m.latency.count ? `样本 ${m.latency.count} · 最大 ${fmtMs(m.latency.max)}` : "尚无样本";
const rows = Object.entries(m.models || {});
$("#tb-models").innerHTML = rows.length ? rows.map(([id, v]) => `
| ${esc(id)} |
${fmtInt(v.requests)} |
${fmtInt(v.errors)} |
${fmtInt(v.prompt_tokens + v.completion_tokens)} |
${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"} |
`).join("")
: '| 暂无数据 |
';
$("#site-cards").innerHTML = (ov.sites || []).map(s => {
const ts = (ov.turnstile.status || []).find(x => x.site === s.code) || {};
const ms = m.sites && m.sites[s.code];
const pct = ts.remaining_s > 0 ? Math.max(0, Math.min(100, ts.remaining_s / (ov.turnstile.config.cookie_ttl_seconds || 10800) * 100)) : 0;
return `
请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}
`;
}).join("") || '无站点
';
$$("[data-probe]").forEach(b => b.onclick = async () => {
b.disabled = true; b.textContent = "…";
try {
const r = await api("/api/actions", { body: { action: "probe_site", site: b.dataset.probe } });
toast(r.ok ? `${b.dataset.probe}: HTTP ${r.status} · ${r.ms}ms · 对话容器 ${r.chat_container ? "有" : "无"}` : `${b.dataset.probe}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
} catch (e) { toast(e.message, "err"); }
b.disabled = false; b.textContent = "探测";
});
$$("[data-quota]").forEach(b => b.onclick = async () => {
b.disabled = true; b.textContent = "…";
try {
const r = await api("/api/actions", { body: { action: "site_balance", site: b.dataset.quota } });
const period = r.reset_period === "daily" ? "每日" : (r.reset_period || "");
toast(r.ok ? `${b.dataset.quota}: 剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)} · 已用 ${fmtInt(r.used)} · ${period}重置` : `${b.dataset.quota}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
} catch (e) { toast(e.message, "err"); }
b.disabled = false; b.textContent = "额度";
});
$$("[data-rotate]").forEach(b => b.onclick = async () => {
b.disabled = true; b.textContent = "…";
try {
const r = await api("/api/actions", { body: { action: "rotate_guest", site: b.dataset.rotate } });
toast(r.ok ? `${b.dataset.rotate}: 已换新访客身份,剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)}` : `${b.dataset.rotate}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
} catch (e) { toast(e.message, "err"); }
b.disabled = false; b.textContent = "重置";
});
const rec = (m.recent || []).slice(0, 30);
$("#tb-recent").innerHTML = rec.length ? rec.map(r => `
| ${timeStr(r.at)} |
${esc(r.served_by || r.model)} |
${esc(r.site)} | ${esc(r.route || "–")} |
${r.stream ? "流式" : "同步"} |
${fmtMs(r.duration_ms)} |
${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))} |
${esc(r.status)}${r.error ? ` ${esc(r.error.slice(0, 60))}` : ""} |
`).join("")
: '| 暂无请求 |
';
}
await refresh();
overviewTimer = setInterval(refresh, 5000);
};
PAGES.models = async () => {
const ov = await fetchOverview();
view.innerHTML = `
`;
$("#tb").innerHTML = ov.models.map(m => `
| ${esc(m.id)} |
|
${esc(m.site)} | ${esc(m.upstream_id)} |
|
|
|
${switchHTML("sw-" + m.id, m.enabled)} |
|
`).join("");
$("#tb-sites").innerHTML = ov.sites.map(s => `
| ${esc(s.code.toUpperCase())} |
|
|
|
${switchHTML("sws-" + s.code, s.enabled)}
|
`).join("");
$$("[data-save]").forEach(b => b.onclick = async () => {
const tr = b.closest("tr"), id = b.dataset.save;
const g = f => $(`[data-f="${f}"]`, tr).value;
try {
await api("/api/actions", { body: { action: "update_model", id, label: g("label"), page_path: g("page_path"), bot_id: +g("bot_id"), post_id: +g("post_id") } });
toast("模型已保存", "ok");
} catch (e) { toast(e.message, "err"); }
});
$$("[data-savesite]").forEach(b => b.onclick = async () => {
const tr = b.closest("tr"), code = b.dataset.savesite;
const g = f => $(`[data-f="${f}"]`, tr).value;
try {
await api("/api/actions", { body: { action: "update_site", code, base_url: g("base_url"), ajax_url: g("ajax_url"), language: g("language") } });
toast("站点已保存", "ok");
} catch (e) { toast(e.message, "err"); }
});
ov.models.forEach(m => {
$("#sw-" + CSS.escape(m.id)).onchange = async ev => {
try { await api("/api/actions", { body: { action: "toggle_model", id: m.id, enabled: ev.target.checked } }); toast("已更新", "ok"); }
catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; }
};
});
ov.sites.forEach(s => {
$("#sws-" + CSS.escape(s.code)).onchange = async ev => {
try { await api("/api/actions", { body: { action: "toggle_site", id: s.code, enabled: ev.target.checked } }); toast("已更新", "ok"); }
catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; }
};
});
};
PAGES.keys = async () => {
const ov = await fetchOverview();
view.innerHTML = `
修改会立即生效并写入 config.toml(自动备份为 .bak)
`;
const render = keys => {
$("#tb").innerHTML = keys.length ? keys.map(k => `
${esc(mask(k))} | 启用 |
|
`).join("")
: '| 未配置任何 Key —— 当前不鉴权 |
';
$$("[data-copy]").forEach(b => b.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); toast("已复制", "ok"); });
$$("[data-del]").forEach(b => b.onclick = async () => {
if (!confirm("删除该 Key?")) return;
try { await api("/api/actions", { body: { action: "delete_key", key: b.dataset.del } }); toast("已删除", "ok"); PAGES.keys(); }
catch (e) { toast(e.message, "err"); }
});
};
render(ov.keys || []);
$("#btn-gen").onclick = async () => {
try {
const r = await api("/api/actions", { body: { action: "create_key" } });
if (r.ok) { toast("已生成", "ok"); PAGES.keys(); }
} catch (e) { toast(e.message, "err"); }
};
};
const mask = k => k.length > 16 ? k.slice(0, 7) + "…" + k.slice(-6) : k;
PAGES.proxy = async () => {
const ov = await fetchOverview();
const p = ov.proxy;
const pool = ov.proxypool || { xray: {}, entries: [], subscriptions: [], sticky: {} };
view.innerHTML = `
主线路
支持 http:// · https:// · socks5://
限流与故障切换
${switchHTML("px-cross", p.cross_site_failover)}
主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。
当前生效顺序:${ov.cache.routes.map(r => `${esc(r.name)} → ${esc(r.proxy)}`).join(" ")}
代理池 · 基础
${pool.last_error ? `${esc(pool.last_error)}` : "节点健康与订阅拉取在后台按间隔巡检。"}
Xray 核心
Xray:${pool.xray.running ? "运行中 · pid " + pool.xray.pid : "未运行"}
${pool.xray.error ? ` ${esc(pool.xray.error)}` : ""}
分享链接(vless / vmess / trojan / ss)由内置 Xray 核心承载;ip:port / socks5:// 等端点直接使用。
站点绑定(按顺序,粘性选路)
| 站点 | 绑定(逗号分隔:名称 或 sub:订阅名) | 当前出口 | |
|---|
未绑定、或绑定节点全部不健康时,回退到主线路 / 备用线路。Cookie 与出口 IP 绑定:切换出口后该站点会在下一轮巡检重新求解 Turnstile。
`;
const renderFb = list => {
$("#tb-fb").innerHTML = list.length ? list.map((u, i) => `
| ${i + 1} | ${esc(u)} |
|
`).join("")
: '| 无备用线路 |
';
$$("[data-rm]").forEach(b => b.onclick = async () => {
try { await api("/api/actions", { body: { action: "remove_fallback", url: b.dataset.rm } }); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
};
renderFb(p.fallbacks || []);
const renderPool = () => {
const entries = pool.entries || [];
$("#tb-pool").innerHTML = entries.length ? entries.map(e => {
const health = e.parse_error ? `解析失败`
: e.checked ? (e.healthy ? '健康' : `不可用`)
: '未探测';
return `${esc(e.name)}${e.source ? ` ← ${esc(e.source)}` : ""} |
${esc(e.kind === "url" ? e.scheme : (e.scheme || "xray"))} |
${esc(e.target || "")} |
${health} | ${e.latency_ms ? e.latency_ms + "ms" : "–"} |
${e.source ? "" : ``} |
`;
}).join("") : '| 尚无节点 —— 添加分享链接或 ip:port 端点 |
';
$$("[data-ptoggle]").forEach(b => b.onclick = async () => {
try { await api("/api/actions", { body: { action: "pool_toggle_entry", name: b.dataset.ptoggle, enabled: b.dataset.pen === "1" } }); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
$$("[data-pdel]").forEach(b => b.onclick = async () => {
if (!confirm("删除节点 " + b.dataset.pdel + "?")) return;
try { await api("/api/actions", { body: { action: "pool_delete_entry", name: b.dataset.pdel } }); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
const subs = pool.subscriptions || [];
$("#tb-subs").innerHTML = subs.length ? subs.map(s => `
${esc(s.name)} | ${esc(s.url)} |
${s.interval_minutes || 60}m | ${s.nodes || 0} |
${s.fetched_at ? timeStr(s.fetched_at * 1000) : "未拉取"}${s.last_error ? ` ${esc(String(s.last_error).slice(0, 60))}` : ""} |
|
`).join("")
: '| 尚无订阅 |
';
$$("[data-stoggle]").forEach(b => b.onclick = async () => {
try { await api("/api/actions", { body: { action: "pool_toggle_sub", name: b.dataset.stoggle, enabled: b.dataset.sen === "1" } }); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
$$("[data-sdel]").forEach(b => b.onclick = async () => {
if (!confirm("删除订阅 " + b.dataset.sdel + "?")) return;
try { await api("/api/actions", { body: { action: "pool_delete_sub", name: b.dataset.sdel } }); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
const sticky = pool.sticky || {};
$("#tb-bind").innerHTML = (ov.sites || []).map(s => {
const cur = sticky[s.code];
const curEntry = entries.find(e => e.name === cur);
return `| ${esc(s.code.toUpperCase())} |
|
${cur ? `${esc(cur)} → ${esc(curEntry ? curEntry.target : "?")}` : "全局线路"} |
|
`;
}).join("");
$$("[data-bsave]").forEach(b => b.onclick = async () => {
const input = $(`[data-bind="${CSS.escape(b.dataset.bsave)}"]`);
const list = input.value.split(",").map(x => x.trim()).filter(Boolean);
try { await api("/api/actions", { body: { action: "pool_set_site", site: b.dataset.bsave, proxies: list } }); toast("已保存", "ok"); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
});
};
renderPool();
// 「基础线路 / 代理池」两个按钮组各自带保存,共用同一提交逻辑
$$("[data-pool-save]").forEach(b => b.onclick = async () => {
try {
await api("/api/actions", { body: {
action: "pool_set_config",
enabled: $("#pool-en").checked,
check_interval_seconds: +$("#pool-check-int").value || 120,
check_timeout_seconds: +$("#pool-check-to").value || 10,
check_url: $("#pool-check-url").value.trim(),
default_scheme: $("#pool-scheme").value.trim() || "http",
xray_path: $("#pool-xray-path").value.trim(),
xray_version: $("#pool-xray-ver").value.trim(),
xray_auto_download: $("#pool-xray-auto").checked } });
toast("池配置已保存", "ok"); PAGES.proxy();
} catch (e) { toast(e.message, "err"); }
});
$("#pool-refresh").onclick = async () => {
const b = $("#pool-refresh"); b.disabled = true; b.textContent = "刷新中…";
try { await api("/api/actions", { body: { action: "pool_refresh" } }); toast("已在后台拉取订阅并检查健康,稍后重进本页查看", "ok"); PAGES.proxy(); }
catch (e) { toast(e.message, "err"); b.disabled = false; b.textContent = "刷新节点与健康检查"; }
};
$("#pool-add").onclick = async () => {
try {
await api("/api/actions", { body: { action: "pool_add_entry", name: $("#pool-name").value.trim(), link: $("#pool-link").value.trim() } });
toast("已添加", "ok"); PAGES.proxy();
} catch (e) { toast(e.message, "err"); }
};
$("#sub-add").onclick = async () => {
try {
await api("/api/actions", { body: { action: "pool_add_sub", name: $("#sub-name").value.trim(), url: $("#sub-url").value.trim(), interval_minutes: +$("#sub-int").value || 60 } });
toast("已添加", "ok"); PAGES.proxy();
} catch (e) { toast(e.message, "err"); }
};
$("#px-save").onclick = async () => {
try {
await api("/api/config", { method: "PUT", body: await merged(c => { c.proxy.url = $("#px-primary").value.trim(); }) });
toast("主线路已保存", "ok"); PAGES.proxy();
} catch (e) { toast(e.message, "err"); }
};
$("#px-test").onclick = async () => {
const b = $("#px-test"); b.disabled = true; b.textContent = "测试中…";
try {
const r = await api("/api/actions", { body: { action: "test_proxy", url: $("#px-primary").value.trim() } });
toast(r.ok ? `连通 · HTTP ${r.status} · ${r.ms}ms` : (r.error || "失败"), r.ok ? "ok" : "err");
} catch (e) { toast(e.message, "err"); }
b.disabled = false; b.textContent = "连通性测试";
};
$("#px-savelim").onclick = async () => {
try {
await api("/api/actions", { body: {
action: "set_limits", slow_start: +$("#px-slow").value,
max_concurrent: +$("#px-conc").value, rate_per_minute: +$("#px-rate").value,
cross_site_failover: $("#px-cross").checked } });
toast("已保存", "ok");
} catch (e) { toast(e.message, "err"); }
};
$("#px-add").onclick = async () => {
const v = $("#px-fb").value.trim(); if (!v) return;
try { await api("/api/actions", { body: { action: "add_fallback", url: v } }); $("#px-fb").value = ""; PAGES.proxy(); }
catch (e) { toast(e.message, "err"); }
};
// 顶部页签切换:基础线路 / 代理池 两组卡片三选一显隐
const syncPx = () => {
$$("#px-seg button").forEach(b => b.classList.toggle("on", b.dataset.m === pxMode));
$$(".px-mode").forEach(el => { el.style.display = el.dataset.mode === pxMode ? "" : "none"; });
$("#px-hint").textContent = pxMode === "basic"
? "上游请求的出口:主线路优先,失败或超时按备用线路顺序降级;限流与故障切换作用于全局。"
: "聚合分享链接与订阅节点,按站点粘性绑定出口;未绑定或全部不健康时回退到基础线路。";
};
$$("#px-seg button").forEach(b => b.onclick = () => { pxMode = b.dataset.m; syncPx(); });
syncPx();
};
async function merged(mutate) {
const cfg = await api("/api/config");
mutate(cfg);
return cfg;
}
PAGES.turnstile = async () => {
if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; }
const ov = await fetchOverview();
const t = ov.turnstile.config;
view.innerHTML = `
Token 获取方式
Cookie 与出口 IP / UA 绑定:请在同一代理线路的浏览器中获取。导入后在 TTL 内免求解。
求解超时与重试
只作用于自动求解(方式 1 / 2),手动导入不走这条路径。
已验证 Cookie
缓存 TTL ${t.cookie_ttl_seconds}s · 求解超时 ${t.timeout_seconds || 90}s · 重试 ${t.retries} 次
`;
$("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => `
${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}
`).join("");
const hist = ov.turnstile.history || [];
$("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => `
| ${timeStr(h.at)} | ${esc(h.site)} | ${esc(h.proxy)} |
${fmtMs(h.elapsed_ms)} |
${h.ok ? "成功" : "失败"}${h.error ? ` ${esc(h.error.slice(0, 70))}` : ""} |
`).join("")
: '| 尚无记录 |
';
const renderPool = (o) => {
const pool = (o.turnstile && o.turnstile.pool) || { entries: [] };
const label = $("#ts-pool-state");
if (label) {
label.textContent = `状态:${pool.enabled ? (pool.running ? "运行中" : "已启用,等待下一轮巡检") : "未开启"} · 阈值 剩余 ${Math.round((pool.ratio || 0.3) * 100)}% · 每 ${pool.check_seconds || 60}s 巡检一次`;
}
const box = $("#ts-pool");
if (!box) return;
const entries = pool.entries || [];
box.innerHTML = entries.length ? entries.map(e => {
const state = e.warming ? '预热中'
: e.valid ? '就绪'
: e.last_error ? '求解失败' : '未建立';
const left = e.valid ? `剩余 ${Math.max(0, Math.round(e.remaining_s / 60))} 分钟` : "等待预热";
const next = e.next_warm_at ? `下次 ${timeStr(e.next_warm_at * 1000)}` : "—";
const last = e.last_at ? `上次 ${timeStr(e.last_at * 1000)} · ${fmtMs(e.last_ms)}` : "尚未求解";
return `
${left} · ${next}
${last}${e.last_error ? `
${esc(String(e.last_error).slice(0, 90))}` : ""}
`;
}).join("") : '暂无池内记录 — 开启预热并等待下一轮巡检
';
};
renderPool(ov);
$$("[data-rf]").forEach(b => b.onclick = async () => {
b.disabled = true;
try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast(t.warm_enabled ? "已作废,Cookie 池将在下一轮巡检时重新预热" : "已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); }
catch (e) { toast(e.message, "err"); }
b.disabled = false;
});
// 所选方式存在局部变量里,seg 按钮只负责切换,不依赖隐藏的 select。
let provider = t.provider || "api";
$("#ts-style").value = t.api_style === "ezsolver" ? "ezsolver" : "sync";
const TS_HINTS = {
api: "调用求解服务 API 获取 Turnstile Token",
browser: "用下方配置的本地浏览器自动求解 Token",
manual: "不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)",
};
// 组件级显隐:方式 N 的字段只在选中时出现;超时重试 / 右侧池与历史
// 只在方式 1/2 出现;缓存卡与已验证 Cookie 恒显。求解恒开启,无开关。
const syncProvider = () => {
const solving = provider === "api" || provider === "browser";
$$("#ts-seg button").forEach(b => b.classList.toggle("on", b.dataset.p === provider));
$$(".ts-mode").forEach(el => { el.style.display = el.dataset.mode === provider ? "" : "none"; });
$$(".ts-solve-only").forEach(el => { el.style.display = solving ? "" : "none"; });
$("#ts-refresh").style.display = provider === "manual" ? "none" : "";
// Cookie 池卡片隐藏时连轮询一起停,manual 方式不再每 5s 拉一次 overview。
if (solving) {
if (!overviewTimer) overviewTimer = setInterval(() => { fetchOverview().then(renderPool).catch(() => {}); }, 5000);
} else if (overviewTimer) {
clearInterval(overviewTimer); overviewTimer = null;
}
$("#ts-hint").textContent = TS_HINTS[provider] || TS_HINTS.api;
};
$$("#ts-seg button").forEach(b => b.onclick = () => { provider = b.dataset.p; syncProvider(); });
syncProvider();
$("#ts-save").onclick = async () => {
try {
await api("/api/actions", { body: {
// 求解恒开启:控制台不再提供关闭入口,保存时强制 enabled=true,
// 避免误关后所有站点悄悄退化成直连上游。
action: "set_turnstile", enabled: true,
provider,
api_url: $("#ts-url").value.trim(),
api_key: $("#ts-key").value.trim(), sitekey: $("#ts-sitekey").value.trim(),
challenge_action: $("#ts-action").value.trim(), cookie_ttl_seconds: +$("#ts-ttl").value,
api_style: $("#ts-style").value,
timeout_seconds: Math.round(+$("#ts-timeout").value || 90),
retries: Math.round(+$("#ts-retries").value || 5),
retry_backoff_seconds: Math.max(0, +$("#ts-backoff").value || 0),
warm_enabled: $("#ts-warm").checked,
warm_ratio: Math.min(95, Math.max(5, Math.round(+$("#ts-ratio").value || 30))) / 100,
warm_check_seconds: Math.round(+$("#ts-check").value || 60),
browser_path: $("#ts-bpath").value.trim(),
browser_headless: $("#ts-bhead").checked,
browser_user_data_dir: $("#ts-bprof").value.trim(),
browser_timezone: $("#ts-btz").value.trim(),
browser_locale: $("#ts-bloc").value.trim() } });
toast("已保存", "ok"); PAGES.turnstile();
} catch (e) { toast(e.message, "err"); }
};
$("#ts-refresh").onclick = async () => {
try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废全部 cookie", "ok"); PAGES.turnstile(); }
catch (e) { toast(e.message, "err"); }
};
$("#ts-clear").onclick = async () => {
try { await api("/api/actions", { body: { action: "clear_cookies" } }); toast("已清空 Cookie", "ok"); PAGES.turnstile(); }
catch (e) { toast(e.message, "err"); }
};
$("#im-save").onclick = async () => {
try {
const r = await api("/api/actions", { body: {
action: "import_cookies", site: $("#im-site").value, cookies: $("#im-cookies").value } });
toast(`已导入 ${r.cookie_count} 个 Cookie,${timeStr(r.expires_at * 1000)} 到期`, "ok");
PAGES.turnstile();
} catch (e) { toast(e.message, "err"); }
};
};
PAGES.playground = async () => {
const ov = await fetchOverview();
const enabled = ov.models.filter(m => m.enabled);
view.innerHTML = `
请求
${switchHTML("pg-stream", true)}
`;
let ac = null, timer = null;
const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
$("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '尚未发送请求'; $("#pg-meta").textContent = ""; };
$("#pg-cancel").onclick = () => { if (ac) ac.abort(); };
$("#pg-send").onclick = async () => {
const input = $("#pg-input").value.trim();
if (!input) { toast("请输入内容", "err"); return; }
let tools = null;
const raw = $("#pg-tools").value.trim();
if (raw) { try { tools = JSON.parse(raw); } catch (e) { toast("Tools JSON 解析失败", "err"); return; } }
const messages = [{ role: "system", content: $("#pg-system").value }, { role: "user", content: input }];
const model = $("#pg-model").value;
const stream = $("#pg-stream").checked;
const out = $("#pg-out");
out.innerHTML = "";
const status = document.createElement("span");
status.className = "dim";
status.textContent = "连接中… 0.0s";
const caret = document.createElement("span"); caret.className = "caret";
out.appendChild(status); out.appendChild(caret);
ac = new AbortController();
const btn = $("#pg-send"), cancel = $("#pg-cancel");
btn.disabled = true; btn.textContent = "请求中…";
cancel.disabled = false;
$("#pg-meta").textContent = "";
const t0 = Date.now();
const tick = () => { status.textContent = `连接中… ${((Date.now() - t0) / 1000).toFixed(1)}s`; };
stopTimer(); timer = setInterval(tick, 100);
const finish = () => {
stopTimer(); ac = null;
btn.disabled = false; btn.textContent = "发送";
cancel.disabled = true;
if (!$("#pg-meta").textContent) $("#pg-meta").textContent = `${fmtMs(Date.now() - t0)}`;
};
const firstText = () => { if (status.parentNode) status.remove(); };
try {
if (stream) {
const res = await fetch("/api/playground", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages, stream: true, tools }), signal: ac.signal });
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || res.statusText); }
out.appendChild(caret);
const reader = res.body.getReader(), dec = new TextDecoder();
let buf = "", first = null;
for (;;) {
const { done, value } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, idx); buf = buf.slice(idx + 2);
if (!frame.startsWith("data: ")) continue;
const payload = frame.slice(6);
if (payload === "[DONE]") continue;
let j; try { j = JSON.parse(payload); } catch (e) { continue; }
if (j.error) { firstText(); out.insertBefore(document.createTextNode("\n[错误] " + j.error), caret); continue; }
if (j.delta) { if (!first) first = Date.now(); firstText(); out.insertBefore(document.createTextNode(j.delta), caret); }
if (j.done) { $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route}`; }
}
}
caret.remove();
} else {
const j = await api("/api/playground", { body: { model, messages, stream: false, tools }, signal: ac.signal });
out.textContent = j.text;
$("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route} · prompt ${j.prompt_chars} 字符`;
}
} catch (e) {
caret.remove();
if (e && e.name === "AbortError") {
out.innerHTML = '已取消';
} else {
out.innerHTML = `请求失败:${esc(e.message)}`;
}
}
finish();
};
};
PAGES.logs = async () => {
view.innerHTML = `
`;
const box = $("#lg-box");
let paused = false, es = null, gen = 0;
const minVal = { DEBUG: -4, INFO: 0, WARN: 4, ERROR: 8 };
function append(e) {
if (paused) return;
const sel = $("#lg-level");
if (sel && minVal[e.level] < minVal[sel.value]) return;
const line = document.createElement("div");
line.className = "log-line";
const attrs = e.attrs ? Object.entries(e.attrs).map(([k, v]) => `${k}=${v}`).join(" ") : "";
line.innerHTML = `${timeStr(e.ts)} ${e.level} ${esc(e.msg)}${attrs ? ' ' + esc(attrs) + "" : ""}`;
box.appendChild(line);
while (box.children.length > 600) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
function stopES() { if (es) { es.close(); es = null; } }
// (Re)load history and reopen the SSE stream with the currently selected
// minimum level. Never re-render the page here: rebuilding the select would
// reset it to INFO before the value is read, and would leak the old stream.
// History comes solely from the server replay on connect (and on every
// automatic reconnect), so the box is cleared in onopen instead of fetching
// the JSON endpoint separately — otherwise entries show up twice.
async function load() {
const my = ++gen;
const level = $("#lg-level").value;
stopES();
box.innerHTML = "";
es = new EventSource("/api/logs?stream=1&level=" + level);
es.onmessage = ev => { if (my !== gen) return; try { append(JSON.parse(ev.data)); } catch (e) {} };
es.onerror = () => { if (my !== gen) return; const st = $("#lg-state"); if (st) st.textContent = "连接中断,自动重连中…"; };
es.onopen = () => {
if (my !== gen) return;
box.innerHTML = "";
const st = $("#lg-state"); if (st) st.textContent = "实时连接中…";
};
logStop = () => { gen++; stopES(); };
}
$("#lg-toggle").onclick = e => { paused = !paused; e.target.textContent = paused ? "继续" : "暂停"; };
$("#lg-clear").onclick = () => { box.innerHTML = ""; };
$("#lg-level").onchange = load;
await load();
};
PAGES.settings = async () => {
const cfg = await api("/api/config");
view.innerHTML = `
危险操作
配置保存会自动备份原文件为 config.toml.bak;修改端口/密码需重启服务生效。
`;
const put = async mutate => {
try {
const c = await api("/api/config");
mutate(c);
await api("/api/config", { method: "PUT", body: c });
toast("已保存并写入 config.toml", "ok");
return true;
} catch (e) { toast(e.message, "err"); return false; }
};
$("#st-save").onclick = () => put(c => {
c.server.host = $("#st-host").value;
c.server.port = +$("#st-port").value;
c.server.log_level = $("#st-log").value;
c.admin.host = $("#st-ah").value;
c.admin.port = +$("#st-ap").value;
if ($("#st-pw").value) c.admin.password = $("#st-pw").value;
});
$("#st-save2").onclick = () => put(c => {
c.upstream.timeout = +$("#st-to").value;
c.upstream.stream_timeout = +$("#st-sto").value;
c.upstream.config_ttl_seconds = +$("#st-ttl").value;
c.upstream.refresh_retries = +$("#st-retry").value;
c.upstream.retry_backoff_seconds = +$("#st-back").value;
c.upstream.auto_refresh = $("#st-ar").checked;
c.upstream.user_agent = $("#st-ua").value;
c.runtime.data_dir = $("#st-dir").value;
});
$("#st-reset").onclick = async () => { try { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("已清空", "ok"); } catch (e) { toast(e.message, "err"); } };
$("#st-inv").onclick = async () => { try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废", "ok"); } catch (e) { toast(e.message, "err"); } };
};
/* ── boot ────────────────────────────────────────────────── */
window.addEventListener("hashchange", route);
$("#login-btn").onclick = doLogin;
$("#login-password").addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); });
$("#logout-btn").onclick = async () => { try { await api("/api/logout", { method: "POST" }); } catch (e) {} location.hash = "#/dashboard"; showLogin(); };
(async function init() {
if (await ensureSession()) route();
})();
})();