Root cause: only admin/index.html had explicit .input / .select / .label classes.
100+ inputs across logical-groups / route-health / accounts / providers /
admin-batch-import + public portal had no class → browser default styling →
页面看起来「未统一」。
Fix:
- portal.css: add global rules that auto-apply design system styling to
any input/select/textarea/label/button/table that doesn't opt out
via .raw-input / .field-label. The existing .input / .select /
.label / .btn classes still win (same styles, just explicit).
- portal.js: detectInitialTheme() now respects HTML's data-theme
attribute first (page author intent), then localStorage, then OS
preference. This makes admin pages' explicit data-theme="dark"
actually stick instead of being overridden.
- admin/index.html: h3 标题 8 个 article 统一用 class="card-title"
(前 4 个 inline 15px / 后 3 个 inline 16px 已统一)
- 6 admin pages: 修复 critical HTML 结构 bug — 之前 batch 处理的
残留让 <link> 和 <style> 嵌套在 <style>:root{} 块内,浏览器
解析时直接忽略,导致所有 stylesheet 不加载、整个页面无样式
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- 8 张 screenshot v4 在 /tmp/portal-screenshots/ (各 600KB-1.2MB)
- 浏览器实测:3 stylesheets 加载,103 个 input 全部 38px/12px 圆角输入框
35 个 label 全部 12px uppercase slate-400
6 个 select 全部 38px + 自定义箭头
342 lines
15 KiB
HTML
342 lines
15 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Admin Portal · Sub2API 中继管理</title>
|
||
<link rel="stylesheet" href="/portal/portal.css" />
|
||
<link rel="stylesheet" href="/portal/admin-common.css" />
|
||
</head>
|
||
<body>
|
||
<main class="shell fade-in">
|
||
|
||
<!-- 顶部导航(由 admin-common.js 渲染;data-admin-nav 是契约) -->
|
||
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="home"></nav>
|
||
|
||
<!-- 页面 hero -->
|
||
<section class="page-hero">
|
||
<div>
|
||
<span class="page-hero__eyebrow">Admin Portal</span>
|
||
<h1>把新增模型、导入帐号与 Route 收进同一套入口</h1>
|
||
<p>
|
||
当前版本统一从 <code>/portal/admin/</code> 进入:一边看 pack/provider 目录、做 preview/import,
|
||
一边继续保留 item 级 <code>reused / reactivated / replaced</code> 的 batch-import 结果面板。
|
||
所有写操作都走 CRM <code>/portal-admin-api</code>,浏览器不直连 Git。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/providers.html" data-cta="providers">
|
||
<span id="providers-cta-icon"></span> 进入供应商目录
|
||
</a>
|
||
<a class="btn" href="/portal/" target="_blank" rel="noreferrer">
|
||
<span id="portal-cta-icon"></span> 打开用户 Portal
|
||
</a>
|
||
<a class="btn btn-ghost" href="https://github.com" target="_blank" rel="noreferrer">
|
||
<span id="docs-cta-icon"></span> 文档
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧 metric 卡片组(host DashboardView 风格) -->
|
||
<div class="stack">
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-primary" id="m1-icon"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">统一入口</p>
|
||
<p class="stat-value">/portal/admin/</p>
|
||
<p class="stat-sub">默认同域走 <code>/portal-admin-api/</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-info" id="m2-icon"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">Logical Group</p>
|
||
<p class="stat-value">/logical-groups</p>
|
||
<p class="stat-sub">logical_group · public_model · route</p>
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-icon stat-icon-success" id="m3-icon"></div>
|
||
<div class="min-w-0">
|
||
<p class="stat-label">Provider 目录</p>
|
||
<p class="stat-value">/providers</p>
|
||
<p class="stat-sub">preview-import · manifest 草稿</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 4 个核心模块入口(用 stat-card 一致风格) -->
|
||
<section class="section-head mt-6">
|
||
<div>
|
||
<h2>核心模块</h2>
|
||
<p>每个模块都对应一个 admin 页面 + 同一套 API base(<code>/portal-admin-api</code>)。</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="grid grid-2">
|
||
|
||
<!-- 逻辑分组 / 路由 -->
|
||
<article class="card card-hover">
|
||
<div class="card-body">
|
||
<div class="row-between">
|
||
<div class="row" style="gap:10px;">
|
||
<div class="stat-icon stat-icon-info" id="lg-icon"></div>
|
||
<div>
|
||
<h3 class="card-title">逻辑分组 / 路由</h3>
|
||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">logical_group · public_model · route · shadow_*</p>
|
||
</div>
|
||
</div>
|
||
<span class="pill pill-primary">运行中</span>
|
||
</div>
|
||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||
给插件前置路由使用,维护 <code>logical_group</code>、<code>public_model</code>、<code>route</code> 与
|
||
<code>shadow_host_id / shadow_group_id</code> 的关系。当前首版已经能直接调
|
||
<code>/api/logical-groups</code> 系列接口,适合先把 canonical shadow route 收进统一管理面。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/logical-groups.html">打开逻辑分组页</a>
|
||
<span class="text-subtle" style="font-size:12px;">首版页面只覆盖新增与查看</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 新增模型 / 供应商目录 -->
|
||
<article class="card card-hover">
|
||
<div class="card-body">
|
||
<div class="row-between">
|
||
<div class="row" style="gap:10px;">
|
||
<div class="stat-icon stat-icon-success" id="pr-icon"></div>
|
||
<div>
|
||
<h3 class="card-title">新增模型 / 供应商目录</h3>
|
||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">pack · provider · preview · manifest draft</p>
|
||
</div>
|
||
</div>
|
||
<span class="pill pill-success">推荐入口</span>
|
||
</div>
|
||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||
这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> / <code>import</code>,
|
||
同时提供 provider manifest 草稿生成与发布。当前版本已经支持先保存草稿,再经由 CRM 服务端写入
|
||
pack/provider 文件并自动提交到仓库。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/providers.html">打开供应商页</a>
|
||
<a class="btn btn-ghost" href="/portal/admin/providers.html#manifest-draft">跳到 manifest 草稿</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- Route 健康视图 -->
|
||
<article class="card card-hover">
|
||
<div class="card-body">
|
||
<div class="row-between">
|
||
<div class="row" style="gap:10px;">
|
||
<div class="stat-icon stat-icon-warning" id="rh-icon"></div>
|
||
<div>
|
||
<h3 class="card-title">Route 健康视图</h3>
|
||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">healthy · cooldown · failing · disabled</p>
|
||
</div>
|
||
</div>
|
||
<span class="pill pill-warning">只读</span>
|
||
</div>
|
||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||
这页专门给运营看 route 当前运行状态,聚合 <code>routefail</code>、<code>routecool</code>、
|
||
最近一次选路与 failover 事件。首版只做只读健康视图,不在这里直接改 route。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/route-health.html">打开健康页</a>
|
||
<a class="btn btn-ghost" href="/portal/admin/route-health.html#matrix">看 matrix</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 帐号资产 -->
|
||
<article class="card card-hover">
|
||
<div class="card-body">
|
||
<div class="row-between">
|
||
<div class="row" style="gap:10px;">
|
||
<div class="stat-icon stat-icon-primary" id="ac-icon"></div>
|
||
<div>
|
||
<h3 class="card-title">帐号资产</h3>
|
||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">logical_group · route · shadow_group · shadow_host</p>
|
||
</div>
|
||
</div>
|
||
<span class="pill">库存</span>
|
||
</div>
|
||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||
把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
|
||
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
|
||
<code>enable / disable / retire</code> 动作。启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
|
||
<a class="btn btn-ghost" href="/portal/admin/accounts.html#binding">显式整理归属</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- 批量导入 + 旧地址兼容 -->
|
||
<article class="card mt-6">
|
||
<div class="card-body">
|
||
<div class="row-between">
|
||
<div class="row" style="gap:10px;">
|
||
<div class="stat-icon stat-icon-info" id="bi-icon"></div>
|
||
<div>
|
||
<h3 class="card-title">导入供应商帐号</h3>
|
||
<p class="text-muted" style="margin:2px 0 0;font-size:12px;">reused · created · reactivated · replaced</p>
|
||
</div>
|
||
</div>
|
||
<span class="pill pill-info">实时</span>
|
||
</div>
|
||
<p class="text-muted mt-3" style="font-size:13px;line-height:1.6;">
|
||
这页继续负责 live batch-import:创建 run、拉取 run summary、查看 item 级别的
|
||
<code>matched_account_state</code> 与 <code>account_resolution</code>。批量导入第三方 key,验证
|
||
<code>reused / created / reactivated / replaced</code> 状态语义。
|
||
</p>
|
||
<div class="row mt-4">
|
||
<a class="btn btn-primary" href="/portal/admin/batch-import.html">打开导入页</a>
|
||
<a class="btn btn-ghost" href="/portal/admin-batch-import.html">旧地址兼容入口</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 边界 + 状态 -->
|
||
<section class="section-head mt-6">
|
||
<div>
|
||
<h2>当前边界与安全前提</h2>
|
||
<p>明确告诉你"哪里能写、哪里只读、谁有权限",避免误操作。</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="grid grid-3">
|
||
<article class="card">
|
||
<div class="card-body">
|
||
<div class="row" style="gap:10px;">
|
||
<span class="dot dot-success"></span>
|
||
<strong style="font-size:14px;">可立即使用</strong>
|
||
</div>
|
||
<h3 class="card-title">逻辑分组 + Provider 导入</h3>
|
||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||
依赖现有 <code>/api/logical-groups</code>、<code>/api/packs</code>、
|
||
<code>/api/providers/*</code>、<code>/api/batch-import/*</code> 即可完成。
|
||
</p>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="card-body">
|
||
<div class="row" style="gap:10px;">
|
||
<span class="dot dot-info"></span>
|
||
<strong style="font-size:14px;">当前边界</strong>
|
||
</div>
|
||
<h3 class="card-title">浏览器提交到 CRM,再由 CRM 写仓库</h3>
|
||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||
页面不会直接拼 Git 命令;所有写 pack/provider 与提交仓库的动作,都统一走 CRM 服务端的发布接口。
|
||
</p>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="card-body">
|
||
<div class="row" style="gap:10px;">
|
||
<span class="dot dot-warning"></span>
|
||
<strong style="font-size:14px;">安全前提</strong>
|
||
</div>
|
||
<h3 class="card-title">仍需 Admin Token</h3>
|
||
<p class="text-muted" style="font-size:13px;line-height:1.6;">
|
||
CRM 的 API 权限仍由 Bearer token 控制,同域反代只解决浏览器可达性,不降低鉴权门槛。
|
||
</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- 管理员会话小工具条 -->
|
||
<section class="card mt-6">
|
||
<div class="card-body">
|
||
<div class="section-head" style="margin-bottom:12px;">
|
||
<div>
|
||
<h2 style="font-size:16px;">管理员会话</h2>
|
||
<p>可在此处建立 session 检查当前鉴权状态,或留作跨页签到。</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn btn-sm" id="check-session-btn" type="button">检查会话</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2">
|
||
<div class="field">
|
||
<label class="label" for="api-base-input">API Base</label>
|
||
<input id="api-base-input" class="input" type="text" placeholder="https://sub.tksea.top/portal-admin-api" />
|
||
<span class="field-help">同域走 <code>/portal-admin-api</code>,调试时可改成完整 URL。</span>
|
||
</div>
|
||
<div class="field">
|
||
<label class="label" for="admin-token-input">Bearer Token(可选)</label>
|
||
<input id="admin-token-input" class="input" type="password" placeholder="不落盘,仅当前会话" />
|
||
<span class="field-help">已登录管理员 session 时可不填。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="statusbar mt-4" id="admin-session-status" data-tone="info">
|
||
点击「检查会话」可拉取 <code>/api/admin/session</code>。
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<!-- 共享层:admin-common.js(保持兼容契约)+ 新 portal.js(toast/icons/theme) -->
|
||
<script src="/portal/admin-common.js"></script>
|
||
<script src="/portal/portal.js"></script>
|
||
<script>
|
||
(function initAdminHome() {
|
||
const runtime = window.Sub2ApiAdminCommon.createAdminPageRuntime({
|
||
apiBaseInput: document.getElementById("api-base-input"),
|
||
adminTokenInput: document.getElementById("admin-token-input"),
|
||
adminSessionStatus: document.getElementById("admin-session-status"),
|
||
});
|
||
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "home");
|
||
|
||
// 注入图标
|
||
const I = (id, name) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(name, 22); };
|
||
I("m1-icon", "shield");
|
||
I("m2-icon", "group");
|
||
I("m3-icon", "package");
|
||
I("lg-icon", "group");
|
||
I("pr-icon", "package");
|
||
I("rh-icon", "activity");
|
||
I("ac-icon", "account");
|
||
I("bi-icon", "import");
|
||
I("providers-cta-icon", "package");
|
||
I("portal-cta-icon", "external");
|
||
I("docs-cta-icon", "file");
|
||
|
||
// 会话检查
|
||
const stored = runtime.readStoredConfig("tksea-admin-home");
|
||
if (stored.apiBase) document.getElementById("api-base-input").value = stored.apiBase;
|
||
const checkBtn = document.getElementById("check-session-btn");
|
||
checkBtn.addEventListener("click", async () => {
|
||
checkBtn.disabled = true;
|
||
const orig = checkBtn.textContent;
|
||
checkBtn.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> 检查中…';
|
||
try {
|
||
await runtime.refreshAdminSession();
|
||
window.Sub2ApiPortal.toast("会话状态已刷新", "success", 1800);
|
||
} catch (e) {
|
||
window.Sub2ApiPortal.toast(`检查失败:${e.message || e}`, "danger");
|
||
} finally {
|
||
checkBtn.disabled = false;
|
||
checkBtn.textContent = orig;
|
||
}
|
||
});
|
||
|
||
["api-base-input"].forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
el.addEventListener("change", () => {
|
||
runtime.writeStoredConfig("tksea-admin-home", { apiBase: el.value });
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|