Files
UI/packages/tools/cli/recipes.md
T

33 KiB
Raw Blame History

组合配方(Recipes)

单个组件的参数表说明「怎么用」,这些配方说明「怎么拼」。所有配方都只消费组件对外 API,不依赖内部文件。

配方 1:表单校验 + 提交反馈

const name = signal('');
const email = signal('');
const formRef = form({ onSubmit: (ok) => (ok ? result({ status: 'success', title: '已保存' }) : toast.error('请检查表单')) }, [
  formItem({ label: '名称', value: name, required: true, rules: [{ min: 2 }] }, textField({ value: name })),
  formItem({ label: '邮箱', value: email, required: true, rules: [{ pattern: /^\S+@\S+$/ }] }, textField({ value: email })),
]);

要点:

  • 校验规则挂在 formItem,值信号只有一个来源(value 同时给 formItem 与控件),避免两处各存一份。
  • 提交结果用 toast(轻量、瞬时)或 result(整页终态)表达,不要两者同时弹。

配方 2:危险操作二次确认

const open = signal(false);
const dangerOpen = (key: string) => {
  open(true);
  return modal({
    title: `确认删除 ${key}?`,
    open,
    size: 'sm',
    onClose: () => open(false),
    footer: h('div', { class: 'row' },
      button({ label: '取消', variant: 'ghost', onClick: () => open(false) }),
      button({ label: '删除', variant: 'danger', onClick: () => { open(false); toast.success('已删除'); } })),
  }, h('p', null, '删除后不可恢复。'));
};

要点:

  • modal 自带焦点陷阱与 Esc 逐层关闭,不要在业务层再挂 Esc 监听。
  • 确认按钮用 variant: 'danger',取消用 ghost,主次分明。

配方 3:表格筛选 + 分页 + 批量操作

const picked = signal<string[]>([]);
const page = signal(1);
dataTable({
  data: rows,
  columns: [{ key: 'name', header: '名称', sortable: true }, { key: 'city', header: '城市' }],
  pageSize: 10,
  page,          // 同一 page 信号也给 pagination,二者自动联动
  selectable: true,
  selected: picked,
  loading: saving,
});
pagination({ page, pageSize: 10, total: rows.length, jumpable: true, onChange: (p) => page(p) });

要点:

  • page 信号是表格与分页器的唯一共享状态;外部改页码前自己钳制范围(core 的 setPage 不做钳制)。
  • 选中一律存 rowKey,翻页/筛选后仍然稳定;全选只作用于当前可见行。
  • 批量操作按钮在 picked().length === 0 时禁用,避免空提交。

配方 4:长列表数据加载与空态

const loading = signal(true);
const items = signal<ListItem[]>([]);
list({ items: items(), bordered: true, emptyText: loading() ? '加载中…' : '暂无数据' });

要点:

  • 空态文案随加载态切换;列表本身会在 items 为空时渲染 empty,不需要业务层再判断一次。
  • 需要骨架屏就换成 skeleton,加载完成再 renderList 成真实行。

配方 5:数据表 + 独立分页器 + 导出

const page = signal(1);
const table = dataTable({
  data: rows,
  columns: [
    { key: 'name', header: '姓名', sortable: true, width: 160 },
    { key: 'city', header: '城市', align: 'center', width: 'flex' },
  ],
  page,                        // 受控页码:与外部 pagination 双向联动
  selectable: true,
  exportable: true,            // 导出筛选 + 排序后的全量行(不是当前页)
  exportName: '用户列表.csv',
});
const pager = pagination({ page, pageSize: 5, total: rows.length, jumpable: true, pageSizes: [5, 10, 20] });

要点:

  • 页码只保留一个来源:dataTable 与 pagination 共传同一个 page 信号,翻页由内核写回,桥接层会钳制越界值。
  • exportable 导出的是「筛选 + 排序后、未分页」的全量行,列序与表头一致、单元格走 format;想导出当前页请自行读 getVisibleRows()。
  • 列宽拖拽只对 width 给数字的列生效('flex' 列交给浏览器自适应);双击手柄回到声明宽度。
  • 行选择一律按 rowKey 记账,筛选换掉可见行后已选仍在,批量操作前用已选计数确认。

