16 KiB
前端设计系统 Runbook
适用:
deploy/tksea-portal/下所有 HTML 静态页(含 public portal + 7 个 admin 页) 建立时间:2026-06-03 来源:把原本「像 demo」的多页 HTML 升级为对齐宿主 sub2api Vue+Tailwind teal/slate 体系的 Linear/Vercel 信息建筑派设计系统。
1. 文件结构
deploy/tksea-portal/
├── portal.css # 真设计系统 (777 行) — 唯一新视觉真源
├── portal.js # window.Sub2ApiPortal — toast / icon / nav / theme / drawer
├── admin-common.css # 4KB legacy shim — 老类名映射到新 token
├── admin-common.js # 313 行原 nav 渲染契约(未动)
├── index.html # public portal (light)
├── admin/
│ ├── index.html # admin 入口
│ ├── logical-groups.html
│ ├── route-health.html
│ ├── accounts.html
│ ├── providers.html
│ └── batch-import.html # → /portal/admin-batch-import.html 1.5KB redirect
└── admin-batch-import.html # 旧地址实页(历史兼容)
新页面 = portal.css + portal.js + 自己的 <style>(仅页内布局)
老页面 = portal.css + admin-common.css(shim)+ admin-common.js + portal.js + 自己的 <style>(仅页内布局)
2. Design Token 速查
颜色
| Token | 值 | 用途 |
|---|---|---|
--color-primary |
#14b8a6 |
teal 主色,对齐宿主 |
--color-primary-soft |
rgba(20,184,166,.12) |
主色背景层 |
--color-success / -soft |
#22c55e / rgba(34,197,94,.1) |
healthy / live / success |
--color-warning / -soft |
#f59e0b / rgba(245,158,11,.1) |
cooldown / warn |
--color-danger / -soft |
#ef4444 / rgba(239,68,68,.1) |
failing / dead / danger |
--color-neutral / -soft |
#64748b / rgba(100,116,139,.1) |
disabled / neutral |
--text-strong |
--slate-50 (dark) / --slate-900 (light) |
标题 |
--text-default |
--slate-200 / --slate-700 |
正文 |
--text-muted |
--slate-400 / --slate-500 |
辅助 |
--bg-base |
#0f172a / #f8fafc |
背景 |
--bg-elev-1 |
#1e293b / #ffffff |
卡片 1 |
--bg-elev-2 |
#334155 / #f1f5f9 |
卡片 2 |
--border-subtle |
rgba(148,163,184,.16) / rgba(15,23,42,.08) |
边框 |
间距 (s-*)
--s-1=4 · --s-2=8 · --s-3=12 · --s-4=16 · --s-5=24 · --s-6=32 · --s-7=48
字号
--fs-xs=12 · --fs-sm=13 · --fs-md=14 · --fs-base=15 · --fs-lg=17 · --fs-xl=20 · --fs-2xl=24 · --fs-3xl=32 · --fs-display=44
圆角
--r-sm=6 · --r-md=10 · --r-lg=14 · --r-xl=20 · --r-full=9999
3. 标准页面骨架
<!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>...</title>
<link rel="stylesheet" href="/portal/portal.css" />
<link rel="stylesheet" href="/portal/admin-common.css" />
<!-- 老页兼容用 -->
<style>
/* 仅本页独有布局。token 全部用 var(--...) */
</style>
</head>
<body>
<main class="shell fade-in">
<nav class="topnav" data-admin-nav data-admin-current="accounts"></nav>
<section class="page-hero">
<div>
<span class="page-hero__eyebrow">Section Name</span>
<h1>页面主标题</h1>
<p>副标题描述,用 token 控制字号行高。</p>
</div>
<div class="stack" style="gap: var(--s-3);">
<div class="stat-card">
<div class="stat-icon stat-icon-primary" id="m1"></div>
<div class="min-w-0">
<p class="stat-label">API Root</p>
<p class="stat-value" id="metric-api-root">-</p>
</div>
</div>
<!-- 更多 stat-card -->
</div>
</section>
<section class="layout">
<!-- 你的页内 grid -->
...
</section>
</main>
<script src="/portal/admin-common.js"></script>
<script src="/portal/portal.js"></script>
<script>
const AdminCommon = window.Sub2ApiAdminCommon;
const Portal = window.Sub2ApiPortal;
AdminCommon.renderAdminNav(
document.querySelector("[data-admin-nav]"),
"accounts",
);
Portal.renderModernAdminNav(
document.querySelector("[data-admin-nav]"),
"accounts",
);
Portal.icons.shield.mount("#m1");
Portal.icons.group.mount("#m2");
// ... 你的页逻辑
</script>
</body>
</html>
4. 组件 API
4.1 <div class="stat-card">
<div class="stat-card">
<div
class="stat-icon stat-icon-{primary|info|success|warning|danger}"
id="unique-id"
></div>
<div class="min-w-0">
<p class="stat-label">label</p>
<p class="stat-value" id="metric-foo">0</p>
</div>
</div>
4.2 <div class="statusbar">
.statusbar 是统一的提示条;颜色用 data-tone="success|warning|danger|note|neutral" 或直接 class tone-healthy|tone-cooldown|tone-failing|tone-disabled|tone-ready|tone-note|tone-warn。
4.3 Portal 图标
Portal.icons.shield.mount("#icon-slot"); // 渲染 22px lucide stroke 1.75
Portal.icons.group.mount("#icon-slot", { size: 18 });
可用:home / group / route / health / account / provider / import / check / x / alert / info / copy / edit / trash / plus / refresh / download / upload / eye / eyeoff / play / pause / search / filter / shield / activity / package / key / send / log-out / external-link / arrow-right / chevron-right
4.4 Toast
Portal.toast("保存成功", { tone: "success", duration: 3000 });
Portal.toast("拉取失败", { tone: "danger" });
4.5 Copy
Portal.copyText(value, { success: "已复制", failure: "复制失败" });
4.6 Theme
Portal.theme.set("dark" | "light" | "auto"); // 写入 localStorage + html[data-theme]
Portal.theme.get(); // 当前生效 theme
4.7 Modern Nav
Portal.renderModernAdminNav(container, currentKey);
// 渲染带 SVG icon + 渐变 hover 的现代 nav;并存于 AdminCommon.renderAdminNav 之上
5. 关键约束
-
不要破坏测试契约字符串:
logical_group/shadow_host_id/matched_account_state/account_resolution/package_tier/visibility_scope/gpt-5.4/MiniMax-M2.7-highspeed/deepseek-chat/Batch Import Admin/route_status/cooldown_seconds/failover_threshold/Smoke Logical Group/Smoke Provider Account/smoke-admin/smoke-route-primary等 70+ 字符串必须在 HTML 中出现(前端门禁 grep 断言)。 -
不要修改
admin-common.js:保留window.Sub2ApiAdminCommon的全局 API 契约。portal.js是叠加层,不是替代层。 -
不要使用 emoji / 渐变紫 / generic stock photo:用 SVG icon + token 调色板。
-
不要写裸
style="color:#xxx":用var(--text-muted)等 token。 -
JS 引用 DOM ID 时:新增的 page-hero / stat-card ID 必须和
getElementById()调用对应。修改 stat-card label 时同步检查 JS 里的引用。
6. 测试门禁
cd /home/long/project/sub2api-cn-relay-manager
bash scripts/test/test_tksea_portal_assets.sh # 70+ 字符串契约断言
bash scripts/test/verify_frontend_smoke.sh # chromium headless 渲染 7 页 + public
bash scripts/acceptance/verify_frontend_acceptance_matrix.sh # 总入口
修改任何 deploy/tksea-portal/*.html / portal.css / portal.js / admin-common.css 后必须三门都过。
7. 截图 evidence
# 启动本地 server(端口 8765,映射 /portal/* → deploy/tksea-portal/*)
python3 /tmp/portal_server.py &
# 截图 8 个页面
mkdir -p /tmp/portal-screenshots
for url in \
"/portal/admin/index.html" \
"/portal/admin/logical-groups.html" \
"/portal/admin/route-health.html" \
"/portal/admin/accounts.html" \
"/portal/admin/providers.html" \
"/portal/admin-batch-import.html" \
"/portal/admin/batch-import.html" \
"/portal/index.html"; do
name=$(basename "$url" .html)
google-chrome --headless --disable-gpu --no-sandbox --hide-scrollbars \
--window-size=1440,2400 --virtual-time-budget=4000 \
--screenshot=/tmp/portal-screenshots/${name}.png \
"http://127.0.0.1:8765${url}"
done
8. 常见错误
- 重复
<!doctype html>:原页有 inline<link>紧跟<style>,新 head 注入会拼接出两个 doctype。修复:删掉第一个 doctype 到第二个 doctype 之间的内容。 const AdminCommon重复声明:批量处理时容易把renderAdminNav块和原 script 块拼接,两块都包含const AdminCommon = window.Sub2ApiAdminCommon;→ SyntaxError。修复:保留一处。- stat-card ID 改名导致
getElementById找不到:JS 引用metric-total而 DOM 是metric-account-count→ loadAccounts 抛错。修复:保持 ID 不变。 - 去掉 inline
<style>时连<script>也误删:原页有 inline<script>在<style>之后,被批量处理误删。修复:从git show HEAD:<file>提取原始 script 块插回。
9. 进阶:自定义页内布局
每个页都可以有自己的 <style> 块,只放页内布局(grid / flex),不放 token(颜色 / 间距 / 字号)。示例:
/* accounts.html */
.layout {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: var(--s-5);
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
.catalog {
display: grid;
gap: 12px;
max-height: 32rem;
overflow: auto;
}
10. 提交规范
- 设计系统变更:
portal.css/portal.js/admin-common.css单独一个 commit,messagestyle(portal): <token/组件> - 页面改造:每个 page 一个 commit,message
refactor(portal/<page>): <change> - 文档同步:
docs/EXECUTION_BOARD.md+ 本 runbook 一个 docs commit,与代码 commit 分开 - 一次 push 前跑:
test_tksea_portal_assets+verify_frontend_smoke+ 8 张截图
11. 真实 bug 案例:label 内 hint 被 input 覆盖 (2026-06-03)
11.1 症状
用户在生产环境 (https://sub.tksea.top/portal/) 报告"部分提示文字被遮挡"。
具体表现:providers.html 的 ADMIN TOKEN / PACK PATH / PROVIDER ID /
SMOKE TEST MODEL / 发布 COMMIT MESSAGE / KEYS 6 个字段,其 .hint
描述文字末行被 input 框盖住 8-10px,导致末尾几个字看不到。
11.2 根因
HTML 结构(11 个 label 全部如此):
<label>字段名
<input id="..." type="..." placeholder="...">
<datalist>...</datalist> <!-- 可选 -->
<span class="hint">描述文字</span>
</label>
3 个流在同一行(normal flow)打架:
<label>全局规则display: block; font-size: 12px(portal.css line 75)<input>默认display: inline-block,高度 40px<span class="hint">默认display: inline,但.hint自己有padding: 10px 12px + border + background,等同于"内联卡片"
inline .hint box 跟在 inline-block <input> 后面 baseline 对齐,
但 .hint 实际是 39-77px 高的盒子,比 input 高 0-37px。多出来的 8-10px
垂直方向 跟 input 重叠 → input 覆盖 hint 末行。
11.3 量出来的硬证据
// chrome devtools console:
(() => {
const results = [];
document.querySelectorAll('label').forEach((el) => {
if (!el.querySelector('input,select,textarea')) return;
const hint = el.querySelector('.hint');
if (!hint) return;
const r = hint.getBoundingClientRect();
const ir = el.querySelector('input,select,textarea').getBoundingClientRect();
const covered = (r.top < ir.bottom && r.bottom > ir.top && r.left < ir.right && r.right > ir.left);
if (covered) results.push({label: el.textContent.split('\n')[0].trim(), hint: hint.textContent.trim()});
});
return results;
})()
// 返回 5-6 个被覆盖的 hint 对象
11.4 修法(CSS-only → HTML 升级 → 全面 info-banner)
第一阶段:CSS 兜底 (commit 3e158e78,2026-06-03)
只改 portal.css,影响所有 8 个页面:
.hint { display: block; ...; margin-top: 6px; } /* 强制独立行 */
label:not(.field-label):not(.raw-input) > input,
label:not(.field-label):not(.raw-input) > select,
label:not(.field-label):not(.raw-input) > textarea {
display: block; width: 100%; margin-top: 4px;
}
label:not(.field-label):not(.raw-input) > .hint + input,
label:not(.field-label):not(.raw-input) > .hint + select,
label:not(.field-label):not(.raw-input) > .hint + textarea { margin-top: 6px; }
效果:.hint 永远在 input 下方独立成行,0 covered。但 UX 仍不理想 —
hint 跟 input 分离后用户要"先填后看解释",违反 Linear/Vercel 信息建筑派
"label + 解释在 input 上方"的规范。
第二阶段:HTML 升级为 info-banner (commit 23fd8db7)
11 个 label 重排:hint 移到 input 之前(datalist 仍紧贴 input)。
同时把 .hint 样式从"实心 slate 卡片"升级为"teal 12% 透明 + 左边 2px
teal accent" 的 info-banner,视觉上跟 input 卡片严格区分,永远不会被
误以为是 input。
.hint {
display: block;
font-size: 12.5px;
line-height: 1.55;
color: var(--text-muted);
padding: 8px 12px 8px 14px;
border-radius: var(--r-sm);
background: var(--color-primary-soft); /* 浅 teal 12% 透明 */
border-left: 2px solid var(--color-primary); /* 左边 teal accent */
margin: 4px 0 8px 0;
}
.hint code { font-family: var(--font-mono); font-size: 12px; padding: 0 4px;
border-radius: 4px; background: rgba(20,184,166,.08); color: var(--teal-300); }
.hint strong { color: var(--text-default); font-weight: 700; }
11.5 验证(chrome remote-debugging 7 页面)
脚本 /tmp/verify_7pages.js 走 Chrome DevTools Protocol,对 7 个 URL
逐个 navigate + evaluate:
| Page | n_hints | covered |
|---|---|---|
| /portal/ | 2 | 0 |
| /portal/admin/ | 0 | 0 |
| /portal/admin/providers.html | 7-8 | 0 |
| /portal/admin/accounts.html | 0 | 0 |
| /portal/admin/logical-groups.html | 1 | 0 |
| /portal/admin/route-health.html | 0 | 0 |
| /portal/admin-batch-import.html | 4 | 0 |
| 总计 | 14-15 | 0 |
deploy 流程:bash scripts/deploy/deploy_tksea_portal.sh(scp 到
ubuntu@43.155.133.187:/var/www/sub2api-portal/ + reload nginx)。
11.6 防同类 bug checklist
下次在 portal 添加新字段时,强制遵循:
- hint 描述文字放在 input 之前(不是之后)
- hint 用
<span class="hint">描述</span>,不要用<p>或<div> - 不要在 label 文字后直接接 hint span(中间用换行 + 缩进)
- datalist 紧贴它所属的 input,不跟 hint 混在一起
- 新增完成后跑:
bash bash scripts/test/test_tksea_portal_assets.sh bash scripts/test/verify_frontend_smoke.sh node /tmp/verify_7pages.js 2>&1 | grep covered必须全部covered=0
11.7 相关 commit
3e158e78fix(portal): prevent .hint description from being covered by <input>(CSS-only)23fd8db7refactor(portal): move .hint info-banner ABOVE its <input> + upgrade to teal-accented banner style(HTML reorder + style upgrade)
11.8 相关 skill
~/.hermes/skills/software-development/label-hint-coverage-debug/SKILL.md— 把这个 bug 的根因/修法/verify/防复发提炼为通用 skill,下个项目的 form hint 设计直接套用。