2026-05-29 13:06:19 +08:00
<!doctype html>
2026-06-03 11:05:10 +08:00
< html lang = "zh-CN" data-theme = "dark" >
2026-05-29 13:06:19 +08:00
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > Logical Group Admin< / title >
2026-06-03 09:11:07 +08:00
< link rel = "stylesheet" href = "/portal/admin-common.css" >
2026-06-03 11:05:10 +08:00
< link rel = "stylesheet" href = "/portal/portal.css" >
2026-06-03 09:11:07 +08:00
< style >
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
.layout {
display: grid;
grid-template-columns: 430px minmax(0, 1fr);
gap: var(--s-5);
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two { grid-template-columns: 1fr 1fr; }
.field-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.catalog {
display: grid;
gap: 12px;
margin-top: 16px;
max-height: 32rem;
overflow: auto;
padding-right: 4px;
}
.catalog-item,
.route-item {
padding: 16px;
border-radius: var(--r-lg);
border: 1px solid var(--border-subtle);
background: var(--bg-elev-1);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.catalog-item:hover, .route-item:hover {
transform: translateY(-1px);
border-color: rgba(20,184,166,0.32);
}
.catalog-item.is-selected, .route-item.is-selected {
background: var(--color-primary-soft);
border-color: rgba(20,184,166,0.32);
}
.catalog-item strong, .route-item strong {
display: block;
margin-bottom: 6px;
font-size: 15px;
color: var(--text-strong);
}
.catalog-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.grid-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
.section { display: grid; gap: var(--s-5); }
.list { display: grid; gap: 10px; }
.list-card {
padding: 14px;
border-radius: var(--r-md);
border: 1px solid var(--border-subtle);
background: var(--bg-elev-1);
}
.list-card strong { display: block; margin-bottom: 6px; color: var(--text-strong); }
.panel-desc { color: var(--text-muted); font-size: 13px; line-height: 1.6; margin: 8px 0 0; }
.empty { color: var(--text-muted); font-size: 13px; line-height: 1.6; }
.tone-ready { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
.tone-note { background: var(--color-primary-soft); color: var(--color-primary); border-color: rgba(20,184,166,0.2); }
.tone-warn { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
.metric-value { color: var(--text-strong); }
.inline-code { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); word-break: break-word; }
@media (max-width: 1200px) {
.layout, .grid-columns, .field-grid.two, .field-grid.three { grid-template-columns: 1fr; }
}
< / style >
< / head >
< body >
< main class = "shell fade-in" >
< nav class = "topnav" aria-label = "Admin Navigation" data-admin-nav data-admin-current = "logical-groups" > < / nav >
2026-05-29 13:06:19 +08:00
2026-06-03 09:11:07 +08:00
< section class = "page-hero" >
< div >
< span class = "page-hero__eyebrow" > Logical Group Admin< / span >
< h1 > 把 logical group、route 与 shadow group 放进同一套管理面< / h1 >
< p >
这页专门给插件前置路由使用。你可以在这里维护 < code > logical_group< / code > 、绑定
< code > public_model< / code > ,再把它映射到某个 < code > route -> shadow_host_id -> shadow_group_id< / code > 。
当前首版覆盖最小运营流:创建 / 编辑分组、补 public model、创建 / 编辑 route、补 route model 映射。
首版页面只覆盖新增与查看,编辑与删除将通过同一套 API 路径继续扩展。
< / p >
< ul class = "hero-points" style = "display:flex;flex-wrap:wrap;gap:10px;margin:18px 0 0;padding:0;list-style:none;" >
< li style = "padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);" > 默认 API Base: < code > /portal-admin-api< / code > < / li >
< li style = "padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);" > 支持管理员登录会话,也保留 Bearer admin token 兜底< / li >
< li style = "padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);" > route model 当前支持新增与查看,删除 / 更新 API 仍待后续补齐< / li >
< / ul >
< / div >
< div class = "stack" style = "gap:var(--s-3);" >
< div class = "stat-card" >
< div class = "stat-icon stat-icon-primary" id = "lg-m1" > < / div >
< div class = "min-w-0" >
< p class = "stat-label" > API Root< / p >
< p class = "stat-value" id = "metric-api-root" > -< / p >
< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-icon stat-icon-info" id = "lg-m2" > < / div >
< div class = "min-w-0" >
< p class = "stat-label" > Logical Groups< / p >
< p class = "stat-value" id = "metric-group-count" > 0< / p >
< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-icon stat-icon-success" id = "lg-m3" > < / div >
< div class = "min-w-0" >
< p class = "stat-label" > 当前分组< / p >
< p class = "stat-value" id = "metric-selected-group" > -< / p >
< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-icon stat-icon-warning" id = "lg-m4" > < / div >
< div class = "min-w-0" >
< p class = "stat-label" > 当前 Route< / p >
< p class = "stat-value" id = "metric-selected-route" > -< / p >
< / div >
< / div >
< / div >
< / section >
2026-05-29 13:06:19 +08:00
< section class = "layout" >
< div class = "stack" >
< article class = "card panel" >
< h2 > 连接与鉴权< / h2 >
< p class = "panel-desc" >
默认优先使用同域管理员会话;如果实例没有开启管理员密码登录,仍然可以填 Bearer admin token 兜底。
< / p >
< div class = "field-grid" >
< label >
API Base
< input id = "api-base" type = "text" value = "/portal-admin-api" >
< / label >
< label >
Bearer Admin Token( 可选)
< input id = "admin-token" type = "password" placeholder = "未启用 session 时可填" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
管理员用户名
< input id = "admin-username" type = "text" placeholder = "portal-admin" >
< / label >
< label >
管理员密码
< input id = "admin-password" type = "password" placeholder = "请输入当前实例管理员密码" >
< / label >
< / div >
< div class = "actions" >
< button class = "secondary" id = "admin-login-btn" type = "button" > 管理员登录< / button >
< button class = "ghost" id = "admin-logout-btn" type = "button" > 退出会话< / button >
< button class = "ghost" id = "save-config-btn" type = "button" > 保存本地配置< / button >
< button class = "ghost" id = "refresh-groups-btn" type = "button" > 刷新分组列表< / button >
< / div >
< div class = "statusbar" id = "admin-session-status" > 正在检查管理员会话…< / div >
< / article >
< article class = "card panel" >
< h2 > Logical Group 列表< / h2 >
< p class = "panel-desc" >
左侧只展示最重要的逻辑信息:`logical_group_id`、状态、模型数量和 route 数量。选中后右侧会拉取聚合详情。
< / p >
< div class = "actions" style = "margin-top:0;" >
< button class = "secondary" id = "new-group-btn" type = "button" > 新建空白分组< / button >
< / div >
< div class = "catalog" id = "group-catalog" >
< div class = "empty" > 还没有 logical group。< / div >
< / div >
< div class = "statusbar" id = "group-status" > 加载后会在这里显示结果。< / div >
< / article >
< / div >
< div class = "section" >
< article class = "card panel" >
< h2 > 分组详情与路由配置< / h2 >
< p class = "panel-desc" >
这一块把 `logical_group`、`public_model`、`route` 放在一个页面里维护,避免运营在多个页面之间跳转。
< / p >
< div class = "grid-columns" >
< section class = "section" >
< div >
< h3 > Logical Group< / h3 >
< div class = "field-grid two" >
< label >
logical_group_id
< input id = "group-id" type = "text" placeholder = "gpt-shared" >
< / label >
< label >
display_name
< input id = "group-display-name" type = "text" placeholder = "GPT Shared" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
status
< select id = "group-status-input" >
< option value = "active" > active< / option >
< option value = "paused" > paused< / option >
< option value = "disabled" > disabled< / option >
< / select >
< / label >
< label >
route_policy
< select id = "group-route-policy" >
< option value = "priority" > priority< / option >
< / select >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
sticky_mode
< select id = "group-sticky-mode" >
< option value = "conversation_preferred" > conversation_preferred< / option >
< option value = "user_model" > user_model< / option >
< / select >
< / label >
< label >
failover_threshold
< input id = "group-failover-threshold" type = "number" min = "1" value = "2" >
< / label >
< / div >
< div class = "field-grid three" style = "margin-top:12px;" >
< label >
conversation_ttl_seconds
< input id = "group-conversation-ttl" type = "number" min = "60" value = "7200" >
< / label >
< label >
user_model_ttl_seconds
< input id = "group-user-model-ttl" type = "number" min = "60" value = "1800" >
< / label >
< label >
cooldown_seconds
< input id = "group-cooldown-seconds" type = "number" min = "0" value = "600" >
< / label >
< / div >
< label style = "margin-top:12px;" >
description
< textarea id = "group-description" placeholder = "例如:对用户只暴露一个 GPT Shared 分组,内部按 route 转到不同 shadow group" > < / textarea >
< / label >
2026-05-30 10:38:59 +08:00
< label style = "margin-top:12px;" >
usage_scenario
< textarea id = "group-usage-scenario" placeholder = "例如:适合高质量推理、复杂编排、统一 GPT 产品入口。" > < / textarea >
< / label >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
recommendation
< textarea id = "group-recommendation" placeholder = "例如:优先使用 gpt-5.4 做主模型。" > < / textarea >
< / label >
< label >
next_step_hint
< textarea id = "group-next-step-hint" placeholder = "例如:先创建测试 Key, 再按推荐模型发起第一次请求。" > < / textarea >
< / label >
< / div >
2026-05-30 10:54:32 +08:00
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
visibility_scope
< select id = "group-visibility-scope" >
< option value = "public" > public< / option >
< option value = "login_required" > login_required< / option >
< option value = "entitled_only" > entitled_only< / option >
< option value = "hidden" > hidden< / option >
< / select >
< / label >
< label >
package_tier
< select id = "group-package-tier" >
< option value = "free" > free< / option >
< option value = "standard" > standard< / option >
< option value = "pro" > pro< / option >
< option value = "enterprise" > enterprise< / option >
< / select >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
purchase_cta_label
< input id = "group-purchase-cta-label" type = "text" placeholder = "例如:升级到 Pro" >
< / label >
< label >
purchase_cta_url
< input id = "group-purchase-cta-url" type = "text" placeholder = "例如: https://sub.tksea.top/portal/upgrade/pro" >
< / label >
< / div >
2026-05-29 13:06:19 +08:00
< div class = "actions" >
< button class = "primary" id = "create-group-btn" type = "button" > 创建分组< / button >
< button class = "secondary" id = "update-group-btn" type = "button" > 更新分组< / button >
< button class = "danger" id = "delete-group-btn" type = "button" > 删除分组< / button >
< / div >
< / div >
< div >
< h3 > Public Models< / h3 >
< div class = "field-grid two" >
< label >
public_model
< input id = "group-model-public-model" type = "text" placeholder = "gpt-5.4" >
< / label >
< label >
status
< select id = "group-model-status" >
< option value = "active" > active< / option >
< option value = "disabled" > disabled< / option >
< / select >
< / label >
< / div >
< div class = "actions" >
< button class = "secondary" id = "create-group-model-btn" type = "button" > 新增 public model< / button >
< / div >
< div class = "list" id = "group-model-list" >
< div class = "empty" > 当前分组还没有 public model。< / div >
< / div >
< / div >
< / section >
< section class = "section" >
< div >
< h3 > Routes< / h3 >
< div class = "field-grid two" >
< label >
route_id
< input id = "route-id" type = "text" placeholder = "asxs-primary" >
< / label >
< label >
name
< input id = "route-name" type = "text" placeholder = "ASXS Primary" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
status
< select id = "route-status" >
< option value = "active" > active< / option >
< option value = "degraded" > degraded< / option >
< option value = "disabled" > disabled< / option >
< / select >
< / label >
< label >
priority
< input id = "route-priority" type = "number" value = "10" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
weight
< input id = "route-weight" type = "number" value = "100" >
< / label >
< label >
cooldown_until
< input id = "route-cooldown-until" type = "text" placeholder = "2026-05-29T18:00:00Z" >
< / label >
< / div >
< div class = "field-grid two" style = "margin-top:12px;" >
< label >
shadow_host_id
< input id = "route-shadow-host-id" type = "text" placeholder = "proxy-real-host-..." >
< / label >
< label >
shadow_group_id
< input id = "route-shadow-group-id" type = "text" placeholder = "9 或 gpt-shared__asxs" >
< / label >
< / div >
< label style = "margin-top:12px;" >
upstream_base_url_hint
< input id = "route-upstream-base-url-hint" type = "text" placeholder = "https://api.asxs.top/v1" >
< / label >
< div class = "actions" >
< button class = "primary" id = "create-route-btn" type = "button" > 创建 route< / button >
< button class = "secondary" id = "update-route-btn" type = "button" > 更新 route< / button >
< button class = "danger" id = "delete-route-btn" type = "button" > 删除 route< / button >
< button class = "ghost" id = "clear-route-btn" type = "button" > 清空 route 表单< / button >
< / div >
< div class = "catalog" id = "route-list" >
< div class = "empty" > 当前分组还没有 route。< / div >
< / div >
< / div >
< div >
< h3 > Route Models< / h3 >
< p class = "hint" >
当前后端已开放 < code > POST /api/logical-groups/{group_id}/routes/{route_id}/models< / code > 与
< code > GET /api/logical-groups/{group_id}/routes/{route_id}/models< / code > 。首版页面只覆盖新增与查看,不假装已有删除 / 更新接口。
< / p >
< div class = "field-grid three" >
< label >
public_model
< input id = "route-model-public-model" type = "text" placeholder = "gpt-5.4" >
< / label >
< label >
shadow_model
< input id = "route-model-shadow-model" type = "text" placeholder = "gpt-5.4" >
< / label >
< label >
status
< select id = "route-model-status" >
< option value = "active" > active< / option >
< option value = "disabled" > disabled< / option >
< / select >
< / label >
< / div >
< div class = "actions" >
< button class = "secondary" id = "create-route-model-btn" type = "button" > 新增 route model< / button >
< / div >
< div class = "list" id = "route-model-list" >
< div class = "empty" > 请选择 route 后查看其 model 映射。< / div >
< / div >
< / div >
< / section >
< / div >
< div class = "statusbar" id = "detail-status" > 选择一个 logical group 后,这里会显示操作结果。< / div >
< / article >
< / div >
< / section >
< / main >
2026-06-03 09:11:07 +08:00
< script src = "/portal/admin-common.js" > < / script >
< script src = "/portal/portal.js" > < / script >
2026-05-29 13:06:19 +08:00
< script >
2026-06-03 09:11:07 +08:00
const AdminCommon = window.Sub2ApiAdminCommon;
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "logical-groups");
// 注入 stat icon
(function injectIcons(){
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
M("lg-m1", "shield");
M("lg-m2", "group");
M("lg-m3", "activity");
M("lg-m4", "route");
})();
2026-05-29 13:06:19 +08:00
const storageKey = "sub2api-logical-groups-admin";
const state = {
groups: [],
selectedGroup: null,
selectedRouteID: "",
};
const apiBaseInput = document.getElementById("api-base");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const adminSessionStatus = document.getElementById("admin-session-status");
const groupStatus = document.getElementById("group-status");
const detailStatus = document.getElementById("detail-status");
const groupCatalog = document.getElementById("group-catalog");
const routeList = document.getElementById("route-list");
const groupModelList = document.getElementById("group-model-list");
const routeModelList = document.getElementById("route-model-list");
const metricApiRoot = document.getElementById("metric-api-root");
const metricGroupCount = document.getElementById("metric-group-count");
const metricSelectedGroup = document.getElementById("metric-selected-group");
const metricSelectedRoute = document.getElementById("metric-selected-route");
const groupIDInput = document.getElementById("group-id");
const groupDisplayNameInput = document.getElementById("group-display-name");
const groupStatusInput = document.getElementById("group-status-input");
const groupDescriptionInput = document.getElementById("group-description");
2026-05-30 10:38:59 +08:00
const groupUsageScenarioInput = document.getElementById("group-usage-scenario");
const groupRecommendationInput = document.getElementById("group-recommendation");
const groupNextStepHintInput = document.getElementById("group-next-step-hint");
2026-05-30 10:54:32 +08:00
const groupVisibilityScopeInput = document.getElementById("group-visibility-scope");
const groupPackageTierInput = document.getElementById("group-package-tier");
const groupPurchaseCTALabelInput = document.getElementById("group-purchase-cta-label");
const groupPurchaseCTAURLInput = document.getElementById("group-purchase-cta-url");
2026-05-29 13:06:19 +08:00
const groupRoutePolicyInput = document.getElementById("group-route-policy");
const groupStickyModeInput = document.getElementById("group-sticky-mode");
const groupConversationTTLInput = document.getElementById("group-conversation-ttl");
const groupUserModelTTLInput = document.getElementById("group-user-model-ttl");
const groupFailoverThresholdInput = document.getElementById("group-failover-threshold");
const groupCooldownSecondsInput = document.getElementById("group-cooldown-seconds");
const groupModelPublicModelInput = document.getElementById("group-model-public-model");
const groupModelStatusInput = document.getElementById("group-model-status");
const routeIDInput = document.getElementById("route-id");
const routeNameInput = document.getElementById("route-name");
const routeStatusInput = document.getElementById("route-status");
const routePriorityInput = document.getElementById("route-priority");
const routeWeightInput = document.getElementById("route-weight");
const routeShadowGroupIDInput = document.getElementById("route-shadow-group-id");
const routeShadowHostIDInput = document.getElementById("route-shadow-host-id");
const routeUpstreamBaseURLHintInput = document.getElementById("route-upstream-base-url-hint");
const routeCooldownUntilInput = document.getElementById("route-cooldown-until");
const routeModelPublicModelInput = document.getElementById("route-model-public-model");
const routeModelShadowModelInput = document.getElementById("route-model-shadow-model");
const routeModelStatusInput = document.getElementById("route-model-status");
2026-06-03 09:11:07 +08:00
const adminRuntime = AdminCommon.createAdminPageRuntime({
apiBaseInput,
adminTokenInput,
adminUsernameInput,
adminPasswordInput,
adminSessionStatus,
onSessionPersist: saveConfig,
});
2026-05-29 13:06:19 +08:00
function defaultApiBase() {
2026-06-03 09:11:07 +08:00
return adminRuntime.defaultApiBase();
2026-05-29 13:06:19 +08:00
}
function normalizeApiBase() {
2026-06-03 09:11:07 +08:00
return adminRuntime.normalizeApiBase();
2026-05-29 13:06:19 +08:00
}
function authHeaders() {
2026-06-03 09:11:07 +08:00
return adminRuntime.authHeaders();
2026-05-29 13:06:19 +08:00
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("& ", "& ")
.replaceAll("< ", "< ")
.replaceAll(">", "> ")
.replaceAll('"', "" ")
.replaceAll("'", "' ");
}
function setStatus(element, message, tone = "note") {
2026-06-03 09:11:07 +08:00
AdminCommon.setStatus(element, message, tone);
2026-05-29 13:06:19 +08:00
}
async function requestJSON(path, options = {}) {
2026-06-03 09:11:07 +08:00
return adminRuntime.requestJSON(path, options);
2026-05-29 13:06:19 +08:00
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
selectedGroupID: state.selectedGroup?.logical_group_id || "",
selectedRouteID: state.selectedRouteID || "",
}));
syncMetrics();
setStatus(groupStatus, "本地配置已保存。", "success");
}
function restoreConfig() {
2026-06-03 09:11:07 +08:00
const payload = AdminCommon.readStoredConfig(storageKey);
2026-05-29 13:06:19 +08:00
apiBaseInput.value = defaultApiBase();
2026-06-03 09:11:07 +08:00
if (!Object.keys(payload).length) {
2026-05-29 13:06:19 +08:00
syncMetrics();
return;
}
2026-06-03 09:11:07 +08:00
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
state.selectedRouteID = payload.selectedRouteID || "";
state.selectedGroup = payload.selectedGroupID ? { logical_group_id: payload.selectedGroupID } : null;
2026-05-29 13:06:19 +08:00
syncMetrics();
}
function syncMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricGroupCount.textContent = String(state.groups.length);
metricSelectedGroup.textContent = state.selectedGroup?.logical_group_id || "-";
metricSelectedRoute.textContent = state.selectedRouteID || "-";
}
async function refreshAdminSession() {
2026-06-03 09:11:07 +08:00
return adminRuntime.refreshAdminSession();
2026-05-29 13:06:19 +08:00
}
async function loginAdminSession() {
2026-06-03 09:11:07 +08:00
return adminRuntime.loginAdminSession();
2026-05-29 13:06:19 +08:00
}
async function logoutAdminSession() {
2026-06-03 09:11:07 +08:00
return adminRuntime.logoutAdminSession();
2026-05-29 13:06:19 +08:00
}
function collectGroupPayload() {
return {
logical_group_id: groupIDInput.value.trim(),
display_name: groupDisplayNameInput.value.trim(),
status: groupStatusInput.value,
description: groupDescriptionInput.value.trim(),
2026-05-30 10:38:59 +08:00
usage_scenario: groupUsageScenarioInput.value.trim(),
recommendation: groupRecommendationInput.value.trim(),
next_step_hint: groupNextStepHintInput.value.trim(),
2026-05-30 10:54:32 +08:00
visibility_scope: groupVisibilityScopeInput.value,
package_tier: groupPackageTierInput.value,
purchase_cta_label: groupPurchaseCTALabelInput.value.trim(),
purchase_cta_url: groupPurchaseCTAURLInput.value.trim(),
2026-05-29 13:06:19 +08:00
route_policy: groupRoutePolicyInput.value,
sticky_mode: groupStickyModeInput.value,
conversation_ttl_seconds: Number(groupConversationTTLInput.value || "0"),
user_model_ttl_seconds: Number(groupUserModelTTLInput.value || "0"),
failover_threshold: Number(groupFailoverThresholdInput.value || "0"),
cooldown_seconds: Number(groupCooldownSecondsInput.value || "0"),
};
}
function collectRoutePayload() {
return {
route_id: routeIDInput.value.trim(),
name: routeNameInput.value.trim(),
status: routeStatusInput.value,
priority: Number(routePriorityInput.value || "0"),
weight: Number(routeWeightInput.value || "0"),
shadow_group_id: routeShadowGroupIDInput.value.trim(),
shadow_host_id: routeShadowHostIDInput.value.trim(),
upstream_base_url_hint: routeUpstreamBaseURLHintInput.value.trim(),
cooldown_until: routeCooldownUntilInput.value.trim(),
};
}
function fillGroupForm(group) {
groupIDInput.value = group?.logical_group_id || "";
groupDisplayNameInput.value = group?.display_name || "";
groupStatusInput.value = group?.status || "active";
groupDescriptionInput.value = group?.description || "";
2026-05-30 10:38:59 +08:00
groupUsageScenarioInput.value = group?.usage_scenario || "";
groupRecommendationInput.value = group?.recommendation || "";
groupNextStepHintInput.value = group?.next_step_hint || "";
2026-05-30 10:54:32 +08:00
groupVisibilityScopeInput.value = group?.visibility_scope || "public";
groupPackageTierInput.value = group?.package_tier || "standard";
groupPurchaseCTALabelInput.value = group?.purchase_cta_label || "";
groupPurchaseCTAURLInput.value = group?.purchase_cta_url || "";
2026-05-29 13:06:19 +08:00
groupRoutePolicyInput.value = group?.route_policy || "priority";
groupStickyModeInput.value = group?.sticky_mode || "conversation_preferred";
groupConversationTTLInput.value = String(group?.conversation_ttl_seconds || 7200);
groupUserModelTTLInput.value = String(group?.user_model_ttl_seconds || 1800);
groupFailoverThresholdInput.value = String(group?.failover_threshold || 2);
groupCooldownSecondsInput.value = String(group?.cooldown_seconds || 600);
}
function clearRouteForm() {
state.selectedRouteID = "";
routeIDInput.value = "";
routeNameInput.value = "";
routeStatusInput.value = "active";
routePriorityInput.value = "10";
routeWeightInput.value = "100";
routeShadowGroupIDInput.value = "";
routeShadowHostIDInput.value = "";
routeUpstreamBaseURLHintInput.value = "";
routeCooldownUntilInput.value = "";
routeModelPublicModelInput.value = "";
routeModelShadowModelInput.value = "";
routeModelStatusInput.value = "active";
renderRoutes();
renderRouteModels();
syncMetrics();
}
function fillRouteForm(route) {
state.selectedRouteID = route?.route_id || "";
routeIDInput.value = route?.route_id || "";
routeNameInput.value = route?.name || "";
routeStatusInput.value = route?.status || "active";
routePriorityInput.value = String(route?.priority ?? 10);
routeWeightInput.value = String(route?.weight ?? 100);
routeShadowGroupIDInput.value = route?.shadow_group_id || "";
routeShadowHostIDInput.value = route?.shadow_host_id || "";
routeUpstreamBaseURLHintInput.value = route?.upstream_base_url_hint || "";
routeCooldownUntilInput.value = route?.cooldown_until || "";
syncMetrics();
}
function activeRoute() {
if (!state.selectedGroup || !state.selectedRouteID) {
return null;
}
return (state.selectedGroup.routes || []).find((route) => route.route_id === state.selectedRouteID) || null;
}
function renderGroupCatalog() {
if (!state.groups.length) {
groupCatalog.innerHTML = '< div class = "empty" > 还没有 logical group。< / div > ';
return;
}
groupCatalog.innerHTML = state.groups.map((group) => `
< button type = "button" class = "catalog-item ${state.selectedGroup?.logical_group_id === group.logical_group_id ? " is-selected " : " " } " data-group-id = "${escapeHTML(group.logical_group_id)}" >
< strong > ${escapeHTML(group.display_name || group.logical_group_id)}< / strong >
< div class = "inline-code" > ${escapeHTML(group.logical_group_id)}< / div >
< div class = "catalog-meta" >
< span class = "pill tone-note" > ${escapeHTML(group.status || "unknown")}< / span >
< span class = "pill" > models: ${escapeHTML((group.models || []).length)}< / span >
< span class = "pill" > routes: ${escapeHTML((group.routes || []).length)}< / span >
< / div >
< / button >
`).join("");
groupCatalog.querySelectorAll("[data-group-id]").forEach((element) => {
element.addEventListener("click", () => {
const groupID = element.getAttribute("data-group-id");
if (!groupID) return;
loadGroup(groupID).catch((error) => setStatus(groupStatus, error.message, "danger"));
});
});
}
function renderGroupModels() {
const models = state.selectedGroup?.models || [];
if (!models.length) {
groupModelList.innerHTML = '< div class = "empty" > 当前分组还没有 public model。< / div > ';
return;
}
groupModelList.innerHTML = models.map((model) => `
< div class = "list-card" >
< strong > ${escapeHTML(model.public_model)}< / strong >
< div class = "catalog-meta" >
< span class = "pill ${model.status === " active " ? " tone-ready " : " tone-warn " } " > ${escapeHTML(model.status || "unknown")}< / span >
< / div >
< div class = "mini-actions" >
< button class = "ghost" type = "button" data-group-model = "${escapeHTML(model.public_model)}" > 删除 model< / button >
< / div >
< / div >
`).join("");
groupModelList.querySelectorAll("[data-group-model]").forEach((element) => {
element.addEventListener("click", async () => {
const groupID = state.selectedGroup?.logical_group_id;
const model = element.getAttribute("data-group-model");
if (!groupID || !model) return;
if (!window.confirm(`确认删除分组模型 ${model} ?`)) {
return;
}
try {
await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/models/${encodeURIComponent(model)}`, {
method: "DELETE",
headers: authHeaders(),
});
await loadGroup(groupID, { keepRouteSelection: true });
setStatus(detailStatus, `已删除 public model: ${model}`, "success");
} catch (error) {
setStatus(detailStatus, `删除失败:${error.message}`, "danger");
}
});
});
}
function renderRoutes() {
const routes = state.selectedGroup?.routes || [];
if (!routes.length) {
routeList.innerHTML = '< div class = "empty" > 当前分组还没有 route。< / div > ';
return;
}
routeList.innerHTML = routes.map((route) => `
< button type = "button" class = "route-item ${state.selectedRouteID === route.route_id ? " is-selected " : " " } " data-route-id = "${escapeHTML(route.route_id)}" >
< strong > ${escapeHTML(route.name || route.route_id)}< / strong >
< div class = "inline-code" > ${escapeHTML(route.route_id)}< / div >
< div class = "catalog-meta" >
< span class = "pill ${route.status === " active " ? " tone-ready " : route . status = == " degraded " ? " tone-warn " : " " } " > ${escapeHTML(route.status || "unknown")}< / span >
< span class = "pill" > priority: ${escapeHTML(route.priority)}< / span >
< span class = "pill" > shadow group: ${escapeHTML(route.shadow_group_id || "-")}< / span >
< / div >
< / button >
`).join("");
routeList.querySelectorAll("[data-route-id]").forEach((element) => {
element.addEventListener("click", () => {
const routeID = element.getAttribute("data-route-id");
if (!routeID) return;
const route = (state.selectedGroup?.routes || []).find((item) => item.route_id === routeID);
if (!route) return;
fillRouteForm(route);
renderRoutes();
renderRouteModels();
setStatus(detailStatus, `已选择 route: ${route.route_id}`, "success");
});
});
}
function renderRouteModels() {
const route = activeRoute();
if (!route) {
routeModelList.innerHTML = '< div class = "empty" > 请选择 route 后查看其 model 映射。< / div > ';
return;
}
const models = route.models || [];
if (!models.length) {
routeModelList.innerHTML = '< div class = "empty" > 当前 route 还没有 model 映射。< / div > ';
return;
}
routeModelList.innerHTML = models.map((item) => `
< div class = "list-card" >
< strong > ${escapeHTML(item.public_model)} -> ${escapeHTML(item.shadow_model || item.public_model)}< / strong >
< div class = "catalog-meta" >
< span class = "pill ${item.status === " active " ? " tone-ready " : " tone-warn " } " > ${escapeHTML(item.status || "unknown")}< / span >
< / div >
< / div >
`).join("");
}
function renderSelectedGroup() {
fillGroupForm(state.selectedGroup);
renderGroupModels();
renderRoutes();
renderRouteModels();
syncMetrics();
}
async function loadGroups(preferredGroupID = state.selectedGroup?.logical_group_id || "") {
const payload = await requestJSON("/api/logical-groups", { headers: authHeaders() });
state.groups = payload.logical_groups || [];
renderGroupCatalog();
syncMetrics();
const nextGroupID = preferredGroupID & & state.groups.some((group) => group.logical_group_id === preferredGroupID)
? preferredGroupID
: state.groups[0]?.logical_group_id;
if (nextGroupID) {
await loadGroup(nextGroupID, { keepRouteSelection: true });
} else {
state.selectedGroup = null;
clearRouteForm();
fillGroupForm(null);
renderGroupModels();
renderRoutes();
renderRouteModels();
setStatus(groupStatus, "当前还没有 logical group。可以先创建一条。", "warning");
}
}
async function loadGroup(groupID, options = {}) {
const { keepRouteSelection = false } = options;
const payload = await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}`, { headers: authHeaders() });
state.selectedGroup = payload.logical_group;
if (!keepRouteSelection || !(state.selectedGroup.routes || []).some((route) => route.route_id === state.selectedRouteID)) {
state.selectedRouteID = state.selectedGroup.routes?.[0]?.route_id || "";
}
renderGroupCatalog();
if (state.selectedRouteID) {
const route = activeRoute();
fillRouteForm(route);
} else {
clearRouteForm();
}
renderSelectedGroup();
saveConfig();
setStatus(groupStatus, `已加载 logical group: ${groupID}`, "success");
}
function resetGroupForm() {
state.selectedGroup = null;
fillGroupForm(null);
clearRouteForm();
renderGroupCatalog();
renderGroupModels();
renderRoutes();
renderRouteModels();
syncMetrics();
setStatus(detailStatus, "已切换到空白分组表单。", "warning");
}
async function createGroup() {
const payload = collectGroupPayload();
if (!payload.logical_group_id || !payload.display_name) {
throw new Error("logical_group_id 和 display_name 不能为空");
}
const result = await requestJSON("/api/logical-groups", {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
await loadGroups(result.logical_group.logical_group_id);
setStatus(detailStatus, `已创建 logical group: ${result.logical_group.logical_group_id}`, "success");
}
async function updateGroup() {
const payload = collectGroupPayload();
if (!payload.logical_group_id) {
throw new Error("请先选择或填写 logical_group_id");
}
const result = await requestJSON(`/api/logical-groups/${encodeURIComponent(payload.logical_group_id)}`, {
method: "PUT",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
await loadGroups(result.logical_group.logical_group_id);
setStatus(detailStatus, `已更新 logical group: ${result.logical_group.logical_group_id}`, "success");
}
async function deleteGroup() {
const groupID = groupIDInput.value.trim();
if (!groupID) {
throw new Error("请先选择 logical group");
}
if (!window.confirm(`确认删除 logical group ${groupID} ?`)) {
return;
}
await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}`, {
method: "DELETE",
headers: authHeaders(),
});
await loadGroups("");
setStatus(detailStatus, `已删除 logical group: ${groupID}`, "success");
}
async function createGroupModel() {
const groupID = state.selectedGroup?.logical_group_id || groupIDInput.value.trim();
const publicModel = groupModelPublicModelInput.value.trim();
if (!groupID || !publicModel) {
throw new Error("请先选择分组并填写 public_model");
}
await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/models`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
public_model: publicModel,
status: groupModelStatusInput.value,
}),
});
groupModelPublicModelInput.value = "";
await loadGroup(groupID, { keepRouteSelection: true });
setStatus(detailStatus, `已新增 public model: ${publicModel}`, "success");
}
async function createRoute() {
const groupID = state.selectedGroup?.logical_group_id || groupIDInput.value.trim();
const payload = collectRoutePayload();
if (!groupID || !payload.route_id || !payload.name) {
throw new Error("请先选择 logical group, 并填写 route_id / name");
}
const result = await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/routes`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
state.selectedRouteID = result.route.route_id;
await loadGroup(groupID, { keepRouteSelection: true });
setStatus(detailStatus, `已创建 route: ${result.route.route_id}`, "success");
}
async function updateRoute() {
const groupID = state.selectedGroup?.logical_group_id || groupIDInput.value.trim();
const payload = collectRoutePayload();
if (!groupID || !payload.route_id) {
throw new Error("请先选择 logical group 和 route");
}
const result = await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/routes/${encodeURIComponent(payload.route_id)}`, {
method: "PUT",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
state.selectedRouteID = result.route.route_id;
await loadGroup(groupID, { keepRouteSelection: true });
setStatus(detailStatus, `已更新 route: ${result.route.route_id}`, "success");
}
async function deleteRoute() {
const groupID = state.selectedGroup?.logical_group_id || groupIDInput.value.trim();
const routeID = routeIDInput.value.trim();
if (!groupID || !routeID) {
throw new Error("请先选择 route");
}
if (!window.confirm(`确认删除 route ${routeID} ?`)) {
return;
}
await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/routes/${encodeURIComponent(routeID)}`, {
method: "DELETE",
headers: authHeaders(),
});
state.selectedRouteID = "";
await loadGroup(groupID, { keepRouteSelection: false });
setStatus(detailStatus, `已删除 route: ${routeID}`, "success");
}
async function createRouteModel() {
const groupID = state.selectedGroup?.logical_group_id || groupIDInput.value.trim();
const routeID = routeIDInput.value.trim();
const publicModel = routeModelPublicModelInput.value.trim();
const shadowModel = routeModelShadowModelInput.value.trim();
if (!groupID || !routeID || !publicModel) {
throw new Error("请先选择 route, 并填写 public_model");
}
await requestJSON(`/api/logical-groups/${encodeURIComponent(groupID)}/routes/${encodeURIComponent(routeID)}/models`, {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
public_model: publicModel,
shadow_model: shadowModel,
status: routeModelStatusInput.value,
}),
});
routeModelPublicModelInput.value = "";
routeModelShadowModelInput.value = "";
await loadGroup(groupID, { keepRouteSelection: true });
setStatus(detailStatus, `已新增 route model: ${publicModel} -> ${shadowModel || publicModel}`, "success");
}
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("refresh-groups-btn").addEventListener("click", async () => {
try {
await loadGroups(state.selectedGroup?.logical_group_id || "");
} catch (error) {
setStatus(groupStatus, `刷新失败:${error.message}`, "danger");
}
});
document.getElementById("new-group-btn").addEventListener("click", resetGroupForm);
document.getElementById("admin-login-btn").addEventListener("click", async () => {
try {
await loginAdminSession();
2026-06-03 09:11:07 +08:00
} catch (error) {}
2026-05-29 13:06:19 +08:00
});
document.getElementById("admin-logout-btn").addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
2026-06-03 09:11:07 +08:00
} catch (error) {}
2026-05-29 13:06:19 +08:00
});
document.getElementById("create-group-btn").addEventListener("click", async () => {
try {
await createGroup();
} catch (error) {
setStatus(detailStatus, `创建分组失败:${error.message}`, "danger");
}
});
document.getElementById("update-group-btn").addEventListener("click", async () => {
try {
await updateGroup();
} catch (error) {
setStatus(detailStatus, `更新分组失败:${error.message}`, "danger");
}
});
document.getElementById("delete-group-btn").addEventListener("click", async () => {
try {
await deleteGroup();
} catch (error) {
setStatus(detailStatus, `删除分组失败:${error.message}`, "danger");
}
});
document.getElementById("create-group-model-btn").addEventListener("click", async () => {
try {
await createGroupModel();
} catch (error) {
setStatus(detailStatus, `新增 public model 失败:${error.message}`, "danger");
}
});
document.getElementById("create-route-btn").addEventListener("click", async () => {
try {
await createRoute();
} catch (error) {
setStatus(detailStatus, `创建 route 失败:${error.message}`, "danger");
}
});
document.getElementById("update-route-btn").addEventListener("click", async () => {
try {
await updateRoute();
} catch (error) {
setStatus(detailStatus, `更新 route 失败:${error.message}`, "danger");
}
});
document.getElementById("delete-route-btn").addEventListener("click", async () => {
try {
await deleteRoute();
} catch (error) {
setStatus(detailStatus, `删除 route 失败:${error.message}`, "danger");
}
});
document.getElementById("clear-route-btn").addEventListener("click", () => {
clearRouteForm();
setStatus(detailStatus, "已清空 route 表单。", "warning");
});
document.getElementById("create-route-model-btn").addEventListener("click", async () => {
try {
await createRouteModel();
} catch (error) {
setStatus(detailStatus, `新增 route model 失败:${error.message}`, "danger");
}
});
restoreConfig();
refreshAdminSession().catch(() => {});
loadGroups(state.selectedGroup?.logical_group_id || "").catch((error) => {
setStatus(groupStatus, `加载 logical groups 失败:${error.message}`, "danger");
});
< / script >
< / body >
< / html >