配方 6:长表单编辑页(吸底操作条 + 提交锁定)

const saving = signal(false);
form(
  {
    onSubmit: (ok) => {
      if (!ok) return; // 失败时汇总条已定位到第一处错误,无需自己数红框
      saving(true);
      save().then(() => toast.success('已保存')).finally(() => saving(false));
    },
  },
  [
    formItem({ label: '标题', value: title, required: true, rules: [{ max: 20 }] }, textField({ value: title, maxCount: 20 })),
    formItem({ label: '权重', value: weight, hint: '0–100%,一位小数' }, inputNumber({ value: weight, min: 0, max: 100, step: 0.1, precision: 1 })),
    formItem({ label: '上架', value: online }, switchButton({ label: '保存后自动上架', checked: online, loading: saving })),
  ],
);
affix({ position: 'bottom', offset: 0 }, h('div', { class: 'row' },
  button({ label: '保存', type: 'submit', disabled: saving }),
));

要点:

  • 长表单的操作条用 affix({ position: 'bottom' }) 贴底固定,翻到表单底部按钮也不丢;提交按钮 type: 'submit' 走 form 聚合校验。
  • 校验失败时 form 顶部的汇总条会播报「有 N 项需要修改」并聚焦第一处(errorSummary: false 可关);hint 写格式说明,报错时自动让位。
  • 提交期间用同一个 saving 信号锁三处:按钮 disabled、开关 loading(转圈 + aria-busy)、业务请求出口,防重复提交与中途改状态。
  • 长度约束一处写够:textField 的 maxCount 设原生上限并实时计数,formItem 的 rules: [{ max }] 只做提交兜底,两处数字保持一致。

配方 7:筛选栏(层级归属 + 日期区间)

const dept = signal('');
const from = signal('');
const to = signal('');
space({ size: 'sm', wrap: true },
  treeSelect({ nodes: orgTree, value: dept, searchable: true, placeholder: '归属部门' }),
  datePicker({ range: true, value: from, end: to, onRangeChange: (a, b) => reload(a, b) }),
  button({ label: '重置', variant: 'ghost', onClick: () => { dept(''); from(''); to(''); } }),
);

要点:

  • 层级用 treeSelect(允许选中间节点、可搜索),只有「必须逐级选到叶子」时才用 cascader。
  • 日期区间传 range: true:组件内部保证先后顺序(用户点早了会自动交换),onRangeChange 拿到的就是排好序的两端。
  • 重置把三个信号清空即可:组件都是受控的,值一变界面自己回到占位态。

配方 8:评论输入(@ 提及 + 参与人头像组)

const text = signal('');
const members = [{ name: 'zhangsan', label: '张三', description: '前端', keywords: ['zs'] }];
card({ title: '评审讨论', extra: avatarGroup({ items: members, max: 4 }) },
  mentions({ items: members, value: text, placeholder: '输入 @ 叫人评审…', onSelect: (m) => toast.info(`已提及 ${m.label}`) }),
  space({ size: 'sm', justify: 'end', block: true },
    button({ label: '发送', onClick: () => { toast.success('已发送'); text(''); } })),
);

要点:

  • 参与人头像用 avatarGroup:超出 max 自动折叠成 +N,整组 title 列出全部姓名,比并排 avatar 省一半横向空间。
  • mentions 的 value 是受控信号:发送后 text('') 即清空,不要在组件外部再改 textarea。
  • 候选人来源由调用方决定(items 可随 onInput 远程拉取后重建),组件不做远程搜索。

配方 9:详情页排版(栅格 + 排版文本 + 描述列表)

grid({ columns: 2, gutter: 'lg' },
  card({ title: '基本信息' },
    typography({ variant: 'title', level: 4, content: '订单 RB-2048' }),
    descriptions({ column: 2, items: [
      { label: '金额', value: '¥1,280.00' },
      { label: '状态', value: tag({ label: '待发货', variant: 'warning' }) },
    ] }),
  ),
  card({ title: '备注' },
    typography({ variant: 'paragraph', ellipsis: 3, content: longNote }),
    typography({ content: 'RB-2048-0917', code: true, copyable: true }),
  ),
);

