Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/providers.html

1318 lines
48 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provider 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; }
.shell {
max-width: 1440px;
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;
}
.hero-copy {
max-width: 58rem;
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: 440px minmax(0, 1fr);
gap: 18px;
margin-bottom: 18px;
}
.stack {
display: grid;
gap: 18px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 24px;
letter-spacing: -0.04em;
}
.panel-desc {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 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: 126px;
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.hint {
margin-top: 6px;
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);
}
.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: 28rem;
overflow: auto;
padding-right: 4px;
}
.catalog-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 { transform: translateY(-1px); border-color: rgba(11,107,203,0.22); }
.catalog-item.is-selected {
background: rgba(11,107,203,0.08);
border-color: rgba(11,107,203,0.22);
}
.catalog-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); }
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr 0.9fr;
gap: 18px;
}
.draft-list {
display: grid;
gap: 10px;
max-height: 30rem;
overflow: auto;
padding-right: 4px;
}
.draft-item {
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px;
background: rgba(255,255,255,0.84);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.draft-item:hover {
transform: translateY(-1px);
border-color: rgba(11,107,203,0.22);
}
.draft-item strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
pre {
margin: 0;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
overflow: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.empty {
color: var(--muted);
font-size: 13px;
}
code {
font-family: var(--font-mono);
font-size: 12px;
}
@media (max-width: 1120px) {
.hero, .layout, .result-grid, .field-grid.two { 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/providers.html" class="is-current">新增模型 / 供应商目录</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">Provider Admin</div>
<h1>在一个页面里看目录、预检导入、执行导入</h1>
<p class="hero-copy">
这页把“新增模型供应商”和“导入供应商帐号”的前置动作收口在一起。当前版本会先列出
pack 里已经存在的 provider允许直接做 <code>preview-import</code><code>import</code>
如果你要新增 provider 模板,本页也支持把草稿保存到 CRM再一键发布成 pack/provider 文件并自动提交到仓库。
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
<li>支持 provider 草稿发布到 pack 仓库</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">当前 Pack</div>
<div class="metric-value" id="metric-pack-id">-</div>
</div>
<div class="metric">
<div class="metric-label">当前 Provider</div>
<div class="metric-value" id="metric-provider-id">-</div>
</div>
</aside>
</section>
<section class="layout">
<article class="card panel">
<h2>连接与目录</h2>
<p class="panel-desc">
先建立到 CRM 的连接,再拉取 pack 与 provider 目录。当前页面优先使用 <code>host_id</code> 驱动导入,
不再要求浏览器直接知道宿主 base URL。
</p>
<div class="field-grid two">
<label>API Base
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
</label>
<label>Admin Token可选
<input id="admin-token" type="password" placeholder="crm-admin-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="actions">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Pack
<select id="pack-id"></select>
</label>
<label>Host ID
<select id="host-id"></select>
</label>
</div>
<div class="field-grid">
<label>Pack Path
<input id="pack-path" type="text" value="/app/packs/openai-cn-pack">
<span class="hint">preview/import 当前仍需显式带上 pack_path默认按 remote43 的运行路径填写。</span>
</label>
</div>
<div class="actions">
<button class="primary" id="load-catalog-btn">加载目录</button>
<button class="ghost" id="save-config-btn">保存本地配置</button>
</div>
<div class="statusbar" id="catalog-status">等待加载目录。</div>
<div class="catalog" id="provider-catalog">
<div class="empty">还没有 provider 目录。</div>
</div>
</article>
<section class="stack">
<article class="card panel">
<h2>Provider 预检与导入</h2>
<p class="panel-desc">
选择 pack/provider 后,可以先预检再执行导入。<code>preview-import</code> 侧重帐号本身与模型探测,
<code>import</code> 会继续走 access closure。
</p>
<div class="field-grid two">
<label>Provider ID
<input id="provider-id" type="text" placeholder="minimax-53hk">
</label>
<label>Mode
<select id="mode">
<option value="strict">strict</option>
<option value="partial">partial</option>
</select>
</label>
</div>
<div class="field-grid two">
<label>Access Mode
<select id="access-mode">
<option value="self_service">self_service</option>
<option value="subscription">subscription</option>
</select>
</label>
<label>Probe API Key
<input id="access-api-key" type="text" placeholder="sk-probe-or-access">
</label>
</div>
<div class="field-grid two" id="subscription-fields" hidden>
<label>Subscription Users
<input id="subscription-users" type="text" placeholder="relay-sub-1,relay-sub-2">
</label>
<label>Subscription Days
<input id="subscription-days" type="number" min="1" value="30">
</label>
</div>
<label>Keys
<textarea id="provider-keys" placeholder="一行一个 key"></textarea>
<span class="hint">一行一个供应商帐号 key。导入页会自动拆成字符串数组传给 CRM。</span>
</label>
<div class="actions">
<button class="secondary" id="preview-provider-btn">预检导入</button>
<button class="primary" id="import-provider-btn">执行导入</button>
<a class="ghost" href="/portal/admin/batch-import.html" style="text-decoration:none; display:inline-flex; align-items:center;">打开 Batch Import</a>
</div>
<div class="statusbar" id="provider-status">先从左侧目录选择 provider。</div>
</article>
<article class="card panel" id="manifest-draft">
<h2>Provider Manifest 草稿</h2>
<p class="panel-desc">
这部分既能生成与保存 provider 草稿,也能从已保存草稿一键发布到 pack/provider 文件并提交到仓库。
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
</p>
<div class="field-grid two">
<label>Provider ID
<input id="draft-provider-id" type="text" placeholder="openai-zhongzhuan">
</label>
<label>Display Name
<input id="draft-display-name" type="text" placeholder="OpenAI 中转">
</label>
</div>
<div class="field-grid two">
<label>Platform
<input id="draft-platform" type="text" placeholder="openai">
</label>
<label>Smoke Test Model
<input id="draft-smoke-model" type="text" placeholder="gpt-5.4">
</label>
</div>
<div class="field-grid two">
<label>Base URL Placeholder
<input id="draft-base-url" type="text" placeholder="https://api.example.com/v1">
</label>
<label>Models
<input id="draft-models" type="text" placeholder="gpt-5.4,gpt-5.4-mini">
</label>
</div>
<label>发布 Commit Message
<input id="draft-commit-message" type="text" placeholder="feat(pack): publish provider draft openai-zhongzhuan">
<span class="hint">留空时会按 provider_id 自动生成标准 commit message。</span>
</label>
<div class="actions">
<button class="secondary" id="generate-draft-btn">生成草稿</button>
<button class="primary" id="save-draft-btn">保存到服务端</button>
<button class="secondary" id="update-draft-btn">更新草稿</button>
<button class="primary" id="publish-draft-btn">发布到仓库</button>
<button class="ghost" id="delete-draft-btn">删除草稿</button>
<button class="ghost" id="copy-draft-btn">复制 JSON</button>
<button class="ghost" id="refresh-drafts-btn">刷新草稿列表</button>
</div>
<div class="statusbar" id="draft-status">填写后生成 provider manifest 草稿。</div>
</article>
</section>
</section>
<section class="result-grid">
<article class="card panel">
<h2>Preview 结果</h2>
<p class="panel-desc">这里直接展示 <code>POST /api/providers/{providerID}/preview-import</code> 的原始 JSON。</p>
<pre id="preview-result">{
"hint": "还没有 preview 结果"
}</pre>
</article>
<article class="card panel">
<h2>Import / Draft 结果</h2>
<p class="panel-desc">导入结果与 manifest 草稿都收在这里,便于直接复制或继续跳转到 batch-import 页面。</p>
<pre id="import-result">{
"hint": "还没有 import 或 draft 结果"
}</pre>
</article>
<article class="card panel">
<h2>服务端草稿</h2>
<p class="panel-desc">
这里展示已经保存到 CRM SQLite 的 provider 草稿。点击条目会把内容回填到 manifest 表单,继续编辑或再次复制。
</p>
<div class="draft-list" id="server-draft-list">
<div class="empty">还没有服务端草稿。</div>
</div>
</article>
</section>
</main>
<script>
const storageKey = "sub2api-crm-provider-admin-v1";
const state = {
packs: [],
hosts: [],
providers: [],
selectedProvider: null,
drafts: [],
currentDraftID: "",
};
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 adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const packIDInput = document.getElementById("pack-id");
const hostIDInput = document.getElementById("host-id");
const packPathInput = document.getElementById("pack-path");
const providerIDInput = document.getElementById("provider-id");
const modeInput = document.getElementById("mode");
const accessModeInput = document.getElementById("access-mode");
const accessAPIKeyInput = document.getElementById("access-api-key");
const subscriptionUsersInput = document.getElementById("subscription-users");
const subscriptionDaysInput = document.getElementById("subscription-days");
const providerKeysInput = document.getElementById("provider-keys");
const subscriptionFields = document.getElementById("subscription-fields");
const providerCatalog = document.getElementById("provider-catalog");
const catalogStatus = document.getElementById("catalog-status");
const providerStatus = document.getElementById("provider-status");
const draftStatus = document.getElementById("draft-status");
const previewResult = document.getElementById("preview-result");
const importResult = document.getElementById("import-result");
const serverDraftList = document.getElementById("server-draft-list");
const metricApiRoot = document.getElementById("metric-api-root");
const metricPackID = document.getElementById("metric-pack-id");
const metricProviderID = document.getElementById("metric-provider-id");
const draftProviderIDInput = document.getElementById("draft-provider-id");
const draftDisplayNameInput = document.getElementById("draft-display-name");
const draftPlatformInput = document.getElementById("draft-platform");
const draftSmokeModelInput = document.getElementById("draft-smoke-model");
const draftBaseURLInput = document.getElementById("draft-base-url");
const draftModelsInput = document.getElementById("draft-models");
const draftCommitMessageInput = document.getElementById("draft-commit-message");
function defaultApiBase() {
return `${window.location.origin}/portal-admin-api`;
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/+$/, "");
}
function setStatus(target, message, tone = "") {
target.textContent = message;
if (tone) {
target.dataset.tone = tone;
} else {
delete target.dataset.tone;
}
}
function authHeaders() {
const headers = {
"Content-Type": "application/json",
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function requestJSON(path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
return fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
})
.then(async (response) => {
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 syncMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricPackID.textContent = packIDInput.value || "-";
metricProviderID.textContent = providerIDInput.value || "-";
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
packID: packIDInput.value,
hostID: hostIDInput.value,
packPath: packPathInput.value.trim(),
providerID: providerIDInput.value.trim(),
mode: modeInput.value,
accessMode: accessModeInput.value,
accessAPIKey: accessAPIKeyInput.value.trim(),
subscriptionUsers: subscriptionUsersInput.value.trim(),
subscriptionDays: subscriptionDaysInput.value,
providerKeys: providerKeysInput.value,
draftProviderID: draftProviderIDInput.value.trim(),
draftDisplayName: draftDisplayNameInput.value.trim(),
draftPlatform: draftPlatformInput.value.trim(),
draftSmokeModel: draftSmokeModelInput.value.trim(),
draftBaseURL: draftBaseURLInput.value.trim(),
draftModels: draftModelsInput.value.trim(),
draftCommitMessage: draftCommitMessageInput.value.trim(),
}));
syncMetrics();
setStatus(catalogStatus, "本地配置已保存。", "success");
}
function restoreConfig() {
const raw = localStorage.getItem(storageKey);
apiBaseInput.value = defaultApiBase();
packPathInput.value = "/app/packs/openai-cn-pack";
subscriptionDaysInput.value = "30";
if (!raw) {
syncMetrics();
return;
}
try {
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
providerIDInput.value = payload.providerID || "";
modeInput.value = payload.mode || "strict";
accessModeInput.value = payload.accessMode || "self_service";
accessAPIKeyInput.value = payload.accessAPIKey || "";
subscriptionUsersInput.value = payload.subscriptionUsers || "";
subscriptionDaysInput.value = payload.subscriptionDays || "30";
providerKeysInput.value = payload.providerKeys || "";
draftProviderIDInput.value = payload.draftProviderID || "";
draftDisplayNameInput.value = payload.draftDisplayName || "";
draftPlatformInput.value = payload.draftPlatform || "";
draftSmokeModelInput.value = payload.draftSmokeModel || "";
draftBaseURLInput.value = payload.draftBaseURL || "";
draftModelsInput.value = payload.draftModels || "";
draftCommitMessageInput.value = payload.draftCommitMessage || "";
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
syncMetrics();
}
function updateAccessModeFields() {
subscriptionFields.hidden = accessModeInput.value !== "subscription";
}
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, "未登录。可直接使用管理员用户名密码建立会话。", "note");
} 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, "管理员会话已退出。", "note");
}
function renderSelectOptions(select, values, currentValue, emptyLabel) {
select.innerHTML = "";
if (!values.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = emptyLabel;
select.appendChild(option);
return;
}
values.forEach((entry) => {
const option = document.createElement("option");
option.value = entry.value;
option.textContent = entry.label;
if (entry.value === currentValue) {
option.selected = true;
}
select.appendChild(option);
});
}
function renderCatalog() {
if (!state.providers.length) {
providerCatalog.innerHTML = '<div class="empty">当前 pack 下没有 provider。</div>';
return;
}
providerCatalog.innerHTML = state.providers.map((provider) => `
<button type="button" class="catalog-item ${state.selectedProvider?.provider_id === provider.provider_id ? "is-selected" : ""}" data-provider-id="${escapeHTML(provider.provider_id)}">
<strong>${escapeHTML(provider.display_name || provider.provider_id)}</strong>
<div class="empty">${escapeHTML(provider.provider_id)}</div>
<div class="catalog-meta">
<span class="pill tone-note">${escapeHTML(provider.platform || "unknown")}</span>
<span class="pill ${provider.host_overlays > 0 ? "tone-ready" : ""}">host overlays: ${escapeHTML(provider.host_overlays || 0)}</span>
</div>
</button>
`).join("");
providerCatalog.querySelectorAll("[data-provider-id]").forEach((element) => {
element.addEventListener("click", () => {
const providerID = element.getAttribute("data-provider-id");
const selected = state.providers.find((item) => item.provider_id === providerID);
if (!selected) return;
state.selectedProvider = selected;
providerIDInput.value = selected.provider_id;
syncMetrics();
renderCatalog();
setStatus(providerStatus, `已选择 provider${selected.provider_id}`, "success");
});
});
}
function renderServerDrafts() {
if (!state.drafts.length) {
serverDraftList.innerHTML = '<div class="empty">还没有服务端草稿。</div>';
return;
}
serverDraftList.innerHTML = state.drafts.map((draft) => `
<button type="button" class="draft-item" data-draft-id="${escapeHTML(draft.draft_id)}">
<strong>${escapeHTML(draft.display_name || draft.provider_id)}</strong>
<div class="empty">${escapeHTML(draft.provider_id)} · ${escapeHTML(draft.pack_id)}</div>
<div class="catalog-meta">
<span class="pill tone-note">${escapeHTML(draft.platform || "unknown")}</span>
<span class="pill">${escapeHTML(draft.draft_id)}</span>
</div>
</button>
`).join("");
serverDraftList.querySelectorAll("[data-draft-id]").forEach((element) => {
element.addEventListener("click", () => {
const draftID = element.getAttribute("data-draft-id");
const draft = state.drafts.find((item) => item.draft_id === draftID);
if (!draft) {
return;
}
fillDraftForm(draft);
importResult.textContent = JSON.stringify(draft.manifest || {}, null, 2);
setStatus(draftStatus, `已回填服务端草稿:${draft.draft_id}`, "success");
});
});
}
function fillDraftForm(draft) {
state.currentDraftID = draft.draft_id || "";
draftProviderIDInput.value = draft.provider_id || "";
draftDisplayNameInput.value = draft.display_name || "";
draftPlatformInput.value = draft.platform || "";
draftSmokeModelInput.value = draft.smoke_test_model || "";
draftBaseURLInput.value = draft.base_url || "";
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
if (!draftCommitMessageInput.value.trim()) {
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
}
}
function clearDraftFormSelection() {
state.currentDraftID = "";
}
async function loadCatalog() {
const button = document.getElementById("load-catalog-btn");
button.disabled = true;
try {
setStatus(catalogStatus, "正在加载 pack / host / provider 目录 ...");
syncMetrics();
const [packsPayload, hostsPayload] = await Promise.all([
requestJSON("/api/packs", { headers: authHeaders() }),
requestJSON("/api/hosts", { headers: authHeaders() }),
]);
state.packs = Array.isArray(packsPayload.packs) ? packsPayload.packs : [];
state.hosts = Array.isArray(hostsPayload.hosts) ? hostsPayload.hosts : [];
const packOptions = state.packs.map((pack) => ({
value: pack.pack_id,
label: `${pack.pack_id} (${pack.version})`,
}));
const hostOptions = state.hosts.map((host) => ({
value: host.host_id,
label: `${host.host_id} · ${host.host_version || "unknown"}`,
}));
const savedPackID = JSON.parse(localStorage.getItem(storageKey) || "{}").packID || "";
const savedHostID = JSON.parse(localStorage.getItem(storageKey) || "{}").hostID || "";
renderSelectOptions(packIDInput, packOptions, savedPackID || packOptions[0]?.value || "", "暂无 pack");
renderSelectOptions(hostIDInput, hostOptions, savedHostID || hostOptions[0]?.value || "", "暂无 host");
const packID = packIDInput.value;
if (!packID) {
state.providers = [];
renderCatalog();
setStatus(catalogStatus, "没有可用 pack。", "warning");
return;
}
const providersPayload = await requestJSON(`/api/packs/${encodeURIComponent(packID)}/providers`, {
headers: authHeaders(),
});
state.providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : [];
state.selectedProvider = state.providers.find((item) => item.provider_id === providerIDInput.value) || state.providers[0] || null;
if (state.selectedProvider) {
providerIDInput.value = state.selectedProvider.provider_id;
}
renderCatalog();
syncMetrics();
saveCurrentIDsOnly();
await loadServerDrafts();
setStatus(catalogStatus, `目录已加载:${state.packs.length} 个 pack${state.providers.length} 个 provider。`, "success");
} catch (error) {
setStatus(catalogStatus, `加载目录失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function saveCurrentIDsOnly() {
const raw = localStorage.getItem(storageKey);
let payload = {};
try {
payload = raw ? JSON.parse(raw) : {};
} catch (error) {
payload = {};
}
payload.packID = packIDInput.value;
payload.hostID = hostIDInput.value;
localStorage.setItem(storageKey, JSON.stringify(payload));
}
function parseKeys() {
const keys = providerKeysInput.value
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!keys.length) {
throw new Error("至少填写一把供应商 key");
}
return keys;
}
function selectedProviderID() {
const providerID = providerIDInput.value.trim();
if (!providerID) {
throw new Error("provider_id 不能为空");
}
return providerID;
}
function baseProviderPayload() {
const hostID = hostIDInput.value.trim();
const packPath = packPathInput.value.trim();
if (!hostID) {
throw new Error("host_id 不能为空");
}
if (!packPath) {
throw new Error("pack_path 不能为空");
}
return {
host_id: hostID,
pack_path: packPath,
provider_id: selectedProviderID(),
keys: parseKeys(),
mode: modeInput.value,
};
}
function buildImportPayload() {
const payload = {
...baseProviderPayload(),
access_mode: accessModeInput.value,
access_api_key: accessAPIKeyInput.value.trim(),
};
if (!payload.access_api_key) {
throw new Error("access_api_key / probe_api_key 不能为空");
}
if (payload.access_mode === "subscription") {
payload.subscription_users = subscriptionUsersInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
payload.subscription_days = Number(subscriptionDaysInput.value || 30);
if (!payload.subscription_users.length) {
throw new Error("subscription 模式下 subscription_users 不能为空");
}
}
return payload;
}
async function previewProvider() {
const button = document.getElementById("preview-provider-btn");
button.disabled = true;
try {
const providerID = selectedProviderID();
setStatus(providerStatus, `正在预检 ${providerID} ...`);
const payload = baseProviderPayload();
const preview = await requestJSON(`/api/providers/${encodeURIComponent(providerID)}/preview-import`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
previewResult.textContent = JSON.stringify(preview, null, 2);
setStatus(providerStatus, `preview-import 已完成:${providerID}`, "success");
} catch (error) {
setStatus(providerStatus, `预检失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function importProvider() {
const button = document.getElementById("import-provider-btn");
button.disabled = true;
try {
const providerID = selectedProviderID();
setStatus(providerStatus, `正在导入 ${providerID} ...`);
const payload = buildImportPayload();
const result = await requestJSON(`/api/providers/${encodeURIComponent(providerID)}/import`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result, null, 2);
setStatus(providerStatus, `import 已完成:${providerID}`, "success");
} catch (error) {
setStatus(providerStatus, `导入失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function buildDraftPayload() {
const providerID = draftProviderIDInput.value.trim();
const displayName = draftDisplayNameInput.value.trim();
if (!providerID || !displayName) {
throw new Error("provider_id 和 display_name 不能为空");
}
const models = draftModelsInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return {
provider_id: providerID,
display_name: displayName,
platform: draftPlatformInput.value.trim() || "openai",
smoke_test_model: draftSmokeModelInput.value.trim() || (models[0] || ""),
base_url_placeholder: draftBaseURLInput.value.trim() || "https://api.example.com/v1",
supported_models: models,
};
}
function buildServerDraftPayload() {
const draft = buildDraftPayload();
return {
pack_id: packIDInput.value || "openai-cn-pack",
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
base_url: draft.base_url_placeholder,
smoke_test_model: draft.smoke_test_model,
supported_models: draft.supported_models,
source_host_id: hostIDInput.value || "",
manifest: {
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
base_url: draft.base_url_placeholder,
smoke_test_model: draft.smoke_test_model,
supported_models: draft.supported_models,
},
};
}
function generateDraft() {
try {
const draft = buildDraftPayload();
const output = {
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
smoke_test_model: draft.smoke_test_model,
base_url: draft.base_url_placeholder,
supported_models: draft.supported_models,
};
importResult.textContent = JSON.stringify(output, null, 2);
setStatus(draftStatus, "manifest 草稿已生成,可以直接复制。", "success");
} catch (error) {
setStatus(draftStatus, `生成失败:${error.message}`, "danger");
}
}
async function copyDraft() {
try {
await navigator.clipboard.writeText(importResult.textContent);
setStatus(draftStatus, "JSON 草稿已复制。", "success");
} catch (error) {
setStatus(draftStatus, "复制失败,请手动复制下方 JSON。", "warning");
}
}
async function saveDraftToServer() {
const button = document.getElementById("save-draft-btn");
button.disabled = true;
try {
const payload = buildServerDraftPayload();
const result = await requestJSON("/api/provider-drafts", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
state.currentDraftID = result.draft?.draft_id || "";
setStatus(draftStatus, `草稿已保存:${result.draft?.draft_id || "-"}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `保存失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function updateDraftOnServer() {
const button = document.getElementById("update-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
const payload = buildServerDraftPayload();
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
setStatus(draftStatus, `草稿已更新:${state.currentDraftID}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `更新失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function deleteDraftOnServer() {
const button = document.getElementById("delete-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "DELETE",
headers: authHeaders(),
});
const deletedDraftID = state.currentDraftID;
clearDraftFormSelection();
importResult.textContent = JSON.stringify({ deleted_draft_id: deletedDraftID }, null, 2);
setStatus(draftStatus, `草稿已删除:${deletedDraftID}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `删除失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function loadServerDrafts() {
try {
const params = new URLSearchParams();
if (packIDInput.value) {
params.set("pack_id", packIDInput.value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
const payload = await requestJSON(`/api/provider-drafts${suffix}`, {
headers: authHeaders(),
});
state.drafts = Array.isArray(payload.provider_drafts) ? payload.provider_drafts : [];
renderServerDrafts();
} catch (error) {
serverDraftList.innerHTML = `<div class="empty">加载草稿失败:${escapeHTML(error.message)}</div>`;
}
}
async function publishDraftToRepo() {
const button = document.getElementById("publish-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}/publish`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
commit_message: draftCommitMessageInput.value.trim(),
}),
});
importResult.textContent = JSON.stringify(result.publish || result, null, 2);
setStatus(
draftStatus,
`已发布到仓库:${result.publish?.provider_path || "-"} · ${result.publish?.pack_version_before || "-"} -> ${result.publish?.pack_version_after || "-"} · ${result.publish?.commit_sha || "-"}`,
"success",
);
} catch (error) {
setStatus(draftStatus, `发布失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("load-catalog-btn").addEventListener("click", loadCatalog);
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("preview-provider-btn").addEventListener("click", previewProvider);
document.getElementById("import-provider-btn").addEventListener("click", importProvider);
document.getElementById("generate-draft-btn").addEventListener("click", generateDraft);
document.getElementById("save-draft-btn").addEventListener("click", saveDraftToServer);
document.getElementById("update-draft-btn").addEventListener("click", updateDraftOnServer);
document.getElementById("publish-draft-btn").addEventListener("click", publishDraftToRepo);
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
await loadCatalog();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
packIDInput.addEventListener("change", loadCatalog);
providerIDInput.addEventListener("input", syncMetrics);
apiBaseInput.addEventListener("input", syncMetrics);
restoreConfig();
updateAccessModeFields();
syncMetrics();
refreshAdminSession().catch(() => {});
renderServerDrafts();
</script>
</body>
</html>