1376 lines
53 KiB
HTML
1376 lines
53 KiB
HTML
<!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/accounts.html">帐号资产</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>
|
||
<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>
|
||
<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 groupUsageScenarioInput = document.getElementById("group-usage-scenario");
|
||
const groupRecommendationInput = document.getElementById("group-recommendation");
|
||
const groupNextStepHintInput = document.getElementById("group-next-step-hint");
|
||
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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(),
|
||
usage_scenario: groupUsageScenarioInput.value.trim(),
|
||
recommendation: groupRecommendationInput.value.trim(),
|
||
next_step_hint: groupNextStepHintInput.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 || "";
|
||
groupUsageScenarioInput.value = group?.usage_scenario || "";
|
||
groupRecommendationInput.value = group?.recommendation || "";
|
||
groupNextStepHintInput.value = group?.next_step_hint || "";
|
||
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>
|