要点:

  • 页面骨架用 grid(columns 固定列 / min 走 auto-fit),窄屏自动单列,不要在调用方写媒体查询。
  • 标题一律 typography({ variant: 'title', level }) 出语义 h1-h5;正文长文本用 ellipsis 截断并自动挂 title 看全文。
  • 只读字段用 descriptions(不要用 form):form 承载输入与校验,descriptions 只负责 label:value 对齐。

配方 10:暗色主题切换(只切 data-theme,不改令牌名)

const saved = localStorage.getItem('ua-theme');
const dark = signal(saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches);
effect(() => {
  document.documentElement.dataset.theme = dark() ? 'dark' : 'light';
  document.documentElement.style.colorScheme = dark() ? 'dark' : 'light'; // 原生控件(date/滚动条)跟着变
  localStorage.setItem('ua-theme', dark() ? 'dark' : 'light');
});
floatButton({ label: '切换主题', onClick: () => dark(!dark()) });

要点:

  • 只改 [data-theme] 的取值:暗色是令牌值的另一套,名字永远不变,组件样式一律不写主题判断。
  • 初值顺序是「先 localStorage、再 prefers-color-scheme」;反过来会让用户的手动选择被系统偏好覆盖。
  • 同步设置 color-scheme,否则原生控件(日期选择、滚动条)在暗色下仍是白底。

配方 11:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉)

const price = signal(200);        // 价格下限
const priceTop = signal(800);     // 价格上限
const from = signal('');          // 日期区间起点
const to = signal('');            // 日期区间终点
const stack = signal('');         // 技术栈(分组下拉)

grid({ columns: 3, gutter: 'md' },
  slider({ value: price, end: priceTop, range: true, min: 0, max: 2000, step: 50 }), // 双滑块:两端都受控
  datePicker({ range: true, value: from, end: to, presets: [{ label: '最近 7 天', days: 7 }, { label: '最近 30 天', days: 30 }] }),
  select({ value: stack, options: [
    { value: 'vue', label: 'Vue 3', group: '前端框架' },
    { value: 'react', label: 'React 19', group: '前端框架' },
    { value: 'node', label: 'Node.js 22', group: '服务端运行时' },
    { value: 'deno', label: 'Deno 2', group: '服务端运行时' },
  ] }),
);

dataTable({ columns, data: rows, selectable: true }); // 表格只吃派生出来的查询条件

要点:

  • 三个筛选控件全部受控于信号:查询参数从信号派生(computed),表格不另存一份筛选状态,否则「重置」要逐处清。
  • 区间类控件都自带先后与越界修正:slider 的 value/end 越界会压平,datePicker 点早了会自动交换两端——调用方不要再写 clamp。
  • 长候选集给 option.group 分块、常用时间段给 presets(days 相对今天,跨月跨年由组件算),比让用户逐行扫列表更快。

配方 12:命令面板(⌘K 全局操作入口)

const cmdkOpen = signal(false);   // 面板显隐:快捷键与按钮共用同一份状态
const cmdk = commandPalette({
  open: cmdkOpen,
  items: [
    { id: 'new-order', label: '新建工单', group: '快捷操作', hint: '⌘N' },
    { id: 'export', label: '导出当前列表', group: '快捷操作' },
    { id: 'invite', label: '邀请成员', group: '协作', keywords: ['yaoqing', 'yao qing'] },
    { id: 'theme', label: '切换暗色主题', group: '偏好', keywords: ['dark'] },
  ],
  onSelect: (item) => {
    if (item.id === 'theme') return toggleTheme();       // 立即型命令:自己带副作用
    if (item.id === 'invite') return inviteModal.open(); // 需要表单的交给已有弹窗,面板只做入口
    toast.success(`已执行:${item.label}`);               // 其余执行完给一次轻反馈
  },
});
document.body.append(cmdk.el); // 面板是 fixed + 遮罩,挂 body 最省事

