外观
共享组件库 ui/
四个站共用的展示层。apps/* 通过 $ui 别名引用。仓里只有一个 package.json, 因此这是普通目录而非依赖包——不需要 exports 映射,svelte / bits-ui 也天然 只有一份实例。
| 文档 | 内容 |
|---|---|
| 本文 | 定位、目录约定、导入路径、横切模块、样式 |
| contracts.md | 宿主契约与五条设计准则(读这个才知道为什么组件长这样) |
| components.md | 基础件目录:primitives / forms / layout / feedback / data |
| charts.md | 图表层:ChartCanvas 架构、8 个图表组件、Chart.js 插件 |
| boards.md | 复合块:4 个看板、6 个抖动可视化、机群地图 |
1. 库的定位
收展示,不收行为。 一条判据:组件能不能在只给它 data 的前提下完整渲染?
| 归库 | 归 app |
|---|---|
| 版式、图表、徽章、表格、地图 | 取数与轮询 |
| 组件自有的文案与语义 | 鉴权与令牌 |
| 设计令牌与视觉一致性 | URL 状态、路由 |
| 组件自有的交互(排序、翻页、缩放、展开) | toast、确认框、错误翻译 |
因此库里没有 api.ts、没有 poll()、没有 goto()。同一个看板既能挂在控制台, 也能挂在只读的门户上,正是因为它两样都不认识。
2. 目录:按组件类型分,不按使用方分
目录不得出现 control/ / peering/ 这类划分。同一个组件通常多个站都在用, 按站分类第二天就会失效,还会诱导出「control 专用组件」这种伪概念——那正是共享库要 消灭的东西。
| 目录 | 收什么 | 成员 |
|---|---|---|
primitives/ | 原子件 | Icon、Tooltip、IconButton、AsLink、HeadInfo |
forms/ | 表单控件 | SegControl、Toggle、CapCheck |
layout/ | 容器与外壳 | AppShell、Widget、CardHead |
feedback/ | 状态反馈 | StatusBadge、HealthBadge、LiveDot、EmptyState、InlineBanner、Skeleton、SkeletonRows、SkeletonText、SkeletonTable |
data/ | 表格与分页 | AsRankTable、Pager、LoadMore |
charts/ | 图表与仪表 | ChartCanvas、TrendChart、Sparkline、BarChart、Donut、HalfDonutGauge、RateGauge、ShareBar、ChartLegend、chartjs.ts |
boards/ | 整块仪表盘区块 | FleetTraffic、FleetRouting、AsTraffic、FlapStatsPanel |
flaps/ | BGP 抖动可视化 | PrefixFlapBoard、PrefixFlapTable、RootCauseBadge、RootCauseSummary、CulpritRef、AsPathStrip、flapZones.ts |
map/ | 机群地图与地理 | FleetMap、geo.ts |
styles/ | 设计令牌与全局样式 | app.css |
vendor/ | 第三方 vendored 产物 | cap/(见 internals/captcha.md) |
尚未 populated 的目录等对应组件迁进来时再建,不预先留空壳。
分层是清晰的:primitives 被所有层用;charts 用 primitives;boards / flaps / map 用前面全部。同层之间尽量不互相依赖——AsRankTable 是唯一的例外(它同时用了 Widget、Pager、SegControl、AsLink、SkeletonRows),因为它本身就是一个成品 表格块。
3. 导入路径
组件从桶文件出:
ts
import { Widget, TrendChart, FleetMap, AsRankTable } from '$ui';
import type { NavItem, IconName } from '$ui';横切模块走各自的子路径,不从桶文件出:
ts
import { setUiHost } from '$ui/host.svelte';
import { fmtBytes, relTime } from '$ui/format';
import type { FleetMapNode } from '$ui/types';分开有两个原因:
- 让纯
.ts模块(例如 app 里的$lib/format垫片)不必把.svelte组件一并拖进 依赖图; - 避免桶文件的副作用连坐。桶里 re-export 了
CapCheck,它的?url资源 (Cap 的 WASM 与 pako)会被一并 emit 到产物目录——JS 侧摇得掉,文件却留下了。 首页的 fleet 岛为此直接引$ui/host.svelte而不走桶文件。
加带
?url资源的组件时记住这条:它会影响所有导入$ui的产物。
4. 横切模块
host.svelte.ts — 宿主契约
见 contracts.md。
i18n.svelte.ts — 库自带字典
四种语言(en / zh / zh-Hant / ja),覆盖库内组件自有语义的 key (topo.*、fstat.*、routing.*、pflap.*、health.*、live.*、st.*、chart.*)。
导出 tUi / lookupUi,供 app 需要与组件共用同一条措辞时使用,避免两份字典改词后 自相矛盾。查不到的 key 回落到宿主字典;再查不到就原样回显 key——可发现的软失败。
分工的完整理由见 internals/i18n-and-theming.md。
format.ts — 格式化工具
| 函数 | 用途 |
|---|---|
parseTs(v) | ISO 串或 epoch 整数 → Date | null。两种后端都在发,统一从这里进 |
fmtTime(v) | 本地时间串 |
relTime(v) | 相对时间(「3 分钟前」),走库字典的 rel.* |
fmtNum(n) | 千分位 |
fmtCompact(n) | 紧凑记数(13.0k;中日文按万/億) |
fmtBytes(n) | 字节 |
fmtRate(n) | 速率(bit/s) |
fmtPerSec(n) | 次/秒 |
fmtDurationHMS(s) | 秒 → hh:mm:ss |
LIVE_CLS | liveness → 徽章色类的映射常量 |
parseTs存在的理由是一次真实事故:门户的TrafficPoint.t是 epoch 秒, 曾被当成 ISO 串处理——旧图喂new Date()不炸但 x 轴显示 1970 年,新图喂.trim()当场抛异常、整张图画不出来。时间戳一律经parseTs。
types.ts — 共享数据形状
后端响应的镜像类型,供库组件与 app 共用(FleetMapNode、FleetTrafficBreakdown、 TrafficByAs、FleetPrefixFlapsGrouped、FlapZone 等)。口径以 接口契约 为准。
5. 样式
ui/styles/app.css 是设计系统的真身。三个 SvelteKit / Vite 站的 app.css 都只是 @import 它再补自己的私有规则;首页内联了同源的一份。
库里组件的 scoped style 全都假设那套令牌存在(--c-data-*、--c-ok/warn/bad、 --fs-*、--radius-*、--border、--text-dim)。缺令牌的表现是「组件渲染成无色」 ——门户此前维护 trimmed 拷贝时就是这样。
令牌全表见 design-tokens.md。
app.css 里还有一批全局类是库与 app 共用的锚点:.widget / .badge / .seg / .segbtn / .iconbtn / .pair / .kv / .empty。组件的 scoped style 挂在这些 类上,因此改全局类会同时影响两边。
6. 相关文档
- 加组件的检查清单 → guides/add-ui-component.md
- 端到端走一遍改动闭环 → tutorials/shared-component-walkthrough.md
- 库为什么不能反向依赖 app → internals/architecture.md