- 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)
1258 lines
56 KiB
HTML
1258 lines
56 KiB
HTML
<!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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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>
|