+
+
代理池 · 基础
+
+
${switchHTML("pool-en", pool.enabled)}
+
+
+
+
+
+
+
+
+
${pool.last_error ? `${esc(pool.last_error)}` : "节点健康与订阅拉取在后台按间隔巡检。"}
-
-
+
Xray 核心
-
-
${switchHTML("pool-xray-auto", pool.xray_auto_download)}
+
+
+
${switchHTML("pool-xray-auto", pool.xray_auto_download)}
+
+
+
Xray:${pool.xray.running ? "运行中 · pid " + pool.xray.pid : "未运行"}
+ ${pool.xray.error ? ` ${esc(pool.xray.error)}` : ""}
-
-
-
-
-
Xray:${pool.xray.running ? "运行中 · pid " + pool.xray.pid : "未运行"}
- ${pool.xray.error ? ` ${esc(pool.xray.error)}` : ""}
- ${pool.last_error ? `
${esc(pool.last_error)}` : ""}
-
节点与端点
+
分享链接(vless / vmess / trojan / ss)由内置 Xray 核心承载;ip:port / socks5:// 等端点直接使用。
-
-
-
站点绑定(按顺序,粘性选路)
-
| 站点 | 绑定(逗号分隔:名称 或 sub:订阅名) | 当前出口 | |
|---|
-
未绑定、或绑定节点全部不健康时,回退到主线路 / 备用线路。Cookie 与出口 IP 绑定:切换出口后该站点会在下一轮巡检重新求解 Turnstile。
+
+
站点绑定(按顺序,粘性选路)
+
| 站点 | 绑定(逗号分隔:名称 或 sub:订阅名) | 当前出口 | |
|---|
+
未绑定、或绑定节点全部不健康时,回退到主线路 / 备用线路。Cookie 与出口 IP 绑定:切换出口后该站点会在下一轮巡检重新求解 Turnstile。
+
`;
const renderFb = list => {
@@ -472,7 +492,8 @@ PAGES.proxy = async () => {
};
renderPool();
- $("#pool-save").onclick = async () => {
+ // 「基础线路 / 代理池」两个按钮组各自带保存,共用同一提交逻辑
+ $$("[data-pool-save]").forEach(b => b.onclick = async () => {
try {
await api("/api/actions", { body: {
action: "pool_set_config",
@@ -486,7 +507,7 @@ PAGES.proxy = async () => {
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(); }
@@ -533,6 +554,17 @@ PAGES.proxy = async () => {
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) {
@@ -547,90 +579,95 @@ PAGES.turnstile = async () => {
const t = ov.turnstile.config;
view.innerHTML = `
-
求解器配置
-
-
-
${switchHTML("ts-en", t.enabled)}
-
-
-
方式 1 · 求解服务 API
-
-
-
-
-
+
+
+
Token 获取方式
+
+
+
+
+
+
+
+
+
+
Sitekey / Action 是全局兜底:站点未单独配置时,方式 2 的浏览器求解也用它们。
-
-
-
方式 2 · 本地浏览器自动获取
-
-
-
-
${switchHTML("ts-bhead", !!t.browser_headless)}
-
-
+
+
+
+
+
${switchHTML("ts-bhead", !!t.browser_headless)}
+
+
+
+
+
CDP 直连浏览器,无需 Playwright / Docker;求解期间会短暂打开一个窗口(无头模式除外)。
-
-
CDP 直连浏览器,无需 Playwright / Docker;求解期间会短暂打开一个窗口(无头模式除外)。
-
-
-
方式 3 · 手动导入 Cookie
-
-
+
+
+
Cookie 与出口 IP / UA 绑定:请在同一代理线路的浏览器中获取。导入后在 TTL 内免求解。
+
-
-
Cookie 与出口 IP / UA 绑定:请在同一代理线路的浏览器中获取。导入后在 TTL 内免求解,状态见右侧「已验证 Cookie」。
-
+
+
-
-
求解与 Cookie 池
+
+
+
求解超时与重试
+
只作用于自动求解(方式 1 / 2),手动导入不走这条路径。
+
+
+
+
Cookie 缓存(通用)
+
+
三种方式共用:自动求解出的与手动导入的 Cookie 都按此时长缓存,到期后自动重新获取。
+
+
+
+
+
已验证 Cookie
+
+
+
+
+
+
缓存 TTL ${t.cookie_ttl_seconds}s · 求解超时 ${t.timeout_seconds || 90}s · 重试 ${t.retries} 次
+
+
Cookie 池
-
${switchHTML("ts-warm", !!t.warm_enabled)}
+
${switchHTML("ts-warm", !!t.warm_enabled)}
-
Cookie 池开启后:后台每隔检查间隔巡检一次,剩余 TTL 低于阈值时主动重解,请求路径不再等待求解耗时(manual 方式不预热)。
+
+
-
-
-
-
已验证 Cookie
-
-
-
-
-
-
缓存 TTL ${t.cookie_ttl_seconds}s · 求解超时 ${t.timeout_seconds || 90}s · 重试 ${t.retries} 次 · 退避 ${t.retry_backoff_seconds}s
-
-
-
求解历史
+ `;
@@ -671,7 +708,6 @@ PAGES.turnstile = async () => {
}).join("") : '暂无池内记录 — 开启预热并等待下一轮巡检
';
};
renderPool(ov);
- overviewTimer = setInterval(() => { fetchOverview().then(renderPool).catch(() => {}); }, 5000);
$$("[data-rf]").forEach(b => b.onclick = async () => {
b.disabled = true;
@@ -679,30 +715,39 @@ PAGES.turnstile = async () => {
catch (e) { toast(e.message, "err"); }
b.disabled = false;
});
- $("#ts-provider").value = t.provider || "api";
+ // 所选方式存在局部变量里,seg 按钮只负责切换,不依赖隐藏的 select。
+ let provider = t.provider || "api";
$("#ts-style").value = t.api_style === "ezsolver" ? "ezsolver" : "sync";
const TS_HINTS = {
- api: ["已开启:调用求解服务 API 获取 Turnstile Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
- browser: ["已开启:用下方配置的本地浏览器自动求解 Token", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
- manual: ["手动方式:不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)", "已关闭:跳过 Turnstile 求解,直接请求上游站点"],
+ api: "调用求解服务 API 获取 Turnstile Token",
+ browser: "用下方配置的本地浏览器自动求解 Token",
+ manual: "不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)",
};
+ // 组件级显隐:方式 N 的字段只在选中时出现;超时重试 / 右侧池与历史
+ // 只在方式 1/2 出现;缓存卡与已验证 Cookie 恒显。求解恒开启,无开关。
const syncProvider = () => {
- const p = $("#ts-provider").value;
- $("#ts-api").style.display = p === "api" ? "" : "none";
- $("#ts-browser").style.display = p === "browser" ? "" : "none";
- $("#ts-manual").style.display = p === "manual" ? "" : "none";
- $("#ts-refresh").style.display = p === "manual" ? "none" : "";
- const hints = TS_HINTS[p] || TS_HINTS.api;
- $("#ts-hint").textContent = $("#ts-en").checked ? hints[0] : hints[1];
+ 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-provider").onchange = syncProvider;
- $("#ts-en").onchange = syncProvider;
+ $$("#ts-seg button").forEach(b => b.onclick = () => { provider = b.dataset.p; syncProvider(); });
syncProvider();
$("#ts-save").onclick = async () => {
try {
await api("/api/actions", { body: {
- action: "set_turnstile", enabled: $("#ts-en").checked,
- provider: $("#ts-provider").value,
+ // 求解恒开启:控制台不再提供关闭入口,保存时强制 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,
diff --git a/internal/admin/web/style.css b/internal/admin/web/style.css
index 9576b33..e622bcf 100644
--- a/internal/admin/web/style.css
+++ b/internal/admin/web/style.css
@@ -43,9 +43,11 @@ main{flex:1;min-width:0;display:flex;flex-direction:column}
.stat-sub{font-size:11px;color:var(--dim);margin-top:4px}
.stat-value.ok{color:var(--ok)}.stat-value.err{color:var(--err)}.stat-value.warn{color:var(--warn)}
-/* turnstile mode panel */
-.ts-panel{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:13px 14px;margin-top:12px}
-.ts-panel-title{font-size:12px;font-weight:650;color:var(--muted);margin-bottom:10px;letter-spacing:.3px}
+/* turnstile provider segmented control */
+.seg{display:flex;gap:8px;flex-wrap:wrap}
+.seg button{flex:1;min-width:130px;padding:9px 10px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--muted);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.12s}
+.seg button:hover{border-color:#3a4454;color:var(--text)}
+.seg button.on{background:var(--accent2);border-color:#3a72e0;color:#fff;box-shadow:0 2px 10px rgba(47,111,235,.35)}
/* table */
table{width:100%;border-collapse:collapse;font-size:13px}