要点:

  • 用 id 做命令分发键、label 只负责显示:以后改文案不会打断路由;同一 id 也是埋点的事件名。
  • 过滤看三层 label / hint / keywords:拼音与别名塞 keywords(如 yaoqing),用户没切输入法也能命中。
  • 同组必须连续排列才会合并成一段标题,items 顺序即展示顺序,组件不排序——按「快捷操作 / 跳转 / 偏好」排好再传。
  • 需要二次确认的命令在 onSelect 里开 modal / popconfirm,别在面板内部再叠一层确认,否则 Esc 栈会有两层。
  • 一页只留一个实例用缺省 hotkey(⌘K / Ctrl+K),其余传 hotkey: false 自己绑,否则一次按键会同时开两个面板。
  • 命令集随权限变化时重建实例(items 是创建时快照),不要往原数组里 push。

配方 13:预约时点(日期 + 时间两段拼装)

import { computed, signal } from '@ui-arch/frame';
import { datePicker, form, formItem, timePicker, toast } from '@ui-arch/ui';

const day = signal('');        // 日期段:YYYY-MM-DD
const clock = signal('09:00'); // 时间段:HH:mm(给初值,用户只需要改自己想改的那半)
const at = computed(() => (day() ? `${day()} ${clock()}` : '')); // 拼装交给业务层:两个组件各管一段,互不知道对方

const dateField = formItem(
  { label: '预约日期', value: day, required: true },
  datePicker({ value: day, min: new Date().toISOString().slice(0, 10) }), // 早于今天的日期直接置灰,比事后校验更省事
);
const timeField = formItem(
  { label: '预约时间', value: clock, required: true, hint: '每 15 分钟一档' },
  timePicker({ value: clock, minuteStep: 15 }), // 步长只影响分列,时列始终 24 档
);

form({
  layout: 'horizontal',
  submitText: '确认预约',
  items: [dateField, timeField],
  onSubmit: () => toast.success(`已预约 ${at()}`), // 两段都非空才会走到这里:required 已经拦住半截状态
});

要点:

  • 两段各管一段:datePicker 只写 YYYY-MM-DD、timePicker 只写 HH:mm,拼成完整时点交给业务 computed——不要试图让某个组件同时产出日期与时间,否则一半值缺失时格式全靠猜。
  • 时间给初值:clock 预置 09:00 这类合理默认,用户多数只想改小时;日期保持空值,避免「默认今天」被误提交。
  • 约束优先于校验:日期上限下限用 datePicker 的 min / max 置灰,时间粒度用 minuteStep 收窄,required 只兜底空值——能点不出来的选项比报错文案更友好。
  • 半截状态别提交:form 的 required 依赖各自 value 信号,日期没选时提交会被拦下并定位到该字段;不要自己写 if (!day()) return 之类的旁路判断。
  • 需要区间就用 datePicker({ range: true }) + 两个 timePicker(起 / 止),拼装时各自独立,别指望一个信号同时表达两端。

配方 14:标签筛选条 → 表格联动

import { computed, signal } from '@ui-arch/frame';
import { button, dataTable, inputTag, select, space } from '@ui-arch/ui';

const tags = signal<string[]>(['本地推理']); // 条件条:可增删的多个标签(「且」关系)
const city = signal('all');                   // 地区:单值下拉,all 表示不限
const rows = signal(listFromApi());           // 全量数据集(轮询 / 搜索时整体替换)

// 派生行集合:信号一变就重算,直接喂给表格的信号数据源
const matched = computed(() =>
  rows().filter((row) =>
    (city() === 'all' || row.city === city()) &&
    tags().every((need) => row.tags.includes(need)), // 多标签是「且」:逐条都要命中
  ),
);

space({ gap: 'sm' }, [
  inputTag({ value: tags, max: 5, placeholder: '按标签筛选,回车添加' }),
  select({ value: city, options: [{ value: 'all', label: '全部地区' }, { value: 'sh', label: '上海' }] }),
  button({ label: '重置', variant: 'ghost', onClick: () => { tags([]); city('all'); } }),
]);

const table = dataTable({ data: matched, columns, pageSize: 10 }); // 信号数据源:换源不重置排序与页码

