feat(portal): switch user catalog to logical groups

This commit is contained in:
phamnazage-jpg
2026-05-30 08:26:28 +08:00
parent 11fb02de9b
commit ac1d8e27cc
2 changed files with 210 additions and 88 deletions

View File

@@ -385,6 +385,9 @@
.group-card.pending { .group-card.pending {
background: linear-gradient(180deg, rgba(255, 240, 219, 0.72), rgba(255, 255, 255, 0.92)); 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 { .group-card h4 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 17px; font-size: 17px;
@@ -586,11 +589,11 @@
<div id="balance-stat" class="stat-value">--</div> <div id="balance-stat" class="stat-value">--</div>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat-label">已开通分组</span> <span class="stat-label">逻辑分组目录</span>
<div id="enabled-groups-stat" class="stat-value">0</div> <div id="enabled-groups-stat" class="stat-value">0</div>
</div> </div>
<div class="stat"> <div class="stat">
<span class="stat-label">活跃订阅</span> <span class="stat-label">已开通兼容线路</span>
<div id="subscriptions-stat" class="stat-value">0</div> <div id="subscriptions-stat" class="stat-value">0</div>
</div> </div>
<div class="stat"> <div class="stat">
@@ -604,8 +607,8 @@
<article class="panel"> <article class="panel">
<div class="section-head"> <div class="section-head">
<div> <div>
<h2>分组与模型线路</h2> <h2>逻辑分组与模型目录</h2>
<p>这里会把你的可用分组、活跃订阅和模型目录合并展示,让“能不能用、该用哪条线”一眼可见</p> <p>这里优先展示插件层的逻辑分组、公开模型、sticky 策略和 route 状态,不再把宿主真实分组当成用户主视角</p>
</div> </div>
</div> </div>
<div id="group-grid" class="group-grid"></div> <div id="group-grid" class="group-grid"></div>
@@ -663,8 +666,8 @@
<article class="panel"> <article class="panel">
<div class="section-head"> <div class="section-head">
<div> <div>
<h2>创建测试 Key</h2> <h2>申请测试 Key</h2>
<p>页面会高亮你当前可用的线路,创建成功后立即显示对应分组和模型建议</p> <p>页面先按逻辑分组展示产品目录;当前 Key 申请仍通过兼容宿主线路完成。只有找到唯一兼容线路时,才会允许直接申请</p>
</div> </div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
@@ -673,7 +676,7 @@
<input id="key-name" value="my-model-key" /> <input id="key-name" value="my-model-key" />
</div> </div>
<div> <div>
<label for="group-id">选择线路</label> <label for="group-id">选择逻辑分组</label>
<select id="group-id"></select> <select id="group-id"></select>
</div> </div>
</div> </div>
@@ -706,7 +709,7 @@
</div> </div>
</div> </div>
<div class="result-box"> <div class="result-box">
<strong>当前线路说明</strong> <strong>当前逻辑分组说明</strong>
<div id="selection-summary" class="tiny">尚未选择线路。</div> <div id="selection-summary" class="tiny">尚未选择线路。</div>
</div> </div>
<div class="result-box"> <div class="result-box">
@@ -726,11 +729,12 @@
<script> <script>
const PORTAL_PROXY_PREFIX = "/portal-proxy/api/v1"; const PORTAL_PROXY_PREFIX = "/portal-proxy/api/v1";
const PORTAL_CATALOG_PREFIX = "/portal-admin-api/api/portal";
const STORAGE = { const STORAGE = {
token: "sub2api.portal.accessToken", token: "sub2api.portal.accessToken",
email: "sub2api.portal.email" email: "sub2api.portal.email"
}; };
const GROUP_CATALOG = { const LEGACY_GROUP_CATALOG = {
2: { 2: {
id: 2, id: 2,
key: "kimi", key: "kimi",
@@ -772,11 +776,12 @@
const state = { const state = {
accessToken: "", accessToken: "",
user: null, user: null,
portalLogicalGroups: [],
groups: [], groups: [],
subscriptions: [], subscriptions: [],
keys: [], keys: [],
lastCreatedKey: "", lastCreatedKey: "",
selectionGroupID: 2 selectionLogicalGroupID: ""
}; };
let toastTimer = null; let toastTimer = null;
@@ -879,6 +884,15 @@
return value.slice(0, 6) + "..." + value.slice(-6); return value.slice(0, 6) + "..." + value.slice(-6);
} }
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function saveSession() { function saveSession() {
if (state.accessToken) { if (state.accessToken) {
localStorage.setItem(STORAGE.token, state.accessToken); localStorage.setItem(STORAGE.token, state.accessToken);
@@ -905,6 +919,7 @@
state.groups = []; state.groups = [];
state.subscriptions = []; state.subscriptions = [];
state.keys = []; state.keys = [];
state.lastCreatedKey = "";
localStorage.removeItem(STORAGE.token); localStorage.removeItem(STORAGE.token);
$("access-token").value = ""; $("access-token").value = "";
$("api-key").value = ""; $("api-key").value = "";
@@ -941,6 +956,28 @@
return payload.data ?? payload; 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) { async function requestJSON(path, method, payload, useAuth = true) {
return request(path, { return request(path, {
method, method,
@@ -950,8 +987,8 @@
}); });
} }
function knownGroup(id) { function knownLegacyGroup(id) {
return GROUP_CATALOG[id] || { return LEGACY_GROUP_CATALOG[id] || {
id, id,
key: "group-" + id, key: "group-" + id,
title: "分组 " + id, title: "分组 " + id,
@@ -961,7 +998,7 @@
}; };
} }
function availableGroupIDs() { function availableLegacyGroupIDs() {
const ids = new Set(); const ids = new Set();
for (const group of state.groups || []) { for (const group of state.groups || []) {
if (group && Number.isFinite(Number(group.id))) { if (group && Number.isFinite(Number(group.id))) {
@@ -984,64 +1021,83 @@
return ids; return ids;
} }
function groupStatusRows() { function portalLogicalGroupModels(group) {
const enabled = availableGroupIDs(); return Array.isArray(group && group.public_models)
const rows = []; ? group.public_models
const rawGroups = new Map((state.groups || []).map((group) => [Number(group.id), group])); .map((item) => String(item && item.public_model ? item.public_model : "").trim())
const subscriptionByGroup = new Map(); .filter(Boolean)
: [];
for (const item of state.subscriptions || []) {
if (Number.isFinite(Number(item.group_id))) {
subscriptionByGroup.set(Number(item.group_id), item);
}
} }
for (const [id, meta] of Object.entries(GROUP_CATALOG)) { function getPortalLogicalGroup(logicalGroupID) {
const numericID = Number(id); const target = String(logicalGroupID || "").trim();
const group = rawGroups.get(numericID) || null; return (state.portalLogicalGroups || []).find((group) => String(group.logical_group_id || "").trim() === target) || null;
const subscription = subscriptionByGroup.get(numericID) || null; }
rows.push({
id: numericID, function legacyCompatibilityCandidates(group) {
catalog: meta, const portalModels = new Set(portalLogicalGroupModels(group));
group, if (!portalModels.size) {
subscription, return [];
enabled: enabled.has(numericID) }
return Object.values(LEGACY_GROUP_CATALOG).filter((candidate) => candidate.models.some((model) => portalModels.has(String(model).trim())));
}
function logicalGroupStatusRows() {
const enabledLegacyGroups = availableLegacyGroupIDs();
const rows = (state.portalLogicalGroups || []).map((group) => {
const candidates = legacyCompatibilityCandidates(group);
const enabledCandidates = candidates.filter((candidate) => enabledLegacyGroups.has(Number(candidate.id)));
return {
logicalGroup: group,
candidates,
enabledCandidates
};
}); });
rows.sort((left, right) => {
const leftActive = Number(left.logicalGroup.active_route_count || 0);
const rightActive = Number(right.logicalGroup.active_route_count || 0);
if (leftActive !== rightActive) {
return rightActive - leftActive;
} }
return String(left.logicalGroup.display_name || left.logicalGroup.logical_group_id || "").localeCompare(
String(right.logicalGroup.display_name || right.logicalGroup.logical_group_id || ""),
"zh-CN"
);
});
return rows; return rows;
} }
function getPresentationStatus(row) { function getPresentationStatus(row) {
if (row.catalog.recommendation === "not_recommended") { if ((row.enabledCandidates || []).length === 1) {
return { cls: "neutral", text: "暂不推荐" }; return { cls: "active", text: "可立即申请兼容 Key" };
} }
if (row.enabled) { if ((row.enabledCandidates || []).length > 1) {
return { cls: "active", text: "可立即使用" }; return { cls: "pending", text: "待人工确认" };
} }
return { cls: "pending", text: "需开通" }; if ((row.candidates || []).length > 0) {
return { cls: "pending", text: "需开通兼容线路" };
}
return { cls: "neutral", text: "目录已上线" };
} }
function renderSessionSummary() { function renderSessionSummary() {
const sessionGrid = $("session-grid"); const sessionGrid = $("session-grid");
const user = state.user; const user = state.user;
$("enabled-groups-stat").textContent = String((state.portalLogicalGroups || []).length);
$("subscriptions-stat").textContent = String(availableLegacyGroupIDs().size);
if (!user) { if (!user) {
statusPill("warn", "未登录"); statusPill("warn", "未登录");
$("balance-stat").textContent = "--"; $("balance-stat").textContent = "--";
$("enabled-groups-stat").textContent = "0";
$("subscriptions-stat").textContent = "0";
$("keys-stat").textContent = "0"; $("keys-stat").textContent = "0";
sessionGrid.innerHTML = [ sessionGrid.innerHTML = [
'<div class="empty">当前还没有有效登录态。登录后会显示账号概览、已开通线路和历史 Key。</div>' '<div class="empty">当前还没有有效登录态。登录后会显示账号概览、兼容线路开通状态和历史 Key逻辑分组目录在未登录时也可浏览。</div>'
].join(""); ].join("");
return; return;
} }
statusPill("ok", "已登录"); statusPill("ok", "已登录");
$("balance-stat").textContent = formatMoney(user.balance); $("balance-stat").textContent = formatMoney(user.balance);
$("enabled-groups-stat").textContent = String(availableGroupIDs().size);
$("subscriptions-stat").textContent = String((state.subscriptions || []).filter((item) => String(item.status || "") === "active").length);
$("keys-stat").textContent = String((state.keys || []).length); $("keys-stat").textContent = String((state.keys || []).length);
const fields = [ const fields = [
@@ -1051,7 +1107,7 @@
["状态", user.status || "--"], ["状态", user.status || "--"],
["并发", user.concurrency ?? "--"], ["并发", user.concurrency ?? "--"],
["RPM 限制", user.rpm_limit ?? "--"], ["RPM 限制", user.rpm_limit ?? "--"],
["允许分组", Array.isArray(user.allowed_groups) && user.allowed_groups.length ? user.allowed_groups.join(", ") : "由订阅/分组接口决定"], ["兼容宿主分组", Array.isArray(user.allowed_groups) && user.allowed_groups.length ? user.allowed_groups.join(", ") : "由订阅/分组接口决定"],
["创建时间", formatDate(user.created_at)] ["创建时间", formatDate(user.created_at)]
]; ];
@@ -1064,61 +1120,91 @@
} }
function renderGroupCatalog() { function renderGroupCatalog() {
const rows = groupStatusRows(); const rows = logicalGroupStatusRows();
const grid = $("group-grid"); 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) => { grid.innerHTML = rows.map((row) => {
const groupName = row.group && row.group.name ? row.group.name : ("group " + row.id); const group = row.logicalGroup;
const subscription = row.subscription;
const presentation = getPresentationStatus(row); const presentation = getPresentationStatus(row);
const subscriptionText = subscription const models = portalLogicalGroupModels(group);
? "订阅状态:" + (subscription.status || "--") + (subscription.expires_at ? " / 到期:" + formatDate(subscription.expires_at) : "") const modelsHTML = models.length
: "尚未检测到当前账号的活跃订阅记录。"; ? "<ul class=\"group-models\">" + models.map((model) => "<li><span class=\"mono\">" + escapeHTML(model) + "</span></li>").join("") + "</ul>"
const models = row.catalog.models.length : "<div class=\"group-note\">当前尚未登记公开模型。</div>";
? "<ul class=\"group-models\">" + row.catalog.models.map((model) => "<li><span class=\"mono\">" + model + "</span></li>").join("") + "</ul>" const compatibilityText = row.enabledCandidates.length === 1
: "<div class=\"group-note\">当前未登记模型别名。</div>"; ? "兼容 Key 申请已就绪。当前账号可以直接申请这一组模型的测试 Key。"
: row.enabledCandidates.length > 1
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属。"
: row.candidates.length > 0
? "逻辑分组目录已上线,但你的账号还没有对应兼容线路。"
: "当前仅开放目录浏览,尚未建立可自动申请的兼容线路。";
return ( return (
'<article class="group-card ' + (row.enabled ? "active" : "pending") + '">' + '<article class="group-card ' + (presentation.cls === "active" ? "active" : (presentation.cls === "neutral" ? "neutral" : "pending")) + '">' +
'<h4>' + row.catalog.title + '</h4>' + '<h4>' + escapeHTML(group.display_name || group.logical_group_id || "未命名逻辑分组") + '</h4>' +
'<div class="group-meta">' + '<div class="group-meta">' +
'<span class="badge">' + groupName + '</span>' + '<span class="badge">logical group</span>' +
'<span class="badge">' + row.catalog.subtitle + '</span>' + '<span class="badge mono">' + escapeHTML(group.logical_group_id || "--") + '</span>' +
'<span class="badge strong ' + presentation.cls + '">' + presentation.text + '</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>' +
'<div class="group-note">' + row.catalog.description + '</div>' + '<div class="group-note">' + escapeHTML(group.description || "当前逻辑分组已对外发布,可按公开模型维度统一查看。") + '</div>' +
models + modelsHTML +
'<div class="group-note">分组 ID<span class="mono">' + row.id + '</span></div>' + '<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">' + subscriptionText + '</div>' + '<div class="group-note">' + escapeHTML(compatibilityText) + '</div>' +
'</article>' '</article>'
); );
}).join(""); }).join("");
const select = $("group-id"); const select = $("group-id");
const previous = Number(select.value || state.selectionGroupID || 2); const previous = String(select.value || state.selectionLogicalGroupID || rows[0].logicalGroup.logical_group_id || "");
const options = rows.map((row) => { const options = rows.map((row) => {
const label = row.catalog.title + " / group " + row.id + " / " + (row.catalog.models.join(", ") || "未登记模型"); const group = row.logicalGroup;
return '<option value="' + row.id + '"' + (row.enabled ? "" : "") + '>' + label + (row.enabled ? "" : "(待开通)") + '</option>'; const label = (group.display_name || group.logical_group_id) + " / " + (portalLogicalGroupModels(group).join(", ") || "未登记模型");
return '<option value="' + escapeHTML(group.logical_group_id || "") + '">' + escapeHTML(label) + '</option>';
}).join(""); }).join("");
select.innerHTML = options; select.innerHTML = options;
if (rows.some((row) => row.id === previous)) { if (rows.some((row) => String(row.logicalGroup.logical_group_id || "") === previous)) {
select.value = String(previous); select.value = previous;
} }
state.selectionGroupID = Number(select.value || 2); state.selectionLogicalGroupID = String(select.value || rows[0].logicalGroup.logical_group_id || "");
renderSelectionSummary(); renderSelectionSummary();
} }
function renderSelectionSummary() { function renderSelectionSummary() {
const groupID = Number($("group-id").value || state.selectionGroupID || 2); const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim();
state.selectionGroupID = groupID; state.selectionLogicalGroupID = logicalGroupID;
const row = groupStatusRows().find((item) => item.id === groupID); const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID);
const meta = knownGroup(groupID); if (!row) {
const availability = row && row.enabled ? "当前账号已开通,可直接创建这一条线路的 key。" : "当前账号尚未开通这条线路,创建时可能会返回无权限。"; $("selection-summary").innerHTML = "当前还没有可选逻辑分组。";
$("create-key-btn").disabled = true;
return;
}
const models = portalLogicalGroupModels(row.logicalGroup);
const canCreate = !!state.accessToken && row.enabledCandidates.length === 1;
const compatibility = row.enabledCandidates.length === 1
? "当前账号已命中唯一兼容宿主线路,可直接申请测试 Key。"
: row.enabledCandidates.length > 1
? "检测到多条兼容宿主线路,当前不自动选择,请联系管理员整理归属后再申请。"
: row.candidates.length > 0
? "你的账号暂未开通兼容宿主线路,当前只能浏览目录,不能直接申请测试 Key。"
: "当前逻辑分组尚未建立自动申请测试 Key 的兼容线路。";
$("create-key-btn").disabled = !canCreate;
$("selection-summary").innerHTML = [ $("selection-summary").innerHTML = [
'<div><strong>' + meta.title + '</strong> / group <span class="mono">' + groupID + '</span></div>', '<div><strong>' + escapeHTML(row.logicalGroup.display_name || row.logicalGroup.logical_group_id || "未命名逻辑分组") + '</strong> / <span class="mono">' + escapeHTML(logicalGroupID) + '</span></div>',
'<div class="mono">推荐模型: ' + (meta.models.join(", ") || "--") + '</div>', '<div class="mono">公开模型: ' + escapeHTML(models.join(", ") || "--") + '</div>',
'<div>' + availability + '</div>' '<div class="mono">route_policy = ' + escapeHTML(row.logicalGroup.route_policy || "priority") + ' / sticky_mode = ' + escapeHTML(row.logicalGroup.sticky_mode || "conversation_preferred") + '</div>',
'<div>' + escapeHTML(compatibility) + '</div>'
].join(""); ].join("");
} }
@@ -1132,7 +1218,13 @@
list.innerHTML = items.map((item) => { list.innerHTML = items.map((item) => {
const groupID = Number(item.group_id); const groupID = Number(item.group_id);
const meta = Number.isFinite(groupID) ? knownGroup(groupID) : null; 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 ( return (
'<article class="key-item">' + '<article class="key-item">' +
'<div class="key-top">' + '<div class="key-top">' +
@@ -1144,7 +1236,8 @@
'</div>' + '</div>' +
'<div class="key-meta">' + '<div class="key-meta">' +
'<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' + '<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' +
'<div><span class="kv-label">分组</span><div>' + (meta ? meta.title + " / group " + groupID : (groupID ? "group " + groupID : "未绑定")) + '</div></div>' + '<div><span class="kv-label">兼容线路</span><div>' + 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.created_at) + '</div></div>' +
'<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' + '<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' +
'</div>' + '</div>' +
@@ -1160,6 +1253,14 @@
} }
async function refreshUserState() { 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) { if (!state.accessToken) {
state.user = null; state.user = null;
state.groups = []; state.groups = [];
@@ -1246,17 +1347,29 @@
async function handleCreateKey() { async function handleCreateKey() {
const name = $("key-name").value.trim(); const name = $("key-name").value.trim();
const groupID = Number($("group-id").value || state.selectionGroupID || 2); 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); setBusy("create-key-btn", true);
try { try {
const data = await requestJSON("/keys", "POST", { const data = await requestJSON("/keys", "POST", {
name, name,
group_id: Number.isFinite(groupID) ? groupID : null group_id: Number(legacyGroup.id)
}, true); }, true);
state.lastCreatedKey = data.key || ""; state.lastCreatedKey = data.key || "";
$("api-key").value = state.lastCreatedKey; $("api-key").value = state.lastCreatedKey;
const meta = knownGroup(groupID); setStatus("key-status", "ok", "Key 创建成功。已按逻辑分组“" + (row.logicalGroup.display_name || row.logicalGroup.logical_group_id) + "”走兼容线路发放测试 Key。");
setStatus("key-status", "ok", "Key 创建成功。已绑定到 " + meta.title + " / group " + groupID + "。");
renderSelectionSummary(); renderSelectionSummary();
await refreshUserState(); await refreshUserState();
} catch (err) { } catch (err) {

View File

@@ -40,6 +40,7 @@ assert_contains_file() {
assert_contains_file "$HTML_FILE" "Sub2API 多模型接入中心" assert_contains_file "$HTML_FILE" "Sub2API 多模型接入中心"
assert_contains_file "$HTML_FILE" "https://sub.tksea.top/portal/" assert_contains_file "$HTML_FILE" "https://sub.tksea.top/portal/"
assert_contains_file "$HTML_FILE" "/portal-proxy/api/v1" assert_contains_file "$HTML_FILE" "/portal-proxy/api/v1"
assert_contains_file "$HTML_FILE" "/portal-admin-api/api/portal"
assert_contains_file "$HTML_FILE" "localStorage.setItem" assert_contains_file "$HTML_FILE" "localStorage.setItem"
assert_contains_file "$HTML_FILE" "/auth/me" assert_contains_file "$HTML_FILE" "/auth/me"
assert_contains_file "$HTML_FILE" "/groups/available" assert_contains_file "$HTML_FILE" "/groups/available"
@@ -48,9 +49,17 @@ assert_contains_file "$HTML_FILE" "/keys?page=1&page_size=20"
assert_contains_file "$HTML_FILE" "copy-existing-key-btn" assert_contains_file "$HTML_FILE" "copy-existing-key-btn"
assert_contains_file "$HTML_FILE" "已有 Key" assert_contains_file "$HTML_FILE" "已有 Key"
assert_contains_file "$HTML_FILE" "showToast" assert_contains_file "$HTML_FILE" "showToast"
assert_contains_file "$HTML_FILE" "可立即使用" assert_contains_file "$HTML_FILE" "逻辑分组目录"
assert_contains_file "$HTML_FILE" "开通" assert_contains_file "$HTML_FILE" "开通兼容线路"
assert_contains_file "$HTML_FILE" "暂不推荐" assert_contains_file "$HTML_FILE" "可立即申请兼容 Key"
assert_contains_file "$HTML_FILE" "需开通兼容线路"
assert_contains_file "$HTML_FILE" "目录已上线"
assert_contains_file "$HTML_FILE" "选择逻辑分组"
assert_contains_file "$HTML_FILE" "当前逻辑分组说明"
assert_contains_file "$HTML_FILE" "兼容宿主线路"
assert_contains_file "$HTML_FILE" "portalLogicalGroups"
assert_contains_file "$HTML_FILE" "LEGACY_GROUP_CATALOG"
assert_contains_file "$HTML_FILE" "route_policy ="
assert_contains_file "$HTML_FILE" "gpt-5.4" assert_contains_file "$HTML_FILE" "gpt-5.4"
assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed" assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed"
assert_contains_file "$HTML_FILE" "deepseek-chat" assert_contains_file "$HTML_FILE" "deepseek-chat"