Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/logical-groups.html
2026-05-29 13:37:43 +08:00

1352 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logical Group Admin</title>
<style>
:root {
--bg: #f2ede4;
--panel: rgba(255, 252, 246, 0.94);
--ink: #201b17;
--muted: #685d54;
--line: rgba(32, 27, 23, 0.12);
--accent: #0b6bcb;
--accent-soft: rgba(11, 107, 203, 0.12);
--success: #126b43;
--success-soft: rgba(18, 107, 67, 0.1);
--warn: #9b6215;
--warn-soft: rgba(155, 98, 21, 0.12);
--danger: #b23131;
--danger-soft: rgba(178, 49, 49, 0.1);
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
--radius: 24px;
--radius-sm: 16px;
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: var(--font-sans);
background:
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
var(--bg);
}
a { color: inherit; }
code, pre {
font-family: var(--font-mono);
font-size: 12px;
}
.shell {
max-width: 1480px;
margin: 0 auto;
padding: 34px 20px 64px;
}
.topnav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.topnav a {
text-decoration: none;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
color: var(--muted);
font-size: 13px;
font-weight: 700;
transition: transform 120ms ease, background 120ms ease;
}
.topnav a:hover { transform: translateY(-1px); background: #fff; }
.topnav a.is-current {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-card, .panel {
padding: 26px;
}
.hero-card {
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
right: -4rem;
bottom: -4rem;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
filter: blur(10px);
}
.eyebrow {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 10px;
font-size: clamp(32px, 4vw, 46px);
line-height: 1.02;
letter-spacing: -0.05em;
}
h2 {
margin: 0 0 8px;
font-size: 24px;
letter-spacing: -0.04em;
}
h3 {
margin: 0 0 8px;
font-size: 18px;
letter-spacing: -0.03em;
}
.hero-copy, .panel-desc {
color: var(--muted);
line-height: 1.75;
font-size: 15px;
}
.hero-points {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.hero-points li {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
font-size: 13px;
font-weight: 700;
}
.metrics {
display: grid;
gap: 12px;
align-content: start;
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.04em;
word-break: break-word;
}
.layout {
display: grid;
grid-template-columns: 430px minmax(0, 1fr);
gap: 18px;
}
.stack {
display: grid;
gap: 18px;
}
.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));
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
color: var(--ink);
background: #fff;
}
textarea {
min-height: 108px;
resize: vertical;
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
border: 0;
cursor: pointer;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 800;
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
.primary { background: var(--ink); color: #fff; }
.secondary { background: var(--accent-soft); color: var(--accent); }
.ghost {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
}
.danger {
background: var(--danger-soft);
color: var(--danger);
border: 1px solid rgba(178, 49, 49, 0.2);
}
.statusbar {
margin-top: 16px;
min-height: 54px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
display: flex;
align-items: center;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.2); }
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(155,98,21,0.2); }
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(178,49,49,0.2); }
.catalog {
display: grid;
gap: 12px;
margin-top: 16px;
max-height: 32rem;
overflow: auto;
padding-right: 4px;
}
.catalog-item,
.route-item {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
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(11,107,203,0.22);
}
.catalog-item.is-selected,
.route-item.is-selected {
background: rgba(11,107,203,0.08);
border-color: rgba(11,107,203,0.22);
}
.catalog-item strong,
.route-item strong {
display: block;
margin-bottom: 6px;
font-size: 16px;
}
.catalog-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
.tone-ready { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.18); }
.tone-note { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.18); }
.tone-warn { background: var(--warn-soft); color: var(--warn); border-color: rgba(155,98,21,0.18); }
.grid-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.section {
display: grid;
gap: 18px;
}
.list {
display: grid;
gap: 10px;
}
.list-card {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.82);
}
.list-card strong {
display: block;
margin-bottom: 6px;
}
.empty {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.mini-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mini-actions button {
padding: 8px 12px;
font-size: 12px;
}
.inline-code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted);
word-break: break-word;
}
@media (max-width: 1200px) {
.hero, .layout, .grid-columns, .field-grid.two, .field-grid.three { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
</nav>
<section class="hero">
<article class="card hero-card">
<div class="eyebrow">Logical Group Admin</div>
<h1>把 logical group、route 与 shadow group 放进同一套管理面</h1>
<p class="hero-copy">
这页专门给插件前置路由使用。你可以在这里维护 <code>logical_group</code>、绑定
<code>public_model</code>,再把它映射到某个 <code>route -> shadow_host_id -> shadow_group_id</code>
当前首版覆盖最小运营流:创建 / 编辑分组、补 public model、创建 / 编辑 route、补 route model 映射。
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
<li>route model 当前支持新增与查看,删除 / 更新 API 仍待后续补齐</li>
</ul>
</article>
<aside class="card metrics">
<div class="metric">
<div class="metric-label">API Root</div>
<div class="metric-value" id="metric-api-root">-</div>
</div>
<div class="metric">
<div class="metric-label">Logical Groups</div>
<div class="metric-value" id="metric-group-count">0</div>
</div>
<div class="metric">
<div class="metric-label">当前分组</div>
<div class="metric-value" id="metric-selected-group">-</div>
</div>
<div class="metric">
<div class="metric-label">当前 Route</div>
<div class="metric-value" id="metric-selected-route">-</div>
</div>
</aside>
</section>
<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>
<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>
<script>
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");
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");
function defaultApiBase() {
if (window.location.origin.includes("sub.tksea.top")) {
return `${window.location.origin}/portal-admin-api`;
}
return "/portal-admin-api";
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/$/, "");
}
function authHeaders() {
const token = adminTokenInput.value.trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function setStatus(element, message, tone = "note") {
element.textContent = message;
if (tone === "note") {
element.removeAttribute("data-tone");
} else {
element.setAttribute("data-tone", tone);
}
}
async function requestJSON(path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
const response = await fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (error) {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
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() {
const raw = localStorage.getItem(storageKey);
apiBaseInput.value = defaultApiBase();
if (!raw) {
syncMetrics();
return;
}
try {
const payload = JSON.parse(raw);
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;
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
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() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
} else if (payload.login_enabled) {
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "warning");
} else {
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
}
return payload;
} catch (error) {
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
setStatus(adminSessionStatus, "管理员会话已退出。", "warning");
}
function collectGroupPayload() {
return {
logical_group_id: groupIDInput.value.trim(),
display_name: groupDisplayNameInput.value.trim(),
status: groupStatusInput.value,
description: groupDescriptionInput.value.trim(),
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 || "";
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();
setStatus(detailStatus, "管理员会话已建立。", "success");
} catch (error) {
setStatus(detailStatus, error.message, "danger");
}
});
document.getElementById("admin-logout-btn").addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(detailStatus, error.message, "danger");
}
});
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>