Files
UI/packages/tools/cli/recipes.md
T
HIsheR c2412fb360 iter(g157): 补配方 10 暗色主题切换
说清三件事:只切 data-theme 不改令牌名、初值先读 localStorage、
同步 color-scheme 让原生控件跟色。MCP get_recipe 10 可取骨架。
2026-10-26 15:56:56 +08:00

210 lines
9.7 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({ icon: '🌗', label: '切换主题', onClick: () => dark(!dark()) });
```
要点:
- 只改 `[data-theme]` 的取值:暗色是令牌**值**的另一套,名字永远不变,组件样式一律不写主题判断。
- 初值顺序是「先 localStorage、再 prefers-color-scheme」;反过来会让用户的手动选择被系统偏好覆盖。
- 同步设置 `color-scheme`,否则原生控件(日期选择、滚动条)在暗色下仍是白底。
## 通用约定
- 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。
- 反馈分层:`toast` 右上角瞬时、`notification` 右下角可长读、`result` 整页终态、`alert` 页内常驻。
- 样式不要写颜色与间距字面值,先跑 `ua tokens` 查 `--ua-*`(verify 会拦截未定义令牌与 `#hex`)。