要点:

  • 筛选在信号层做,不在表格里做:dataTable 的工具栏筛选是「关键字单列」的轻量搜索,多条件组合交给 computed 派生行集合,表格只负责渲染当前结果。
  • 给表格传信号而不是数组:data: matched(Computed<T[]> 同样是 Signal<T[]>)换源时内核保留排序、每页行数、当前页,并按 rowKey 剔除已消失行的选中态——手写 replaceChildren(dataTable(...)) 会把这些状态全部丢掉。
  • 条件条本身就是一个受控信号集合:inputTag 的 value 就是唯一数据源,删除 chip 与增删条件走同一出口,别在业务层再存一份「当前条件」。
  • 「且」还是「或」写在派生里:every 是且(收窄)、some 是或(放大),两种语义只差一个方法调用,但决定了空条件时的行为——空数组 every 恒为真(等于不限),some 恒为假(等于无结果),重置逻辑据此写。
  • 结果为空时不要另写空状态:dataTable 已内置空行提示,筛选无命中会自己显示「没有匹配的行」。

配方 15:表格分页与独立分页器联动(每页条数 + 受控页码)

表格自带的分页条(上一页 / 页码播报 / 下一页 / 每页条数)只够单表自用;需要把页码提到页面上方、或让多个表格共用一个分页器时,用 page 信号把两边的页码接起来,条数候选交给 pageSizes。

const page = signal(1);                 // 唯一页码来源:表格与独立分页器共读共写
const size = signal(10);                // 每页条数由业务决定,表格只负责渲染

const table = dataTable({
  data: rows,                           // 数组或信号均可;换源保留排序、页码与选中态
  columns,
  page,                                 // 受控页码:内部翻页实时写回,外部置值即跳页
  pageSize: size(),
  pageSizes: [10, 25, 50],              // 分页条候选档;当前值不在候选里会自动补进去
  selectable: true,
});

pagination({ page, total: () => rows().length, pageSize: size() }); // 独立分页器与表格共用同一页码信号

要点:

  • 一个页码只有一个来源:page 信号既是表格的内部页码,也是独立分页器的输入;两边各自派发、等值写回短路,不会互相推着翻页。
  • 改页大小要回到第 1 页:pageSize 变化时表格内部按内核语义回第 1 页,外部只需把 size 改掉,别在业务层再手动 page(1)(顺序错了会出现「先跳到 25 条的第 3 页再被推回第 1 页」的闪动)。
  • 越界页码由内核收敛:数据缩水后 state 里的页码会被回写成实际生效页,数据恢复也不会突然跳回旧页码——所以外部依赖这个信号做联动时不需要自己钳制。
  • 每页条数下拉要给名字:无可见 label 的场合(分页条、工具栏)传 ariaLabel,否则读屏只念出一句「10 条/页」而没有主语。

配方 16:后台页骨架

layout(
  {
    header: h('div', { class: 'row' }, breadcrumb({ items: [{ label: '订单' }, { label: '列表' }] })),
    sider: menu({ items: [{ key: 'overview', label: '概览' }, { key: 'orders', label: '订单' }] }),
    footer: '© 2026 内部系统',
    collapsible: true, // 页头左侧出现折叠开关
    sticky: true,      // 长内容页让页头吸顶
  },
  card({ title: '本月订单' }, dataTable({ data: rows, columns })),
)

要点:

  • 页头给 breadcrumb、侧栏给 menu、主体给 card:layout 只负责四区位置与页头吸顶,导航与内容各归其组件,别在 layout 上塞交互。
  • 折叠态想接到别处(例如只显示图标栏)就传 collapsed 信号读值;不给信号则内部自管,不必自己再 signal(false)。
  • 内容区自带滚动与统一细条,不要再套一层 overflow: auto——两层滚动容器会让吸顶与滚动条重复出现。

配方 17:表格刷新遮罩

const busy = signal(false);
const table = dataTable({ data: rows, columns, page, selectable: true });

const refresh = async () => {
  busy(true);
  try { await reload(); } finally { busy(false); } // 遮罩只跟 busy 走,异常也要收
};

loading({ active: busy, text: '正在刷新订单', minHeight: 240 }, table);

要点:

  • 遮罩包住表格而不是整页:用户看得见「哪一块在等」,其余区域仍可操作。
  • loading 默认延迟 120ms 出场——接口快于此不闪;别为了「一定看见」传 delay: 0,那会让每次快请求都闪一下。
  • 刷新期间不要把 data 换成空数组占位:loading 已负责等待感,清空数据会让页码与选中态无谓地收敛一次。

