Files
sub2api-cn-relay-manager/deploy/tksea-portal/index.html
2026-05-27 09:39:05 +08:00

1299 lines
41 KiB
HTML
Raw 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" />
<title>Sub2API 多模型接入中心</title>
<style>
:root {
--bg: #f4efe6;
--panel: rgba(255, 252, 247, 0.92);
--panel-strong: #fffdf9;
--text: #17212d;
--muted: #5d6876;
--line: #d7c9b1;
--teal: #0f766e;
--teal-soft: #d8f0ec;
--amber: #b45309;
--amber-soft: #fff0db;
--ok: #166534;
--bad: #b91c1c;
--shadow: 0 20px 55px rgba(23, 33, 45, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(180, 83, 9, 0.14), transparent 32%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.16), transparent 28%),
linear-gradient(180deg, #faf6ee 0%, var(--bg) 100%);
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 28px 20px 56px;
}
.hero {
position: relative;
overflow: hidden;
padding: 28px;
border: 1px solid rgba(215, 201, 177, 0.92);
border-radius: 28px;
background:
linear-gradient(140deg, rgba(255, 255, 255, 0.94), rgba(249, 243, 231, 0.88)),
linear-gradient(120deg, rgba(15, 118, 110, 0.06), rgba(180, 83, 9, 0.04));
box-shadow: var(--shadow);
}
.hero::after {
content: "";
position: absolute;
top: -88px;
right: -52px;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(15, 118, 110, 0.14), transparent 68%);
pointer-events: none;
}
.topline {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.tag,
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.tag {
background: rgba(15, 118, 110, 0.11);
color: var(--teal);
}
.chip {
border: 1px solid rgba(215, 201, 177, 0.92);
background: rgba(255, 253, 249, 0.72);
color: var(--muted);
}
h1 {
margin: 0;
max-width: 820px;
font-size: clamp(30px, 5vw, 52px);
line-height: 1.04;
letter-spacing: -0.03em;
}
.hero-copy {
max-width: 920px;
margin-top: 16px;
color: var(--muted);
line-height: 1.75;
font-size: 15px;
}
.hero-copy p {
margin: 0 0 10px;
}
.hero-meta {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-top: 18px;
}
.hero-meta .meta-card {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(215, 201, 177, 0.9);
background: rgba(255, 253, 249, 0.78);
}
.hero-meta .meta-label {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 12px;
}
.mono {
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
.dashboard {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
margin-top: 22px;
align-items: start;
}
.stack {
display: grid;
gap: 18px;
}
.panel {
background: var(--panel);
border: 1px solid rgba(215, 201, 177, 0.95);
border-radius: 24px;
padding: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.panel.hero-panel {
padding: 24px;
}
.section-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
margin-bottom: 14px;
}
.section-head h2,
.section-head h3 {
margin: 0;
}
.section-head p {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
max-width: 680px;
}
.section-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.tiny {
color: var(--muted);
font-size: 12px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
border: 1px solid rgba(215, 201, 177, 0.9);
background: rgba(255, 253, 249, 0.88);
color: var(--muted);
}
.status-pill.ok {
background: rgba(22, 101, 52, 0.08);
color: var(--ok);
border-color: rgba(22, 101, 52, 0.24);
}
.status-pill.bad {
background: rgba(185, 28, 28, 0.08);
color: var(--bad);
border-color: rgba(185, 28, 28, 0.22);
}
.status-pill.warn {
background: rgba(180, 83, 9, 0.1);
color: var(--amber);
border-color: rgba(180, 83, 9, 0.24);
}
.stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-top: 14px;
}
.stat {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(215, 201, 177, 0.88);
background: rgba(255, 255, 255, 0.74);
}
.stat-label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
}
.stat-value {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.03em;
}
.session-grid,
.form-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.kv {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(215, 201, 177, 0.88);
background: rgba(255, 255, 255, 0.78);
}
.kv-label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
}
.kv-value {
font-size: 15px;
font-weight: 700;
line-height: 1.5;
word-break: break-word;
}
.auth-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.card {
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(215, 201, 177, 0.92);
background: var(--panel-strong);
box-shadow: 0 12px 28px rgba(23, 33, 45, 0.06);
}
.card h3 {
margin: 0 0 8px;
font-size: 18px;
}
.card p.hint {
margin: 0 0 14px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
label {
display: block;
margin: 10px 0 6px;
color: var(--muted);
font-size: 13px;
}
input,
textarea,
select {
width: 100%;
border: 1px solid rgba(215, 201, 177, 0.95);
background: #fff;
border-radius: 14px;
padding: 12px 13px;
font: inherit;
color: var(--text);
}
input:focus,
textarea:focus,
select:focus {
outline: 2px solid rgba(15, 118, 110, 0.14);
border-color: rgba(15, 118, 110, 0.45);
}
textarea {
min-height: 112px;
resize: vertical;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 8px;
min-width: 132px;
padding: 12px 16px;
border: 0;
border-radius: 14px;
font: inherit;
font-weight: 700;
color: #fff;
cursor: pointer;
transition: transform 140ms ease, box-shadow 140ms ease, opacity 140ms ease;
background: linear-gradient(135deg, var(--teal), #115e59);
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
}
button:hover { transform: translateY(-1px); }
button.alt {
background: linear-gradient(135deg, var(--amber), #92400e);
box-shadow: 0 14px 28px rgba(180, 83, 9, 0.18);
}
button.ghost {
background: rgba(255, 255, 255, 0.82);
color: var(--text);
border: 1px solid rgba(215, 201, 177, 0.92);
box-shadow: none;
}
button.inline {
min-width: 0;
width: auto;
padding: 8px 12px;
font-size: 12px;
box-shadow: none;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.status-box {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px dashed rgba(215, 201, 177, 0.95);
background: #faf6ef;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.status-box.ok {
color: var(--ok);
background: rgba(22, 101, 52, 0.06);
border-color: rgba(22, 101, 52, 0.18);
}
.status-box.bad {
color: var(--bad);
background: rgba(185, 28, 28, 0.06);
border-color: rgba(185, 28, 28, 0.16);
}
.group-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.group-card {
position: relative;
overflow: hidden;
padding: 16px;
border-radius: 20px;
border: 1px solid rgba(215, 201, 177, 0.92);
background: rgba(255, 255, 255, 0.82);
}
.group-card.active {
border-color: rgba(15, 118, 110, 0.34);
background: linear-gradient(180deg, rgba(216, 240, 236, 0.92), rgba(255, 255, 255, 0.92));
}
.group-card.pending {
background: linear-gradient(180deg, rgba(255, 240, 219, 0.72), rgba(255, 255, 255, 0.92));
}
.group-card h4 {
margin: 0 0 8px;
font-size: 17px;
}
.group-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: rgba(23, 33, 45, 0.05);
color: var(--muted);
}
.badge.active {
background: rgba(22, 101, 52, 0.1);
color: var(--ok);
}
.badge.pending {
background: rgba(180, 83, 9, 0.12);
color: var(--amber);
}
.badge.neutral {
background: rgba(23, 33, 45, 0.08);
color: var(--muted);
}
.badge.strong {
padding: 7px 11px;
font-size: 12px;
letter-spacing: 0.01em;
}
.group-models {
margin: 10px 0 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.7;
font-size: 13px;
}
.group-note {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.result-card {
display: grid;
gap: 14px;
}
.result-box {
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(215, 201, 177, 0.92);
background: rgba(255, 255, 255, 0.8);
}
.result-box strong {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.list {
display: grid;
gap: 12px;
}
.key-item {
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(215, 201, 177, 0.88);
background: rgba(255, 255, 255, 0.8);
}
.key-top {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.key-name {
font-weight: 800;
font-size: 15px;
}
.key-meta {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.empty {
padding: 14px;
border-radius: 18px;
border: 1px dashed rgba(215, 201, 177, 0.95);
color: var(--muted);
background: rgba(255, 255, 255, 0.72);
font-size: 13px;
}
.footer-note {
margin-top: 22px;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
min-width: 220px;
max-width: 320px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(215, 201, 177, 0.95);
background: rgba(255, 253, 249, 0.96);
color: var(--text);
box-shadow: 0 18px 38px rgba(23, 33, 45, 0.14);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.ok {
border-color: rgba(22, 101, 52, 0.26);
background: rgba(243, 252, 245, 0.96);
color: var(--ok);
}
.toast.bad {
border-color: rgba(185, 28, 28, 0.2);
background: rgba(254, 244, 244, 0.96);
color: var(--bad);
}
@media (max-width: 980px) {
.dashboard {
grid-template-columns: 1fr;
}
.hero {
padding: 22px;
}
}
</style>
</head>
<body>
<div class="shell">
<section class="hero">
<div class="topline">
<span class="tag">Sub2API 公网多模型接入中心</span>
<span class="chip">兼容 OpenAI root endpoint</span>
<span class="chip">旧地址 /kimi-portal 自动跳转</span>
</div>
<h1>一个入口,管理你的多模型测试账号、线路与 Key</h1>
<div class="hero-copy">
<p>这里不再只是 Kimi 入口,而是统一承接当前公网多模型接入。登录后,你可以直接看到账号状态、已开通线路、活跃订阅和历史 Key并按所需模型族快速创建新的测试 Key。</p>
<p>同一个公网 endpoint 支持多模型接入,但 key 仍按分组发放。页面会明确告诉你哪些线路可以直接使用,哪些还需要管理员补开通。</p>
</div>
<div class="hero-meta">
<div class="meta-card">
<span class="meta-label">推荐 Base URL</span>
<div class="mono">https://sub.tksea.top</div>
</div>
<div class="meta-card">
<span class="meta-label">显式 /v1 Base URL</span>
<div class="mono">https://sub.tksea.top/v1</div>
</div>
<div class="meta-card">
<span class="meta-label">通用 Portal 地址</span>
<div class="mono">https://sub.tksea.top/portal/</div>
</div>
</div>
</section>
<section class="dashboard">
<div class="stack">
<article class="panel hero-panel">
<div class="section-head">
<div>
<h2>会话状态</h2>
<p>登录后会自动恢复会话,并同步当前用户信息、可用线路、订阅状态和历史 Key。</p>
</div>
<div class="section-actions">
<span id="session-pill" class="status-pill warn">未登录</span>
<button id="refresh-session-btn" class="ghost inline">刷新状态</button>
<button id="logout-btn" class="ghost inline">退出登录</button>
</div>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">账户余额</span>
<div id="balance-stat" class="stat-value">--</div>
</div>
<div class="stat">
<span class="stat-label">已开通分组</span>
<div id="enabled-groups-stat" class="stat-value">0</div>
</div>
<div class="stat">
<span class="stat-label">活跃订阅</span>
<div id="subscriptions-stat" class="stat-value">0</div>
</div>
<div class="stat">
<span class="stat-label">已有 Key</span>
<div id="keys-stat" class="stat-value">0</div>
</div>
</div>
<div id="session-grid" class="session-grid" style="margin-top:14px;"></div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>分组与模型线路</h2>
<p>这里会把你的可用分组、活跃订阅和模型目录合并展示,让“能不能用、该用哪条线”一眼可见。</p>
</div>
</div>
<div id="group-grid" class="group-grid"></div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>已有 Key</h2>
<p>历史 Key 默认只展示必要信息;你可以直接在列表里一键复制,无需先重新创建。</p>
</div>
<div class="tiny mono">GET /api/v1/keys?page=1&page_size=20</div>
</div>
<div id="keys-list" class="list"></div>
</article>
</div>
<div class="stack">
<article class="panel">
<div class="section-head">
<div>
<h2>注册与登录</h2>
<p>刷新页面或稍后回来,都可以直接登录恢复会话。首次注册成功后会自动登录,并回填邮箱。</p>
</div>
</div>
<div class="auth-grid">
<section class="card">
<h3>注册</h3>
<p class="hint">当前无需邮箱验证码、邀请码或 Turnstile。</p>
<label for="reg-email">邮箱</label>
<input id="reg-email" placeholder="you@example.com" />
<label for="reg-password">密码</label>
<input id="reg-password" type="password" placeholder="至少 6 位" />
<div class="button-row">
<button id="register-btn">注册并登录</button>
</div>
<div id="register-status" class="status-box">未执行</div>
</section>
<section class="card">
<h3>登录</h3>
<p class="hint">如果你已经有账号,可以直接登录并同步当前用户状态。</p>
<label for="login-email">邮箱</label>
<input id="login-email" placeholder="you@example.com" />
<label for="login-password">密码</label>
<input id="login-password" type="password" placeholder="你的密码" />
<div class="button-row">
<button id="login-btn" class="alt">登录</button>
</div>
<div id="login-status" class="status-box">未执行</div>
</section>
</div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>创建测试 Key</h2>
<p>页面会高亮你当前可用的线路,创建成功后立即显示对应分组和模型建议。</p>
</div>
</div>
<div class="form-grid">
<div>
<label for="key-name">Key 名称</label>
<input id="key-name" value="my-model-key" />
</div>
<div>
<label for="group-id">选择线路</label>
<select id="group-id"></select>
</div>
</div>
<div class="button-row">
<button id="create-key-btn">创建 Key</button>
</div>
<div id="key-status" class="status-box">未执行</div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>结果与接入信息</h2>
<p>创建成功后,这里会显示 Access Token、最新 API Key以及这一把 key 对应的分组和推荐模型。</p>
</div>
</div>
<div class="result-card">
<div class="result-box">
<strong>Access Token</strong>
<textarea id="access-token" readonly></textarea>
<div class="button-row">
<button id="copy-token-btn" class="ghost inline">复制 Access Token</button>
</div>
</div>
<div class="result-box">
<strong>最新创建的 API Key</strong>
<textarea id="api-key" readonly></textarea>
<div class="button-row">
<button id="copy-key-btn" class="ghost inline">复制 API Key</button>
</div>
</div>
<div class="result-box">
<strong>当前线路说明</strong>
<div id="selection-summary" class="tiny">尚未选择线路。</div>
</div>
<div class="result-box">
<strong>推荐配置</strong>
<div class="mono">base_url = https://sub.tksea.top</div>
<div class="mono">或 base_url = https://sub.tksea.top/v1</div>
<div class="mono">model = 按当前分组对应模型名填写</div>
<div class="mono">api_key = 你刚生成的 key</div>
</div>
</div>
<p class="footer-note">如果某条线路显示“待开通”,说明你的账号还没有对应 subscription 或可绑定分组。此时可以先注册并登录,再联系管理员补组,无需重新创建账号。</p>
</article>
</div>
</section>
</div>
<div id="toast" class="toast" aria-live="polite"></div>
<script>
const PORTAL_PROXY_PREFIX = "/portal-proxy/api/v1";
const STORAGE = {
token: "sub2api.portal.accessToken",
email: "sub2api.portal.email"
};
const GROUP_CATALOG = {
2: {
id: 2,
key: "kimi",
title: "Kimi A7M",
subtitle: "默认订阅线路",
recommendation: "recommended",
models: ["kimi-k2.6"],
description: "适合日常聊天和智能体对话,通常也是新注册用户默认会拿到的第一条线路。"
},
4: {
id: 4,
key: "gpt",
title: "GPT asxs 中转",
subtitle: "多模型 GPT 线路",
recommendation: "recommended",
models: ["gpt-5.4", "gpt-5.4-mini"],
description: "适合需要 GPT 能力的使用场景。当前建议优先使用 gpt-5.4 和 gpt-5.4-mini。"
},
5: {
id: 5,
key: "minimax",
title: "MiniMax 53hk 中转",
subtitle: "双模型高速线路",
recommendation: "recommended",
models: ["MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"],
description: "适合需要 MiniMax 双模型切换和稳定 OpenAI-compatible 接入的场景。"
},
6: {
id: 6,
key: "deepseek",
title: "DeepSeek 官方",
subtitle: "官方 chat 路线",
recommendation: "recommended",
models: ["deepseek-chat"],
description: "适合 DeepSeek 官方 chat 路线需求。当前用户侧建议直接使用 deepseek-chat。"
}
};
const state = {
accessToken: "",
user: null,
groups: [],
subscriptions: [],
keys: [],
lastCreatedKey: "",
selectionGroupID: 2
};
let toastTimer = null;
function $(id) {
return document.getElementById(id);
}
function setStatus(id, kind, text) {
const el = $(id);
el.textContent = text;
el.className = "status-box" + (kind ? " " + kind : "");
}
function statusPill(kind, text) {
$("session-pill").textContent = text;
$("session-pill").className = "status-pill" + (kind ? " " + kind : "");
}
function setBusy(buttonID, busy) {
const el = $(buttonID);
if (!el) {
return;
}
el.disabled = busy;
}
function showToast(kind, text) {
const el = $("toast");
if (!el) {
return;
}
if (toastTimer) {
clearTimeout(toastTimer);
}
el.textContent = text;
el.className = "toast " + (kind || "");
requestAnimationFrame(() => {
el.classList.add("show");
});
toastTimer = setTimeout(() => {
el.classList.remove("show");
}, 1800);
}
async function copyText(value, statusID, label, button) {
if (!value) {
setStatus(statusID, "bad", label + " 为空,暂无可复制内容。");
showToast("bad", label + " 为空");
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus(statusID, "ok", label + " 已复制到剪贴板。");
showToast("ok", label + " 已复制");
if (button) {
const originalText = button.dataset.originalLabel || button.textContent;
button.dataset.originalLabel = originalText;
button.textContent = "已复制";
button.disabled = true;
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
}, 1400);
}
} catch (err) {
setStatus(statusID, "bad", label + " 复制失败: " + err.message);
showToast("bad", label + " 复制失败");
}
}
function formatMoney(value) {
if (value === null || value === undefined || value === "") {
return "--";
}
const num = Number(value);
if (!Number.isFinite(num)) {
return String(value);
}
return num.toFixed(2);
}
function formatDate(value) {
if (!value) {
return "--";
}
try {
return new Date(value).toLocaleString("zh-CN", { hour12: false });
} catch {
return String(value);
}
}
function maskKey(value) {
if (!value) {
return "--";
}
if (value.length <= 14) {
return value;
}
return value.slice(0, 6) + "..." + value.slice(-6);
}
function saveSession() {
if (state.accessToken) {
localStorage.setItem(STORAGE.token, state.accessToken);
} else {
localStorage.removeItem(STORAGE.token);
}
const email = $("login-email").value.trim() || $("reg-email").value.trim();
if (email) {
localStorage.setItem(STORAGE.email, email);
}
}
function restoreSession() {
state.accessToken = localStorage.getItem(STORAGE.token) || "";
const rememberedEmail = localStorage.getItem(STORAGE.email) || "";
$("login-email").value = rememberedEmail;
$("reg-email").value = rememberedEmail;
$("access-token").value = state.accessToken;
}
function clearSession() {
state.accessToken = "";
state.user = null;
state.groups = [];
state.subscriptions = [];
state.keys = [];
localStorage.removeItem(STORAGE.token);
$("access-token").value = "";
$("api-key").value = "";
setStatus("login-status", "", "未执行");
setStatus("register-status", "", "未执行");
setStatus("key-status", "", "未执行");
renderAll();
}
async function request(path, options = {}) {
const headers = Object.assign({}, options.headers || {});
if (state.accessToken && options.useAuth !== false) {
headers.Authorization = "Bearer " + state.accessToken;
}
const res = await fetch(PORTAL_PROXY_PREFIX + path, {
method: options.method || "GET",
headers,
body: options.body
});
const text = await res.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = { raw: text };
}
if (!res.ok || (typeof payload.code === "number" && payload.code !== 0)) {
const message = payload.message || payload.raw || ("HTTP " + res.status);
const error = new Error(message);
error.statusCode = res.status;
error.payload = payload;
throw error;
}
return payload.data ?? payload;
}
async function requestJSON(path, method, payload, useAuth = true) {
return request(path, {
method,
useAuth,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
function knownGroup(id) {
return GROUP_CATALOG[id] || {
id,
key: "group-" + id,
title: "分组 " + id,
subtitle: "未命名线路",
models: [],
description: "当前页面没有这条分组的预设模型映射。"
};
}
function availableGroupIDs() {
const ids = new Set();
for (const group of state.groups || []) {
if (group && Number.isFinite(Number(group.id))) {
ids.add(Number(group.id));
}
}
if (state.user && Array.isArray(state.user.allowed_groups)) {
for (const id of state.user.allowed_groups) {
if (Number.isFinite(Number(id))) {
ids.add(Number(id));
}
}
}
for (const item of state.subscriptions || []) {
const status = String(item.status || "");
if ((status === "active" || status === "trialing") && Number.isFinite(Number(item.group_id))) {
ids.add(Number(item.group_id));
}
}
return ids;
}
function groupStatusRows() {
const enabled = availableGroupIDs();
const rows = [];
const rawGroups = new Map((state.groups || []).map((group) => [Number(group.id), group]));
const subscriptionByGroup = new Map();
for (const item of state.subscriptions || []) {
if (Number.isFinite(Number(item.group_id))) {
subscriptionByGroup.set(Number(item.group_id), item);
}
}
for (const [id, meta] of Object.entries(GROUP_CATALOG)) {
const numericID = Number(id);
const group = rawGroups.get(numericID) || null;
const subscription = subscriptionByGroup.get(numericID) || null;
rows.push({
id: numericID,
catalog: meta,
group,
subscription,
enabled: enabled.has(numericID)
});
}
return rows;
}
function getPresentationStatus(row) {
if (row.catalog.recommendation === "not_recommended") {
return { cls: "neutral", text: "暂不推荐" };
}
if (row.enabled) {
return { cls: "active", text: "可立即使用" };
}
return { cls: "pending", text: "需开通" };
}
function renderSessionSummary() {
const sessionGrid = $("session-grid");
const user = state.user;
if (!user) {
statusPill("warn", "未登录");
$("balance-stat").textContent = "--";
$("enabled-groups-stat").textContent = "0";
$("subscriptions-stat").textContent = "0";
$("keys-stat").textContent = "0";
sessionGrid.innerHTML = [
'<div class="empty">当前还没有有效登录态。登录后会显示账号概览、已开通线路和历史 Key。</div>'
].join("");
return;
}
statusPill("ok", "已登录");
$("balance-stat").textContent = formatMoney(user.balance);
$("enabled-groups-stat").textContent = String(availableGroupIDs().size);
$("subscriptions-stat").textContent = String((state.subscriptions || []).filter((item) => String(item.status || "") === "active").length);
$("keys-stat").textContent = String((state.keys || []).length);
const fields = [
["邮箱", user.email || "--"],
["用户名", user.username || "--"],
["角色", user.role || "--"],
["状态", user.status || "--"],
["并发", user.concurrency ?? "--"],
["RPM 限制", user.rpm_limit ?? "--"],
["允许分组", Array.isArray(user.allowed_groups) && user.allowed_groups.length ? user.allowed_groups.join(", ") : "由订阅/分组接口决定"],
["创建时间", formatDate(user.created_at)]
];
sessionGrid.innerHTML = fields.map(([label, value]) => (
'<div class="kv">' +
'<span class="kv-label">' + label + '</span>' +
'<div class="kv-value">' + value + '</div>' +
'</div>'
)).join("");
}
function renderGroupCatalog() {
const rows = groupStatusRows();
const grid = $("group-grid");
grid.innerHTML = rows.map((row) => {
const groupName = row.group && row.group.name ? row.group.name : ("group " + row.id);
const subscription = row.subscription;
const presentation = getPresentationStatus(row);
const subscriptionText = subscription
? "订阅状态:" + (subscription.status || "--") + (subscription.expires_at ? " / 到期:" + formatDate(subscription.expires_at) : "")
: "尚未检测到当前账号的活跃订阅记录。";
const models = row.catalog.models.length
? "<ul class=\"group-models\">" + row.catalog.models.map((model) => "<li><span class=\"mono\">" + model + "</span></li>").join("") + "</ul>"
: "<div class=\"group-note\">当前未登记模型别名。</div>";
return (
'<article class="group-card ' + (row.enabled ? "active" : "pending") + '">' +
'<h4>' + row.catalog.title + '</h4>' +
'<div class="group-meta">' +
'<span class="badge">' + groupName + '</span>' +
'<span class="badge">' + row.catalog.subtitle + '</span>' +
'<span class="badge strong ' + presentation.cls + '">' + presentation.text + '</span>' +
'</div>' +
'<div class="group-note">' + row.catalog.description + '</div>' +
models +
'<div class="group-note">分组 ID<span class="mono">' + row.id + '</span></div>' +
'<div class="group-note">' + subscriptionText + '</div>' +
'</article>'
);
}).join("");
const select = $("group-id");
const previous = Number(select.value || state.selectionGroupID || 2);
const options = rows.map((row) => {
const label = row.catalog.title + " / group " + row.id + " / " + (row.catalog.models.join(", ") || "未登记模型");
return '<option value="' + row.id + '"' + (row.enabled ? "" : "") + '>' + label + (row.enabled ? "" : "(待开通)") + '</option>';
}).join("");
select.innerHTML = options;
if (rows.some((row) => row.id === previous)) {
select.value = String(previous);
}
state.selectionGroupID = Number(select.value || 2);
renderSelectionSummary();
}
function renderSelectionSummary() {
const groupID = Number($("group-id").value || state.selectionGroupID || 2);
state.selectionGroupID = groupID;
const row = groupStatusRows().find((item) => item.id === groupID);
const meta = knownGroup(groupID);
const availability = row && row.enabled ? "当前账号已开通,可直接创建这一条线路的 key。" : "当前账号尚未开通这条线路,创建时可能会返回无权限。";
$("selection-summary").innerHTML = [
'<div><strong>' + meta.title + '</strong> / group <span class="mono">' + groupID + '</span></div>',
'<div class="mono">推荐模型: ' + (meta.models.join(", ") || "--") + '</div>',
'<div>' + availability + '</div>'
].join("");
}
function renderKeys() {
const list = $("keys-list");
const items = state.keys || [];
if (!items.length) {
list.innerHTML = '<div class="empty">当前还没有历史 Key。创建成功后新 Key 会显示在右侧结果区,这里也会同步更新列表。</div>';
return;
}
list.innerHTML = items.map((item) => {
const groupID = Number(item.group_id);
const meta = Number.isFinite(groupID) ? knownGroup(groupID) : null;
return (
'<article class="key-item">' +
'<div class="key-top">' +
'<div class="key-name">' + (item.name || "未命名 Key") + '</div>' +
'<div class="section-actions">' +
'<span class="badge ' + (String(item.status || "") === "active" ? "active" : "pending") + '">' + (item.status || "--") + '</span>' +
'<button class="ghost inline copy-existing-key-btn" data-key="' + (item.key || "") + '">复制 Key</button>' +
'</div>' +
'</div>' +
'<div class="key-meta">' +
'<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' +
'<div><span class="kv-label">分组</span><div>' + (meta ? meta.title + " / group " + groupID : (groupID ? "group " + groupID : "未绑定")) + '</div></div>' +
'<div><span class="kv-label">创建时间</span><div>' + formatDate(item.created_at) + '</div></div>' +
'<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' +
'</div>' +
'</article>'
);
}).join("");
}
function renderAll() {
renderSessionSummary();
renderGroupCatalog();
renderKeys();
}
async function refreshUserState() {
if (!state.accessToken) {
state.user = null;
state.groups = [];
state.subscriptions = [];
state.keys = [];
renderAll();
return;
}
try {
const [user, groups, subscriptions, keysPage] = await Promise.all([
request("/auth/me"),
request("/groups/available"),
request("/subscriptions"),
request("/keys?page=1&page_size=20")
]);
state.user = user || null;
state.groups = Array.isArray(groups) ? groups : [];
state.subscriptions = Array.isArray(subscriptions) ? subscriptions : [];
state.keys = Array.isArray(keysPage && keysPage.items) ? keysPage.items : [];
renderAll();
} catch (err) {
clearSession();
statusPill("bad", "登录失效");
setStatus("login-status", "bad", "会话已失效,请重新登录:" + err.message);
}
}
function rememberAuth(data) {
state.accessToken = data.access_token || state.accessToken || "";
$("access-token").value = state.accessToken;
saveSession();
}
async function handleRegister() {
const email = $("reg-email").value.trim();
const password = $("reg-password").value;
setBusy("register-btn", true);
try {
const data = await requestJSON("/auth/register", "POST", {
email,
password,
verify_code: "",
turnstile_token: "",
promo_code: "",
invitation_code: "",
aff_code: ""
}, false);
rememberAuth(data);
$("login-email").value = email;
$("login-password").value = password;
setStatus("register-status", "ok", "注册成功,已自动登录。正在同步你的账号和线路状态。");
setStatus("login-status", "ok", "已自动登录。");
await refreshUserState();
} catch (err) {
setStatus("register-status", "bad", "注册失败: " + err.message);
} finally {
saveSession();
setBusy("register-btn", false);
}
}
async function handleLogin() {
const email = $("login-email").value.trim();
const password = $("login-password").value;
setBusy("login-btn", true);
try {
const data = await requestJSON("/auth/login", "POST", {
email,
password,
turnstile_token: ""
}, false);
rememberAuth(data);
$("reg-email").value = email;
setStatus("login-status", "ok", "登录成功,正在同步你的账号状态与可用线路。");
await refreshUserState();
} catch (err) {
setStatus("login-status", "bad", "登录失败: " + err.message);
} finally {
saveSession();
setBusy("login-btn", false);
}
}
async function handleCreateKey() {
const name = $("key-name").value.trim();
const groupID = Number($("group-id").value || state.selectionGroupID || 2);
setBusy("create-key-btn", true);
try {
const data = await requestJSON("/keys", "POST", {
name,
group_id: Number.isFinite(groupID) ? groupID : null
}, true);
state.lastCreatedKey = data.key || "";
$("api-key").value = state.lastCreatedKey;
const meta = knownGroup(groupID);
setStatus("key-status", "ok", "Key 创建成功。已绑定到 " + meta.title + " / group " + groupID + "。");
renderSelectionSummary();
await refreshUserState();
} catch (err) {
setStatus("key-status", "bad", "创建 Key 失败: " + err.message);
} finally {
setBusy("create-key-btn", false);
}
}
async function copyField(fieldID, statusID, label, button) {
const value = $(fieldID).value.trim();
await copyText(value, statusID, label, button);
}
$("register-btn").addEventListener("click", handleRegister);
$("login-btn").addEventListener("click", handleLogin);
$("create-key-btn").addEventListener("click", handleCreateKey);
$("refresh-session-btn").addEventListener("click", refreshUserState);
$("logout-btn").addEventListener("click", () => {
clearSession();
statusPill("warn", "已退出");
});
$("copy-token-btn").addEventListener("click", (event) => copyField("access-token", "login-status", "Access Token", event.currentTarget));
$("copy-key-btn").addEventListener("click", (event) => copyField("api-key", "key-status", "API Key", event.currentTarget));
$("keys-list").addEventListener("click", async (event) => {
const button = event.target.closest(".copy-existing-key-btn");
if (!button) {
return;
}
await copyText(button.dataset.key || "", "key-status", "已有 Key", button);
});
$("group-id").addEventListener("change", renderSelectionSummary);
restoreSession();
renderAll();
refreshUserState();
</script>
</body>
</html>