外观
基础件目录
primitives / forms / layout / feedback / data 五层的组件与 props。 图表见 charts.md,整块看板见 boards.md。
约定:? = 可选 prop;⇄ = $bindable;snippet 参数写在类型里。
primitives —— 原子件
Icon
统一的线性图标集(Lucide 派生,MIT)。24px 网格、1.75 描边、currentColor——尺寸与 颜色由调用点决定,这样整套 UI 共享同一套图标语言,而不是各处凑字形。
svelte
<Icon name="nodes" size={16} />IconName 是一个联合类型(约 40 个名字,从 dashboard、nodes 到 wireguard、 bird)。加图标要改这个类型 + PATHS 表,这是有意的摩擦:图标集应当有人把关。
wireguard 是唯一按实心填充绘制的(品牌标),其余都是描边图标。
Tooltip
包一层 bits-ui 的 tooltip。
| prop | 类型 | 说明 |
|---|---|---|
label | string | 提示文字 |
enabled? | boolean | false 时裸渲染 trigger、不挂提示——用于「只在侧栏折叠时才提示」 |
side? | 'top'|'right'|'bottom'|'left' | 默认 right |
trigger | Snippet<[Record<string, unknown>]> | 收到要展开到自己元素上的 props |
trigger 是 snippet 而不是 children,这样真正的触发元素仍然是调用方的 <a> / <button>——语义与键盘可达性不被包装层吃掉。
需要树上有一个
<Tooltip.Provider>(AppShell已经提供)。
IconButton
顶栏图标控件的唯一形态(全局 30px .iconbtn 结构)。总是带 Tooltip; 传 choices 时点击展开 bits-ui 的 DropdownMenu,项按 value 单选打勾。
| prop | 类型 | 说明 |
|---|---|---|
icon | IconName | — |
label | string | tooltip 文字 + aria-label |
side? | 方位 | 默认 bottom |
active? | boolean | 开关态,渲染 aria-pressed(仅普通模式) |
spin? | boolean | 一次性旋转动效 |
off? | boolean | 「已关闭」外观:淡化 + 斜杠 |
choices? | IconChoice[] | 菜单模式的选项 |
value? | string | 菜单模式的当前选中项(打 ✓) |
onselect? / onclick? | 回调 | 菜单 / 普通模式 |
它统一了此前分裂的三套行为:自动刷新开关(Tooltip 化的 toggle)、语言切换器 (原生 title 的 Select trigger)、主题切换器(CSS hover 展开的菜单)。一套交互语法。
AsLink
指向 DN42 registry explorer 的外链。explorer URL 模板与外链卫生(target / rel) 只在这一处。
| prop | 类型 | 说明 |
|---|---|---|
asn | number | — |
name? | string | null | 运营者名 |
showName? | boolean | 渲染名字(回落 AS{asn})而不是号码 |
title? / cls? | string | 原生 title / 额外类名(截断助手等) |
HeadInfo
表格列头的「ⓘ」提示。悬停或聚焦展示该指标的口径定义。
svelte
<th>抖动分 <HeadInfo label={t('pflap.scoreTip')} /></th>存在的理由:让 th 的文字继续承担排序与标签职责,把「这个数是什么意思」交给图标, 不让列头变成一大段解释。
forms —— 表单控件
SegControl
分段视图切换器(Radar 卡角的「两者 / IPv4 / IPv6」控件)。
ts
options: { value: string; label: string }[];
value: string;
onchange: (v: string) => void;值一律是 string——联合类型的调用方在自己的 onchange 里转型。理由见 contracts.md 准则四。
Toggle
带标签的开关(Radar 的橙色「最小/最大比例」开关):左文字、右滑轨。
ts
label: string;
checked?: boolean; // ⇄ bindableCapCheck
人机验证控件。解题引擎是 vendored 的上游 Cap,界面全部是自绘的,用设计令牌画, 和库里其他控件一样。
| prop | 类型 | 说明 |
|---|---|---|
endpoint | string | Cap 实例 + site key,含结尾斜杠 |
labels | 对象 | initial / verifying / solved / error / blocked? / loadFailed。文案归宿主 |
onToken | (token: string) => void | 解出票时带 token 触发;reset / 出错时带 '' |
onLoadError? | () => void | 解题器 chunk 取不到时触发一次 |
自绘界面换来的三件事:不必透过第二套 CSS 变量去 theme 一个 shadow root;切语言不重挂 (文案是普通 prop);没有字标要对抗。
⚠️ 票据一次性,前端不做预校验(会烧掉 token)。背景见 internals/captcha.md。
layout —— 容器与外壳
AppShell
应用外壳:整行顶栏(品牌永远不随侧栏折叠)+ 可折叠侧栏(图标轨 / hover 暂展开 / 手机抽屉)+ 内容区。控制台与门户共用同一份,两个站的骨架、间距、断点因此不会各自漂移。
| prop | 类型 | 说明 |
|---|---|---|
nav | NavItem[] | 带 children 的是可折叠分组,否则是叶子链接 |
pathname | string | 当前路径。库不能 import $app/state,由宿主传 |
crumbs? | {label, href?}[] | 面包屑;最后一项渲染为当前页(不可点) |
pageDesc? | string | 页面描述,挂在面包屑旁的 info 气泡里;空则不渲染 |
brandHref? / brandLabel? | string | 默认 / / NATLAN |
collapsed? | boolean ⇄ | 侧栏折叠态。宿主自己持久化(两站的 localStorage 键不同) |
logo? / actions? / sidebarFoot? | Snippet | 品牌标 / 顶栏右侧控件 / 侧栏底部 |
children | Snippet | 内容区 |
ts
type NavLeaf = { href: string; key: string };
type NavItem = { key: string; icon: IconName; href?: string; children?: NavLeaf[] };key 是 i18n key(nav.overview 之类),走 t()——库字典查不到会回落到宿主字典。 这正是「通用词汇归 app」那条分工的用处。
hover peek:折叠成图标轨后,悬停把侧栏以浮层展开(脱离文档流,内容列保持 64px 偏移),移开还原。只有折叠按钮会真正改变布局宽度。用折叠按钮收起后若指针还停在 侧栏内,要等它离开过一次才允许 peek——否则一收起就立刻又展开。
Widget
Radar widget 卡片——每个仪表盘区块都用这一种解剖结构:
┌ 标题 (+ 可选 ↗ 深链) [控制簇] ┐
│ 描述 │
│ …内容… │
└ 更新于 …(可选) ┘| prop | 类型 | 说明 |
|---|---|---|
title | string | — |
sub? | string | 标题下的一行灰色描述 |
count? | number | 标题后的淡色 (n) |
icon? | IconName | — |
href? | string | 深链,渲染 Radar 式的橙色 ↗ 跳转箭头 |
asof? | string | 左下角「更新于 …」戳 |
cls? | string | 页面级尺寸控制的额外类名 |
controls? | Snippet | 右上控制簇(分段控件、下拉、按钮) |
children | Snippet | 内容 |
标题 / 描述 / 控制簇 / as-of 的位置在这里被钉死,页面不再各自手写头部行。
asof 不是装饰:dashboard 有服务端缓存、routing 采集有分钟级延迟,「这个数字是几点的」 直接决定可信度。
CardHead
Widget 的轻量兄弟——卡内的分节标题。节点详情的各页签与面板用它。
| prop | 类型 | 说明 |
|---|---|---|
title | string | — |
count? | number | 标题后的淡色 (n) |
level? | 3 | 4 | 标题层级,嵌套子节用 4 |
icon? | IconName | 前置强调图标 |
loading? | boolean | 取数中禁用刷新按钮 |
onrefresh? | () => void | 传了才渲染标准的图标刷新按钮 |
controls? | Snippet | 额外右侧控件 |
选哪个:整块仪表盘卡片用 Widget;卡片内部或页签内部的分节用 CardHead。
feedback —— 状态反馈
StatusBadge 与 HealthBadge
两个不能互换:
StatusBadge | HealthBadge | |
|---|---|---|
| 知不知道词汇表 | 不知道 | 知道 |
| 输入 | cls(色调)+ label(成品文案) | value(后端原值) |
| 谁做映射 | 调用点(页面特定的业务逻辑) | 组件(内建两张表 + 本地化) |
| 用在哪 | 任意状态胶囊 | 机群 health / report·apply status |
svelte
<StatusBadge cls="warn" label={t('flaps.middleBand')} />
<HealthBadge value={node.health} muted={node.liveness === 'offline'} />HealthBadge 内建两张表:health(ok / stale / degraded / down / unknown / bad)与 status(succeeded / failed / skipped / running / pending), 无法识别的值回落成中性 + 原样显示。
muted 把徽章淡化,用于表示「这是陈旧的 / 最后已知值」(例如已断连节点的 report 状态)。
LiveDot
活性圆点,用图表色板(--c-*)着色,因此与地图标记、状态图在两个主题下都一致。
ts
status?: 'ok' | 'stale' | 'down' | 'unknown'; // 默认 unknown
size?: number; // 默认 7
title?: string;InlineBanner
图标 + 着色细条。默认形态(不传任何 prop)就是「刷新失败——显示最后已知数据」, 即 keep-last-good 在陈旧内容上方渲染的那条。
| prop | 类型 | 说明 |
|---|---|---|
message? | string | 默认为 stale-data 文案 |
detail? | string | 消息后的截断灰字(错误原文,悬停看全) |
tone? | 'warn' | 'bad' | 'ok' | 'neutral' | 默认 warn,决定默认图标 |
icon? | IconName | 覆盖默认图标 |
children? | Snippet | 富文本消息,优先于 message |
这是交互契约 2 与 5 的落点:加载错误走就地 banner,数据保留,而不是用错误卡片 替换整张表。见 internals/interaction-contracts.md。
EmptyState
居中图标 + 标题 + 提示 + 可选 CTA。用于「暂无数据」占位,不用于临时加载文案。
ts
icon?: IconName; // 默认 'dashboard'
title: string;
hint?: string;
actionLabel?: string;
onaction?: () => void;契约 6 规定:空状态只允许用它,不写裸 .empty div。
抖动板块的空态要写成正向文案(「近期无会话抖动」「全网路由平稳」)——空列表在那里 是机群健康的信号,不是缺数据。
Skeleton 四件套
| 组件 | 形状 | 主要 props |
|---|---|---|
Skeleton | 单个微光块 | w / h / radius / circle |
SkeletonRows | N 条等宽横条 | count(默认 6)/ h / gap |
SkeletonText | 段落形(最后一行短) | lines(默认 3)/ gap / height |
SkeletonTable | 表格形(可选表头 + 按列宽的单元格) | headers? / cols(长度即列数)/ rows |
SkeletonTable 的 cols 传每列的 CSS 宽度,让占位与真实列宽对齐——加载态因此能平滑 交叉淡入到内容,而不是跳变。
契约 6 规定:骨架屏只允许这四个,不手写骨架行。
data —— 表格与分页
AsRankTable
Radar 的「前 100 个 AS」排名表,做成可复用的: [排序依据分段控件] + [图标搜索] + 排名/ASN/名称/指标列 + ⏮◀▶⏭ 分页 + as-of 戳。 筛选、排序、分页全在客户端。
两种外壳:传 title 渲染成完整的 Widget 卡片(控制簇落在卡头,与 Radar 一致); 省略则是裸表格,嵌进别的卡片里(例如图表旁边)。
| prop | 类型 | 说明 |
|---|---|---|
rows | T[] | 全量行集,组件自己筛选/排序/分页 |
metrics | AsMetric<T>[] | ≥1;>1 时出现分段控件,激活的指标驱动列头、取值与排序 |
title? | string | 传了就包 Widget 卡 |
sub? | (metricLabel) => string | 卡片描述,随激活指标变 |
searchable? | boolean | 图标搜索框;同时匹配 ASN 与名称,并会剥掉输入里的 AS 前缀 |
pageSize? | number | 默认 20 |
edges? | boolean | 分页加首页/末页跳转(Radar 大表变体) |
asof? / loading? / emptyText? | — | |
headerExtra? | Snippet | 额外卡头控件 |
ts
interface AsRow { asn: number; name: string | null; }
interface AsMetric<T> { key: string; label: string; value: (r: T) => number; format?: (v: number) => string; }name 应当是注册表 as-name(权威),调用方在传入前解析好。
Pager
共享分页器(Radar Top-N 表脚):⏮ ◀ ▶ ⏭ + 「第 x / y 页」。
ts
page: number; // 零基
pages: number;
onchange: (p: number) => void;
edges?: boolean; // 首/末跳转
label?: string; // 前置元信息(如「共 X 条」)LoadMore
追加式列表(审计日志、状态事件)的居中游标分页脚。与 Pager 分工明确: Pager 管页码导航,LoadMore 管「加载更早」。
ts
hasMore: boolean;
loading: boolean; // 列表的 loadingMore 标志,不是首载标志
onload: () => void;
label: string;loading 必须传独立的「加载更多中」标志——用首载标志会让整张表在追加时闪成骨架。