配方 18:变更审阅面板

const panel = h('div', { class: 'review' },
  tag({ label: '待审阅', variant: 'warning' }),
  codeBlock({ code: before, title: '旧版本', lang: 'ts', maxHeight: 220 }),
  diffView({ before, after }),
  copyButton({ text: after, label: '复制新版本' }),
);

要点:

  • 三段各司其职:codeBlock 看全文(带行号与复制)、diffView 只看变化、copyButton 把新版本带走——不要用 diffView 代替全文展示。
  • codeBlock 的行号不参与复制,copyButton 直接读原文,所以「复制出来的代码」永远干净。
  • 差异行多了给 diffView 外层限定高度(或放进抽屉),否则长差异会把审阅按钮挤出视口。

配方 19:可排序的检查单(拖拽 + 键盘搬位 + 顺序回写)

const steps = ['需求评审', '接口联调', '灰度发布', '线上回归'];
const order = signal(steps); // 顺序唯一真相:组件与下方摘要读同一个信号
const list = sortableList({
  items: steps,               // 文案即排序键,要求唯一
  value: order,
  onChange: (next) => toast.success(`已调整:${next[0]} 置顶`),
});
card({ title: '发布检查单(可拖动,Alt+↑↓ 键盘搬位)' },
  list.el,                    // 返回的是句柄不是节点:忘了 append(el) 页面上一片空白
  h('p', { class: 'desc' }, () => order().map((t, i) => `${i + 1}. ${t}`).join(' ')),
  button({ label: '把「灰度发布」置顶', variant: 'ghost', onClick: () => list.move(2, 0) }),
);

要点:

  • sortableList 返回 { el, move, order } 句柄(与 form / virtualList 同类):节点在 el 上,业务侧要改顺序用 move(from, to),读当前顺序用 order()。
  • 顺序只存一份:value 信号同时喂组件与摘要 / 上报逻辑,否则会出现「界面拖了、提交的仍是老顺序」。
  • move() 与拖拽走同一条提交路径(都会触发 onChange),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。
  • 拖到最近滚动容器的上下沿会按帧自动滚(感应带 48px),长清单一屏装不下也能拖到位;容器是整页滚动时组件不接管,交给浏览器。跨列表搬运用两个实例各持一个顺序信号。
  • 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案('确认条款 #1')。

配方 20:分步表单向导(步进条 + 按步校验 + 完成反馈)

const step = signal(0); // 当前步索引:stepper 与内容区共用一份信号
const name = signal(''); const phone = signal(''); const remark = signal('');
const account = formItem({ label: '账号名', required: true, value: name, rules: [{ min: 2, max: 12 }] }, textField({ value: name }));
const contact = formItem({ label: '手机号', required: true, value: phone, rules: [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }] }, textField({ value: phone }));
const note = formItem({ label: '备注', value: remark }, textarea({ value: remark }));
const pages = [[account], [contact, note], []]; // 每步的字段句柄,末页是完成页:推进校验与回跳重放都读这一份
const body = h('div', {});
effect(() => { body.replaceChildren(...pages[step()].map((f) => f.el)); }); // 步进即换页:只挂当前步的字段,末页自然留白
const last = pages.length - 1; // 末步下标:步进条、边界钳制与提交判断共用
const go = (delta) => { // delta>0 是「下一步」:先过当前步校验,不过就不换页
  if (delta > 0 && pages[step()].some((f) => !f.validate())) return;
  step(Math.min(Math.max(step() + delta, 0), last));
};
card({ title: '开通向导' },
  stepper({ steps: [{ label: '账号' }, { label: '联系' }, { label: '完成' }], active: step, clickable: true }),
  body,
  button({ label: '上一步', variant: 'ghost', disabled: computed(() => step() === 0), onClick: () => go(-1) }),
  button({ label: '下一步', onClick: () => {
    if (step() === last) { toast.success('已提交开通申请'); step(0); return; } // 末步即提交:同步校验已在前两步拦过,直接收尾归零
    go(1);
  } }),
);

