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

267 lines
13 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。
## 通用约定
- 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。
- 反馈分层:`toast` 右上角瞬时、`notification` 右下角可长读、`result` 整页终态、`alert` 页内常驻。
- 样式不要写颜色与间距字面值,先跑 `ua tokens` 查 `--ua-*`(verify 会拦截未定义令牌与 `#hex`)。