Files
sub2api-cn-relay-manager/deploy/tksea-portal/index.html

1532 lines
54 KiB
HTML
Raw Normal View History

<!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.neutral {
background: linear-gradient(180deg, rgba(240, 244, 248, 0.82), rgba(255, 255, 255, 0.94));
}
.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>这里优先展示插件层的逻辑分组、公开模型、sticky 策略和 route 状态,不再把宿主真实分组当成用户主视角。</p>
</div>
</div>
<div id="group-grid" class="group-grid"></div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>权限与订阅视图</h2>
<p>这里把宿主兼容线路、订阅与历史 Key 重新聚合回逻辑分组层,帮助你判断“我对哪个产品组已经可用、还缺什么”。</p>
</div>
</div>
<div id="entitlement-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>页面先按逻辑分组展示产品目录;当前 Key 申请仍通过兼容宿主线路完成。只有找到唯一兼容线路时,才会允许直接申请。</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>
<p class="footer-note">当前页面已经把“目录、权限、订阅、Key”统一投影到逻辑分组产品层兼容宿主线路只作为过渡实现细节保留在申请流程里。</p>
</article>
</div>
</section>
</div>
<div id="toast" class="toast" aria-live="polite"></div>
<script>
const PORTAL_PROXY_PREFIX = "/portal-proxy/api/v1";
const PORTAL_CATALOG_PREFIX = "/portal-admin-api/api/portal";
const STORAGE = {
token: "sub2api.portal.accessToken",
email: "sub2api.portal.email"
};
const LEGACY_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,
portalLogicalGroups: [],
groups: [],
subscriptions: [],
keys: [],
lastCreatedKey: "",
selectionLogicalGroupID: ""
};
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 escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 = [];
state.lastCreatedKey = "";
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 requestPortal(path) {
const res = await fetch(PORTAL_CATALOG_PREFIX + path, {
method: "GET",
headers: { Accept: "application/json" }
});
const text = await res.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = { raw: text };
}
if (!res.ok) {
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;
}
async function requestJSON(path, method, payload, useAuth = true) {
return request(path, {
method,
useAuth,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
function knownLegacyGroup(id) {
return LEGACY_GROUP_CATALOG[id] || {
id,
key: "group-" + id,
title: "分组 " + id,
subtitle: "未命名线路",
models: [],
description: "当前页面没有这条分组的预设模型映射。"
};
}
function availableLegacyGroupIDs() {
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 portalLogicalGroupModels(group) {
return Array.isArray(group && group.public_models)
? group.public_models
.map((item) => String(item && item.public_model ? item.public_model : "").trim())
.filter(Boolean)
: [];
}
function legacyCompatibilityCandidates(group) {
const portalModels = new Set(portalLogicalGroupModels(group));
if (!portalModels.size) {
return [];
}
return Object.values(LEGACY_GROUP_CATALOG).filter((candidate) => candidate.models.some((model) => portalModels.has(String(model).trim())));
}
function logicalGroupStatusRows() {
const enabledLegacyGroups = availableLegacyGroupIDs();
const rows = (state.portalLogicalGroups || []).map((group) => {
const candidates = legacyCompatibilityCandidates(group);
const enabledCandidates = candidates.filter((candidate) => enabledLegacyGroups.has(Number(candidate.id)));
return {
logicalGroup: group,
candidates,
enabledCandidates
};
});
rows.sort((left, right) => {
const leftActive = Number(left.logicalGroup.active_route_count || 0);
const rightActive = Number(right.logicalGroup.active_route_count || 0);
if (leftActive !== rightActive) {
return rightActive - leftActive;
}
return String(left.logicalGroup.display_name || left.logicalGroup.logical_group_id || "").localeCompare(
String(right.logicalGroup.display_name || right.logicalGroup.logical_group_id || ""),
"zh-CN"
);
});
return rows;
}
function activeSubscriptionItems() {
return (state.subscriptions || []).filter((item) => {
const status = String(item && item.status ? item.status : "").trim().toLowerCase();
return status === "active" || status === "trialing";
});
}
function dateValue(value) {
if (!value) {
return 0;
}
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function logicalGroupEntitlementRows() {
const legacyEnabled = availableLegacyGroupIDs();
const subscriptions = state.subscriptions || [];
const activeSubscriptions = activeSubscriptionItems();
const keys = state.keys || [];
return logicalGroupStatusRows().map((row) => {
const candidateIDs = new Set((row.candidates || []).map((candidate) => Number(candidate.id)));
const enabledCandidates = (row.enabledCandidates || []).map((candidate) => Number(candidate.id));
const matchingSubscriptions = subscriptions.filter((item) => candidateIDs.has(Number(item.group_id)));
const matchingActiveSubscriptions = activeSubscriptions.filter((item) => candidateIDs.has(Number(item.group_id)));
const matchingKeys = keys.filter((item) => candidateIDs.has(Number(item.group_id)));
const latestExpiresAt = [...matchingSubscriptions, ...matchingKeys].reduce((latest, item) => {
return dateValue(item && item.expires_at) > dateValue(latest) ? String(item.expires_at || "") : latest;
}, "");
let stateKey = "catalog_only";
let stateText = "仅目录";
if ((row.candidates || []).length > 0 && enabledCandidates.length === 0) {
stateKey = "pending";
stateText = "待开通";
}
if (enabledCandidates.length === 1) {
stateKey = matchingActiveSubscriptions.length > 0 ? "active" : "granted";
stateText = matchingActiveSubscriptions.length > 0 ? "已开通订阅" : "已授予权限";
}
if (enabledCandidates.length > 1) {
stateKey = "ambiguous";
stateText = "归属待整理";
}
return {
...row,
stateKey,
stateText,
matchingSubscriptions,
matchingActiveSubscriptions,
matchingKeys,
latestExpiresAt,
enabledCandidateIDs: enabledCandidates,
legacyEnabledCount: Array.from(legacyEnabled).filter((groupID) => candidateIDs.has(groupID)).length
};
});
}
function activeLogicalGroupNames() {
return logicalGroupEntitlementRows()
.filter((row) => row.stateKey === "active" || row.stateKey === "granted")
.map((row) => row.logicalGroup.display_name || row.logicalGroup.logical_group_id);
}
function renderEntitlementView() {
const grid = $("entitlement-grid");
const rows = logicalGroupEntitlementRows();
if (!rows.length) {
grid.innerHTML = '<div class="empty">当前没有可投影到权限视图的逻辑分组。</div>';
return;
}
grid.innerHTML = rows.map((row) => {
const group = row.logicalGroup;
const stateBadgeClass = row.stateKey === "active"
? "active"
: row.stateKey === "granted"
? "neutral"
: "pending";
const compatibilityNames = (row.candidates || []).map((candidate) => candidate.title).join(" / ") || "无兼容线路";
const models = portalLogicalGroupModels(group).join(", ") || "--";
return (
'<article class="group-card ' + (row.stateKey === "active" ? "active" : (row.stateKey === "catalog_only" ? "neutral" : "pending")) + '">' +
'<h4>' + escapeHTML(group.display_name || group.logicalGroupID || "未命名逻辑分组") + '</h4>' +
'<div class="group-meta">' +
'<span class="badge strong ' + stateBadgeClass + '">' + escapeHTML(row.stateText) + '</span>' +
'<span class="badge">兼容线路 ' + String(row.candidates.length) + '</span>' +
'<span class="badge">活跃订阅 ' + String(row.matchingActiveSubscriptions.length) + '</span>' +
'<span class="badge">已有 Key ' + String(row.matchingKeys.length) + '</span>' +
'</div>' +
'<div class="group-note">公开模型:<span class="mono">' + escapeHTML(models) + '</span></div>' +
'<div class="group-note">兼容宿主线路:' + escapeHTML(compatibilityNames) + '</div>' +
'<div class="group-note">最近到期时间:' + escapeHTML(formatDate(row.latestExpiresAt)) + '</div>' +
'<div class="group-note">权限解释:' + escapeHTML(
row.stateKey === "active"
? "当前账号已具备订阅与兼容线路,可直接使用或继续申请测试 Key。"
: row.stateKey === "granted"
? "当前账号已拿到兼容线路权限,但尚未检测到活跃订阅记录。"
: row.stateKey === "pending"
? "当前逻辑分组还没有可直接使用的兼容线路,请联系管理员补开通。"
: row.stateKey === "ambiguous"
? "当前逻辑分组命中多条兼容线路,权限归属仍待管理员整理。"
: "当前逻辑分组已公开,但还未绑定可直接使用的兼容线路。"
) + '</div>' +
'</article>'
);
}).join("");
}
function getPresentationStatus(row) {
if ((row.enabledCandidates || []).length === 1) {
return { cls: "active", text: "可立即申请兼容 Key" };
}
if ((row.enabledCandidates || []).length > 1) {
return { cls: "pending", text: "待人工确认" };
}
if ((row.candidates || []).length > 0) {
return { cls: "pending", text: "需开通兼容线路" };
}
return { cls: "neutral", text: "目录已上线" };
}
function renderSessionSummary() {
const sessionGrid = $("session-grid");
const user = state.user;
const entitlementRows = logicalGroupEntitlementRows();
const activeLogicalGroups = activeLogicalGroupNames();
$("enabled-groups-stat").textContent = String((state.portalLogicalGroups || []).length);
$("subscriptions-stat").textContent = String(entitlementRows.filter((row) => row.stateKey === "active" || row.stateKey === "granted").length);
if (!user) {
statusPill("warn", "未登录");
$("balance-stat").textContent = "--";
$("keys-stat").textContent = "0";
sessionGrid.innerHTML = [
'<div class="empty">当前还没有有效登录态。登录后会显示账号概览、兼容线路开通状态和历史 Key逻辑分组目录在未登录时也可浏览。</div>'
].join("");
return;
}
statusPill("ok", "已登录");
$("balance-stat").textContent = formatMoney(user.balance);
$("keys-stat").textContent = String((state.keys || []).length);
const fields = [
["邮箱", user.email || "--"],
["用户名", user.username || "--"],
["角色", user.role || "--"],
["状态", user.status || "--"],
["并发", user.concurrency ?? "--"],
["RPM 限制", user.rpm_limit ?? "--"],
["逻辑分组权限", activeLogicalGroups.length ? activeLogicalGroups.join(" / ") : "当前未检测到已激活的逻辑分组权限"],
["兼容宿主分组", 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 = logicalGroupStatusRows();
const grid = $("group-grid");
if (!rows.length) {
grid.innerHTML = '<div class="empty">当前还没有对外发布的逻辑分组目录。管理员发布后,这里会自动显示公开模型与兼容线路状态。</div>';
$("group-id").innerHTML = '<option value="">暂无可用逻辑分组</option>';
$("group-id").value = "";
state.selectionLogicalGroupID = "";
renderSelectionSummary();
return;
}
grid.innerHTML = rows.map((row) => {
const group = row.logicalGroup;
const presentation = getPresentationStatus(row);
const models = portalLogicalGroupModels(group);
const modelsHTML = models.length
? "<ul class=\"group-models\">" + models.map((model) => "<li><span class=\"mono\">" + escapeHTML(model) + "</span></li>").join("") + "</ul>"
: "<div class=\"group-note\">当前尚未登记公开模型。</div>";
const compatibilityText = row.enabledCandidates.length === 1
? "兼容 Key 申请已就绪。当前账号可以直接申请这一组模型的测试 Key。"
: row.enabledCandidates.length > 1
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属。"
: row.candidates.length > 0
? "逻辑分组目录已上线,但你的账号还没有对应兼容线路。"
: "当前仅开放目录浏览,尚未建立可自动申请的兼容线路。";
return (
'<article class="group-card ' + (presentation.cls === "active" ? "active" : (presentation.cls === "neutral" ? "neutral" : "pending")) + '">' +
'<h4>' + escapeHTML(group.display_name || group.logical_group_id || "未命名逻辑分组") + '</h4>' +
'<div class="group-meta">' +
'<span class="badge">logical group</span>' +
'<span class="badge mono">' + escapeHTML(group.logical_group_id || "--") + '</span>' +
'<span class="badge">' + escapeHTML(group.route_policy || "priority") + '</span>' +
'<span class="badge">' + escapeHTML(group.sticky_mode || "conversation_preferred") + '</span>' +
'<span class="badge strong ' + presentation.cls + '">' + escapeHTML(presentation.text) + '</span>' +
'</div>' +
'<div class="group-note">' + escapeHTML(group.description || "当前逻辑分组已对外发布,可按公开模型维度统一查看。") + '</div>' +
modelsHTML +
'<div class="group-note">公开模型:<span class="mono">' + String(models.length) + '</span> / route<span class="mono">' + String(group.route_count || 0) + '</span> / active route<span class="mono">' + String(group.active_route_count || 0) + '</span></div>' +
'<div class="group-note">' + escapeHTML(compatibilityText) + '</div>' +
'</article>'
);
}).join("");
const select = $("group-id");
const previous = String(select.value || state.selectionLogicalGroupID || rows[0].logicalGroup.logical_group_id || "");
const options = rows.map((row) => {
const group = row.logicalGroup;
const label = (group.display_name || group.logical_group_id) + " / " + (portalLogicalGroupModels(group).join(", ") || "未登记模型");
return '<option value="' + escapeHTML(group.logical_group_id || "") + '">' + escapeHTML(label) + '</option>';
}).join("");
select.innerHTML = options;
if (rows.some((row) => String(row.logicalGroup.logical_group_id || "") === previous)) {
select.value = previous;
}
state.selectionLogicalGroupID = String(select.value || rows[0].logicalGroup.logical_group_id || "");
renderSelectionSummary();
}
function renderSelectionSummary() {
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
state.selectionLogicalGroupID = logicalGroupID;
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
if (!row) {
$("selection-summary").innerHTML = "当前还没有可选逻辑分组。";
$("create-key-btn").disabled = true;
return;
}
const models = portalLogicalGroupModels(row.logicalGroup);
const canCreate = !!state.accessToken && row.enabledCandidates.length === 1;
const compatibility = row.enabledCandidates.length === 1
? "当前账号已命中唯一兼容宿主线路,可直接申请测试 Key。"
: row.enabledCandidates.length > 1
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属后再申请。"
: row.candidates.length > 0
? "你的账号暂未开通兼容宿主线路,当前只能浏览目录,不能直接申请测试 Key。"
: "当前逻辑分组尚未建立自动申请测试 Key 的兼容线路。";
$("create-key-btn").disabled = !canCreate;
$("selection-summary").innerHTML = [
'<div><strong>' + escapeHTML(row.logicalGroup.display_name || row.logicalGroup.logical_group_id || "未命名逻辑分组") + '</strong> / <span class="mono">' + escapeHTML(logicalGroupID) + '</span></div>',
'<div class="mono">公开模型: ' + escapeHTML(models.join(", ") || "--") + '</div>',
'<div class="mono">route_policy = ' + escapeHTML(row.logicalGroup.route_policy || "priority") + ' / sticky_mode = ' + escapeHTML(row.logicalGroup.sticky_mode || "conversation_preferred") + '</div>',
'<div>' + escapeHTML(compatibility) + '</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) ? knownLegacyGroup(groupID) : null;
const logicalCandidates = meta
? (state.portalLogicalGroups || []).filter((group) => meta.models.some((model) => portalLogicalGroupModels(group).includes(String(model).trim())))
: [];
const logicalCandidateText = logicalCandidates.length
? logicalCandidates.map((group) => group.display_name || group.logical_group_id).join(" / ")
: "未建立逻辑分组映射";
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>' + escapeHTML(meta ? meta.title : (groupID ? "group " + groupID : "未绑定")) + '</div></div>' +
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalCandidateText) + '</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();
renderEntitlementView();
renderKeys();
}
async function refreshUserState() {
try {
const payload = await requestPortal("/logical-groups");
state.portalLogicalGroups = Array.isArray(payload.logical_groups) ? payload.logical_groups : [];
} catch (err) {
state.portalLogicalGroups = [];
setStatus("login-status", "bad", "逻辑分组目录拉取失败: " + err.message);
}
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 logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
if (!row) {
setStatus("key-status", "bad", "当前还没有可用于申请测试 Key 的逻辑分组。");
return;
}
if (row.enabledCandidates.length !== 1) {
const tip = row.enabledCandidates.length > 1
? "当前逻辑分组命中多条兼容宿主线路,暂不自动选择,请联系管理员整理归属。"
: "当前逻辑分组尚未命中唯一兼容宿主线路,暂不能直接申请测试 Key。";
setStatus("key-status", "bad", tip);
return;
}
const legacyGroup = row.enabledCandidates[0];
setBusy("create-key-btn", true);
try {
const data = await requestJSON("/keys", "POST", {
name,
group_id: Number(legacyGroup.id)
}, true);
state.lastCreatedKey = data.key || "";
$("api-key").value = state.lastCreatedKey;
setStatus("key-status", "ok", "Key 创建成功。已按逻辑分组“" + (row.logicalGroup.display_name || row.logicalGroup.logical_group_id) + "”走兼容线路发放测试 Key。");
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>