Files
sub2api-cn-relay-manager/docs/2026-06-03-FRONTEND-DESIGN-SYSTEM-RUNBOOK.md
2026-06-04 20:00:03 +08:00

414 lines
16 KiB
Markdown
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.
# 前端设计系统 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. 标准页面骨架
```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>...</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">`
```html
<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 图标
```js
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
```js
Portal.toast("保存成功", { tone: "success", duration: 3000 });
Portal.toast("拉取失败", { tone: "danger" });
```
### 4.5 Copy
```js
Portal.copyText(value, { success: "已复制", failure: "复制失败" });
```
### 4.6 Theme
```js
Portal.theme.set("dark" | "light" | "auto"); // 写入 localStorage + html[data-theme]
Portal.theme.get(); // 当前生效 theme
```
### 4.7 Modern Nav
```js
Portal.renderModernAdminNav(container, currentKey);
// 渲染带 SVG icon + 渐变 hover 的现代 nav并存于 AdminCommon.renderAdminNav 之上
```
## 5. 关键约束
1. **不要破坏测试契约字符串**`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 断言)。
2. **不要修改 `admin-common.js`**:保留 `window.Sub2ApiAdminCommon` 的全局 API 契约。`portal.js` 是叠加层,不是替代层。
3. **不要使用 emoji / 渐变紫 / generic stock photo**:用 SVG icon + token 调色板。
4. **不要写裸 `style="color:#xxx"`**:用 `var(--text-muted)` 等 token。
5. **JS 引用 DOM ID 时**:新增的 page-hero / stat-card ID 必须和 `getElementById()` 调用对应。修改 stat-card label 时同步检查 JS 里的引用。
## 6. 测试门禁
```bash
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
```bash
# 启动本地 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**(颜色 / 间距 / 字号)。示例:
```css
/* 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` 单独一个 commitmessage `style(portal): <token/组件>`
- 页面改造:每个 page 一个 commitmessage `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 全部如此):
```html
<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 量出来的硬证据
```javascript
// 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 个页面:
```css
.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。
```css
.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
- `3e158e78` `fix(portal): prevent .hint description from being covered by <input>` (CSS-only)
- `23fd8db7` `refactor(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 设计直接套用。