Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/index.html
phamnazage-jpg 85954e516a fix(review): address 2026-06-08 review report issues
## Fixed

### High-4: CI 与质量门禁不一致
- Add quality-gates job that runs verify_quality_gates.sh
- Fix Docker job: correct binary paths and remove || true
- Replace fake version/help checks with real health endpoint probe

### High-5: 敏感信息持久化到 localStorage
- Add SENSITIVE_FIELDS list to admin-common.js (adminToken, token, password, key, apiKey, etc.)
- writeStoredConfig now filters sensitive fields by default
- Add allowSensitive option for explicit opt-in (default false)
- Add createSensitiveStorageToggle() UI helper with warning banner
- Update admin/index.html placeholder text to remove misleading 不落盘 claim

### Medium-4: JSON 解码错误静默
- Fix scanUserKeys: return error when allowed_models JSON decode fails
- Fix scanOneUserKey: return error when allowed_models JSON decode fails
- Prevents silent data corruption that would show empty model list

## Quality Gates
 go build ./... - PASS
 go test ./internal/... - PASS (all packages)
 bash ./scripts/test/verify_quality_gates.sh - PASS

## Notes
- High-6 (凭证可预测) requires architecture change to store random credentials in DB
- Medium-3 (部署脚本默认值) considered lower priority for current scope
2026-06-09 09:35:18 +08:00

342 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN" 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.jstoast/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>