Files
OneOS1.2/docs/superpowers/plans/2026-07-22-message-hub.md

549 lines
19 KiB
Markdown
Raw Permalink 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.
# Message Hub 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 落地统一消息中枢(模型 + 本地路由表 + Web 消息中心原型),并让工作台/外壳铃铛读同一中枢。
**Architecture:** `src/common/message-hub/` 持有 Message、ChannelDelivery、RouteRule 与 `resolve()`;新原型 `message-center` 提供筛选/列表/详情/端模拟器/去处理;工作台与 `notice-bridge` 通过适配层映射为铃铛摘要。真推送与真唤端不做。
**Tech Stack:** React + TypeScript现有 Axhub Make 原型、Vitest`resolve` 单测、localStorage可选已读、现有 `oneos-app-shell` 铃铛 UI。
**Spec:** [docs/superpowers/specs/2026-07-22-message-hub-design.md](../specs/2026-07-22-message-hub-design.md)
---
## 文件结构(锁定)
| 路径 | 职责 |
|------|------|
| `src/common/message-hub/types.ts` | Message / Channel / RouteRule 类型 |
| `src/common/message-hub/routes.ts` | 五端 × 多系统路由表常量 |
| `src/common/message-hub/resolve.ts` | `resolveMessageTarget()` |
| `src/common/message-hub/resolve.test.ts` | 路由优先级与失败态单测 |
| `src/common/message-hub/seed.ts` | 宽首期种子消息(含旧 notices 映射) |
| `src/common/message-hub/store.ts` | 读列表、标已读、未读数(内存 + localStorage |
| `src/common/message-hub/shell-adapter.ts` | Message → `ShellNoticeItem` |
| `src/common/message-hub/index.ts` | 公共导出 |
| `src/prototypes/message-center/index.tsx` | 原型入口 |
| `src/prototypes/message-center/MessageCenterApp.tsx` | 主 UI |
| `src/prototypes/message-center/styles/index.css` | 样式 |
| `src/prototypes/message-center/annotation-source.json` | 标注目录 |
| `src/prototypes/message-center/.spec/route-resolve.md` | 路由判定全文 |
| `src/prototypes/message-center/.spec/requirements-prd.md` | AutoPRD |
| `src/resources/prd/message-center-autoprd.md` | PRD 副本 |
| 修改 `oneos-app-shell/ShellNoticeCenter.tsx` | 「查看全部」→ 消息中心;详情优先于行内直跳 |
| 修改 `oneos-web-workbench-new` | 铃铛数据改读 message-hub |
| 修改 `nav-menu.json` | 增加「消息中心」菜单项 |
---
### Task 1: message-hub 类型 + resolve + 单测
**Files:**
- Create: `src/common/message-hub/types.ts`
- Create: `src/common/message-hub/resolve.ts`
- Create: `src/common/message-hub/resolve.test.ts`
- Create: `src/common/message-hub/index.ts`(先导出类型与 resolve
- [ ] **Step 1: 写入类型**
```ts
// src/common/message-hub/types.ts
export type SourceSystem = 'oneos' | 'vehicle-mid' | 'other-system' | 'external';
export type MessageClient = 'web' | 'ios' | 'android' | 'harmony' | 'wechat_oa';
export type ChannelStatus = 'pending' | 'sent' | 'failed' | 'skipped';
export type MessagePriority = 'normal' | 'high';
export type ExternalAppId = 'external-app-a' | 'external-app-b';
export type ChannelDelivery = {
client: MessageClient;
status: ChannelStatus;
updatedAt?: string;
};
export type HubMessage = {
id: string;
sourceSystem: SourceSystem;
bizType: string;
bizId: string;
title: string;
summary: string;
detail: string;
priority: MessagePriority;
createdAt: string;
readAt?: string;
/** 角色占位;空数组表示全员可见 */
audienceRoleIds: string[];
channels: ChannelDelivery[];
/** 展示用业务标签,如「交车任务」 */
bizTag: string;
};
export type RouteRule = {
sourceSystem: SourceSystem;
bizType: string;
client: MessageClient;
targetTemplate: string;
label: string;
externalApp?: ExternalAppId;
/** 精确未命中时,再试该 client如 harmony → android */
fallbackClient?: MessageClient;
};
export type ResolveOk = {
ok: true;
uri: string;
label: string;
externalApp?: ExternalAppId;
rule: RouteRule;
};
export type ResolveFail = {
ok: false;
reason: 'no_rule';
message: '当前端暂无可跳转目标';
};
export type ResolveResult = ResolveOk | ResolveFail;
```
- [ ] **Step 2: 写失败单测(先不实现 resolve 逻辑体)**
```ts
// src/common/message-hub/resolve.test.ts
import { describe, expect, it } from 'vitest';
import { resolveMessageTarget } from './resolve';
import type { HubMessage, RouteRule } from './types';
const baseMsg: HubMessage = {
id: 'm1',
sourceSystem: 'oneos',
bizType: 'approval.arrive',
bizId: 'AP-1',
title: '待审',
summary: '',
detail: '详情',
priority: 'normal',
createdAt: '2026-07-22T10:00:00+08:00',
audienceRoleIds: [],
channels: [],
bizTag: '租赁合同',
};
describe('resolveMessageTarget', () => {
it('精确匹配 web 规则', () => {
const rules: RouteRule[] = [
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'web',
targetTemplate: '/prototypes/oneos-web-approval-todo?id={bizId}',
label: '审批待办',
},
];
const r = resolveMessageTarget(baseMsg, 'web', rules);
expect(r.ok).toBe(true);
if (r.ok) expect(r.uri).toBe('/prototypes/oneos-web-approval-todo?id=AP-1');
});
it('harmony 可 fallback 到 android', () => {
const rules: RouteRule[] = [
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'android',
targetTemplate: 'oneos://approval/{bizId}',
label: '安卓审批',
fallbackClient: undefined,
},
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'harmony',
targetTemplate: '',
label: '鸿蒙走安卓模板',
fallbackClient: 'android',
},
];
// 实现约定harmony 行若 targetTemplate 为空且带 fallbackClient则用 fallback 的模板
const r = resolveMessageTarget(baseMsg, 'harmony', rules);
expect(r.ok).toBe(true);
if (r.ok) expect(r.uri).toBe('oneos://approval/AP-1');
});
it('无规则返回固定文案', () => {
const r = resolveMessageTarget(baseMsg, 'wechat_oa', []);
expect(r).toEqual({
ok: false,
reason: 'no_rule',
message: '当前端暂无可跳转目标',
});
});
it('带 externalApp', () => {
const msg = { ...baseMsg, sourceSystem: 'external' as const, bizType: 'order.action', bizId: 'O-9' };
const rules: RouteRule[] = [
{
sourceSystem: 'external',
bizType: 'order.action',
client: 'web',
targetTemplate: 'ext-app-a://order/{bizId}',
label: '外部 App A 订单',
externalApp: 'external-app-a',
},
];
const r = resolveMessageTarget(msg, 'web', rules);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.uri).toBe('ext-app-a://order/O-9');
expect(r.externalApp).toBe('external-app-a');
}
});
});
```
- [ ] **Step 3: 跑测确认失败**
Run: `npx vitest --run src/common/message-hub/resolve.test.ts`
Expected: FAIL模块不存在或函数未导出
- [ ] **Step 4: 实现 resolve**
```ts
// src/common/message-hub/resolve.ts
import type { HubMessage, MessageClient, ResolveResult, RouteRule } from './types';
function fillTemplate(template: string, msg: HubMessage): string {
return template
.replaceAll('{bizId}', encodeURIComponent(msg.bizId))
.replaceAll('{id}', encodeURIComponent(msg.id));
}
function findRule(
rules: RouteRule[],
sourceSystem: HubMessage['sourceSystem'],
bizType: string,
client: MessageClient,
): RouteRule | undefined {
return rules.find(
(r) => r.sourceSystem === sourceSystem && r.bizType === bizType && r.client === client,
);
}
export function resolveMessageTarget(
msg: HubMessage,
client: MessageClient,
rules: RouteRule[],
): ResolveResult {
const primary = findRule(rules, msg.sourceSystem, msg.bizType, client);
if (primary) {
const useFallback =
(!primary.targetTemplate || primary.targetTemplate.trim() === '') && primary.fallbackClient;
if (useFallback && primary.fallbackClient) {
const fb = findRule(rules, msg.sourceSystem, msg.bizType, primary.fallbackClient);
if (fb?.targetTemplate) {
return {
ok: true,
uri: fillTemplate(fb.targetTemplate, msg),
label: primary.label || fb.label,
externalApp: fb.externalApp ?? primary.externalApp,
rule: fb,
};
}
}
if (primary.targetTemplate?.trim()) {
return {
ok: true,
uri: fillTemplate(primary.targetTemplate, msg),
label: primary.label,
externalApp: primary.externalApp,
rule: primary,
};
}
}
return { ok: false, reason: 'no_rule', message: '当前端暂无可跳转目标' };
}
```
- [ ] **Step 5: 跑测通过**
Run: `npx vitest --run src/common/message-hub/resolve.test.ts`
Expected: PASS4 tests
- [ ] **Step 6: Commit**(仅当用户要求提交时执行)
```bash
git add src/common/message-hub/
git commit -m "$(cat <<'EOF'
feat(message-hub): add types and resolve() with vitest coverage
EOF
)"
```
---
### Task 2: 路由表 + 种子消息
**Files:**
- Create: `src/common/message-hub/routes.ts`
- Create: `src/common/message-hub/seed.ts`
- Modify: `src/common/message-hub/index.ts`
- [ ] **Step 1: 写入 `ROUTE_RULES`**
至少覆盖(每个 listed bizType 需含 web/ios/android/harmony/wechat_oa 五行wechat 无办理页可用空 template + 不配 fallback使 resolve 失败并在 UI 显示 skipped 通道态):
| sourceSystem | bizType | web 目标示例 |
|--------------|---------|--------------|
| oneos | approval.arrive | `/prototypes/oneos-web-approval-todo?id={bizId}` |
| oneos | urge.remind | `/prototypes/oneos-web-ops`(可带 query |
| oneos | contract.expire | `/prototypes/customer-management` |
| oneos | license.expire | `/prototypes/customer-management` |
| oneos | bill.ready | `/prototypes/lease-business-ledger` |
| oneos | h2.balance | `/prototypes/payment-records` |
| oneos | system.release | (无跳转或打开版本说明占位 path |
| vehicle-mid | fault.overdue | `/prototypes/vehicle-fault-handling#page=detail&id={bizId}` |
| vehicle-mid | vehicle.status | `/prototypes/vehicle-management` |
| other-system | generic.notice | `/prototypes/message-center`(仅详情,无外跳也可失败) |
| external | order.action | `ext-app-a://order/{bizId}` + `externalApp: external-app-a` |
| external | settle.action | `ext-app-b://settle/{bizId}` + `externalApp: external-app-b` |
harmony 行:`targetTemplate: ''`, `fallbackClient: 'android'`
ios/android`oneos://…` / `oneos-android://…` 占位 scheme。
wechat_oa对可 H5 办理的给 `https://oa.example.com/...` 占位;纯 PC 办理给空 template。
- [ ] **Step 2: 写入 `SEED_MESSAGES`**
映射旧 `SEED_NOTICES`(保留原 id 如 `n-1``RELEASE_NOTICE_ID` 便于对照)+ 新增 vehicle-mid / other-system / external 各至少规格要求条数。
`audienceRoleIds` 对齐原 `roleIds`;无角色限制用 `[]`
`channels`web=sent移动三端=sent 或 pendingwechat_oa 按样例 sent/skipped。
- [ ] **Step 3: 导出**
```ts
// index.ts
export * from './types';
export * from './resolve';
export { ROUTE_RULES } from './routes';
export { SEED_MESSAGES } from './seed';
```
- [ ] **Step 4: 用真实 ROUTE_RULES 补一条集成断言(可选放 resolve.test**
```ts
import { ROUTE_RULES } from './routes';
import { SEED_MESSAGES } from './seed';
it('种子 approval 在 web 可解析', () => {
const msg = SEED_MESSAGES.find((m) => m.bizType === 'approval.arrive');
expect(msg).toBeTruthy();
const r = resolveMessageTarget(msg!, 'web', ROUTE_RULES);
expect(r.ok).toBe(true);
});
```
Run: `npx vitest --run src/common/message-hub/resolve.test.ts` → PASS
- [ ] **Step 5: Commit**(用户要求时)
```bash
git add src/common/message-hub/
git commit -m "$(cat <<'EOF'
feat(message-hub): add route table and wide-scope seed messages
EOF
)"
```
---
### Task 3: store + shell 适配
**Files:**
- Create: `src/common/message-hub/store.ts`
- Create: `src/common/message-hub/shell-adapter.ts`
- Modify: `src/common/message-hub/index.ts`
- [ ] **Step 1: store**
```ts
// 要点
const STORAGE_KEY = 'oneos.message-hub.readAt.v1';
// loadSeed(): HubMessage[] — 合并 SEED 与 localStorage 中的 readAt
// markRead(id): void
// unreadCount(messages, roleId?): number
// listForRole(messages, roleId?): HubMessage[]
// sort: 未读优先 → priority high → createdAt desc
```
- [ ] **Step 2: shell-adapter**
```ts
import type { ShellNoticeItem } from '../oneos-app-shell/notice-bridge';
import type { HubMessage } from './types';
import { resolveMessageTarget } from './resolve';
import { ROUTE_RULES } from './routes';
export function toShellNoticeItem(msg: HubMessage): ShellNoticeItem {
const web = resolveMessageTarget(msg, 'web', ROUTE_RULES);
return {
id: msg.id,
type: msg.priority === 'high' && msg.bizType.startsWith('urge') ? '催办提醒' : msg.bizTag,
bizTag: msg.bizTag,
title: msg.title,
summary: msg.summary,
detail: msg.detail,
time: msg.createdAt.replace('T', ' ').slice(0, 16),
read: !!msg.readAt,
href: web.ok && web.uri.startsWith('/') ? web.uri : undefined,
taskId: msg.bizId,
};
}
```
注意:铃铛点击按规格**只打开详情、不在行上直跳**`href` 仍可供详情内「去处理」使用。`type === '催办提醒'` 用于外壳排序/标签adapter 对 `urge.remind` 必须输出 `'催办提醒'`
- [ ] **Step 3: 导出 store / adapter**
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 4: message-center 原型 UI
**Files:**
- Create: `src/prototypes/message-center/index.tsx`
- Create: `src/prototypes/message-center/MessageCenterApp.tsx`
- Create: `src/prototypes/message-center/styles/index.css`
- 实现前 Read: `~/.agents/skills/ui-ux-pro-max/SKILL.md`,并跑
`python3 ~/.agents/skills/ui-ux-pro-max/scripts/search.py "admin dashboard notification inbox" --design-system -p "OneOS message-center"`
视觉对齐现有工作台,不用营销风。
- [ ] **Step 1: 入口**
```tsx
/**
* @name 消息中心
*/
import React from 'react';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import annotationSource from './annotation-source.json';
import MessageCenterApp from './MessageCenterApp';
import './styles/index.css';
export default function MessageCenterPage() {
return (
<>
<MessageCenterApp />
<PrototypeAnnotationHost source={annotationSource as any} />
</>
);
}
```
(若项目 annotation 挂载方式与 fault-handling 不同,以同目录近期原型为准。)
- [ ] **Step 2: MessageCenterApp 行为清单(必须全部有)**
1. 左栏:全部/未读/已读;系统源多选或下拉;业务类型下拉;**端模拟器**五端
2. 右栏列表未读点、bizTag、title、时间
3. 选中详情detail、channels 触达态表、`resolve` 预览(随端模拟器变)、「去处理」
4. 去处理:`markRead` → resolve →
- `uri``/` 开头:`window.location.href` 或外壳 `postMessage({ type: 'ONEOS_SHELL_NAV', href })`(与工作台 `protoNav` 一致)
- 否则:模态「将打开 {externalApp} / {uri}」(不 `window.open` scheme
- fail页面内固定文案「当前端暂无可跳转目标」
5. 触控/对比度遵循 ui-ux-pro-max表格数字可用 tabular-nums
- [ ] **Step 3: 本地预览**
打开 `/prototypes/message-center`手测切换端模拟器预览变化external 消息出示意模态;无 wechat 规则时提示不可跳。
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 5: 业务规格 + AutoPRD + 标注
**Files:**
- Create: `src/prototypes/message-center/.spec/route-resolve.md`(判定优先级表、前置条件、数据源、用户可见结果、种子 vs 未接 API
- Create: `src/prototypes/message-center/.spec/requirements-prd.md`(用户故事:起点→运作→闭环;摘要链到 route-resolve
- Create: `src/resources/prd/message-center-autoprd.md`(与 PRD 同步)
- Create/Update: `annotation-source.json` 顶层 `directory.nodes` 含「产品需求说明PRD」全文
- [ ] **Step 1: 按 oneos-autoprd 写 PRD**(产品语言,不写文件路径堆砌)
- [ ] **Step 2: route-resolve.md 必须含**
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | 精确 source+bizType+client 且 template 非空 | 填充 URI |
| 2 | template 空且 fallbackClient 有对应规则 | 用 fallback URI |
| 3 | 否则 | 不可跳转文案 |
- [ ] **Step 3: 若存在脚本则运行**
`node scripts/sync-annotation-directory.mjs`(或项目内同等命令)
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 6: 导航登记
**Files:**
- Modify: `src/prototypes/oneos-prototype-nav/nav-menu.json`(在「工作台」旁或系统能力区增加「消息中心」→ `prototypes/message-center`
- Run: `npm run nav:sync -- --prototype message-center --note "新增统一消息中心原型"`
- [ ] **Step 1: 加菜单项**
- [ ] **Step 2: nav:sync**
- [ ] **Step 3: 刷新原型导航页确认可见**
- [ ] **Step 4: Commit**(用户要求时)
---
### Task 7: 工作台铃铛 + 外壳对接中枢
**Files:**
- Modify: `src/prototypes/oneos-web-workbench-new/components/WorkbenchPage.tsx`(及 NoticePanel 数据源)
- Modify: `src/prototypes/oneos-web-workbench-new/data/notices.ts` — 改为从 hub re-export 适配函数,或标记 deprecated 并改调用方
- Modify: `src/common/oneos-app-shell/ShellNoticeCenter.tsx`
- 列表项点击:`onOpen` 打开详情(勿直接 `onHandle`
- 「查看全部」主按钮导航到 `/prototypes/message-center`
- Modify: `.spec/notice-center.md` 注明数据源迁移至 message-hub
- Update workbench AutoPRD 中通知章节(行为变更需 sync
- [ ] **Step 1: Workbench 使用 `loadMessages` + `toShellNoticeItem` / 自有列表组件读 hub**
- [ ] **Step 2: postNoticesSync 仍发 `ShellNoticeItem[]`adapter 输出)**
- [ ] **Step 3: 外壳「查看全部」→ message-center**
- [ ] **Step 4: 手测:原型演示嵌入工作台时铃铛未读与中枢一致;查看全部进入消息中心**
- [ ] **Step 5: Commit**(用户要求时)
---
### Task 8: 验收对照 + 收尾
- [ ] **Step 1: 对照规格 §10 验收清单逐条勾选**
- [ ] **Step 2: `npx vitest --run src/common/message-hub/resolve.test.ts`**
- [ ] **Step 3: 更新设计文档状态旁注明「已实现原型」(若本轮做完)**
- [ ] **Step 4: 停止 brainstorm companion可选**
`/Users/sylvawong/.agents/skills/brainstorming/scripts/stop-server.sh /Users/sylvawong/oneos1.2/.superpowers/brainstorm/82675-1784712939`
---
## Spec 覆盖自检
| 规格要求 | 任务 |
|----------|------|
| Message / Channel / RouteRule 模型 | Task 12 |
| 本地路由 + 优先级 + 固定失败文案 | Task 1, 5 |
| 五端 × 多系统种子与外部 A/B | Task 2 |
| 消息中心 UI + 端模拟器 + 去处理 | Task 4 |
| 铃铛快捷 + 查看全部 | Task 7 |
| 独立中枢架构 | Task 14 |
| .spec + AutoPRD + 标注 | Task 5 |
| 导航登记 | Task 6 |
| 不做真推送/真唤端 | Task 4 模态示意 |
无 TBD 占位;`resolveMessageTarget` / `HubMessage` / `ROUTE_RULES` 命名全计划一致。