1739 lines
63 KiB
HTML
1739 lines
63 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>
|
||
<style>
|
||
:root {
|
||
--bg: #f4efe6;
|
||
--panel: rgba(255, 252, 247, 0.92);
|
||
--panel-strong: #fffdf9;
|
||
--text: #17212d;
|
||
--muted: #5d6876;
|
||
--line: #d7c9b1;
|
||
--teal: #0f766e;
|
||
--teal-soft: #d8f0ec;
|
||
--amber: #b45309;
|
||
--amber-soft: #fff0db;
|
||
--ok: #166534;
|
||
--bad: #b91c1c;
|
||
--shadow: 0 20px 55px rgba(23, 33, 45, 0.08);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
color: var(--text);
|
||
background:
|
||
radial-gradient(circle at top left, rgba(180, 83, 9, 0.14), transparent 32%),
|
||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.16), transparent 28%),
|
||
linear-gradient(180deg, #faf6ee 0%, var(--bg) 100%);
|
||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
.shell {
|
||
max-width: 1240px;
|
||
margin: 0 auto;
|
||
padding: 28px 20px 56px;
|
||
}
|
||
.hero {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 28px;
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
border-radius: 28px;
|
||
background:
|
||
linear-gradient(140deg, rgba(255, 255, 255, 0.94), rgba(249, 243, 231, 0.88)),
|
||
linear-gradient(120deg, rgba(15, 118, 110, 0.06), rgba(180, 83, 9, 0.04));
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.hero::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: -88px;
|
||
right: -52px;
|
||
width: 220px;
|
||
height: 220px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(15, 118, 110, 0.14), transparent 68%);
|
||
pointer-events: none;
|
||
}
|
||
.topline {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.tag,
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.tag {
|
||
background: rgba(15, 118, 110, 0.11);
|
||
color: var(--teal);
|
||
}
|
||
.chip {
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
background: rgba(255, 253, 249, 0.72);
|
||
color: var(--muted);
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
max-width: 820px;
|
||
font-size: clamp(30px, 5vw, 52px);
|
||
line-height: 1.04;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.hero-copy {
|
||
max-width: 920px;
|
||
margin-top: 16px;
|
||
color: var(--muted);
|
||
line-height: 1.75;
|
||
font-size: 15px;
|
||
}
|
||
.hero-copy p {
|
||
margin: 0 0 10px;
|
||
}
|
||
.hero-meta {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
margin-top: 18px;
|
||
}
|
||
.hero-meta .meta-card {
|
||
padding: 14px 16px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(215, 201, 177, 0.9);
|
||
background: rgba(255, 253, 249, 0.78);
|
||
}
|
||
.hero-meta .meta-label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.mono {
|
||
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||
font-size: 12px;
|
||
word-break: break-all;
|
||
}
|
||
.dashboard {
|
||
display: grid;
|
||
gap: 18px;
|
||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||
margin-top: 22px;
|
||
align-items: start;
|
||
}
|
||
.stack {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid rgba(215, 201, 177, 0.95);
|
||
border-radius: 24px;
|
||
padding: 22px;
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
.panel.hero-panel {
|
||
padding: 24px;
|
||
}
|
||
.section-head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
margin-bottom: 14px;
|
||
}
|
||
.section-head h2,
|
||
.section-head h3 {
|
||
margin: 0;
|
||
}
|
||
.section-head p {
|
||
margin: 6px 0 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
max-width: 680px;
|
||
}
|
||
.section-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.tiny {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
border: 1px solid rgba(215, 201, 177, 0.9);
|
||
background: rgba(255, 253, 249, 0.88);
|
||
color: var(--muted);
|
||
}
|
||
.status-pill.ok {
|
||
background: rgba(22, 101, 52, 0.08);
|
||
color: var(--ok);
|
||
border-color: rgba(22, 101, 52, 0.24);
|
||
}
|
||
.status-pill.bad {
|
||
background: rgba(185, 28, 28, 0.08);
|
||
color: var(--bad);
|
||
border-color: rgba(185, 28, 28, 0.22);
|
||
}
|
||
.status-pill.warn {
|
||
background: rgba(180, 83, 9, 0.1);
|
||
color: var(--amber);
|
||
border-color: rgba(180, 83, 9, 0.24);
|
||
}
|
||
.stats {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||
margin-top: 14px;
|
||
}
|
||
.stat {
|
||
padding: 14px 16px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(215, 201, 177, 0.88);
|
||
background: rgba(255, 255, 255, 0.74);
|
||
}
|
||
.stat-label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.session-grid,
|
||
.form-grid {
|
||
display: grid;
|
||
gap: 14px;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
}
|
||
.kv {
|
||
padding: 14px 16px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(215, 201, 177, 0.88);
|
||
background: rgba(255, 255, 255, 0.78);
|
||
}
|
||
.kv-label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.kv-value {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
}
|
||
.auth-grid {
|
||
display: grid;
|
||
gap: 18px;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
}
|
||
.card {
|
||
padding: 18px;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
background: var(--panel-strong);
|
||
box-shadow: 0 12px 28px rgba(23, 33, 45, 0.06);
|
||
}
|
||
.card h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 18px;
|
||
}
|
||
.card p.hint {
|
||
margin: 0 0 14px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin: 10px 0 6px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
input,
|
||
textarea,
|
||
select {
|
||
width: 100%;
|
||
border: 1px solid rgba(215, 201, 177, 0.95);
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 12px 13px;
|
||
font: inherit;
|
||
color: var(--text);
|
||
}
|
||
input:focus,
|
||
textarea:focus,
|
||
select:focus {
|
||
outline: 2px solid rgba(15, 118, 110, 0.14);
|
||
border-color: rgba(15, 118, 110, 0.45);
|
||
}
|
||
textarea {
|
||
min-height: 112px;
|
||
resize: vertical;
|
||
}
|
||
.button-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
button {
|
||
display: inline-flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 132px;
|
||
padding: 12px 16px;
|
||
border: 0;
|
||
border-radius: 14px;
|
||
font: inherit;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
transition: transform 140ms ease, box-shadow 140ms ease, opacity 140ms ease;
|
||
background: linear-gradient(135deg, var(--teal), #115e59);
|
||
box-shadow: 0 14px 28px rgba(15, 118, 110, 0.18);
|
||
}
|
||
button:hover { transform: translateY(-1px); }
|
||
button.alt {
|
||
background: linear-gradient(135deg, var(--amber), #92400e);
|
||
box-shadow: 0 14px 28px rgba(180, 83, 9, 0.18);
|
||
}
|
||
button.ghost {
|
||
background: rgba(255, 255, 255, 0.82);
|
||
color: var(--text);
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
box-shadow: none;
|
||
}
|
||
button.inline {
|
||
min-width: 0;
|
||
width: auto;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
box-shadow: none;
|
||
}
|
||
button:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
.status-box {
|
||
margin-top: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
border: 1px dashed rgba(215, 201, 177, 0.95);
|
||
background: #faf6ef;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.status-box.ok {
|
||
color: var(--ok);
|
||
background: rgba(22, 101, 52, 0.06);
|
||
border-color: rgba(22, 101, 52, 0.18);
|
||
}
|
||
.status-box.bad {
|
||
color: var(--bad);
|
||
background: rgba(185, 28, 28, 0.06);
|
||
border-color: rgba(185, 28, 28, 0.16);
|
||
}
|
||
.group-grid {
|
||
display: grid;
|
||
gap: 14px;
|
||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||
}
|
||
.group-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 16px;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
background: rgba(255, 255, 255, 0.82);
|
||
}
|
||
.group-card.active {
|
||
border-color: rgba(15, 118, 110, 0.34);
|
||
background: linear-gradient(180deg, rgba(216, 240, 236, 0.92), rgba(255, 255, 255, 0.92));
|
||
}
|
||
.group-card.pending {
|
||
background: linear-gradient(180deg, rgba(255, 240, 219, 0.72), rgba(255, 255, 255, 0.92));
|
||
}
|
||
.group-card.neutral {
|
||
background: linear-gradient(180deg, rgba(240, 244, 248, 0.82), rgba(255, 255, 255, 0.94));
|
||
}
|
||
.group-card h4 {
|
||
margin: 0 0 8px;
|
||
font-size: 17px;
|
||
}
|
||
.group-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 5px 9px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
background: rgba(23, 33, 45, 0.05);
|
||
color: var(--muted);
|
||
}
|
||
.badge.active {
|
||
background: rgba(22, 101, 52, 0.1);
|
||
color: var(--ok);
|
||
}
|
||
.badge.pending {
|
||
background: rgba(180, 83, 9, 0.12);
|
||
color: var(--amber);
|
||
}
|
||
.badge.neutral {
|
||
background: rgba(23, 33, 45, 0.08);
|
||
color: var(--muted);
|
||
}
|
||
.badge.strong {
|
||
padding: 7px 11px;
|
||
font-size: 12px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
.group-models {
|
||
margin: 10px 0 0;
|
||
padding-left: 18px;
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
font-size: 13px;
|
||
}
|
||
.group-note {
|
||
margin-top: 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.result-card {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
.result-box {
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
background: rgba(255, 255, 255, 0.8);
|
||
}
|
||
.result-box strong {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
}
|
||
.list {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.key-item {
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(215, 201, 177, 0.88);
|
||
background: rgba(255, 255, 255, 0.8);
|
||
}
|
||
.key-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.key-name {
|
||
font-weight: 800;
|
||
font-size: 15px;
|
||
}
|
||
.key-meta {
|
||
display: grid;
|
||
gap: 8px;
|
||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.empty {
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
border: 1px dashed rgba(215, 201, 177, 0.95);
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, 0.72);
|
||
font-size: 13px;
|
||
}
|
||
.footer-note {
|
||
margin-top: 22px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
.guide-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
}
|
||
.guide-card {
|
||
padding: 16px;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(215, 201, 177, 0.92);
|
||
background: rgba(255, 255, 255, 0.82);
|
||
}
|
||
.guide-card h4 {
|
||
margin: 0 0 8px;
|
||
font-size: 17px;
|
||
}
|
||
.guide-card .guide-copy {
|
||
margin: 0 0 10px;
|
||
color: var(--muted);
|
||
line-height: 1.65;
|
||
font-size: 13px;
|
||
}
|
||
.guide-card .guide-lines {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.guide-card .guide-line {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
.cta-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(15, 118, 110, 0.24);
|
||
background: rgba(15, 118, 110, 0.08);
|
||
color: var(--teal);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
text-decoration: none;
|
||
}
|
||
.cta-link:hover {
|
||
background: rgba(15, 118, 110, 0.14);
|
||
}
|
||
.toast {
|
||
position: fixed;
|
||
right: 20px;
|
||
bottom: 20px;
|
||
z-index: 1000;
|
||
min-width: 220px;
|
||
max-width: 320px;
|
||
padding: 12px 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(215, 201, 177, 0.95);
|
||
background: rgba(255, 253, 249, 0.96);
|
||
color: var(--text);
|
||
box-shadow: 0 18px 38px rgba(23, 33, 45, 0.14);
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
pointer-events: none;
|
||
transition: opacity 160ms ease, transform 160ms ease;
|
||
}
|
||
.toast.show {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
.toast.ok {
|
||
border-color: rgba(22, 101, 52, 0.26);
|
||
background: rgba(243, 252, 245, 0.96);
|
||
color: var(--ok);
|
||
}
|
||
.toast.bad {
|
||
border-color: rgba(185, 28, 28, 0.2);
|
||
background: rgba(254, 244, 244, 0.96);
|
||
color: var(--bad);
|
||
}
|
||
@media (max-width: 980px) {
|
||
.dashboard {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.hero {
|
||
padding: 22px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell">
|
||
<section class="hero">
|
||
<div class="topline">
|
||
<span class="tag">Sub2API 公网多模型接入中心</span>
|
||
<span class="chip">兼容 OpenAI root endpoint</span>
|
||
<span class="chip">旧地址 /kimi-portal 自动跳转</span>
|
||
</div>
|
||
<h1>一个入口,管理你的多模型测试账号、线路与 Key</h1>
|
||
<div class="hero-copy">
|
||
<p>这里不再只是 Kimi 入口,而是统一承接当前公网多模型接入。登录后,你可以直接看到账号状态、已开通线路、活跃订阅和历史 Key,并按所需模型族快速创建新的测试 Key。</p>
|
||
<p>同一个公网 endpoint 支持多模型接入,但 key 仍按分组发放。页面会明确告诉你哪些线路可以直接使用,哪些还需要管理员补开通。</p>
|
||
</div>
|
||
<div class="hero-meta">
|
||
<div class="meta-card">
|
||
<span class="meta-label">推荐 Base URL</span>
|
||
<div class="mono">https://sub.tksea.top</div>
|
||
</div>
|
||
<div class="meta-card">
|
||
<span class="meta-label">显式 /v1 Base URL</span>
|
||
<div class="mono">https://sub.tksea.top/v1</div>
|
||
</div>
|
||
<div class="meta-card">
|
||
<span class="meta-label">通用 Portal 地址</span>
|
||
<div class="mono">https://sub.tksea.top/portal/</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="dashboard">
|
||
<div class="stack">
|
||
<article class="panel hero-panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>会话状态</h2>
|
||
<p>登录后会自动恢复会话,并同步当前用户信息、可用线路、订阅状态和历史 Key。</p>
|
||
</div>
|
||
<div class="section-actions">
|
||
<span id="session-pill" class="status-pill warn">未登录</span>
|
||
<button id="refresh-session-btn" class="ghost inline">刷新状态</button>
|
||
<button id="logout-btn" class="ghost inline">退出登录</button>
|
||
</div>
|
||
</div>
|
||
<div class="stats">
|
||
<div class="stat">
|
||
<span class="stat-label">账户余额</span>
|
||
<div id="balance-stat" class="stat-value">--</div>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">逻辑分组目录</span>
|
||
<div id="enabled-groups-stat" class="stat-value">0</div>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">已激活产品权限</span>
|
||
<div id="subscriptions-stat" class="stat-value">0</div>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">已有 Key</span>
|
||
<div id="keys-stat" class="stat-value">0</div>
|
||
</div>
|
||
</div>
|
||
<div id="session-grid" class="session-grid" style="margin-top:14px;"></div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>逻辑分组与模型目录</h2>
|
||
<p>这里优先展示插件层的逻辑分组、公开模型、sticky 策略和 route 状态,不再把宿主真实分组当成用户主视角。</p>
|
||
</div>
|
||
</div>
|
||
<div id="group-grid" class="group-grid"></div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>权限与订阅视图</h2>
|
||
<p>这里把宿主兼容线路、订阅与历史 Key 重新聚合回逻辑分组层,帮助你判断“我对哪个产品组已经可用、还缺什么”。</p>
|
||
</div>
|
||
</div>
|
||
<div id="entitlement-grid" class="group-grid"></div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>使用建议与可用模型说明</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-grid">
|
||
<section class="card">
|
||
<h3>注册</h3>
|
||
<p class="hint">当前无需邮箱验证码、邀请码或 Turnstile。</p>
|
||
<label for="reg-email">邮箱</label>
|
||
<input id="reg-email" placeholder="you@example.com" />
|
||
<label for="reg-password">密码</label>
|
||
<input id="reg-password" type="password" placeholder="至少 6 位" />
|
||
<div class="button-row">
|
||
<button id="register-btn">注册并登录</button>
|
||
</div>
|
||
<div id="register-status" class="status-box">未执行</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h3>登录</h3>
|
||
<p class="hint">如果你已经有账号,可以直接登录并同步当前用户状态。</p>
|
||
<label for="login-email">邮箱</label>
|
||
<input id="login-email" placeholder="you@example.com" />
|
||
<label for="login-password">密码</label>
|
||
<input id="login-password" type="password" placeholder="你的密码" />
|
||
<div class="button-row">
|
||
<button id="login-btn" class="alt">登录</button>
|
||
</div>
|
||
<div id="login-status" class="status-box">未执行</div>
|
||
</section>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>申请测试 Key</h2>
|
||
<p>页面先按逻辑分组展示产品目录;当前 Key 申请仍通过兼容宿主线路完成。只有找到唯一兼容线路时,才会允许直接申请。</p>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div>
|
||
<label for="key-name">Key 名称</label>
|
||
<input id="key-name" value="my-model-key" />
|
||
</div>
|
||
<div>
|
||
<label for="group-id">选择逻辑分组</label>
|
||
<select id="group-id"></select>
|
||
</div>
|
||
</div>
|
||
<div class="button-row">
|
||
<button id="create-key-btn">创建 Key</button>
|
||
</div>
|
||
<div id="key-status" class="status-box">未执行</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<h2>结果与接入信息</h2>
|
||
<p>创建成功后,这里会显示 Access Token、最新 API Key,以及这一把 key 对应的分组和推荐模型。</p>
|
||
</div>
|
||
</div>
|
||
<div class="result-card">
|
||
<div class="result-box">
|
||
<strong>Access Token</strong>
|
||
<textarea id="access-token" readonly></textarea>
|
||
<div class="button-row">
|
||
<button id="copy-token-btn" class="ghost inline">复制 Access Token</button>
|
||
</div>
|
||
</div>
|
||
<div class="result-box">
|
||
<strong>最新创建的 API Key</strong>
|
||
<textarea id="api-key" readonly></textarea>
|
||
<div class="button-row">
|
||
<button id="copy-key-btn" class="ghost inline">复制 API Key</button>
|
||
</div>
|
||
</div>
|
||
<div class="result-box">
|
||
<strong>当前逻辑分组说明</strong>
|
||
<div id="selection-summary" class="tiny">尚未选择线路。</div>
|
||
</div>
|
||
<div class="result-box">
|
||
<strong>推荐配置</strong>
|
||
<div class="mono">base_url = https://sub.tksea.top</div>
|
||
<div class="mono">或 base_url = https://sub.tksea.top/v1</div>
|
||
<div class="mono">model = 按当前分组对应模型名填写</div>
|
||
<div class="mono">api_key = 你刚生成的 key</div>
|
||
</div>
|
||
</div>
|
||
<p class="footer-note">如果某条线路显示“待开通”,说明你的账号还没有对应 subscription 或可绑定分组。此时可以先注册并登录,再联系管理员补组,无需重新创建账号。</p>
|
||
<p class="footer-note">当前页面已经把“目录、权限、订阅、Key”统一投影到逻辑分组产品层;兼容宿主线路只作为过渡实现细节保留在申请流程里。</p>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div id="toast" class="toast" aria-live="polite"></div>
|
||
|
||
<script>
|
||
const PORTAL_PROXY_PREFIX = "/portal-proxy/api/v1";
|
||
const PORTAL_CATALOG_PREFIX = "/portal-admin-api/api/portal";
|
||
const STORAGE = {
|
||
token: "sub2api.portal.accessToken",
|
||
email: "sub2api.portal.email"
|
||
};
|
||
const LEGACY_GROUP_CATALOG = {
|
||
2: {
|
||
id: 2,
|
||
key: "kimi",
|
||
title: "Kimi A7M",
|
||
subtitle: "默认订阅线路",
|
||
recommendation: "recommended",
|
||
models: ["kimi-k2.6"],
|
||
description: "适合日常聊天和智能体对话,通常也是新注册用户默认会拿到的第一条线路。"
|
||
},
|
||
4: {
|
||
id: 4,
|
||
key: "gpt",
|
||
title: "GPT asxs 中转",
|
||
subtitle: "多模型 GPT 线路",
|
||
recommendation: "recommended",
|
||
models: ["gpt-5.4", "gpt-5.4-mini"],
|
||
description: "适合需要 GPT 能力的使用场景。当前建议优先使用 gpt-5.4 和 gpt-5.4-mini。"
|
||
},
|
||
5: {
|
||
id: 5,
|
||
key: "minimax",
|
||
title: "MiniMax 53hk 中转",
|
||
subtitle: "双模型高速线路",
|
||
recommendation: "recommended",
|
||
models: ["MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"],
|
||
description: "适合需要 MiniMax 双模型切换和稳定 OpenAI-compatible 接入的场景。"
|
||
},
|
||
6: {
|
||
id: 6,
|
||
key: "deepseek",
|
||
title: "DeepSeek 官方",
|
||
subtitle: "官方 chat 路线",
|
||
recommendation: "recommended",
|
||
models: ["deepseek-chat"],
|
||
description: "适合 DeepSeek 官方 chat 路线需求。当前用户侧建议直接使用 deepseek-chat。"
|
||
}
|
||
};
|
||
const 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 = $("login-email").value.trim() || $("reg-email").value.trim();
|
||
if (email) {
|
||
localStorage.setItem(STORAGE.email, email);
|
||
}
|
||
}
|
||
|
||
function restoreSession() {
|
||
state.accessToken = localStorage.getItem(STORAGE.token) || "";
|
||
const rememberedEmail = localStorage.getItem(STORAGE.email) || "";
|
||
$("login-email").value = rememberedEmail;
|
||
$("reg-email").value = rememberedEmail;
|
||
$("access-token").value = state.accessToken;
|
||
}
|
||
|
||
function clearSession() {
|
||
state.accessToken = "";
|
||
state.user = null;
|
||
state.groups = [];
|
||
state.subscriptions = [];
|
||
state.keys = [];
|
||
state.lastCreatedKey = "";
|
||
localStorage.removeItem(STORAGE.token);
|
||
$("access-token").value = "";
|
||
$("api-key").value = "";
|
||
setStatus("login-status", "", "未执行");
|
||
setStatus("register-status", "", "未执行");
|
||
setStatus("key-status", "", "未执行");
|
||
renderAll();
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const headers = Object.assign({}, options.headers || {});
|
||
if (state.accessToken && options.useAuth !== false) {
|
||
headers.Authorization = "Bearer " + state.accessToken;
|
||
}
|
||
const res = await fetch(PORTAL_PROXY_PREFIX + path, {
|
||
method: options.method || "GET",
|
||
headers,
|
||
body: options.body
|
||
});
|
||
const text = await res.text();
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(text);
|
||
} catch {
|
||
payload = { raw: text };
|
||
}
|
||
if (!res.ok || (typeof payload.code === "number" && payload.code !== 0)) {
|
||
const message = payload.message || payload.raw || ("HTTP " + res.status);
|
||
const error = new Error(message);
|
||
error.statusCode = res.status;
|
||
error.payload = payload;
|
||
throw error;
|
||
}
|
||
return payload.data ?? payload;
|
||
}
|
||
|
||
async function requestPortal(path) {
|
||
const res = await fetch(PORTAL_CATALOG_PREFIX + path, {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" }
|
||
});
|
||
const text = await res.text();
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(text);
|
||
} catch {
|
||
payload = { raw: text };
|
||
}
|
||
if (!res.ok) {
|
||
const message = payload.message || payload.raw || ("HTTP " + res.status);
|
||
const error = new Error(message);
|
||
error.statusCode = res.status;
|
||
error.payload = payload;
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function requestJSON(path, method, payload, useAuth = true) {
|
||
return request(path, {
|
||
method,
|
||
useAuth,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
}
|
||
|
||
function knownLegacyGroup(id) {
|
||
return LEGACY_GROUP_CATALOG[id] || {
|
||
id,
|
||
key: "group-" + id,
|
||
title: "分组 " + id,
|
||
subtitle: "未命名线路",
|
||
models: [],
|
||
description: "当前页面没有这条分组的预设模型映射。"
|
||
};
|
||
}
|
||
|
||
function availableLegacyGroupIDs() {
|
||
const ids = new Set();
|
||
for (const group of state.groups || []) {
|
||
if (group && Number.isFinite(Number(group.id))) {
|
||
ids.add(Number(group.id));
|
||
}
|
||
}
|
||
if (state.user && Array.isArray(state.user.allowed_groups)) {
|
||
for (const id of state.user.allowed_groups) {
|
||
if (Number.isFinite(Number(id))) {
|
||
ids.add(Number(id));
|
||
}
|
||
}
|
||
}
|
||
for (const item of state.subscriptions || []) {
|
||
const status = String(item.status || "");
|
||
if ((status === "active" || status === "trialing") && Number.isFinite(Number(item.group_id))) {
|
||
ids.add(Number(item.group_id));
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function portalLogicalGroupModels(group) {
|
||
return Array.isArray(group && group.public_models)
|
||
? group.public_models
|
||
.map((item) => String(item && item.public_model ? item.public_model : "").trim())
|
||
.filter(Boolean)
|
||
: [];
|
||
}
|
||
|
||
function 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 activeLogicalGroupNames() {
|
||
return logicalGroupEntitlementRows()
|
||
.filter((row) => row.stateKey === "active" || row.stateKey === "granted")
|
||
.map((row) => row.logicalGroup.display_name || row.logicalGroup.logical_group_id);
|
||
}
|
||
|
||
function renderEntitlementView() {
|
||
const grid = $("entitlement-grid");
|
||
const rows = logicalGroupEntitlementRows();
|
||
if (!rows.length) {
|
||
grid.innerHTML = '<div class="empty">当前没有可投影到权限视图的逻辑分组。</div>';
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = rows.map((row) => {
|
||
const group = row.logicalGroup;
|
||
const stateBadgeClass = row.stateKey === "active"
|
||
? "active"
|
||
: row.stateKey === "granted"
|
||
? "neutral"
|
||
: "pending";
|
||
const compatibilityNames = (row.candidates || []).map((candidate) => candidate.title).join(" / ") || "无兼容线路";
|
||
const models = portalLogicalGroupModels(group).join(", ") || "--";
|
||
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">兼容宿主线路:' + escapeHTML(compatibilityNames) + '</div>' +
|
||
'<div class="group-note">最近到期时间:' + escapeHTML(formatDate(row.latestExpiresAt)) + '</div>' +
|
||
'<div class="group-note">权限解释:' + escapeHTML(
|
||
row.stateKey === "active"
|
||
? "当前账号已具备订阅与兼容线路,可直接使用或继续申请测试 Key。"
|
||
: row.stateKey === "granted"
|
||
? "当前账号已拿到兼容线路权限,但尚未检测到活跃订阅记录。"
|
||
: row.stateKey === "pending"
|
||
? "当前逻辑分组还没有可直接使用的兼容线路,请联系管理员补开通。"
|
||
: row.stateKey === "ambiguous"
|
||
? "当前逻辑分组命中多条兼容线路,权限归属仍待管理员整理。"
|
||
: "当前逻辑分组已公开,但还未绑定可直接使用的兼容线路。"
|
||
) + '</div>' +
|
||
'</article>'
|
||
);
|
||
}).join("");
|
||
}
|
||
|
||
function 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。"
|
||
: row.stateKey === "ambiguous"
|
||
? "当前逻辑分组命中多条兼容线路;建议等待管理员整理归属,避免申请到不确定线路。"
|
||
: "当前可先浏览模型目录与接入建议,待管理员发布兼容线路后再申请测试 Key。";
|
||
const compatibility = (row.candidates || []).map((candidate) => candidate.title).join(" / ") || "尚未建立兼容线路";
|
||
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,
|
||
compatibility,
|
||
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>兼容线路:</strong>' + escapeHTML(guide.compatibility) + '</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();
|
||
$("enabled-groups-stat").textContent = String((state.portalLogicalGroups || []).length);
|
||
$("subscriptions-stat").textContent = String(entitlementRows.filter((row) => row.stateKey === "active" || row.stateKey === "granted").length);
|
||
|
||
if (!user) {
|
||
statusPill("warn", "未登录");
|
||
$("balance-stat").textContent = "--";
|
||
$("keys-stat").textContent = "0";
|
||
sessionGrid.innerHTML = [
|
||
'<div class="empty">当前还没有有效登录态。登录后会显示账号概览、兼容线路开通状态和历史 Key;逻辑分组目录在未登录时也可浏览。</div>'
|
||
].join("");
|
||
return;
|
||
}
|
||
|
||
statusPill("ok", "已登录");
|
||
$("balance-stat").textContent = formatMoney(user.balance);
|
||
$("keys-stat").textContent = String((state.keys || []).length);
|
||
|
||
const fields = [
|
||
["邮箱", user.email || "--"],
|
||
["用户名", user.username || "--"],
|
||
["角色", user.role || "--"],
|
||
["状态", user.status || "--"],
|
||
["并发", user.concurrency ?? "--"],
|
||
["RPM 限制", user.rpm_limit ?? "--"],
|
||
["逻辑分组权限", activeLogicalGroups.length ? activeLogicalGroups.join(" / ") : "当前未检测到已激活的逻辑分组权限"],
|
||
["兼容宿主分组", Array.isArray(user.allowed_groups) && user.allowed_groups.length ? user.allowed_groups.join(", ") : "由订阅/分组接口决定"],
|
||
["创建时间", formatDate(user.created_at)]
|
||
];
|
||
|
||
sessionGrid.innerHTML = fields.map(([label, value]) => (
|
||
'<div class="kv">' +
|
||
'<span class="kv-label">' + label + '</span>' +
|
||
'<div class="kv-value">' + value + '</div>' +
|
||
'</div>'
|
||
)).join("");
|
||
}
|
||
|
||
function renderGroupCatalog() {
|
||
const rows = logicalGroupStatusRows();
|
||
const grid = $("group-grid");
|
||
|
||
if (!rows.length) {
|
||
grid.innerHTML = '<div class="empty">当前还没有对外发布的逻辑分组目录。管理员发布后,这里会自动显示公开模型与兼容线路状态。</div>';
|
||
$("group-id").innerHTML = '<option value="">暂无可用逻辑分组</option>';
|
||
$("group-id").value = "";
|
||
state.selectionLogicalGroupID = "";
|
||
renderSelectionSummary();
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = rows.map((row) => {
|
||
const group = row.logicalGroup;
|
||
const presentation = getPresentationStatus(row);
|
||
const models = portalLogicalGroupModels(group);
|
||
const 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 compatibilityText = row.enabledCandidates.length === 1
|
||
? "兼容 Key 申请已就绪。当前账号可以直接申请这一组模型的测试 Key。"
|
||
: row.enabledCandidates.length > 1
|
||
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属。"
|
||
: row.candidates.length > 0
|
||
? "逻辑分组目录已上线,但你的账号还没有对应兼容线路。"
|
||
: "当前仅开放目录浏览,尚未建立可自动申请的兼容线路。";
|
||
|
||
return (
|
||
'<article class="group-card ' + (presentation.cls === "active" ? "active" : (presentation.cls === "neutral" ? "neutral" : "pending")) + '">' +
|
||
'<h4>' + escapeHTML(group.display_name || group.logical_group_id || "未命名逻辑分组") + '</h4>' +
|
||
'<div class="group-meta">' +
|
||
'<span class="badge">logical group</span>' +
|
||
'<span class="badge mono">' + escapeHTML(group.logical_group_id || "--") + '</span>' +
|
||
'<span class="badge">' + escapeHTML(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 strong ' + presentation.cls + '">' + escapeHTML(presentation.text) + '</span>' +
|
||
'</div>' +
|
||
'<div class="group-note">' + escapeHTML(group.description || "当前逻辑分组已对外发布,可按公开模型维度统一查看。") + '</div>' +
|
||
modelsHTML +
|
||
'<div class="group-note">公开模型:<span class="mono">' + String(models.length) + '</span> / route:<span class="mono">' + String(group.route_count || 0) + '</span> / active route:<span class="mono">' + String(group.active_route_count || 0) + '</span></div>' +
|
||
'<div class="group-note">' + escapeHTML(compatibilityText) + '</div>' +
|
||
'</article>'
|
||
);
|
||
}).join("");
|
||
|
||
const select = $("group-id");
|
||
const previous = String(select.value || state.selectionLogicalGroupID || rows[0].logicalGroup.logical_group_id || "");
|
||
const options = rows.map((row) => {
|
||
const group = row.logicalGroup;
|
||
const label = (group.display_name || group.logical_group_id) + " / " + (portalLogicalGroupModels(group).join(", ") || "未登记模型");
|
||
return '<option value="' + escapeHTML(group.logical_group_id || "") + '">' + escapeHTML(label) + '</option>';
|
||
}).join("");
|
||
select.innerHTML = options;
|
||
|
||
if (rows.some((row) => String(row.logicalGroup.logical_group_id || "") === previous)) {
|
||
select.value = previous;
|
||
}
|
||
state.selectionLogicalGroupID = String(select.value || rows[0].logicalGroup.logical_group_id || "");
|
||
renderSelectionSummary();
|
||
}
|
||
|
||
function renderSelectionSummary() {
|
||
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
|
||
state.selectionLogicalGroupID = logicalGroupID;
|
||
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
|
||
if (!row) {
|
||
$("selection-summary").innerHTML = "当前还没有可选逻辑分组。";
|
||
$("create-key-btn").disabled = true;
|
||
return;
|
||
}
|
||
const models = portalLogicalGroupModels(row.logicalGroup);
|
||
const canCreate = !!state.accessToken && row.enabledCandidates.length === 1;
|
||
const compatibility = row.enabledCandidates.length === 1
|
||
? "当前账号已命中唯一兼容宿主线路,可直接申请测试 Key。"
|
||
: row.enabledCandidates.length > 1
|
||
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属后再申请。"
|
||
: row.candidates.length > 0
|
||
? "你的账号暂未开通兼容宿主线路,当前只能浏览目录,不能直接申请测试 Key。"
|
||
: "当前逻辑分组尚未建立自动申请测试 Key 的兼容线路。";
|
||
$("create-key-btn").disabled = !canCreate;
|
||
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(compatibility) + '</div>',
|
||
(cta.label && cta.url ? ('<div><a class="cta-link" href="' + escapeHTML(cta.url) + '" target="_blank" rel="noreferrer">' + escapeHTML(cta.label) + '</a></div>') : '')
|
||
].join("");
|
||
}
|
||
|
||
function renderKeys() {
|
||
const list = $("keys-list");
|
||
const items = state.keys || [];
|
||
if (!items.length) {
|
||
list.innerHTML = '<div class="empty">当前还没有历史 Key。创建成功后,新 Key 会显示在右侧结果区,这里也会同步更新列表。</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = items.map((item) => {
|
||
const groupID = Number(item.group_id);
|
||
const meta = Number.isFinite(groupID) ? knownLegacyGroup(groupID) : null;
|
||
const logicalCandidates = meta
|
||
? (state.portalLogicalGroups || []).filter((group) => meta.models.some((model) => portalLogicalGroupModels(group).includes(String(model).trim())))
|
||
: [];
|
||
const logicalCandidateText = logicalCandidates.length
|
||
? logicalCandidates.map((group) => group.display_name || group.logical_group_id).join(" / ")
|
||
: "未建立逻辑分组映射";
|
||
return (
|
||
'<article class="key-item">' +
|
||
'<div class="key-top">' +
|
||
'<div class="key-name">' + (item.name || "未命名 Key") + '</div>' +
|
||
'<div class="section-actions">' +
|
||
'<span class="badge ' + (String(item.status || "") === "active" ? "active" : "pending") + '">' + (item.status || "--") + '</span>' +
|
||
'<button class="ghost inline copy-existing-key-btn" data-key="' + (item.key || "") + '">复制 Key</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="key-meta">' +
|
||
'<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' +
|
||
'<div><span class="kv-label">兼容线路</span><div>' + escapeHTML(meta ? meta.title : (groupID ? "group " + groupID : "未绑定")) + '</div></div>' +
|
||
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalCandidateText) + '</div></div>' +
|
||
'<div><span class="kv-label">创建时间</span><div>' + formatDate(item.created_at) + '</div></div>' +
|
||
'<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' +
|
||
'</div>' +
|
||
'</article>'
|
||
);
|
||
}).join("");
|
||
}
|
||
|
||
function renderAll() {
|
||
renderSessionSummary();
|
||
renderGroupCatalog();
|
||
renderEntitlementView();
|
||
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("login-status", "bad", "逻辑分组目录拉取失败: " + err.message);
|
||
}
|
||
|
||
if (!state.accessToken) {
|
||
state.user = null;
|
||
state.groups = [];
|
||
state.subscriptions = [];
|
||
state.keys = [];
|
||
renderAll();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const [user, groups, subscriptions, keysPage] = await Promise.all([
|
||
request("/auth/me"),
|
||
request("/groups/available"),
|
||
request("/subscriptions"),
|
||
request("/keys?page=1&page_size=20")
|
||
]);
|
||
state.user = user || null;
|
||
state.groups = Array.isArray(groups) ? groups : [];
|
||
state.subscriptions = Array.isArray(subscriptions) ? subscriptions : [];
|
||
state.keys = Array.isArray(keysPage && keysPage.items) ? keysPage.items : [];
|
||
renderAll();
|
||
} catch (err) {
|
||
clearSession();
|
||
statusPill("bad", "登录失效");
|
||
setStatus("login-status", "bad", "会话已失效,请重新登录:" + err.message);
|
||
}
|
||
}
|
||
|
||
function rememberAuth(data) {
|
||
state.accessToken = data.access_token || state.accessToken || "";
|
||
$("access-token").value = state.accessToken;
|
||
saveSession();
|
||
}
|
||
|
||
async function handleRegister() {
|
||
const email = $("reg-email").value.trim();
|
||
const password = $("reg-password").value;
|
||
setBusy("register-btn", true);
|
||
try {
|
||
const data = await requestJSON("/auth/register", "POST", {
|
||
email,
|
||
password,
|
||
verify_code: "",
|
||
turnstile_token: "",
|
||
promo_code: "",
|
||
invitation_code: "",
|
||
aff_code: ""
|
||
}, false);
|
||
rememberAuth(data);
|
||
$("login-email").value = email;
|
||
$("login-password").value = password;
|
||
setStatus("register-status", "ok", "注册成功,已自动登录。正在同步你的账号和线路状态。");
|
||
setStatus("login-status", "ok", "已自动登录。");
|
||
await refreshUserState();
|
||
} catch (err) {
|
||
setStatus("register-status", "bad", "注册失败: " + err.message);
|
||
} finally {
|
||
saveSession();
|
||
setBusy("register-btn", false);
|
||
}
|
||
}
|
||
|
||
async function handleLogin() {
|
||
const email = $("login-email").value.trim();
|
||
const password = $("login-password").value;
|
||
setBusy("login-btn", true);
|
||
try {
|
||
const data = await requestJSON("/auth/login", "POST", {
|
||
email,
|
||
password,
|
||
turnstile_token: ""
|
||
}, false);
|
||
rememberAuth(data);
|
||
$("reg-email").value = email;
|
||
setStatus("login-status", "ok", "登录成功,正在同步你的账号状态与可用线路。");
|
||
await refreshUserState();
|
||
} catch (err) {
|
||
setStatus("login-status", "bad", "登录失败: " + err.message);
|
||
} finally {
|
||
saveSession();
|
||
setBusy("login-btn", false);
|
||
}
|
||
}
|
||
|
||
async function handleCreateKey() {
|
||
const name = $("key-name").value.trim();
|
||
const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
|
||
const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
|
||
if (!row) {
|
||
setStatus("key-status", "bad", "当前还没有可用于申请测试 Key 的逻辑分组。");
|
||
return;
|
||
}
|
||
if (row.enabledCandidates.length !== 1) {
|
||
const tip = row.enabledCandidates.length > 1
|
||
? "当前逻辑分组命中多条兼容宿主线路,暂不自动选择,请联系管理员整理归属。"
|
||
: "当前逻辑分组尚未命中唯一兼容宿主线路,暂不能直接申请测试 Key。";
|
||
setStatus("key-status", "bad", tip);
|
||
return;
|
||
}
|
||
const legacyGroup = row.enabledCandidates[0];
|
||
setBusy("create-key-btn", true);
|
||
try {
|
||
const data = await requestJSON("/keys", "POST", {
|
||
name,
|
||
group_id: Number(legacyGroup.id)
|
||
}, true);
|
||
state.lastCreatedKey = data.key || "";
|
||
$("api-key").value = state.lastCreatedKey;
|
||
setStatus("key-status", "ok", "Key 创建成功。已按逻辑分组“" + (row.logicalGroup.display_name || row.logicalGroup.logical_group_id) + "”走兼容线路发放测试 Key。");
|
||
renderSelectionSummary();
|
||
await refreshUserState();
|
||
} catch (err) {
|
||
setStatus("key-status", "bad", "创建 Key 失败: " + err.message);
|
||
} finally {
|
||
setBusy("create-key-btn", false);
|
||
}
|
||
}
|
||
|
||
async function copyField(fieldID, statusID, label, button) {
|
||
const value = $(fieldID).value.trim();
|
||
await copyText(value, statusID, label, button);
|
||
}
|
||
|
||
$("register-btn").addEventListener("click", handleRegister);
|
||
$("login-btn").addEventListener("click", handleLogin);
|
||
$("create-key-btn").addEventListener("click", handleCreateKey);
|
||
$("refresh-session-btn").addEventListener("click", refreshUserState);
|
||
$("logout-btn").addEventListener("click", () => {
|
||
clearSession();
|
||
statusPill("warn", "已退出");
|
||
});
|
||
$("copy-token-btn").addEventListener("click", (event) => copyField("access-token", "login-status", "Access Token", event.currentTarget));
|
||
$("copy-key-btn").addEventListener("click", (event) => copyField("api-key", "key-status", "API Key", event.currentTarget));
|
||
$("keys-list").addEventListener("click", async (event) => {
|
||
const button = event.target.closest(".copy-existing-key-btn");
|
||
if (!button) {
|
||
return;
|
||
}
|
||
await copyText(button.dataset.key || "", "key-status", "已有 Key", button);
|
||
});
|
||
$("group-id").addEventListener("change", renderSelectionSummary);
|
||
|
||
restoreSession();
|
||
renderAll();
|
||
refreshUserState();
|
||
</script>
|
||
</body>
|
||
</html>
|