要点:

  • stepper 的 active 与字段容器读同一个 step 信号:点步骤条回跳、点「上一步」、提交后归零都只改这一个信号,不存在两份步号。
  • 「下一步」先对当前步的 formItem 句柄逐个 validate(),有失败项就停在原地标红——把校验拦在步与步之间,不要攒到最后一步一起报错,用户会迷失在哪一步填错。
  • clickable: true 只放行已完成步骤的回跳,点不存在的未来步骤不会生效,所以回跳不会绕过校验;回跳后再次「下一步」会重放该步校验,已填过的值不会丢(信号还在)。
  • 换页用 body.replaceChildren() 重挂当前步字段:formItem 句柄是稳定对象,重复挂载不会重建控件,信号值与错误态都保留;末页句柄数组为空,replaceChildren() 空参即清空,不用特判。
  • 「下一步」按钮的 label 是静态的,想在末步显示「提交」就用两个按钮按 step() 条件渲染(h 里读信号的函数会跟随重渲染);上面的示例为省篇幅在 onClick 里判末步。
  • 末步提交前若还有异步规则(如邀请码查重),改用 formItem 的 validateAsync 聚合 await,校验期间禁用提交按钮;纯同步表单 validate() 已足够。
  • 提交成功给 toast.success 反馈并把 step 归零,向导可以立即重开;要保留数据就别归零,改跳一个只读的完成页。

配方 21:消息中心(铃铛角标 + 抽屉通知流)

const open = signal(false); // 抽屉开关:铃铛与抽屉共用一份
const notices = signal([ // 通知数据唯一真相:未读角标、列表、按钮禁用态都从它派生
  { title: '审批通过', description: '报销单 RB-2048 已打款', time: Date.now() - 5 * 60_000, read: false },
  { title: '库存预警', description: 'SKU-88 低于安全库存线', time: Date.now() - 26 * 3600_000, read: false },
]);
const unread = computed(() => notices().filter((n) => !n.read).length); // 未读数单一来源,不另立计数变量
const markRead = (title) => notices(notices().map((n) => (n.title === title ? { ...n, read: true } : n))); // 点单条置已读:只写数据源
const bellSlot = h('span'); // badge 的 count 是静态快照:未读数变化时重挂新实例
effect(() => bellSlot.replaceChildren(badge({ count: unread() })));
const bell = h('button', { class: 'ua-btn', onclick: () => open(true) }, '🔔 消息', bellSlot); // badge 不是按钮:点进中心的语义与键盘可达性交给外层 button
const feed = h('div');
effect(() => { // list 同样是静态快照组件:数据一变整个重建
  feed.replaceChildren(list({
    header: `未读 ${unread()}`,
    items: notices().map((n) => ({
      title: n.title,
      description: n.description,
      extra: h('span', { class: 'desc' }, relativeTime({ value: n.time, ariaLabel: '通知时间' }), n.read ? '' : badge({ label: '未读', variant: 'danger' })),
    })),
    onItemClick: (item) => markRead(item.title),
  }));
});
card({ title: '消息中心' },
  bell,
  drawer({ title: '消息中心', open, width: 380 },
    feed,
    button({ label: '全部已读', variant: 'ghost', disabled: computed(() => unread() === 0), onClick: () => notices(notices().map((n) => ({ ...n, read: true }))) }),
  ),
);

要点:

  • 未读数只有一个来源:unread 从 notices 派生,铃铛角标、列表头、按钮禁用态都读它——任何一处「自己再记一份未读数」都会在已读回写时漂移。
  • list / badge 返回 DOM 而非句柄,是静态快照组件,不自己订信号:接信号统一用「effect + replaceChildren 重挂」这一招;句柄式组件(drawer 的 open、button 的 disabled 收 Signal)才直接传信号。
  • badge 不是按钮(无 tabindex 也无键盘响应):「点角标进消息中心」把手势交给外层 button,角标只负责报数——这正是 badge 手把手第 4 条的落点。
  • 已读回写只改数据源:markRead 与「全部已读」都写 notices,角标、列表、按钮态一处写处处更新,不要在回调里手动改 DOM。
  • relativeTime 默认每 30 秒自刷新,「3 分钟前」自己会走字;列表重建后新实例接着算,不用手动更新时间文案。条数极多时把 list 换 virtualList,并把 refresh 调大或传 0。
  • 通知条目要可操作时在 list 的 onItemClick 里跳详情或调 notification(右下角)补一条带按钮的强提醒;轻提示走 toast,别在同一屏堆两层浮层。

