v65/v66:六个空壳模块接线 + 桌面服务卡片

用户 2026-10-20:
「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的
新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不
进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在
也点不进去,记笔记也记不了。」

v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套
空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有
`onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口:

· 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成
  (走 `/tasks/<id>/status` —— 服务端 `PATCH` **显式忽略** status,走 Patch
  会得到"看起来成功的假成功")、按看板筛选
· 资源:新增 / 查看 / 改 / 删(`FbResourceSheet` + `FbResourceRepo`);
  过滤维度从**并不存在的**「证件 / 资料」改成按权限与归属
  (`resources` 表没有 category 列,那个筛选一直是筛空的)
· 纪念日:新增 / 编辑(`FbDayEditSheet` + `FbDayRepo`);派生生日
  (id=0、来自名册)只给提示不给编辑 —— 点开 `/days/0` 必然 404
· 笔记:记一条 / 打开 / 改(`FbNoteSheet` + `FbNoteRepo`),正文按 blocks 存
· 账本:记一笔(`FbLedgerEntrySheet` + `FbLedgerRepo`);修掉财务显示自相
  矛盾 —— 资产/负债改回**服务端 summary**(原来端上按金额正负号重算,与
  服务端"负债取绝对值"的口径打架,会算出 净资产 ≠ 资产 − 负债)
· 旅程:题图卡片(封面 + 标题 + 城市)+ 行程 + 新建旅程 + 新建节点

服务端同时修掉两处**静默数据丢失**(网页端因提交格式凑巧没暴露,只有走
API 的客户端会中招):
· 新建笔记传的是 `body`,而 `Note::create()` 只读 `blocks` ⇒ 新笔记只有标题
· 改笔记时 `blocks` 恒为空,而 `Note::update()` 是整组替换 ⇒ 只改标题就把
  正文全删光;另修 `annivPeopleOf()`(`find()` 的 SQL 不含 people,原来那个
  兜底恒为空 ⇒ 只改纪念日标题会清空关联人)

v66 —— 桌面服务卡片(用户选了「桌面服务卡片」这一项):
· `FbFormAbility`(FormExtensionAbility)+ `form_config.json` 三块尺寸:
  2×2 今日概览 / 2×4 左右分栏 / 4×4 今日全景
· `FbCardData` 只打 `/home` 一个接口(与网页首页同源),数据**扁平化**后
  由 `formBindingData` 推给卡片;断网时用上次成功的缓存并注明"离线"
· 卡片是后台进程 ⇒ 请求带 `authProbe: true`:401 **不能**触发全局"踢回
  登录页"(那会顺手把正在用应用的人踢下线,v62 那次事故同一个坑)
· 点卡片 → `postCardAction(router, fbModule)` → `EntryAbility` 读 Want 参数
  直接落到对应模块;补了 `onNewWant`(没有它,第二次点卡片"什么也没发生")
· 4×4 右上角 ↻ 走 `postAction(message)` → `onFormEvent` 重取数再推回

验证:`hvigorw assembleHap` BUILD SUCCESSFUL;打包后的 hap 里确认
`FbFormAbility`(type=form)与 `$profile:form_config` 都在,卡片代码进了
`ets/widgets.abc`。静态守卫 730 条全绿,反向验证 110 条注入全部能让守卫变红。
⚠️ 设备仍断连(`hdc list targets` 为空),真机尚未验收。
This commit is contained in:
2026-10-20 15:27:32 +08:00
parent 3b7238a418
commit b3e1330244
32 changed files with 5572 additions and 227 deletions
+113 -23
View File
@@ -124,10 +124,26 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', '
// ============================================================
export const FB_DEMO_TRIPS: FbTripItem[] = [
{ id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03', nodeCount: 8, doneCount: 8 },
{ id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16', nodeCount: 6, doneCount: 6 },
{ id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', nodeCount: 12, doneCount: 12 },
{ id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', nodeCount: 0, doneCount: 0 }
{
id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03',
nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', summary: '海边住了两晚,早上赶海、下午爬山。',
attendeeCount: 3
},
{
id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16',
nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', summary: '带孩子去看海,吃了顿海鲜。',
attendeeCount: 4
},
{
id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04',
nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', summary: '兵马俑、城墙、回民街。',
attendeeCount: 3
},
{
id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24',
nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', summary: '',
attendeeCount: 0
}
];
/** 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际」) */
@@ -172,13 +188,41 @@ export const FB_DEMO_EVENTS_SOON: FbCalEvent[] = [
// ============================================================
export const FB_DEMO_TASKS: FbTaskItem[] = [
{ id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
{ id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C', dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3 },
{ id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
{ id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F', dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0 },
{ id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82', dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
{ id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8', dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0 },
{ id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }
{
id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F',
dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0,
boardId: 1, note: '', dueAt: '2026-10-13 18:00'
},
{
id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C',
dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3,
boardId: 2, note: '', dueAt: '2026-10-20 18:00'
},
{
id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F',
dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0,
boardId: 1, note: '', dueAt: ''
},
{
id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F',
dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0,
boardId: 1, note: '', dueAt: '2026-10-09 18:00'
},
{
id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82',
dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0,
boardId: 3, note: '', dueAt: '2026-10-15 18:00'
},
{
id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8',
dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0,
boardId: 4, note: '', dueAt: ''
},
{
id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F',
dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0,
boardId: 1, note: '', dueAt: ''
}
];
/** 任务看板(tasks/settings) */
@@ -224,12 +268,30 @@ export const FB_DEMO_LEDGER_ENTRIES: FbLedgerEntry[] = [
// ============================================================
export const FB_DEMO_RESOURCES: FbResourceItem[] = [
{ id: 1, name: '户口本', kind: 'document', ownerName: '全家', fileCount: 2, shared: true, updatedAt: '2026-09-14' },
{ id: 2, name: '房产证', kind: 'document', ownerName: '我们', fileCount: 3, shared: false, updatedAt: '2026-07-02' },
{ id: 3, name: '儿子学籍资料', kind: 'material', ownerName: '儿子', fileCount: 6, shared: true, updatedAt: '2026-09-01' },
{ id: 4, name: '全家保险合同', kind: 'document', ownerName: '全家', fileCount: 4, shared: true, updatedAt: '2026-08-12' },
{ id: 5, name: '装修图纸', kind: 'material', ownerName: '我', fileCount: 12, shared: false, updatedAt: '2026-05-19' },
{ id: 6, name: '车险单', kind: 'document', ownerName: '我', fileCount: 1, shared: false, updatedAt: '2026-04-08' }
{
id: 1, name: '户口本', ownerName: '全家', ownerId: 1, perm: 'owner',
content: '放在书房第二个抽屉。编号 3702…', shared: false, updatedAt: '2026-09-14'
},
{
id: 2, name: '房产证', ownerName: '我们', ownerId: 1, perm: 'owner',
content: '', shared: false, updatedAt: '2026-07-02'
},
{
id: 3, name: '儿子学籍资料', ownerName: '儿子', ownerId: 2, perm: 'write',
content: '学籍号在班主任那里。', shared: true, updatedAt: '2026-09-01'
},
{
id: 4, name: '全家保险合同', ownerName: '全家', ownerId: 1, perm: 'owner',
content: '每年 8 月续保。', shared: false, updatedAt: '2026-08-12'
},
{
id: 5, name: '装修图纸', ownerName: '我', ownerId: 1, perm: 'owner',
content: '', shared: false, updatedAt: '2026-05-19'
},
{
id: 6, name: '车险单', ownerName: '我', ownerId: 1, perm: 'read',
content: '', shared: true, updatedAt: '2026-04-08'
}
];
// ============================================================
@@ -255,11 +317,31 @@ export const FB_DEMO_BOOKS: FbBookItem[] = [
// ============================================================
export const FB_DEMO_NOTES: FbNoteItem[] = [
{ id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2 },
{ id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……', tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0 },
{ id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1 },
{ id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6 },
{ id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0 }
{
id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…',
tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2,
body: '1. 换推拉门(断桥铝,考虑隔音)\n2. 加一组置物架\n3. 补两盆绿萝', mine: true
},
{
id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……',
tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0,
body: '「地方政府的行为,要从它面对的制度约束去理解。」', mine: true
},
{
id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…',
tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1,
body: '周一 16:30 数学\n周三 17:00 游泳\n周六 09:00 钢琴', mine: false
},
{
id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…',
tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6,
body: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218', mine: true
},
{
id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…',
tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0,
body: '拖车绳、搭电线、应急灯、保温毯、常用药', mine: true
}
];
// ============================================================
@@ -298,8 +380,16 @@ export function fbGroupTask(list: FbTaskItem[]): FbGroup<FbTaskItem>[] {
}
/** 按 kind 分组建资源(证件 / 资料 / 其他) */
/**
* 按**归属人**分组(资源挂在某人名下)。
*
* 🔴 v65:原来按"证件 / 资料"分组 —— 但服务端 `resources` 表根本没有类别列
* (`app/Schema.php` 只有 title / content / owner_id),解析时只能全填 `other`
* ⇒ 列表永远只出现一个「其他」组,两个筛选胶囊点了永远是空列表。
* 改成按归属人分组:这是真实存在的字段,也正好是网页版列表的读法。
*/
export function fbGroupResource(list: FbResourceItem[]): FbGroup<FbResourceItem>[] {
return fbGroupBy(list, (it: FbResourceItem): string => fbResourceKindName(it.kind));
return fbGroupBy(list, (it: FbResourceItem): string => it.ownerName);
}
/** 按 status 分组建书架(在读 / 想读 / 读完) */
+22 -7
View File
@@ -41,9 +41,13 @@ export const FB_SET_BOOKS: FbSettingItem[] = [
{ icon: 'share', label: '微信读书导入', hint: '同步微信读书的书单', action: '' }
];
/** 纪念日(templates/days/_settings_menu.php) */
/** 纪念日(templates/days/_settings_menu.php)
* 🔴 v65:原来 `action === ''` ⇒ 画灰、点了不响应。
* 分类名在服务端是**自由文本**(`anniv_kinds` 那份清单没有 API),
* 所以端上做的是「按已经出现过的分类筛」——
* 增删改分类本身仍在网页端「纪念日 → 设置」里。 */
export const FB_SET_DAYS: FbSettingItem[] = [
{ icon: 'filter', label: '纪念日分类', hint: '全家共用一套分类', action: '' }
{ icon: 'filter', label: '纪念日分类', hint: '按分类挑着看', action: 'daykinds' }
];
/** 账本(templates/ledger/_settings_menu.php) */
@@ -52,10 +56,16 @@ export const FB_SET_LEDGER: FbSettingItem[] = [
{ icon: 'edit', label: '账本名字', hint: '改这本账本的名字', action: '' }
];
/** 任务(templates/tasks/_settings_menu.php) */
/** 任务(templates/tasks/_settings_menu.php)
* 🔴 v65(用户 2026-10-20「设置这些东西都这都没有」):两条都接上了。
* · taskdone → 只看已完成(网页版是 `?view=done` 换视图)
* · taskboards → 看板列表(手机宽放不下多列看板,这里只做"挑一个看板";
* 增删改看板仍在网页端 `templates/tasks/boards.php`)
* ⚠️ 之所以不把两条合成一条:"已完成"是**状态**筛选、"分类与看板"是**归属**筛选,
* 点进去看到的列表不是同一份,合起来必有一条永远进不去。 */
export const FB_SET_TASKS: FbSettingItem[] = [
{ icon: 'tasks', label: '分类与看板', hint: '管分类、管看板', action: '' },
{ icon: 'check', label: '已完成', hint: '看已经完成的任务', action: '' }
{ icon: 'tasks', label: '分类与看板', hint: '按看板找任务', action: 'taskboards' },
{ icon: 'check', label: '已完成', hint: '看已经完成的任务', action: 'taskdone' }
];
/** 旅程(templates/trips/_settings_menu.php) */
@@ -65,7 +75,12 @@ export const FB_SET_TRIPS: FbSettingItem[] = [
{ icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' }
];
/** 资源(网页版没有独立菜单;这里只放已有的入口) */
/** 资源(网页版没有独立菜单;这里只放端上真做得到的两个筛选)
*
* 🔴 v65:原来两条是「格式筛选」(`action === ''` ⇒ 点了不响应),
* 而且端上的"格式"根本不存在 —— 服务端 `resources` 表没有类别列。
* 改成按**权限/归属**筛:`perm` 与 `owner_id` 都是服务端真给的字段。 */
export const FB_SET_RESOURCES: FbSettingItem[] = [
{ icon: 'filter', label: '格式筛选', hint: '只看某类资源', action: '' }
{ icon: 'share', label: '共享给我的', hint: '别人名下、共享给我看的', action: 'resourceShared' },
{ icon: 'people', label: '我名下的', hint: '挂在自己名下的资源', action: 'resourceMine' }
];
+86 -9
View File
@@ -435,6 +435,25 @@ export interface FbTripItem {
nodeCount: number;
/** 已完成段数 */
doneCount: number;
/**
* 题图(`trip_details.cover_key` 的**原始键**,如 `covers/ab12.jpg`)。
*
* ⚠️ 存原始键、渲染时才拼完整地址(见 `data/FbHttp.fbMediaUrl`)——
* 与 `FbPersonItem.avatar` 同一套。存拼好的地址会在换服务器/换令牌后失效。
* ⚠️ 空串 = 这趟旅程没设题图,卡片显示占位图标(网页版是 🧳)。
*/
coverKey: string;
/**
* 城市串(服务端派生,如「青岛 · 威海 → 济南」)。
*
* 与 `sub` 的区别:`sub` 是"城市或天数"的合并文案(历史字段,保持兼容),
* 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。
*/
city: string;
/** 简介(网页版列表卡截 80 字显示) */
summary: string;
/** 同行人数(所有行程参与人的合集,与网页版一致) */
attendeeCount: number;
}
/** 旅程里的一段行程(trips/show 的时间轴节点) */
@@ -545,6 +564,27 @@ export interface FbTaskItem {
/** 子任务完成数 / 总数 */
childDone: number;
childTotal: number;
/** 看板 id(0 = 还没归到看板)。
* 🔴 v65:新建任务/搬看板都要传它;原来只存了**看板名**,端上没法回填。 */
boardId: number;
/** 备注正文(详情浮层显示、编辑时回填) */
note: string;
/** 截止时间 ISO 原文(`2026-10-25 18:00`;空 = 没有期限) */
dueAt: string;
}
/**
* 任务看板选项(新建/编辑任务时挑"归到哪个看板")。
*
* ⚠️ 服务端 `GET /tasks` 的 board 视图里自带 `boards: [{id,name,color,tasks}]`,
* 但端上 `parseTasks` 把结果**摊平**成任务列表、丢掉了 boards 那一层。
* ⇒ 这里从已加载的任务里**反推**一份(同 boardId 只留一条)。
* 等哪天要「新增看板」时,再改成正经解析 `boards`。
*/
export interface FbBoardOpt {
id: number;
name: string;
color: string;
}
// ============================================================
@@ -565,6 +605,26 @@ export interface FbAnniversaryItem {
people: FbKinChip[];
/** 是否每年重复 */
yearly: boolean;
/**
* 数据来源:`custom`(自己添加的)/ `birthday_solar` / `birthday_lunar`(名册生日派生)。
*
* 🔴 v65:派生出来的生日行 **`id` 是 0**(见 `Anniversary::birthdays()`),
* 拿它去 PATCH `/days/0` 必然 404;而且"改生日"该去名册改。
* ⇒ 端上只允许编辑/删除 `custom` 的条目,其余点开时明说去哪儿改。
*/
source: string;
/** 月(1-12)—— 编辑要回填 */
month: number;
/** 日(1-31)—— 编辑要回填 */
day: number;
/** `solar` / `lunar` / `once` */
repeatType: string;
/** 原始的 kind **文本**(如「结婚纪念日」)—— 上面那个 `kind` 是映射后的类别码 */
rawKind: string;
/** 备注 */
note: string;
/** 关联人 id(自定义条目才有;服务端 v65 起列表里会带) */
personIds: number[];
}
// ============================================================
@@ -624,15 +684,22 @@ export interface FbResourceItem {
id: number;
/** 名称,如「户口本」 */
name: string;
/** 类别:document(证件)/ material(资料)/ other */
kind: string;
/** 归属人(空表示全家) */
/** 归属人显示名 */
ownerName: string;
/** 附件数 */
fileCount: number;
/** 是否共享给我 */
/** 归属人 id(`POST /resources` 只能挂自己名下;这里留着是为了以后能选人) */
ownerId: number;
/**
* 我的权限:`owner` / `write` / `read`。
*
* 🔴 v65:服务端 `Resource::all` 的 SELECT 里就有这个 CASE WHEN,
* 但端上原来**扔掉了** ⇒ 只读共享来的资源也画成可编辑的样子。
*/
perm: string;
/** 正文(证件编号、有效期、放在哪…)—— 详情要显示、编辑要回填 */
content: string;
/** 是否共享来的(`perm !== 'owner'`) */
shared: boolean;
/** 更新时间 */
/** 更新时间(只取日期部分) */
updatedAt: string;
}
@@ -677,6 +744,17 @@ export interface FbNoteItem {
shared: boolean;
/** 附件数 */
attachCount: number;
/**
* 正文**纯文本**(把 `note_blocks` 里 text 块拼起来,图片块记成「[图片]」)。
*
* 🔴 v65(用户 2026-10-20「笔记现在也点不进去,记笔记也记不了」):
* 服务端 `/notes` 列表其实**已经带 `blocks`**(`Note::attachChildren` 一次性挂上),
* 只是端上原来只用它算了个摘要扔掉 ⇒ 点进详情时正文没了。
* 这里存下来,详情直接显示,不必再打一次 `/notes/<id>`。
*/
body: string;
/** 是不是我写的(不是的话只能看,服务端也只让作者删) */
mine: boolean;
}
// ============================================================
@@ -701,8 +779,7 @@ export interface FbNotificationItem {
// ============================================================
/** 一个分组(名册按分组、导航按分类、日历按日历本…都用它) */
export interface FbGroup<T> {
/** 分组键(唯一) */
export interface FbGroup<T> { /** 分组键(唯一) */
key: string;
/** 分组标题 */
title: string;