From 7056b62bf08b4d844a4253bb02b1b6230a14bdd8 Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 01:05:52 +0800 Subject: [PATCH] =?UTF-8?q?iter(g320):=20=E8=A1=A5=E9=85=8D=E6=96=B9=2021?= =?UTF-8?q?=20=E7=A4=BA=E8=8C=83=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E7=9A=84=E8=A7=92=E6=A0=87=E4=B8=8E=E9=80=9A=E7=9F=A5=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 9 ++++++++ llms-full.txt | 42 +++++++++++++++++++++++++++++++++++ packages/tools/cli/recipes.md | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/.ua/backlog.json b/.ua/backlog.json index 9d991de..e675274 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -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": "" } ] } diff --git a/llms-full.txt b/llms-full.txt index 7eb4ffd..f2677c0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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`),组件只读写信号,不持有业务数据副本。 diff --git a/packages/tools/cli/recipes.md b/packages/tools/cli/recipes.md index e08639f..13a9490 100644 --- a/packages/tools/cli/recipes.md +++ b/packages/tools/cli/recipes.md @@ -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`),组件只读写信号,不持有业务数据副本。