feat(portal): switch user catalog to logical groups
This commit is contained in:
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
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 || []) {
|
function getPortalLogicalGroup(logicalGroupID) {
|
||||||
if (Number.isFinite(Number(item.group_id))) {
|
const target = String(logicalGroupID || "").trim();
|
||||||
subscriptionByGroup.set(Number(item.group_id), item);
|
return (state.portalLogicalGroups || []).find((group) => String(group.logical_group_id || "").trim() === target) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCompatibilityCandidates(group) {
|
||||||
|
const portalModels = new Set(portalLogicalGroupModels(group));
|
||||||
|
if (!portalModels.size) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.values(LEGACY_GROUP_CATALOG).filter((candidate) => candidate.models.some((model) => portalModels.has(String(model).trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logicalGroupStatusRows() {
|
||||||
|
const enabledLegacyGroups = availableLegacyGroupIDs();
|
||||||
|
const rows = (state.portalLogicalGroups || []).map((group) => {
|
||||||
|
const candidates = legacyCompatibilityCandidates(group);
|
||||||
|
const enabledCandidates = candidates.filter((candidate) => enabledLegacyGroups.has(Number(candidate.id)));
|
||||||
|
return {
|
||||||
|
logicalGroup: group,
|
||||||
|
candidates,
|
||||||
|
enabledCandidates
|
||||||
|
};
|
||||||
|
});
|
||||||
|
rows.sort((left, right) => {
|
||||||
|
const leftActive = Number(left.logicalGroup.active_route_count || 0);
|
||||||
|
const rightActive = Number(right.logicalGroup.active_route_count || 0);
|
||||||
|
if (leftActive !== rightActive) {
|
||||||
|
return rightActive - leftActive;
|
||||||
}
|
}
|
||||||
}
|
return String(left.logicalGroup.display_name || left.logicalGroup.logical_group_id || "").localeCompare(
|
||||||
|
String(right.logicalGroup.display_name || right.logicalGroup.logical_group_id || ""),
|
||||||
for (const [id, meta] of Object.entries(GROUP_CATALOG)) {
|
"zh-CN"
|
||||||
const numericID = Number(id);
|
);
|
||||||
const group = rawGroups.get(numericID) || null;
|
});
|
||||||
const subscription = subscriptionByGroup.get(numericID) || null;
|
|
||||||
rows.push({
|
|
||||||
id: numericID,
|
|
||||||
catalog: meta,
|
|
||||||
group,
|
|
||||||
subscription,
|
|
||||||
enabled: enabled.has(numericID)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user