feat(portal): add logical group entitlement view
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user