c2412fb360
说清三件事:只切 data-theme 不改令牌名、初值先读 localStorage、 同步 color-scheme 让原生控件跟色。MCP get_recipe 10 可取骨架。
210 lines
9.7 KiB
Markdown
210 lines
9.7 KiB
Markdown
# 组合配方(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`)。
|