feat(portal): add logical group entitlement view

This commit is contained in:
phamnazage-jpg
2026-05-30 10:13:31 +08:00
parent e87decee4d
commit 542c6823a5
2 changed files with 135 additions and 9 deletions

View File

@@ -593,7 +593,7 @@
<div id="enabled-groups-stat" class="stat-value">0</div>
</div>
<div class="stat">
<span class="stat-label">开通兼容线路</span>
<span class="stat-label">激活产品权限</span>
<div id="subscriptions-stat" class="stat-value">0</div>
</div>
<div class="stat">
@@ -614,11 +614,21 @@
<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>已有 Key</h2>
<p>历史 Key 默认只展示必要信息;你可以直接在列表里一键复制,无需先重新创建。</p>
<p>历史 Key 会优先投影回逻辑分组产品层;你可以直接在列表里一键复制,无需先重新创建。</p>
</div>
<div class="tiny mono">GET /api/v1/keys?page=1&page_size=20</div>
</div>
@@ -721,6 +731,7 @@
</div>
</div>
<p class="footer-note">如果某条线路显示“待开通”,说明你的账号还没有对应 subscription 或可绑定分组。此时可以先注册并登录,再联系管理员补组,无需重新创建账号。</p>
<p class="footer-note">当前页面已经把“目录、权限、订阅、Key”统一投影到逻辑分组产品层兼容宿主线路只作为过渡实现细节保留在申请流程里。</p>
</article>
</div>
</section>
@@ -1029,11 +1040,6 @@
: [];
}
function getPortalLogicalGroup(logicalGroupID) {
const target = String(logicalGroupID || "").trim();
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) {
@@ -1067,6 +1073,116 @@
return rows;
}
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(", ") || "--";
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">兼容线路 ' + 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 getPresentationStatus(row) {
if ((row.enabledCandidates || []).length === 1) {
return { cls: "active", text: "可立即申请兼容 Key" };
@@ -1083,8 +1199,10 @@
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(availableLegacyGroupIDs().size);
$("subscriptions-stat").textContent = String(entitlementRows.filter((row) => row.stateKey === "active" || row.stateKey === "granted").length);
if (!user) {
statusPill("warn", "未登录");
@@ -1107,6 +1225,7 @@
["状态", 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)]
];
@@ -1249,6 +1368,7 @@
function renderAll() {
renderSessionSummary();
renderGroupCatalog();
renderEntitlementView();
renderKeys();
}

View File

@@ -50,7 +50,8 @@ assert_contains_file "$HTML_FILE" "copy-existing-key-btn"
assert_contains_file "$HTML_FILE" "已有 Key"
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" "可立即申请兼容 Key"
assert_contains_file "$HTML_FILE" "需开通兼容线路"
assert_contains_file "$HTML_FILE" "目录已上线"
@@ -59,6 +60,11 @@ 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" "逻辑分组权限"
assert_contains_file "$HTML_FILE" "renderEntitlementView"
assert_contains_file "$HTML_FILE" "已开通订阅"
assert_contains_file "$HTML_FILE" "已授予权限"
assert_contains_file "$HTML_FILE" "归属待整理"
assert_contains_file "$HTML_FILE" "route_policy ="
assert_contains_file "$HTML_FILE" "gpt-5.4"
assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed"