Skip to content

交互契约

回答的问题:控制台与门户在「状态放哪、数据怎么刷、表格长什么样、危险操作怎么确认、 错误往哪走」这五件事上遵循什么共同约定,以及为什么是这些约定。

新写或重构任何页面时逐条对照。不满足即不算完成——这就是防止各页面各自即兴的机制。

参照系

这是一个单管理员、机器数据密集、长驻轮询的运维控制台。适合它的不是更多动效, 而是一套稳定的交互契约。契约取自两个成熟参照系:

来源取的部分不取的部分
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.tsurlParam(key, default, opts)。 读取经 $app/statepage 保持反应性,写入经 goto()

ts
const tab = urlParam('tab', 'overview', { push: true });
tab.value = 'dns';   // 回写 URL

写入方式分两种,这个区分是契约的一部分

  • 离散切换(tab、筛选)用 push——浏览器后退按钮应当在它们之间移动;
  • 连续输入(搜索词)用默认的 replace——每敲一键都留一条历史记录是噪音。

goto() 一律带 keepFocusnoScroll,否则改筛选会把焦点和滚动位置打飞。

验收:任何页面 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.tscreateSort);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.tsconfirmDialog() / promptDialog(), 由布局里挂一次的 ConfirmHost 渲染。任何消解方式(ESC / 点遮罩 / 取消)都 resolve 成 false/null——破坏性操作因此不可能在未确认的情况下执行。这一条修的是原生 prompt() ?? undefined 把「取消」当成「无备注继续」的真实缺陷。
  • 不可逆操作用 typeToConfirm 选项要求手输资源名(GitHub 式)。
  • dirty 守卫是 dirtyGuard(open, value):弹窗打开瞬间对表单状态做 JSON 快照, 实时比对,结果喂给 Modaldirty prop。表单都是小状态对象,逐键 stringify 的开销可以忽略。
  • 保存一律悲观:按钮进 saving 态 → 成功 toast + 就地更新。

契约 5:错误分层,末端闭环

类别去向
加载错误契约 2 的就地 banner(数据保留)
动作错误toast + (表单场景)字段错误
401全局兜底:丢弃令牌,路由守卫弹回登录页
403翻译过的「无权限 / 已锁定」

后端的错误 detail稳定接口apps/control/src/lib/api.tsDETAIL_PATTERNS 用正则把已知句式翻译成四种语言,未知句式原样透传。 完整口径与后端侧约定见 reference/api/control.md

Toaster 的加固项:错误用 role="alert"(不是 role="status")、同文案 2 秒内去重、 队列上限 5、悬停暂停自动消失。

契约 6:一致性基线

  • 骨架屏只允许 SkeletonTable / SkeletonText / SkeletonRows,不手写骨架行;
  • 空状态只允许 EmptyState,不写裸 .empty div;
  • 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 呈现为「该节点已被其他会话 修改,请刷新后重试」。