# 组合配方(Recipes) > 单个组件的参数表说明「怎么用」,这些配方说明「怎么拼」。所有配方都只消费组件对外 API,不依赖内部文件。 ## 配方 1:表单校验 + 提交反馈 ```ts 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:危险操作二次确认 ```ts 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:表格筛选 + 分页 + 批量操作 ```ts const picked = signal([]); 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:长列表数据加载与空态 ```ts const loading = signal(true); const items = signal([]); list({ items: items(), bordered: true, emptyText: loading() ? '加载中…' : '暂无数据' }); ``` 要点: - 空态文案随加载态切换;列表本身会在 `items` 为空时渲染 `empty`,不需要业务层再判断一次。 - 需要骨架屏就换成 `skeleton`,加载完成再 `renderList` 成真实行。 ## 配方 5:数据表 + 独立分页器 + 导出 ```ts 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:长表单编辑页(吸底操作条 + 提交锁定) ```ts 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:筛选栏(层级归属 + 日期区间) ```ts 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:评论输入(@ 提及 + 参与人头像组) ```ts 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:详情页排版(栅格 + 排版文本 + 描述列表) ```ts 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,不改令牌名) ```ts 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:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉) ```ts 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 全局操作入口) ```ts 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:预约时点(日期 + 时间两段拼装) ```ts 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:标签筛选条 → 表格联动 ```ts import { computed, signal } from '@ui-arch/frame'; import { button, dataTable, inputTag, select, space } from '@ui-arch/ui'; const tags = signal(['本地推理']); // 条件条:可增删的多个标签(「且」关系) 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` 同样是 `Signal`)换源时内核保留排序、每页行数、当前页,并按 `rowKey` 剔除已消失行的选中态——手写 `replaceChildren(dataTable(...))` 会把这些状态全部丢掉。 - **条件条本身就是一个受控信号集合**:`inputTag` 的 `value` 就是唯一数据源,删除 chip 与增删条件走同一出口,别在业务层再存一份「当前条件」。 - **「且」还是「或」写在派生里**:`every` 是且(收窄)、`some` 是或(放大),两种语义只差一个方法调用,但决定了空条件时的行为——空数组 `every` 恒为真(等于不限),`some` 恒为假(等于无结果),重置逻辑据此写。 - 结果为空时不要另写空状态:`dataTable` 已内置空行提示,筛选无命中会自己显示「没有匹配的行」。 ## 配方 15:表格分页与独立分页器联动(每页条数 + 受控页码) 表格自带的分页条(上一页 / 页码播报 / 下一页 / 每页条数)只够单表自用;需要把页码提到页面上方、或让多个表格共用一个分页器时,用 `page` 信号把两边的页码接起来,条数候选交给 `pageSizes`。 ```ts 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:后台页骨架 ```ts 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:表格刷新遮罩 ```ts 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:变更审阅面板 ```ts 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:可排序的检查单(拖拽 + 键盘搬位 + 顺序回写) ```ts 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`),所以「置顶 / 上移」按钮不必另写状态同步;越界下标静默忽略,不用自己判边界。 - 拖到边缘不会自动滚动:条目多到一屏放不下时改用筛选或上下移按钮,不要指望拖出视口;跨列表搬运用两个实例各持一个信号。 - 条目文案要唯一——重名会让「谁被搬走了」失去参照,需要重复内容时把序号拼进文案(`'确认条款 #1'`)。 ## 通用约定 - 状态一律放信号(`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 的双源一致性校验会直接报错。