iter(g320): 补配方 21 示范消息中心的角标与通知流

This commit is contained in:
HIsheR
2026-10-27 01:05:52 +08:00
parent 9cd6d14ed2
commit 7056b62bf0
3 changed files with 93 additions and 0 deletions
+9
View File
@@ -3266,6 +3266,15 @@
"motivation": "Agent 拼一个界面通常要 3–5 个组件的参数,现在只能 `ua info button`、`ua info select` 一条条查(MCP get_component 同样单查),每轮多花好几次往返;逗号分隔批量一次拿全,文本 / --md / --json 三种模式都覆盖。",
"acceptance": "① `ua info button,select --md` 一次输出两个组件的 Markdown 片段(片段间以分隔线隔开);② `--json` 多值时输出数组、单值保持对象(不破坏现有脚本消费);③ 不存在的 id 报错并指明是第几个;④ manifest usage 串同步更新;⑤ npm run check 通过。",
"notes": ""
},
{
"id": "g320",
"domain": "tooling",
"status": "open",
"title": "配方 21:消息中心(铃铛角标 + 抽屉通知流)",
"motivation": "badge 的手把手里明确说「点角标进消息中心应当把 badge 塞进 button 里」,但 20 条配方没有一条给出消息中心的全貌:未读数怎么单一来源、静态快照组件(list / badge)怎么接信号、已读回写怎么只写数据源,都缺示范。",
"acceptance": "① recipes.md 新增配方 21(编号连续、代码块与要点齐全,ua recipes 能认出 badge / drawer / list / relativeTime / button / card 组件);② 配方与真实签名一致(badge count、drawer open 收信号、list items 静态数组、relativeTime value 收时间戳);③ npm run check 通过。",
"notes": ""
}
]
}
+42
View File
@@ -3363,6 +3363,48 @@ card({ title: '开通向导' },
- 末步提交前若还有异步规则(如邀请码查重),改用 formItem 的 `validateAsync` 聚合 await,校验期间禁用提交按钮;纯同步表单 `validate()` 已足够。
- 提交成功给 `toast.success` 反馈并把 `step` 归零,向导可以立即重开;要保留数据就别归零,改跳一个只读的完成页。
## 配方 21:消息中心(铃铛角标 + 抽屉通知流)
```ts
const open = signal(false); // 抽屉开关:铃铛与抽屉共用一份
const notices = signal([ // 通知数据唯一真相:未读角标、列表、按钮禁用态都从它派生
{ title: '审批通过', description: '报销单 RB-2048 已打款', time: Date.now() - 5 * 60_000, read: false },
{ title: '库存预警', description: 'SKU-88 低于安全库存线', time: Date.now() - 26 * 3600_000, read: false },
]);
const unread = computed(() => notices().filter((n) => !n.read).length); // 未读数单一来源,不另立计数变量
const markRead = (title) => notices(notices().map((n) => (n.title === title ? { ...n, read: true } : n))); // 点单条置已读:只写数据源
const bellSlot = h('span'); // badge 的 count 是静态快照:未读数变化时重挂新实例
effect(() => bellSlot.replaceChildren(badge({ count: unread() })));
const bell = h('button', { class: 'ua-btn', onclick: () => open(true) }, '🔔 消息', bellSlot); // badge 不是按钮:点进中心的语义与键盘可达性交给外层 button
const feed = h('div');
effect(() => { // list 同样是静态快照组件:数据一变整个重建
feed.replaceChildren(list({
header: `未读 ${unread()}`,
items: notices().map((n) => ({
title: n.title,
description: n.description,
extra: h('span', { class: 'desc' }, relativeTime({ value: n.time, ariaLabel: '通知时间' }), n.read ? '' : badge({ label: '未读', variant: 'danger' })),
})),
onItemClick: (item) => markRead(item.title),
}));
});
card({ title: '消息中心' },
bell,
drawer({ title: '消息中心', open, width: 380 },
feed,
button({ label: '全部已读', variant: 'ghost', disabled: computed(() => unread() === 0), onClick: () => notices(notices().map((n) => ({ ...n, read: true }))) }),
),
);
```
要点:
- 未读数只有一个来源:`unread` 从 `notices` 派生,铃铛角标、列表头、按钮禁用态都读它——任何一处「自己再记一份未读数」都会在已读回写时漂移。
- `list` / `badge` 返回 DOM 而非句柄,是静态快照组件,不自己订信号:接信号统一用「effect + `replaceChildren` 重挂」这一招;句柄式组件(drawer 的 `open`、button 的 `disabled` 收 Signal)才直接传信号。
- badge 不是按钮(无 tabindex 也无键盘响应):「点角标进消息中心」把手势交给外层 `button`,角标只负责报数——这正是 badge 手把手第 4 条的落点。
- 已读回写只改数据源:`markRead` 与「全部已读」都写 `notices`,角标、列表、按钮态一处写处处更新,不要在回调里手动改 DOM。
- `relativeTime` 默认每 30 秒自刷新,「3 分钟前」自己会走字;列表重建后新实例接着算,不用手动更新时间文案。条数极多时把 `list` 换 `virtualList`,并把 `refresh` 调大或传 0。
- 通知条目要可操作时在 list 的 `onItemClick` 里跳详情或调 `notification`(右下角)补一条带按钮的强提醒;轻提示走 `toast`,别在同一屏堆两层浮层。
## 通用约定
- 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。
+42
View File
@@ -467,6 +467,48 @@ card({ title: '开通向导' },
- 末步提交前若还有异步规则(如邀请码查重),改用 formItem 的 `validateAsync` 聚合 await,校验期间禁用提交按钮;纯同步表单 `validate()` 已足够。
- 提交成功给 `toast.success` 反馈并把 `step` 归零,向导可以立即重开;要保留数据就别归零,改跳一个只读的完成页。
## 配方 21:消息中心(铃铛角标 + 抽屉通知流)
```ts
const open = signal(false); // 抽屉开关:铃铛与抽屉共用一份
const notices = signal([ // 通知数据唯一真相:未读角标、列表、按钮禁用态都从它派生
{ title: '审批通过', description: '报销单 RB-2048 已打款', time: Date.now() - 5 * 60_000, read: false },
{ title: '库存预警', description: 'SKU-88 低于安全库存线', time: Date.now() - 26 * 3600_000, read: false },
]);
const unread = computed(() => notices().filter((n) => !n.read).length); // 未读数单一来源,不另立计数变量
const markRead = (title) => notices(notices().map((n) => (n.title === title ? { ...n, read: true } : n))); // 点单条置已读:只写数据源
const bellSlot = h('span'); // badge 的 count 是静态快照:未读数变化时重挂新实例
effect(() => bellSlot.replaceChildren(badge({ count: unread() })));
const bell = h('button', { class: 'ua-btn', onclick: () => open(true) }, '🔔 消息', bellSlot); // badge 不是按钮:点进中心的语义与键盘可达性交给外层 button
const feed = h('div');
effect(() => { // list 同样是静态快照组件:数据一变整个重建
feed.replaceChildren(list({
header: `未读 ${unread()}`,
items: notices().map((n) => ({
title: n.title,
description: n.description,
extra: h('span', { class: 'desc' }, relativeTime({ value: n.time, ariaLabel: '通知时间' }), n.read ? '' : badge({ label: '未读', variant: 'danger' })),
})),
onItemClick: (item) => markRead(item.title),
}));
});
card({ title: '消息中心' },
bell,
drawer({ title: '消息中心', open, width: 380 },
feed,
button({ label: '全部已读', variant: 'ghost', disabled: computed(() => unread() === 0), onClick: () => notices(notices().map((n) => ({ ...n, read: true }))) }),
),
);
```
要点:
- 未读数只有一个来源:`unread` 从 `notices` 派生,铃铛角标、列表头、按钮禁用态都读它——任何一处「自己再记一份未读数」都会在已读回写时漂移。
- `list` / `badge` 返回 DOM 而非句柄,是静态快照组件,不自己订信号:接信号统一用「effect + `replaceChildren` 重挂」这一招;句柄式组件(drawer 的 `open`、button 的 `disabled` 收 Signal)才直接传信号。
- badge 不是按钮(无 tabindex 也无键盘响应):「点角标进消息中心」把手势交给外层 `button`,角标只负责报数——这正是 badge 手把手第 4 条的落点。
- 已读回写只改数据源:`markRead` 与「全部已读」都写 `notices`,角标、列表、按钮态一处写处处更新,不要在回调里手动改 DOM。
- `relativeTime` 默认每 30 秒自刷新,「3 分钟前」自己会走字;列表重建后新实例接着算,不用手动更新时间文案。条数极多时把 `list` 换 `virtualList`,并把 `refresh` 调大或传 0。
- 通知条目要可操作时在 list 的 `onItemClick` 里跳详情或调 `notification`(右下角)补一条带按钮的强提醒;轻提示走 `toast`,别在同一屏堆两层浮层。
## 通用约定
- 状态一律放信号(`signal` / `computed`),组件只读写信号,不持有业务数据副本。