外观
交互契约
回答的问题:控制台与门户在「状态放哪、数据怎么刷、表格长什么样、危险操作怎么确认、 错误往哪走」这五件事上遵循什么共同约定,以及为什么是这些约定。
新写或重构任何页面时逐条对照。不满足即不算完成——这就是防止各页面各自即兴的机制。
参照系
这是一个单管理员、机器数据密集、长驻轮询的运维控制台。适合它的不是更多动效, 而是一套稳定的交互契约。契约取自两个成熟参照系:
| 来源 | 取的部分 | 不取的部分 |
|---|---|---|
| GitHub(Primer) | 状态与安全:URL 即状态、就地 banner、危险区输名确认、字段级表单错误 | PR / review 协作流、社交面 |
| Cloudflare Dashboard | 骨架与密度:列表→详情 tab、表格工具栏、行级操作菜单、向导带 review 步、轮询静默 | 多账户/多产品导航、营销位 |
明确不做的三件事,与做的同样重要:
- 乐观更新。基础设施操作(删 peering、回滚 generation)宁可悲观等待 + 明确 pending 态。错误的「看起来成功了」比慢半秒昂贵得多。
- 批量选择 / 批量操作。节点数量级为几十,表格契约留出左侧 checkbox 列的扩展位即可。
- 命令面板 / 全局快捷键体系。先把鼠标路径做对。
契约 1:URL 是唯一可分享状态
列表页的筛选、搜索词、分页游标,详情页的 tab 与子 tab,一律进 querystring。
实现:apps/control/src/lib/urlstate.svelte.ts 的 urlParam(key, default, opts)。 读取经 $app/state 的 page 保持反应性,写入经 goto()。
ts
const tab = urlParam('tab', 'overview', { push: true });
tab.value = 'dns'; // 回写 URL写入方式分两种,这个区分是契约的一部分:
- 离散切换(tab、筛选)用
push——浏览器后退按钮应当在它们之间移动; - 连续输入(搜索词)用默认的
replace——每敲一键都留一条历史记录是噪音。
goto() 一律带 keepFocus 与 noScroll,否则改筛选会把焦点和滚动位置打飞。
验收:任何页面 F5 后回到完全相同的视图;后退按钮在 tab 间移动。
契约 2:数据永不因刷新而消失
全站共用一个 35 秒的定时器(apps/control/src/lib/refresh.svelte.ts),顶栏的刷新 按钮是总开关(状态持久化)。页面通过 pollEffect() 订阅同一个 tick,因此不存在 每页各自计时的漂移。
四条行为约定:
- keep-last-good:取数失败保留旧数据,在内容区顶部渲染一条
InlineBanner细条,而不是用错误卡片替换整张表。后台一次瞬时失败不应让整页数据消失。 - 标签页隐藏时暂停:
document.visibilityState !== 'visible'不计 tick。 - 首载 skeleton,后续静默:轮询更新数据但不重挂 DOM,编辑中的表单不被打断。
- 每个 tab 显式声明刷新策略(tick / manual / stream),不留隐式分裂。
契约 3:表格能力基线
每张资源表的结构固定:
┌ 工具栏:[搜索框(防抖 300ms)] [筛选] ───── [计数] [刷新] ┐
│ 表头:可排序列(aria-sort;数字列右对齐 + tabular-nums) │
│ 表体:主键列 = 链接;行尾 "⋯" 菜单收纳 编辑/删除/次要操作 │
│ 状态:skeleton(首载)/ InlineBanner(错误,数据保留) │
│ / EmptyState(空,带 CTA) │
└ 页脚:分页 ┘- 排序与搜索在行数 < 500 时纯客户端(
apps/control/src/lib/table.svelte.ts的createSort);audit 与 status-events 走后端before_id游标。 - 行操作一律进
RowMenu的 "⋯" 菜单,行内不平铺按钮。统一 affordance,顺带消除 「一处写 Delete 文字、一处写 ✕ 字形」这类分裂。
契约 4:写操作分三档,确认有等级
| 档 | 适用 | 交互 |
|---|---|---|
| 行内 | 单字段开关/下拉(interface enabled、目标版本) | 控件即保存:pending 时控件内转圈,失败回滚 + toast |
| Modal 表单 | 创建 / 编辑资源 | 提交前客户端校验,错误贴在字段下方;dirty 时关闭需确认 |
| 危险 / 不可逆 | 删节点、回滚 generation、revoke token | 输入资源名确认 |
- 全站零
window.confirm/window.prompt。替代物是apps/control/src/lib/confirm.svelte.ts的confirmDialog()/promptDialog(), 由布局里挂一次的ConfirmHost渲染。任何消解方式(ESC / 点遮罩 / 取消)都 resolve 成 false/null——破坏性操作因此不可能在未确认的情况下执行。这一条修的是原生prompt() ?? undefined把「取消」当成「无备注继续」的真实缺陷。 - 不可逆操作用
typeToConfirm选项要求手输资源名(GitHub 式)。 - dirty 守卫是
dirtyGuard(open, value):弹窗打开瞬间对表单状态做 JSON 快照, 实时比对,结果喂给Modal的dirtyprop。表单都是小状态对象,逐键 stringify 的开销可以忽略。 - 保存一律悲观:按钮进
saving态 → 成功 toast + 就地更新。
契约 5:错误分层,末端闭环
| 类别 | 去向 |
|---|---|
| 加载错误 | 契约 2 的就地 banner(数据保留) |
| 动作错误 | toast + (表单场景)字段错误 |
| 401 | 全局兜底:丢弃令牌,路由守卫弹回登录页 |
| 403 | 翻译过的「无权限 / 已锁定」 |
后端的错误 detail 是稳定接口:apps/control/src/lib/api.ts 的 DETAIL_PATTERNS 用正则把已知句式翻译成四种语言,未知句式原样透传。 完整口径与后端侧约定见 reference/api/control.md。
Toaster 的加固项:错误用 role="alert"(不是 role="status")、同文案 2 秒内去重、 队列上限 5、悬停暂停自动消失。
契约 6:一致性基线
- 骨架屏只允许
SkeletonTable/SkeletonText/SkeletonRows,不手写骨架行; - 空状态只允许
EmptyState,不写裸.emptydiv; loading = true一律加 empty 守卫(否则首载会闪一下空态);- 变更后反馈统一为「toast + 就地重载」,不做直接替换;
- 所有页面同构地带
page-head。
与后端的耦合点
三条契约的根治依赖后端能力,不是前端能单独做完的:
| 契约 | 依赖 | 现状 |
|---|---|---|
| 2(游标分页) | status-events / flap-alerts 的 before_id | 已交付 |
| 4(无部分成功) | peering provision 的事务化 bgp_specs[] | 已交付 |
| 4(并发写) | 节点整体 PUT 仍是 last-write-wins | 未做,单管理员下影响很小 |
最后一条的根治路径已定:NodeOut 已带 updated_at,后端支持 If-Unmodified-Since、冲突返回 409 即可,前端会把 409 呈现为「该节点已被其他会话 修改,请刷新后重试」。