Files
UI/packages/tools/cli/recipes.md
T
2026-10-26 22:22:37 +08:00

363 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 组合配方(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<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:长列表数据加载与空态
```ts
const loading = signal(true);
const items = signal<ListItem[]>([]);
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<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`。
```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 条/页」而没有主语。
## 通用约定
- 状态一律放信号(`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 的双源一致性校验会直接报错。