配方 22:表格里就地改数据(editable + dataTable)

场景:后台列表要改一个预算、改一个区域名,不想让用户点进详情页再回来。

type Row = { id: string; name: string; budget: string };
const rows = signal<Row[]>([
  { id: 'r1', name: '华东区', budget: '120' },
  { id: 'r2', name: '华南区', budget: '95' },
]);

/** 回写单格:只有 editable 的 onCommit 会走到这里,Esc 取消根本不触发,所以取消不会污染数据 */
const patch = (id: string, key: 'name' | 'budget', value: string): void => { rows(rows().map((r) => (r.id === id ? { ...r, [key]: value } : r))); }; // 只改这一行这一列

/** 一格的草稿信号:初值取自行数据,与 rows 解耦——编辑中不会被别处的刷新冲掉 */
const cell = (row: Row, key: 'name' | 'budget', title: string) => editable({
  value: signal(row[key]), // 草稿缓冲,不是数据源
  label: title,
  validate: (v) => (v.trim() ? null : `${title}不能为空`), // 与 form 同口径:返回文案即不提交
  onCommit: (v) => { patch(row.id, key, v); toast.success(`${title}已保存`); },
});

dataTable({
  data: rows, // 传信号:提交回写后内核整表换源,重建出的格子拿到新草稿
  columns: [
    { key: 'name', header: '区域', width: 200, render: (row) => cell(row, 'name', '区域') }, // render 决定这一格画什么
    { key: 'budget', header: '预算(万)', align: 'right', render: (row) => cell(row, 'budget', '预算') },
  ],
  pageSize: 5,
});

要点:

  • 唯一数据源仍是 rows:editable 里的信号只是草稿缓冲,提交才回写。反过来把草稿当数据源,Esc 取消后表格显示的就是「没保存的值」。
  • 列的 render 只负责画什么,排序 / 筛选 / 导出仍按 data 里的原值走——所以行数据保持原始字符串,别为了显示把数据换成 DOM。
  • 提交会让内核整表换源,正在编辑的另一格草稿会被重建冲掉:一次只编辑一格是行内编辑的合理约束,要并行编辑就把草稿提到 rows 之外的状态里自己管。
  • 校验口径与 form 一致:validate 返回文案则不提交、保持编辑态并标红(aria-invalid + role=alert),用户改对再提交即可,不要在回调里弹 toast 了事。
  • 格内的点击会被拦下,不会触发 onRowClick:点输入框不该等于「打开详情」。反过来说,想整行进详情就别把可交互控件铺满整行。
  • 纯文本列继续用 format:它走 textContent,天然免注入;render 只在真要塞组件(tag / avatar / progress / editable)时才用。

通用约定

  • 状态一律放信号(signal / computed),组件只读写信号,不持有业务数据副本。
  • 反馈分层:toast 右上角瞬时、notification 右下角可长读、result 整页终态、alert 页内常驻。
  • 样式不要写颜色与间距字面值,先跑 ua tokens 查 --ua-*(verify 会拦截未定义令牌与 #hex)。
  • 动效与弹层方向不要各写一份:入场用 ua-pop-drop、翻到触发器上方用 ua-pop-rise(.is-flip),时长缓动走 --ua-duration-* / --ua-ease-*;verify 会拦「帧体完全相同但名字不同」的复制粘贴。
  • 新增可滚动容器(自绘列表 / 代码块 / 面板)时,把类名加进 ui.css 顶部那条 scrollbar-width 统一规则里,否则暗色下会露出系统原生亮条;verify 会逐个核对 overflow: auto|scroll 的容器是否都在名单内。
  • 新增颜色令牌要同时改 tokens.css(:root + [data-theme='dark'])与 tokens.json(color.*.light/dark):只改一处时文档色板会凭空多/少一个色块,verify 的双源一致性校验会直接报错。