Files
sub2api-cn-relay-manager/deploy/tksea-portal/index.html
phamnazage-jpg 4b743848bc
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled
refactor(portal): merge register + login into single auth entry
- Two separate cards (register + login) -> single unified card
- handleRegister + handleLogin -> single handleAuth that tries login first,
  falls back to register if login fails (new user detection)
- Single email/password input, single button, single status display
- Enter key submits on both fields
- File size 57873 -> 51329 chars (-11%)

Test: test_tksea_portal_assets.sh PASS, verify_frontend_smoke.sh PASS,
      verify_quality_gates.sh PASS (gofmt+vet+cov+integration)
2026-06-04 13:52:18 +08:00

1258 lines
56 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>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sub2API 多模型接入中心</title>
<link rel="stylesheet" href="/portal/portal.css">
<link rel="stylesheet" href="/portal/admin-common.css">
<style>
/* Public portal: keep light theme (customer-facing) but use the modern design tokens. */
[data-theme="light"], :root:not([data-theme]) {
/* override body bg with portal light theme — but use the new teal/slate palette */
}
.dashboard { display: grid; gap: var(--s-5); grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); margin-top: 22px; align-items: start; }
.stack { display: grid; gap: var(--s-5); }
.panel.hero-panel { padding: 22px; }
.meta-card { padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
.meta-label { display: block; margin-bottom: 6px; color: var(--text-muted); font-size: 12px; }
.mono { font-family: var(--font-mono); font-size: 12px; word-break: break-all; color: var(--text-default); }
.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin-top: 14px; }
.stat { padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
.stat-label { display: block; margin-bottom: 8px; color: var(--text-muted); font-size: 12px; }
.stat-value { font-size: 24px; font-weight: 800; letter-spacing: -0.03em; color: var(--text-strong); }
.session-grid, .form-grid, .group-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.kv { padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
.kv-label { display: block; margin-bottom: 8px; color: var(--text-muted); font-size: 12px; }
.kv-value { font-size: 15px; font-weight: 700; line-height: 1.5; color: var(--text-default); }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: var(--r-full); font-size: 13px; font-weight: 700; border: 1px solid var(--border-subtle); background: var(--bg-elev-1); color: var(--text-muted); }
.status-pill.ok { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
.status-pill.bad { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.2); }
.status-pill.warn { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
.tag, .chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: var(--r-full); font-size: 12px; font-weight: 700; }
.tag { background: var(--color-primary-soft); color: var(--color-primary); }
.chip { border: 1px solid var(--border-subtle); background: var(--bg-elev-1); color: var(--text-muted); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.empty-row { padding: 18px; border: 1px dashed var(--border-subtle); border-radius: var(--r-lg); color: var(--text-muted); font-size: 13px; line-height: 1.6; text-align: center; }
.key-list { display: grid; gap: 10px; }
.key-card { padding: 14px 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); display: flex; align-items: center; gap: 12px; }
.key-card .key-text { flex: 1; font-family: var(--font-mono); font-size: 12.5px; color: var(--text-default); word-break: break-all; }
.key-card .actions { flex-shrink: 0; }
.table-wrap { margin-top: 12px; }
pre { margin: 0; padding: 16px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: rgba(2,6,23,0.6); color: var(--text-default); font-size: 12px; line-height: 1.65; overflow: auto; white-space: pre-wrap; word-break: break-word; }
[data-theme="light"] pre { background: var(--slate-900); color: var(--slate-100); }
@media (max-width: 980px) { .dashboard { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="shell fade-in">
<section class="page-hero">
<div>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px;">
<span class="tag">Sub2API 公网多模型接入中心</span>
<span class="chip">兼容 OpenAI root endpoint</span>
<span class="chip">旧地址 /kimi-portal 自动跳转</span>
</div>
<h1>一个入口,管理你的多模型测试账号、线路与 Key</h1>
<div>
<p>这里不再只是 Kimi 入口,而是统一承接当前公网多模型接入。登录后,你可以直接看到账号状态、已开通线路、活跃订阅和历史 Key并按所需模型族快速创建新的测试 Key。</p>
<p>同一个公网 endpoint 支持多模型接入,但 key 仍按分组发放。页面会明确告诉你哪些线路可以直接使用,哪些还需要管理员补开通。</p>
</div>
</div>
<aside style="display:grid;gap:12px;align-content:start;">
<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>
</aside>
</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 依赖、订阅与历史 Key 重新聚合回逻辑分组层,帮助你判断“我对哪个产品组已经可用、还缺什么”。宿主兼容分组只保留为后端实现细节,不再作为主视角暴露。</p>
</div>
</div>
<div id="entitlement-grid" class="group-grid"></div>
</article>
<article class="panel">
<div class="section-head">
<div>
<h2>使用建议与可用模型说明</h2>
<p>这里按逻辑分组给出推荐模型、适用场景、接入建议和下一步动作,让普通用户不需要理解宿主分组也能直接开始使用。</p>
</div>
</div>
<div id="guide-grid" class="guide-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-single">
<section class="card">
<h3>登录或注册</h3>
<p class="hint">输入邮箱和密码。如果是新邮箱会自动创建账号并登录,已有账号则直接登录。无需验证码、邀请码或 Turnstile。</p>
<label for="auth-email">邮箱</label>
<input id="auth-email" placeholder="you@example.com" />
<label for="auth-password">密码</label>
<input id="auth-password" type="password" placeholder="至少 6 位" />
<div class="button-row">
<button id="auth-btn">登录 / 注册</button>
<span id="auth-hint" class="hint inline">新邮箱将自动创建</span>
</div>
<div id="auth-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>申请 Key 依赖状态</strong>
<div id="dependency-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">如果某个逻辑分组显示“待补开通”,说明你的账号还没有对应的申请 Key 依赖链路。此时可以先注册并登录,再联系管理员补开通,无需重新创建账号。</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 LEGACY_MODEL_GUIDANCE = {
"kimi-k2.6": {
scenario: "适合日常聊天、长上下文问答和轻量智能体使用。",
recommendation: "默认先从这条模型线开始验证接入是否通畅。"
},
"gpt-5.4": {
scenario: "适合高质量推理、复杂编排、代码辅助和更稳的通用对话。",
recommendation: "如果你已经开通对应权限,优先用它做主模型。"
},
"gpt-5.4-mini": {
scenario: "适合低成本试跑、轻量自动化和更高频的小请求。",
recommendation: "更适合作为低成本补充线路或快速压测模型。"
},
"MiniMax-M2.5-highspeed": {
scenario: "适合对速度敏感的 MiniMax 使用场景。",
recommendation: "优先做高速场景和批量调用验证。"
},
"MiniMax-M2.7-highspeed": {
scenario: "适合需要更强能力、同时仍关注速度的 MiniMax 场景。",
recommendation: "适合和 M2.5 做效果与时延对照。"
},
"deepseek-chat": {
scenario: "适合通用 DeepSeek Chat 入口与官方兼容场景。",
recommendation: "建议直接按公开模型名调用,不需要额外记宿主细节。"
}
};
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 = $("auth-email").value.trim();
if (email) {
localStorage.setItem(STORAGE.email, email);
}
}
function restoreSession() {
state.accessToken = localStorage.getItem(STORAGE.token) || "";
const rememberedEmail = localStorage.getItem(STORAGE.email) || "";
$("auth-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("auth-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 logicalGroupVisibilityScope(group) {
return String(group && group.visibility_scope ? group.visibility_scope : "public").trim() || "public";
}
function logicalGroupPackageTier(group) {
return String(group && group.package_tier ? group.package_tier : "standard").trim() || "standard";
}
function logicalGroupPurchaseCTA(group) {
return {
label: String(group && group.purchase_cta_label ? group.purchase_cta_label : "").trim(),
url: String(group && group.purchase_cta_url ? group.purchase_cta_url : "").trim()
};
}
function logicalGroupVisibleForViewer(row) {
const scope = logicalGroupVisibilityScope(row.logicalGroup);
if (scope === "hidden") {
return false;
}
if (scope === "public") {
return true;
}
if (scope === "login_required") {
return !!state.user;
}
if (scope === "entitled_only") {
return !!state.user && (row.stateKey === "active" || row.stateKey === "granted" || row.stateKey === "ambiguous");
}
return true;
}
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
};
});
const filtered = rows.filter((row) => logicalGroupVisibleForViewer(row));
filtered.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 filtered;
}
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 dependencyStateForRow(row) {
if (!row) {
return {
key: "none",
badgeText: "未选择逻辑分组",
summary: "请先选择一个逻辑分组,页面才会判断当前账号能否直接申请测试 Key。",
actionHint: "先从目录里选择目标逻辑分组。",
lineCountText: "依赖线路0",
canCreate: false
};
}
const enabledCount = Array.isArray(row.enabledCandidates) ? row.enabledCandidates.length : 0;
const candidateCount = Array.isArray(row.candidates) ? row.candidates.length : 0;
const activeSubscriptionCount = Array.isArray(row.matchingActiveSubscriptions) ? row.matchingActiveSubscriptions.length : 0;
const lineCountText = candidateCount > 0
? `依赖线路:已识别 ${candidateCount} 条候选链路`
: "依赖线路:当前还没有可自动发放的候选链路";
if (enabledCount === 1 && activeSubscriptionCount > 0) {
return {
key: "ready",
badgeText: "可直接申请",
summary: "当前账号已命中唯一申请链路,并且检测到活跃订阅或可直接使用状态。",
actionHint: "可以直接创建测试 Key并优先按推荐模型完成第一次调用验证。",
lineCountText,
canCreate: true
};
}
if (enabledCount === 1) {
return {
key: "granted",
badgeText: "可申请,调用前需确认状态",
summary: "当前账号已命中唯一申请链路,所以可以直接申请测试 Key但页面没有检测到活跃订阅记录首次调用前建议先确认订阅或余额状态。",
actionHint: "可以先创建 Key若调用时报余额或订阅问题再联系管理员确认当前产品状态。",
lineCountText,
canCreate: true
};
}
if (enabledCount > 1) {
return {
key: "ambiguous",
badgeText: "待人工整理",
summary: "当前逻辑分组命中多条申请链路,系统不会替你自动选择,避免把 Key 发到不确定的线路上。",
actionHint: "请联系管理员整理归属;整理完成前,这个逻辑分组只适合浏览目录与模型说明。",
lineCountText,
canCreate: false
};
}
if (candidateCount > 0) {
return {
key: "pending",
badgeText: "待补开通",
summary: "当前逻辑分组已经公开,但你的账号还没有对应的申请 Key 依赖链路。",
actionHint: "请联系管理员补开通;补开通后不需要重新注册账号。",
lineCountText,
canCreate: false
};
}
return {
key: "catalog_only",
badgeText: "仅目录可见",
summary: "当前逻辑分组已经对外发布,但还没有建立可自动申请测试 Key 的依赖链路。",
actionHint: "当前适合先浏览模型目录与接入建议,待后端发布申请链路后再创建测试 Key。",
lineCountText,
canCreate: false
};
}
function dependencySummaryCounts() {
const counts = {
ready: 0,
granted: 0,
pending: 0,
ambiguous: 0,
catalog_only: 0
};
logicalGroupEntitlementRows().forEach((row) => {
const state = dependencyStateForRow(row);
if (Object.prototype.hasOwnProperty.call(counts, state.key)) {
counts[state.key] += 1;
}
});
return counts;
}
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 dependency = dependencyStateForRow(row);
const models = portalLogicalGroupModels(group).join(", ") || "--";
const packageTier = logicalGroupPackageTier(group);
const visibilityScope = logicalGroupVisibilityScope(group);
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">' + escapeHTML(packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(visibilityScope) + '</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">申请 Key 依赖:' + escapeHTML(dependency.badgeText) + ' · ' + escapeHTML(dependency.lineCountText) + '</div>' +
'<div class="group-note">最近到期时间:' + escapeHTML(formatDate(row.latestExpiresAt)) + '</div>' +
'<div class="group-note">权限解释:' + escapeHTML(dependency.summary) + '</div>' +
'</article>'
);
}).join("");
}
function buildGuideEntries() {
return logicalGroupEntitlementRows().map((row) => {
const group = row.logicalGroup;
const models = portalLogicalGroupModels(group);
const primaryModel = models[0] || "";
const configuredScenario = String(group.usage_scenario || "").trim();
const configuredRecommendation = String(group.recommendation || "").trim();
const configuredNextStep = String(group.next_step_hint || "").trim();
const guidance = LEGACY_MODEL_GUIDANCE[primaryModel] || {
scenario: "适合按该逻辑分组下的公开模型集合统一接入。",
recommendation: "建议先用列表中的第一个公开模型做连通性验证。"
};
const defaultNextStep = row.stateKey === "active"
? "当前已具备订阅与权限,建议直接创建测试 Key 并使用推荐模型发起第一次请求。"
: row.stateKey === "granted"
? "当前已具备线路权限,但未发现活跃订阅;建议先确认订阅状态后再发起调用。"
: row.stateKey === "pending"
? "当前目录已公开,但你还没有对应的申请 Key 依赖链路;建议联系管理员补开通后再申请 Key。"
: row.stateKey === "ambiguous"
? "当前逻辑分组命中多条申请链路;建议等待管理员整理归属,避免申请到不确定线路。"
: "当前可先浏览模型目录与接入建议,待管理员发布申请链路后再申请测试 Key。";
const dependency = dependencyStateForRow(row);
return {
title: group.display_name || group.logical_group_id || "未命名逻辑分组",
logicalGroupID: group.logical_group_id || "",
models,
stateText: row.stateText,
stateKey: row.stateKey,
packageTier: logicalGroupPackageTier(group),
visibilityScope: logicalGroupVisibilityScope(group),
scenario: configuredScenario || guidance.scenario,
recommendation: configuredRecommendation || guidance.recommendation,
nextStep: configuredNextStep || defaultNextStep,
dependencyBadgeText: dependency.badgeText,
dependencySummary: dependency.summary,
cta: logicalGroupPurchaseCTA(group),
stickyMode: group.sticky_mode || "conversation_preferred",
routePolicy: group.route_policy || "priority"
};
});
}
function renderUsageGuides() {
const grid = $("guide-grid");
const guides = buildGuideEntries();
if (!guides.length) {
grid.innerHTML = '<div class="empty">当前还没有可展示的逻辑分组使用建议。</div>';
return;
}
grid.innerHTML = guides.map((guide) => {
const badgeClass = guide.stateKey === "active"
? "active"
: guide.stateKey === "granted"
? "neutral"
: "pending";
return (
'<article class="guide-card">' +
'<div class="group-meta">' +
'<span class="badge strong ' + badgeClass + '">' + escapeHTML(guide.stateText) + '</span>' +
'<span class="badge">' + escapeHTML(guide.packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(guide.visibilityScope) + '</span>' +
'<span class="badge mono">' + escapeHTML(guide.logicalGroupID) + '</span>' +
'</div>' +
'<h4>' + escapeHTML(guide.title) + '</h4>' +
'<p class="guide-copy">' + escapeHTML(guide.scenario) + '</p>' +
'<div class="guide-lines">' +
'<div class="guide-line"><strong>推荐模型:</strong><span class="mono">' + escapeHTML(guide.models.join(", ") || "--") + '</span></div>' +
'<div class="guide-line"><strong>接入建议:</strong>' + escapeHTML(guide.recommendation) + '</div>' +
'<div class="guide-line"><strong>下一步:</strong>' + escapeHTML(guide.nextStep) + '</div>' +
'<div class="guide-line"><strong>申请 Key 依赖:</strong>' + escapeHTML(guide.dependencyBadgeText) + ' / ' + escapeHTML(guide.dependencySummary) + '</div>' +
'<div class="guide-line"><strong>路由策略:</strong><span class="mono">' + escapeHTML(guide.routePolicy) + '</span> / <span class="mono">' + escapeHTML(guide.stickyMode) + '</span></div>' +
'</div>' +
((guide.cta.label && guide.cta.url && guide.stateKey !== "active")
? ('<a class="cta-link" href="' + escapeHTML(guide.cta.url) + '" target="_blank" rel="noreferrer">' + escapeHTML(guide.cta.label) + '</a>')
: '') +
'</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();
const dependencyCounts = dependencySummaryCounts();
$("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 依赖状态和历史 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(" / ") : "当前未检测到已激活的逻辑分组权限"],
["申请 Key 依赖状态", `可直接申请 ${dependencyCounts.ready + dependencyCounts.granted} 组 / 待补开通 ${dependencyCounts.pending} 组 / 待整理 ${dependencyCounts.ambiguous} 组 / 仅目录 ${dependencyCounts.catalog_only}`],
["创建时间", 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">当前还没有对外发布的逻辑分组目录。管理员发布后,这里会自动显示公开模型与申请 Key 依赖状态。</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 packageTier = logicalGroupPackageTier(group);
const visibilityScope = logicalGroupVisibilityScope(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 dependency = dependencyStateForRow(row);
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(packageTier) + '</span>' +
'<span class="badge">' + escapeHTML(visibilityScope) + '</span>' +
'<span class="badge">' + escapeHTML(group.route_policy || "priority") + '</span>' +
'<span class="badge">' + escapeHTML(group.sticky_mode || "conversation_preferred") + '</span>' +
'<span class="badge">依赖链路 ' + String(row.candidates.length) + '</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">申请 Key 依赖:' + escapeHTML(dependency.badgeText) + ' · ' + escapeHTML(dependency.summary) + '</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 = "当前还没有可选逻辑分组。";
$("dependency-summary").innerHTML = "当前还没有可用于判断申请状态的逻辑分组。";
$("create-key-btn").disabled = true;
return;
}
const models = portalLogicalGroupModels(row.logicalGroup);
const dependency = dependencyStateForRow(row);
const canCreate = !!state.accessToken && dependency.canCreate;
$("create-key-btn").disabled = !canCreate;
const cta = logicalGroupPurchaseCTA(row.logicalGroup);
$("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">package_tier = ' + escapeHTML(logicalGroupPackageTier(row.logicalGroup)) + ' / visibility_scope = ' + escapeHTML(logicalGroupVisibilityScope(row.logicalGroup)) + '</div>',
'<div class="mono">route_policy = ' + escapeHTML(row.logicalGroup.route_policy || "priority") + ' / sticky_mode = ' + escapeHTML(row.logicalGroup.sticky_mode || "conversation_preferred") + '</div>',
'<div>' + escapeHTML(dependency.summary) + '</div>',
(cta.label && cta.url ? ('<div><a class="cta-link" href="' + escapeHTML(cta.url) + '" target="_blank" rel="noreferrer">' + escapeHTML(cta.label) + '</a></div>') : '')
].join("");
$("dependency-summary").innerHTML = [
'<div><strong>' + escapeHTML(dependency.badgeText) + '</strong></div>',
'<div>' + escapeHTML(dependency.lineCountText) + '</div>',
'<div>' + escapeHTML(dependency.actionHint) + '</div>',
(!state.accessToken ? '<div>当前未登录。登录后才能真正发起 Key 创建请求。</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(logicalCandidateText) + '</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();
renderUsageGuides();
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("auth-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("auth-status", "bad", "会话已失效,请重新登录:" + err.message);
}
}
function rememberAuth(data) {
state.accessToken = data.access_token || state.accessToken || "";
$("access-token").value = state.accessToken;
saveSession();
}
async function handleAuth() {
const email = $("auth-email").value.trim();
const password = $("auth-password").value;
if (!email || !password) {
setStatus("auth-status", "bad", "邮箱和密码都不能为空。");
return;
}
setBusy("auth-btn", true);
setStatus("auth-status", "", "正在验证账号…");
try {
// 先尝试登录
const data = await requestJSON("/auth/login", "POST", {
email, password, turnstile_token: ""
}, false);
rememberAuth(data);
setStatus("auth-status", "ok", "登录成功,正在同步你的账号状态与申请资格。");
await refreshUserState();
} catch (loginErr) {
// 登录失败,尝试注册(可能是新用户)
try {
const regData = await requestJSON("/auth/register", "POST", {
email, password,
verify_code: "", turnstile_token: "",
promo_code: "", invitation_code: "", aff_code: ""
}, false);
rememberAuth(regData);
setStatus("auth-status", "ok", "新账号注册成功,已自动登录。正在同步…");
await refreshUserState();
} catch (regErr) {
setStatus("auth-status", "bad", "登录或注册均失败:" + regErr.message);
}
} finally {
saveSession();
setBusy("auth-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);
}
$("create-key-btn").addEventListener("click", handleCreateKey);
$("refresh-session-btn").addEventListener("click", refreshUserState);
$("logout-btn").addEventListener("click", () => {
clearSession();
statusPill("warn", "已退出");
});
$("auth-btn").addEventListener("click", handleAuth);
$("auth-email").addEventListener("keydown", (e) => { if (e.key === "Enter") $("auth-btn").click(); });
$("auth-password").addEventListener("keydown", (e) => { if (e.key === "Enter") $("auth-btn").click(); });
$("copy-token-btn").addEventListener("click", (event) => copyField("access-token", "auth-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>
<script src="/portal/portal.js"></script>
</body>
</html>