16 KiB
16 KiB
组合配方(Recipes)
单个组件的参数表说明「怎么用」,这些配方说明「怎么拼」。所有配方都只消费组件对外 API,不依赖内部文件。
配方 1:表单校验 + 提交反馈
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:危险操作二次确认
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:表格筛选 + 分页 + 批量操作
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:长列表数据加载与空态
const loading = signal(true);
const items = signal<ListItem[]>([]);
list({ items: items(), bordered: true, emptyText: loading() ? '加载中…' : '暂无数据' });
要点:
- 空态文案随加载态切换;列表本身会在
items为空时渲染empty,不需要业务层再判断一次。 - 需要骨架屏就换成
skeleton,加载完成再renderList成真实行。
配方 5:数据表 + 独立分页器 + 导出
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:长表单编辑页(吸底操作条 + 提交锁定)
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:筛选栏(层级归属 + 日期区间)
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:评论输入(@ 提及 + 参与人头像组)
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:详情页排版(栅格 + 排版文本 + 描述列表)
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,不改令牌名)
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:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉)
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 全局操作入口)
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:预约时点(日期 + 时间两段拼装)
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(起 / 止),拼装时各自独立,别指望一个信号同时表达两端。
通用约定
- 状态一律放信号(
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 的双源一致性校验会直接报错。