style: animal-island 9090 panel + docs

This commit is contained in:
Hermes
2026-07-21 12:38:53 +00:00
commit 19f08eeb68
18 changed files with 2907 additions and 0 deletions
+649
View File
@@ -0,0 +1,649 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数据出口代理 · 小岛控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<style>
/* animal-island-ui design tokens (from DESIGN_PROMPT / PROMPT.md) */
:root {
--primary: #19c8b9;
--primary-hover: #3dd4c6;
--primary-active: #11a89b;
--primary-bg: #e6f9f6;
--text: #794f27;
--text-body: #725d42;
--text-secondary: #9f927d;
--text-muted: #8a7b66;
--text-disabled: #c4b89e;
--bg: #f8f8f0;
--bg-content: rgb(247, 243, 223);
--bg-disabled: #f0ece2;
--border: #c4b89e;
--border-hover: #a89878;
--border-strong: #9f927d;
--success: #6fba2c;
--success-active: #5a9e1e;
--warning: #f5c31c;
--error: #e05a5a;
--error-active: #c94444;
--focus-yellow: #ffcc00;
--focus-yellow-d: #e0b800;
--shadow-btn: #bdaea0;
--shadow-soft: rgba(61, 52, 40, 0.06);
--r-sm: 12px;
--r-base: 18px;
--r-lg: 24px;
--r-pill: 50px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--d-base: 0.25s;
--app-teal: #82d5bb;
--app-blue: #889df0;
--app-yellow: #f7cd67;
--app-green: #8ac68a;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: Nunito, "Noto Sans SC", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--text-body);
background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(130, 213, 187, 0.35), transparent 55%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(247, 205, 103, 0.28), transparent 50%),
radial-gradient(ellipse 50% 30% at 50% 100%, rgba(136, 157, 240, 0.18), transparent 55%),
var(--bg);
}
/* decorative dots (card pattern vibe) */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.35;
background-image: radial-gradient(circle at 20px 20px, rgba(159, 146, 125, 0.18) 2px, transparent 2.5px);
background-size: 28px 28px;
z-index: 0;
}
.page { position: relative; z-index: 1; max-width: 980px; margin: 0 auto; padding: 20px 16px 48px; }
/* Title ribbon (simplified swallowtail) */
.title-ribbon {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 36px 10px 22px;
background: linear-gradient(180deg, #fff9e8 0%, var(--app-yellow) 100%);
color: var(--text);
font-weight: 900;
font-size: 1.05rem;
letter-spacing: 0.04em;
border: 2.5px solid #e0b85a;
border-radius: 8px 4px 4px 8px;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 50%, calc(100% - 18px) 100%, 0 100%);
box-shadow: 3px 4px 0 0 rgba(189, 174, 160, 0.55);
margin: 0;
}
.title-ribbon .leaf {
width: 18px; height: 18px;
background: var(--success);
border-radius: 60% 0 60% 0;
transform: rotate(-25deg);
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.08);
flex-shrink: 0;
}
header.top {
display: flex; flex-wrap: wrap; gap: 14px;
align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.sub { color: var(--text-muted); font-size: 0.9rem; margin-top: 8px; font-weight: 600; }
.header-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--r-pill);
font-size: 0.82rem; font-weight: 700; letter-spacing: 0.02em;
border: 2px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.pill.ok { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
.pill.bad { background: #fdecec; border-color: var(--error); color: var(--error-active); }
.pill.warn { background: #fff8df; border-color: var(--warning); color: #a07a08; }
/* Card */
.card {
background: var(--bg-content);
border: 2px solid var(--border-strong);
border-radius: 20px;
padding: 16px 18px;
margin-bottom: 14px;
transition: transform var(--d-base) var(--ease);
}
.card:hover { transform: translateY(-2px); }
.card h2 {
margin: 0 0 12px; font-size: 1rem; font-weight: 800;
color: var(--text); letter-spacing: 0.02em;
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.card h2 .hint { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
.banner {
background: linear-gradient(135deg, var(--primary-bg), #fff8df 70%);
border: 2px solid rgba(25, 200, 185, 0.45);
border-radius: 20px;
padding: 14px 16px;
margin-bottom: 14px;
color: var(--text-body);
line-height: 1.55;
font-weight: 600;
}
.banner strong { color: var(--text); }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 150px; flex: 1 1 160px; }
.field label {
font-size: 0.8rem; font-weight: 700; color: var(--text-muted);
letter-spacing: 0.03em;
}
/* Input — pill, yellow focus */
input[type="text"], input[type="password"], input:not([type]) {
height: 40px;
padding: 0 18px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
color: var(--text-body);
background: var(--bg-content);
border: 2.5px solid var(--border);
border-radius: var(--r-pill);
outline: none;
transition: border-color var(--d-base) var(--ease), box-shadow var(--d-base) var(--ease);
min-width: 0;
width: 100%;
}
input:hover { border-color: var(--border-hover); }
input:focus {
border-color: var(--focus-yellow);
box-shadow: 0 0 0 3px rgba(255, 204, 0, 0.18);
}
input::placeholder { color: var(--text-disabled); font-weight: 400; }
/* Buttons */
button {
font-family: inherit;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
border: 2px solid transparent;
transition: all var(--d-base) var(--ease);
}
.btn {
height: 40px;
padding: 0 18px;
border-radius: var(--r-pill);
font-size: 13px;
display: inline-flex; align-items: center; justify-content: center;
white-space: nowrap;
}
.btn-primary {
color: var(--text);
background: var(--bg);
border-color: var(--bg);
box-shadow: 0 5px 0 0 var(--shadow-btn);
}
.btn-primary:hover {
box-shadow: 0 6px 0 0 var(--shadow-btn);
transform: translateY(-1px);
}
.btn-primary:active {
box-shadow: 0 1px 0 0 var(--shadow-btn);
transform: translateY(2px);
}
.btn-primary:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn-default {
color: var(--text-body);
background: var(--bg);
border: 2px solid var(--border-strong);
box-shadow: 0 2px 4px 0 var(--shadow-soft);
}
.btn-default:hover {
color: var(--primary-active);
border-color: var(--primary);
box-shadow: 0 3px 10px 0 rgba(61, 52, 40, 0.10);
transform: translateY(-1px);
}
.btn-default:active { transform: translateY(0); }
.btn-danger {
color: #fff;
background: var(--error);
border-color: var(--error);
box-shadow: 0 5px 0 0 var(--error-active);
}
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 6px 0 0 var(--error-active); }
.btn-danger:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--error-active); }
.btn-sm { height: 32px; padding: 0 14px; font-size: 12px; border-radius: 16px; }
.btn-mint {
color: #fff;
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 4px 0 0 var(--primary-active);
}
.btn-mint:hover { background: var(--primary-hover); transform: translateY(-1px); box-shadow: 0 5px 0 0 var(--primary-active); }
.btn-mint:active { transform: translateY(2px); box-shadow: 0 1px 0 0 var(--primary-active); }
.muted { color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.msg { margin-top: 10px; font-size: 0.85rem; font-weight: 700; min-height: 1.2em; }
.msg.ok { color: var(--success-active); }
.msg.bad { color: var(--error-active); }
/* Table */
.table-wrap { overflow: auto; border-radius: 16px; border: 2px solid var(--border); background: #fffef8; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1.5px solid rgba(196, 184, 158, 0.55); }
th {
color: var(--text-muted); font-weight: 700; font-size: 0.78rem;
letter-spacing: 0.04em; text-transform: uppercase;
background: rgba(247, 243, 223, 0.9);
}
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(230, 249, 246, 0.55); }
pre {
margin: 0; white-space: pre-wrap; word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.8rem; font-weight: 500;
color: #f0e8d8;
background: #2b2118;
border: 2px solid #3d3028;
border-radius: 16px;
padding: 12px 14px;
max-height: 240px; overflow: auto;
}
.howto pre {
color: var(--text-body);
background: #fffef8;
border: 2px solid var(--border);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.stat {
background: #fffef8;
border: 2px solid var(--border);
border-radius: 16px;
padding: 12px 14px;
}
.stat .k { font-size: 0.75rem; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; }
.stat .v { margin-top: 4px; font-size: 1.05rem; font-weight: 800; color: var(--text); word-break: break-all; }
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: var(--r-pill);
font-size: 0.75rem;
font-weight: 700;
border: 1.5px solid var(--border-strong);
background: #fff;
color: var(--text-body);
}
.tag.teal { background: var(--primary-bg); border-color: var(--primary); color: var(--primary-active); }
.tag.green { background: #eef8e3; border-color: var(--success); color: var(--success-active); }
footer.foot {
margin-top: 22px;
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
}
.wave {
height: 28px;
margin: 18px 0 8px;
background:
radial-gradient(circle at 10px 18px, var(--app-teal) 8px, transparent 9px) 0 0 / 28px 28px,
radial-gradient(circle at 24px 18px, var(--app-blue) 6px, transparent 7px) 0 0 / 28px 28px;
opacity: 0.55;
border-radius: 20px;
}
@media (max-width: 560px) {
.btn { width: 100%; }
.header-actions { width: 100%; }
.title-ribbon { font-size: 0.95rem; }
}
</style>
</head>
<body>
<div class="page">
<header class="top">
<div>
<h1 class="title-ribbon"><span class="leaf" aria-hidden="true"></span>数据出口代理</h1>
<div class="sub">走代理 = 走运营商流量 · animal-island 风格控制台</div>
</div>
<div class="header-actions">
<span id="connState" class="pill warn">未连接</span>
<button type="button" class="btn btn-default" onclick="refreshAll()">刷新</button>
</div>
</header>
<section class="banner">
<strong>行为说明:</strong>系统默认仍可走 WiFi。
只要客户端使用本机 HTTP/SOCKS 代理,出口一律绑定<strong>数据网卡</strong>(流量)。
无需配置域名分流。面板视觉参考
<a href="https://github.com/guokaigdg/animal-island-ui" target="_blank" rel="noopener" style="color:var(--primary-active);font-weight:800">animal-island-ui</a>
设计 token(纯静态实现,无 React 依赖)。
</section>
<section class="card">
<h2>连接设置 <span class="hint">密钥仅存本浏览器 localStorage</span></h2>
<div class="row">
<div class="field">
<label>Clash API9090</label>
<input id="apiBase" type="text" placeholder="http://127.0.0.1:9090" />
</div>
<div class="field">
<label>管理 API9091</label>
<input id="adminBase" type="text" placeholder="http://127.0.0.1:9091" />
</div>
<div class="field">
<label>面板密钥 secret</label>
<input id="secret" type="password" placeholder="PANEL_SECRET" autocomplete="current-password" />
</div>
<div style="padding-bottom:1px">
<button type="button" class="btn btn-primary" onclick="saveAuth(); refreshAll()">保存并连接</button>
</div>
</div>
</section>
<section class="card">
<h2>
代理账号密码(7890)
<span id="proxyAuthState" class="pill warn">未知</span>
</h2>
<div class="row">
<div class="field">
<label>用户名</label>
<input id="proxyUser" type="text" placeholder="proxy" autocomplete="username" />
</div>
<div class="field">
<label>密码</label>
<input id="proxyPass" type="password" placeholder="新密码(不回显已有密码)" autocomplete="new-password" />
</div>
<div class="row" style="flex:0 0 auto;padding-bottom:1px">
<button type="button" class="btn btn-mint" onclick="saveProxyAuth()">保存并生效</button>
<button type="button" class="btn btn-default" onclick="clearProxyAuth()">清除鉴权</button>
<button type="button" class="btn btn-default" onclick="loadProxyAuth()">刷新状态</button>
</div>
</div>
<div class="muted" style="margin-top:10px">保存后会重写 settings、重生 sing-box 配置并重启服务(约 1~2 秒)。</div>
<div id="authMsg" class="msg"></div>
</section>
<section class="card">
<h2>状态</h2>
<div class="stat-grid">
<div class="stat"><div class="k">VERSION</div><div class="v" id="verInfo"></div></div>
<div class="stat"><div class="k">PROXY</div><div class="v">7890 · HTTP/SOCKS</div></div>
<div class="stat"><div class="k">OUTBOUND</div><div class="v"><span class="tag teal">cellular</span></div></div>
</div>
</section>
<section class="card">
<h2>出站 <span class="hint">应只有 cellular(数据网)</span></h2>
<div id="proxies" class="muted">加载中…</div>
</section>
<section class="card">
<h2>
当前连接
<button type="button" class="btn btn-default btn-sm" onclick="loadConnections()">刷新</button>
</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>目标</th><th>出站链</th><th>上传</th><th>下载</th><th></th></tr>
</thead>
<tbody id="connBody"></tbody>
</table>
</div>
</section>
<section class="card howto">
<h2>怎么用</h2>
<pre>1. 代理端口默认 7890HTTP + SOCKS5 同一端口)
2. 设备设置系统/应用代理 → Debian 的 LAN-IP:7890
3. 需要鉴权时在上方设置用户/密码,或: sudo cpxy auth --user u --pass p
4. 验证:直连 ifconfig.me 与 走代理 ifconfig.me 公网 IP 应不同
5. 改网卡:sudo cpxy rebind
6. 低内存:MEMORY_MAX_MB=64~96LOG_LEVEL=warn</pre>
</section>
<section class="card">
<h2>API 调试</h2>
<pre id="raw"></pre>
</section>
<div class="wave" aria-hidden="true"></div>
<footer class="foot">cellular-proxy · 视觉参考 animal-island-ui · 走代理 = 数据流量</footer>
</div>
<script>
const $ = (id) => document.getElementById(id);
function defaultBase() { return location.origin || "http://127.0.0.1:9090"; }
function defaultAdminBase() {
try {
const u = new URL(defaultBase());
u.port = "9091";
return u.origin;
} catch {
return "http://127.0.0.1:9091";
}
}
function loadAuth() {
$("apiBase").value = localStorage.getItem("cpxy_api") || defaultBase();
$("adminBase").value = localStorage.getItem("cpxy_admin") || defaultAdminBase();
$("secret").value = localStorage.getItem("cpxy_secret") || "";
}
function saveAuth() {
localStorage.setItem("cpxy_api", $("apiBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_admin", $("adminBase").value.trim().replace(/\/$/, ""));
localStorage.setItem("cpxy_secret", $("secret").value);
}
function base() { return ($("apiBase").value || defaultBase()).replace(/\/$/, ""); }
function adminBase() { return ($("adminBase").value || defaultAdminBase()).replace(/\/$/, ""); }
function headers() {
const h = { "Content-Type": "application/json" };
const s = $("secret").value;
if (s) h["Authorization"] = "Bearer " + s;
return h;
}
async function api(path, opt = {}) {
const r = await fetch(base() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
if (!r.ok) throw new Error(path + " " + r.status + " " + (await r.text()));
const t = await r.text();
try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
async function adminApi(path, opt = {}) {
const r = await fetch(adminBase() + path, { ...opt, headers: { ...headers(), ...(opt.headers || {}) } });
const t = await r.text();
let data = {};
try { data = t ? JSON.parse(t) : {}; } catch { data = { raw: t }; }
if (!r.ok) throw new Error((data.error || data.message || t || r.status) + "");
return data;
}
function fmt(n) {
if (n == null) return "—";
const u = ["B", "KB", "MB", "GB"];
let i = 0, x = Number(n);
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(i ? 1 : 0) + " " + u[i];
}
function setAuthMsg(text, ok) {
const el = $("authMsg");
el.textContent = text || "";
el.className = "msg" + (ok === true ? " ok" : (ok === false ? " bad" : ""));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
async function loadProxyAuth() {
try {
const data = await adminApi("/proxy-auth");
$("proxyUser").value = data.proxy_user || "";
if (data.auth_enabled) {
$("proxyAuthState").textContent = "已启用 · " + (data.proxy_user || "");
$("proxyAuthState").className = "pill ok";
setAuthMsg("当前已设置鉴权" + (data.proxy_pass_masked ? "(密码 " + data.proxy_pass_masked + "" : ""), true);
} else {
$("proxyAuthState").textContent = "无鉴权";
$("proxyAuthState").className = "pill warn";
setAuthMsg("当前 7890 无账号密码", null);
}
$("proxyPass").value = "";
} catch (e) {
$("proxyAuthState").textContent = "管理 API 不可用";
$("proxyAuthState").className = "pill bad";
setAuthMsg("无法连接管理 API9091)。确认 cellular-proxy-admin 已启动,或: sudo cpxy auth --user u --pass p。错误: " + e.message, false);
}
}
async function saveProxyAuth() {
saveAuth();
const user = $("proxyUser").value.trim();
const pass = $("proxyPass").value;
if (!user) { setAuthMsg("请填写用户名", false); return; }
if (!pass) { setAuthMsg("请填写密码(不会回显已有密码)", false); return; }
setAuthMsg("正在应用…", null);
try {
const data = await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ user, pass })
});
setAuthMsg("已生效:用户 " + (data.proxy_user || user), true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("保存失败: " + e.message, false);
}
}
async function clearProxyAuth() {
if (!confirm("确定清除代理账号密码?清除后 7890 无需鉴权。")) return;
saveAuth();
setAuthMsg("正在清除…", null);
try {
await adminApi("/proxy-auth", {
method: "POST",
body: JSON.stringify({ clear: true })
});
$("proxyUser").value = "";
$("proxyPass").value = "";
setAuthMsg("已清除鉴权", true);
await loadProxyAuth();
} catch (e) {
setAuthMsg("清除失败: " + e.message, false);
}
}
async function refreshAll() {
try {
const ver = await api("/version");
$("verInfo").textContent = ver.version || JSON.stringify(ver);
$("connState").textContent = "已连接";
$("connState").className = "pill ok";
$("raw").textContent = JSON.stringify(ver, null, 2);
await loadProxies();
await loadConnections();
await loadProxyAuth();
} catch (e) {
$("connState").textContent = "失败";
$("connState").className = "pill bad";
$("proxies").innerHTML = '<span class="muted">无法连接 API。请确认服务已启动,并填写正确 secret。<br/>' + esc(e.message) + "</span>";
await loadProxyAuth();
}
}
async function loadProxies() {
const data = await api("/proxies");
const proxies = data.proxies || {};
const names = Object.keys(proxies).filter(k => ["cellular", "DIRECT", "REJECT"].includes(k) || proxies[k].type === "Direct");
const keys = names.length ? names : Object.keys(proxies).slice(0, 15);
let html = '<div class="table-wrap"><table><thead><tr><th>名称</th><th>类型</th><th>测速</th></tr></thead><tbody>';
for (const name of keys) {
const p = proxies[name] || {};
const safe = esc(name);
html += `<tr>
<td><span class="tag ${name === "cellular" ? "teal" : ""}">${safe}</span></td>
<td>${esc(p.type || "")}</td>
<td><button type="button" class="btn btn-default btn-sm" data-delay="${safe}">测速</button> <span id="d-${safe}"></span></td>
</tr>`;
}
html += "</tbody></table></div>";
$("proxies").innerHTML = html;
$("proxies").querySelectorAll("[data-delay]").forEach(btn => {
btn.addEventListener("click", () => delay(btn.getAttribute("data-delay")));
});
}
async function delay(name) {
try {
const r = await api("/proxies/" + encodeURIComponent(name) + "/delay?timeout=5000&url=" +
encodeURIComponent("https://www.gstatic.com/generate_204"));
const el = document.getElementById("d-" + name);
if (el) el.innerHTML = r.delay != null
? `<span class="tag green">${r.delay}ms</span>`
: esc(JSON.stringify(r));
} catch (e) {
const el = document.getElementById("d-" + name);
if (el) el.textContent = e.message;
}
}
async function loadConnections() {
try {
const data = await api("/connections");
const conns = data.connections || [];
const body = $("connBody");
body.innerHTML = "";
conns.slice(0, 50).forEach(c => {
const tr = document.createElement("tr");
const host = (c.metadata && (c.metadata.host || c.metadata.destinationIP)) || "—";
const port = c.metadata && c.metadata.destinationPort ? ":" + c.metadata.destinationPort : "";
tr.innerHTML = `<td>${esc(host)}${esc(port)}</td>
<td>${esc(c.chains ? c.chains.join("→") : "—")}</td>
<td>${esc(fmt(c.upload))}</td><td>${esc(fmt(c.download))}</td>
<td><button type="button" class="btn btn-danger btn-sm" data-close="${esc(c.id)}">断开</button></td>`;
body.appendChild(tr);
});
body.querySelectorAll("[data-close]").forEach(btn => {
btn.addEventListener("click", () => closeConn(btn.getAttribute("data-close")));
});
if (!conns.length) body.innerHTML = '<tr><td colspan="5" class="muted">暂无连接</td></tr>';
} catch (e) {
$("connBody").innerHTML = '<tr><td colspan="5">' + esc(e.message) + "</td></tr>";
}
}
async function closeConn(id) {
await api("/connections/" + id, { method: "DELETE" });
loadConnections();
}
loadAuth();
refreshAll();
setInterval(() => { loadConnections().catch(() => {}); }, 5000);
</script>
</body>
</html>