fix(harmony): 图标按 px→vp 正确缩放 + 底栏四入口 + 卡片 10vp 缩进

图标过小的根因(实图硬证):`Path.commands` 的坐标按 **px** 解释,不是 vp。
- `Shape.viewPort` 只裁剪不缩放(官方样例"缩放成立"是因为它用 Rect/Circle
  自带 vp 宽高,不是裸 Path.commands);
- 改动前的 `Path.scale({iconSize/24})` 对 iconSize=24 等于 1.0,同样没缩放。
  症状:FAB 内 compose 只画 24px(应 24vp=84px),且偏在盒子左上角。

修法:`scale({vp2px(iconSize)/24})` 把 24 单位路径放大到 iconSize vp;
`scale` 会连带放大描边,故 `strokeWidth = strokeWeight / vp2px(1)` 抵消,
否则 1.8vp 描边会变成 ~7.3vp 的"实心块"(实图已验证)。
用 `this.getUIContext().vp2px()` 而非全局 `vp2px`——后者已废弃,
`harmony-system-api` 判据对全局调用默认判红。

验收(模拟器实图,密度 3.489):
- FAB 内 compose 图标 84px = 24vp ✓(原 24px)
- 底栏图标描边 7-8px ≈ 1.8/24×28vp=7.3px ✓(原为实心块)
- 四个图标均为清晰描边,与 WebUI icons.tsx 同几何

底栏四入口:新增"我的"(person → pages/SettingsPage),对齐 WebUI
通信/日历/联系人/我的;NAV_CONTENT_ITEMS 保持宽屏侧栏三项,
避免宽屏出现重复 person 图标。

卡片缩进:`width('100%') + margin({left:10,right:10})` 在 ArkUI 里不会
缩到 90%——margin 加在宽度外面,卡片顶出父容器(实测左缘只留 ~1px,
列表卡才有正确 10vp)。改为外层容器加 padding。验收:页头卡与邮件卡
左缘同为 x=35(10.0vp)、右缘 x=1219。

推送:reportToken 先申请通知权限再取 token(部分设备权限未开时
getToken 返回空 / 报 1600004),并补两条文件形状判据钉住
client_id 配置与调用顺序。

测试:198 passed / 0 failed。
This commit is contained in:
2026-09-17 16:27:27 +08:00
parent a5f680a147
commit ff75339baa
10 changed files with 301 additions and 179 deletions

View File

@ -489,7 +489,12 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否
assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.match(main, new RegExp(`${pane}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `通信页要把 bgActive 传给 ${pane}`);
// 参数已是多行形式(InboxTab/SentTab 还接了 onOpenMail),所以判"这个组件收到了 bgActive"
// 而不是要求整行字面量 —— 只判字面量的话,加点别的参数就会假红。
const at = main.indexOf(`${pane}({`);
assert.ok(at >= 0, `通信页要渲染 ${pane}`);
const call = main.slice(at, main.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*this\.bgActive/, `通信页要把 bgActive 传给 ${pane}`);
}
// 开关必须由**背景计划**驱动(不是写死的 true/false)
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');

View File

@ -161,7 +161,13 @@ test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => {
!/共 ' \+ this\.total \+ ' 封/.test(pageCode),
'页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数'
);
assert.match(pageCode, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"');
/*
* 页头文案已按 WebUI 的列表头重写为「N 组 · N 封」(parity 那一轮),
* 不再是「已加载 N 封」—— 但这条判据的本意没变:显示的必须是**本地数到的东西**
* (groups / loaded),不能是服务端那个未读 total。
*/
assert.match(pageCode, /this\.loaded \+ ' 封'/, '应如实说"本地取到多少封"(而不是服务端 total)');
assert.match(pageCode, /this\.groups\.length \+ ' 组/, '列表头要报告本地分组数(WebUI 的「N 组 · N 封」形状)');
});
// ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ─────────────────────────
@ -240,7 +246,8 @@ test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
// 2026-09-14:日历页(P6 第 1 步)落地后入口上架 ⇒ 三项。撤掉「会话」这条判断本身没变:
// 它不是第三个地方,而是"同一批数据的另一种看法"(收件箱折叠 + 联系人卡片视图)。
assert.deepEqual(labels, ['通信', '日历', '联系人'], `平级项应为 通信/日历/联系人,实际:${labels.join('、')}`);
// 2026-09-17:与 WebUI NarrowNav 对齐 ⇒ 四项(前三内容窗格 + 外壳入口「我的」)。
assert.deepEqual(labels, ['通信', '日历', '联系人', '我的'], `平级项应为 通信/日历/联系人/我的,实际:${labels.join('、')}`);
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)');
});
@ -515,7 +522,8 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
// 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人)
assert.deepEqual(tabLabels, ['通信', '日历', '联系人'], `底部应为通信/日历/联系人三项,实际:${tabLabels.join('、')}`);
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」是外壳入口(见 NavItem.route)
assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
assert.match(sendCode, /CommPage\(\{ bgActive: this\.bgActive \}\)/, '第一项要渲染通信页(并把背景开关传下去)');
@ -525,16 +533,17 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
assert.match(sendCode, /commTabLabel\(key\)/, '页签文字走同一份标签');
assert.match(sendCode, /badgeText\(badgeCount\(key, this\.unreadCount, this\.pendingCount\)\)/, '徽标数字走同一份规则');
assert.match(sendCode, /this\.commTab = normalizeCommTab\(key\)/, '点击要过状态机的归一化,不能直接赋值');
// 三个 pane 都要真的存在(少一个就是空页签)
// 三个 pane 都要真的存在(少一个就是空页签),且都要收到背景开关
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
assert.ok(sendCode.includes(`${pane}({ bgActive: this.bgActive })`),
`通信页要渲染 ${pane}(并把背景开关传下去)`);
const at = sendCode.indexOf(`${pane}({`);
assert.ok(at >= 0, `通信页要渲染 ${pane}`);
const call = sendCode.slice(at, sendCode.indexOf('})', at) + 2);
assert.match(call, /bgActive:\s*this\.bgActive/, `通信页要把 bgActive 传给 ${pane}`);
}
// 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形
// 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形 + compose 图标(与 WebUI 同几何)
const commPage = sendCode.slice(sendCode.indexOf('struct CommPage'), sendCode.indexOf('struct ContactsTab'));
assert.match(commPage, /Text\('\+'\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形');
assert.match(commPage, /AmIcon\(\{[\s\S]{0,80}?iconName: 'compose'[^}]*}\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形且用 compose 图标(不是 Text \'+\')');
assert.match(commPage, /this\.openCompose\(\)/, '加号要真的能进写信页');
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)

View File

@ -70,12 +70,14 @@ test('① 点击:每个导航项点下去落到**它自己**那个 index(配
* 只看"出现过 normalizeNavIndex"不够(可以写 `normalizeNavIndex(0)` 骗过去),
* 所以要求括号里的实参就是 `index`。
*/
const assigns = [...item.matchAll(/this\.currentIndex\s*=\s*([^;]+);/g)].map(m => m[1].trim());
assert.equal(assigns.length, 1, `导航项里应当恰好有一次点击赋值(实际 ${assigns.length} 次)`);
// 只数**赋值**(`= `),不数三元式里的 `===` —— 外壳入口不赋值,所以正文里仍只能有一次赋值
const assigns = [...item.matchAll(/(?<!=)this\.currentIndex\s*=\s*([^;=]+);/g)].map(m => m[1].trim());
assert.equal(assigns.length, 1, `导航项里应当恰好一次 currentIndex 赋值(实际 ${assigns.length} 次)—— 外壳入口(route 非空)走路由,不赋 currentIndex`);
assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/,
`点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`);
// 处理器必须挂在**这一项**上(不是别的成员上)
assert.match(item, /\.onClick\(\(\)\s*=>\s*\{[\s\S]{0,120}?this\.currentIndex/, '点击处理器要挂在本项上');
// 外壳入口(route 非空)走路由、内容窗格才赋 currentIndex —— 两者都走在本项的 onClick 里
assert.match(item, /item\.route.*pushUrl|pushUrl.*item\.route|if \(item\.route[\s\S]{0,80}pushUrl/,
'外壳入口(route 非空)要路由到 @Entry 页(与 WideSidebar 的设置按钮同一行为)');
// ForEach 要真的把 index 传进来(否则上面那句 index 无从谈起)
const bar = builderBody(main, 'NavBar() {');
assert.match(bar, /ForEach\(NAV_ITEMS,\s*\(item: NavItem,\s*index: number\)/, 'ForEach 要带 index 参数');
@ -120,12 +122,20 @@ test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
'日历不该写成 if/else 的一支:它是常驻 pane(卸载重挂会丢掉"在看哪个月",且 today 只能靠挂载重算)');
/*
* 分派必须**穷尽** NAV_ITEMS。2026-09-14 日历入口上架时这条**如约变红**
* (当时是 `length === 2` + 两支 if/else)—— 加项必须同时加分派,否则第 3 项点了没内容。
* 现在的形状与"三项"绑定:通信(0) / 日历(1) / 联系人(2),日历那一支**常驻挂载**。
* 分派必须**穷尽内容窗格**(`NAV_CONTENT_COUNT` = 3:通信/日历/联系人),但
* **不**把外壳入口(第 4 项「我的」)算进去 —— 它是 `@Entry` 页,点击走路由,
* 不在 currentIndex 分派里挂内容。2026-09-14 日历入口上架时这条**如约变红**
* (当时是 `length === 2` + 两支 if/else)—— 加**窗格**必须同时加分派。
* 2026-09-17:加了第 4 枚图标「我的」(外壳入口,与 WebUI 四入口对齐),
* 所以这里判的是 **NAV_CONTENT_COUNT** 与分派一致,而 NAV_ITEMS 是 4。
*/
assert.equal(N.NAV_ITEMS.length, 3,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,与分派(通信/日历/联系人)不匹配 —— 加了项必须同时加分派`);
assert.equal(N.NAV_CONTENT_COUNT, 3, `内容窗格应为 3(通信/日历/联系人),实际 ${N.NAV_CONTENT_COUNT}`);
assert.equal(N.NAV_ITEMS.length, N.NAV_CONTENT_COUNT + 1,
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项;应为 3 个内容窗格 + 1 个外壳入口(我的)`);
// 第 4 项必须是外壳入口(带 route),否则它会被 currentIndex 分派静默漏掉
const shell = N.NAV_ITEMS[N.NAV_CONTENT_COUNT];
assert.equal(shell.key, 'me', '第 4 项应是「我的」');
assert.equal(shell.route, N.NAV_SETTINGS_ROUTE, '「我的」要路由到设置页');
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
const navAt = stack.indexOf('this.NavBar()');
const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })');
@ -244,7 +254,7 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
* 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)——
* 所以这条只在"根组件"的范围内断言,别扫全文。
*/
const root = main.slice(main.indexOf('build() {\n /*\n * 底部三项'));
const root = main.slice(main.indexOf('build() {\n /*\n * 底部四枚图标'));
assert.ok(root.length > 300, '要能取到根 build() 的正文');
const navCode = stripComments(root);
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
@ -253,16 +263,19 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
// 平级项:通信/日历/联系人(日历是 P6 第 1 步做完后上架的,顺序与 WebUI 一致)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '日历', '联系人'], '平级项与顺序');
// 平级项:通信/日历/联系人 + 外壳入口「我的」(与 WebUI NarrowNav 的四入口一致)
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '日历', '联系人', '我的'], '底栏四入口与顺序');
assert.equal(new Set(N.NAV_ITEMS.map(i => i.key)).size, N.NAV_ITEMS.length, '键要唯一(ForEach 的 key)');
assert.deepEqual(N.NAV_ITEMS.map(i => N.navKeyAt(N.NAV_ITEMS.indexOf(i))), N.NAV_ITEMS.map(i => i.key),
'navKeyAt 与清单一致');
// navKeyAt 只覆盖内容窗格(前三项),越界回 0 —— 外壳入口不在此函数范围内
assert.deepEqual(N.NAV_CONTENT_ITEMS.map(i => N.navKeyAt(N.NAV_CONTENT_ITEMS.indexOf(i))), N.NAV_CONTENT_ITEMS.map(i => i.key),
'navKeyAt 与内容窗格清单一致');
// 归一化:越界/负数都退回第一项(点击与外部传值都过它)
assert.equal(N.normalizeNavIndex(-1), 0);
assert.equal(N.normalizeNavIndex(99), 0);
assert.equal(N.normalizeNavIndex(1), 1);
assert.equal(N.normalizeNavIndex(2), 2, '第三项(联系人)也必须归一到自己');
// 外壳入口(index 3)不归一到自己 —— 它不进 currentIndex 分派,normalize 越界即回 0
assert.equal(N.normalizeNavIndex(3), 0, '外壳入口(我的)不该被当作内容窗格下标');
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
});

View File

@ -141,3 +141,49 @@ test('★ DELETE 也带 JSON body(不是 query / 不是 path 参数)—— A
'DELETE 端点要 JSON body,而`del`只有 path ⇒ 推送的注销调用会 400/无效。' +
'**正确修法**:给 del 加可选 body(向后兼容)。**最常见的错误修法**:把 token 拼进 URL(那不是线上形状)。');
});
/*
* ★ 取 token 的两个硬前提(2026-09-17 实测根因,pi 邮件线上链路):
*
* 症状:鸿蒙 App 连上了、登录了(服务端收到 GET /me/devices/push-token 200),
* 但**从来没有 POST** —— getToken() 返回空串,reportToken 直接 return。
*
* 根因 ①:module.json5 的 module.metadata **没有配 client_id**。
* Push Kit 必须先读到这个 client_id 才能向华为推送服务器申请 token ——
* 缺了它 getToken() 永远返回空(且不报错,静默)。
* 业界示例(dev.to 的 HarmonyOS Next PushKit 集成)与之互证:
* "获取 token 前需在 module.json5 的 module.metadata 中配置 client_id(来自 AGC)"。
*
* 根因 ②:通知权限申请必须在取 token **之前**。
* 部分设备上通知权限没开时 getToken 会返回空 / 报 1600004。
*
* 这两条都不需要设备就能钉(文件形状),且是最容易悄悄回归的地方。
*/
test('★ 取 token 前提①:module.json5 的 module.metadata 必须配 client_id(缺了 getToken 恒空)', () => {
const mod = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'module.json5'));
// module 级 metadata(不是 abilities/extensionAbilities 里的)
assert.match(mod, /"metadata":\s*\[\s*\{\s*"name":\s*"client_id"\s*,\s*"value":\s*"[0-9]+"\s*\}/,
'module.metadata 里必须有 {"name":"client_id","value":"<数字>"}。' +
'这是 Push Kit 申请 token 的硬前提;缺了它 getToken() 静默返回空串 —— ' +
'症状是"App 能连服务端、能 GET,但永远不 POST token"。');
// client_id 的值要跟 agconnect-services.json 里的一致(不是随手编一个数)
const agc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'agconnect-services.json'));
const m = agc.match(/"client_id"\s*:\s*"(\d+)"/);
assert.ok(m, 'agconnect-services.json 里要能找到 client_id');
assert.ok(mod.includes(`"value": "${m[1]}"`),
`module.json5 的 client_id 必须等于 agconnect-services.json 里的 ${m[1]}(两处不一致时 Push Kit 认不出应用)`);
});
test('★ 取 token 前提②:reportToken 里 requestEnableNotification 在 getToken 之前', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
const lines = svc.split('\n');
const start = lines.findIndex(l => /async reportToken\(/.test(l));
assert.ok(start > 0, '要能找到 reportToken');
const body = lines.slice(start, start + 60).join('\n');
const permAt = body.indexOf('requestEnableNotification');
const tokenAt = body.indexOf('this.getToken()');
assert.ok(permAt > 0, 'reportToken 里要有 requestEnableNotification');
assert.ok(tokenAt > 0, 'reportToken 里要有 getToken');
assert.ok(permAt < tokenAt,
'通知权限申请必须在 getToken **之前** —— 部分设备上权限未开时 getToken 返回空 / 报 1600004(2026-09-17 实测)');
});

View File

@ -42,8 +42,9 @@ test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字
test('② 三项导航 + 设置按钮:内容与 WebUI Sidebar 对齐', () => {
const sidebar = read('pages/WideSidebar.ets');
// NAV_ITEMS 遍历(与底部导航同一份清单 —— 加项两边同时出现)
assert.match(sidebar, /ForEach\(NAV_ITEMS,/, '要遍历 NAV_ITEMS(与底部导航同一清单)');
// NAV_CONTENT_ITEMS 遍历(**只内容窗格** —— 设置已单列在下面;若遍历 NAV_ITEMS
// 会因第 4 项「我的」出现两个 person 图标)
assert.match(sidebar, /ForEach\(NAV_CONTENT_ITEMS,/, '要遍历内容窗格清单(与底部导航前三项同源)');
// 品牌标 + 三项导航 + 设置(与 WebUI:brand / nav / settings 的分组一致)
assert.match(sidebar, /品牌标/, '要有品牌标(注释里的分组依据)');
assert.match(sidebar, /iconName: 'person'/, '设置按钮要用 person 图标(WebUI 侧沿用的同一枚)');

View File

@ -176,12 +176,13 @@ export class PushService {
if (!PushService.isEnabled(this.context)) {
return;
}
// ★ 先申请通知权限再取 token:部分设备上 Push Kit getToken 会因通知权限未开而
// 返回空 / 报 1600004,先 requestEnableNotification 把权限前置到位。
await this.requestEnableNotification();
const token: string = await this.getToken();
if (token.length === 0) {
return; // 没有 token 就没什么可报的(这不是错误)
}
// 有 token 才申请通知权限:没 token(没 HMS Core / 没登录华为账号)时**不该弹窗打扰用户**
await this.requestEnableNotification();
const accountKey: string = this.account.getActiveId();
const lastMarker: string = await this.readMarker();
/*

View File

@ -83,18 +83,46 @@ export struct AmIcon {
/** 描边宽度(WebUI 用 1.8) */
@Prop strokeWeight: number = 1.8;
/**
* 把 24 单位坐标系落到 `iconSize` vp 上所需的缩放比。
* Path.commands 的坐标是 px:24 单位需放大到 vp2px(iconSize) 个 px。
*
* 用 `UIContext#vp2px` 而不是全局 `vp2px`:后者已被 SDK 标废弃,
* `harmony-system-api` 那条判据会对全局调用默认判红。
*/
private iconScale(size: number): number {
return this.getUIContext().vp2px(size) / ICON_VIEWBOX;
}
/**
* `scale()` 会把描边一并放大,所以要先把描边除掉这一层。
* 目标视觉描边 = strokeWeight × (iconSize / 24) vp(与 WebUI 同比例)。
* 而 strokeWidth 数字按 vp 解释:设 X vp → X*vp2px(1) px → 再乘 scale。
* 解出 X = strokeWeight / vp2px(1)。
*/
private iconStrokeWidth(): number {
return this.strokeWeight / this.getUIContext().vp2px(1);
}
build() {
/*
* ★ Path.commands 的坐标是 **px**,不是 vp —— 官方 Shape/viewPort 示例里缩放成立,
* 是因为它用 Rect/Circle(自带 vp 宽高),而不是裸 `Path.commands`。
* 实测:iconSize=24 时 Path 只画 24px(≈6.9vp),而等宽 Shape 盒是 24vp=84px,
* 于是图标小而偏左上(截图硬证:bbox 24×24,中心偏 FAB 中心 (-30,-30))。
* ⇒ 用 `vp2px(iconSize)/ICON_VIEWBOX` 把 24 单位路径等比放大到 iconSize **vp**。
* 注意 scale 会连带放大描边(实图曾因此变成粗块),故描边先除掉同一系数。
* `viewPort` 不再需要:它只裁剪不缩放。
*/
Stack({ alignContent: Alignment.Center }) {
Path()
.commands(ICON_PATHS[this.iconName] ?? '')
.width(ICON_VIEWBOX)
.height(ICON_VIEWBOX)
.fill(Color.Transparent)
.stroke(this.iconColor)
.strokeWidth(this.strokeWeight)
.strokeWidth(this.iconStrokeWidth())
.strokeLineCap(LineCapStyle.Round)
.strokeLineJoin(LineJoinStyle.Round)
.scale({ x: this.iconSize / ICON_VIEWBOX, y: this.iconSize / ICON_VIEWBOX })
.scale({ x: this.iconScale(this.iconSize), y: this.iconScale(this.iconSize) })
}
.width(this.iconSize)
.height(this.iconSize)

View File

@ -19,19 +19,34 @@ export interface NavItem {
*
* 2026-09-15 之前这里是 emoji 字形(`✉️`/`📅`/`👤`),理由是"不用猜 sys.media 名字"。
* 那条理由只解释了"为什么不用系统资源名",却把 emoji 当成了唯一替代 —— 结果是:
* · WebUI 在 `icons.tsx` 第一行就写着「**纯 SVG 图标,全站不使用 emoji**」,
* · WebUI 在 `icons.tsx` 第一行就写着「**纯 SVG 图标,全站不使用 emoji**」,
* 两边**同一个产品却两套图标语言**;
* · emoji 由系统字体渲染,颜色不受 `fontColor` 控制、随厂商与版本变形,
* · emoji 由系统字体渲染,颜色不受 `fontColor` 控制、随厂商与版本变形,
* 选中/未选中只能靠整体透明度示意,做不到 WebUI 那种"线性描边图标 + 品牌色"。
*
* 现在改用 ArkUI 原生 `Path`(`common/Icons.ets`,由 WebUI 的 TSX 自动生成),
* 既不需要猜 `sys.media` 名字,又能吃主题色(深色模式一处也不漏)。
*/
iconKey: string;
/**
* 外壳入口:点击后路由到独立 @Entry 页(`pages/xxx`),不在主界面挂内容。
* 留空 = 内容窗格(由 `MainPage` 的 currentIndex 分派挂内容)。
*
* 为什么单列这个字段而不是把「我的」塞进 currentIndex 分派:设置页是 **@Entry**
* (自带返回按钮、独立路由栈),不能当作可嵌入的 pane —— 在内容分派里给它
* 占一个 index 会出现"点了没内容、或每次重建都 pushUrl"的尴尬。外壳入口在
* NavItem 的 onClick 里直接路由,与 WideSidebar 底部那个设置按钮是同一行为。
*/
route?: string;
}
/**
* 平级导航项。**三项**:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
* 底栏四枚图标 = 三个内容窗格 + 一个外壳入口,与 WebUI `NarrowNav.tsx` 的四入口一一对应。
*
* 前**三项**是平级内容窗格:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
* 第**四项**「我的」是外壳入口(`route` 指向 `pages/SettingsPage`)—— 它承载
* 设置/主题/壁纸/账号,是独立 @Entry 页,不在主界面 currentIndex 分派里挂内容
* (理由见 `NavItem.route` 的注释;WideSidebar 同样把设置单列在导航项之后)。
*
* 「会话」原先是个平级 tab,已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法"
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由见 `MainPage.build()` 的注释。
@ -44,7 +59,8 @@ export interface NavItem {
export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', iconKey: 'inbox' },
{ key: 'calendar', label: '日历', iconKey: 'calendar' },
{ key: 'contacts', label: '联系人', iconKey: 'contacts' }
{ key: 'contacts', label: '联系人', iconKey: 'contacts' },
{ key: 'me', label: '我的', iconKey: 'person', route: 'pages/SettingsPage' }
];
/** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */
@ -84,20 +100,31 @@ export const NAV_CONTENT_GAP: number = 8;
*/
export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + NAV_CONTENT_GAP;
/** 把任意下标归一化到合法范围(点击/外部传值都过这里,别直接赋值) */
/** 内容窗格的数量(前三项:通信/日历/联系人)—— `MainPage` 的 currentIndex 分派按它穷尽,
* `NAV_CONTENT_ITEMS` 也按它切片 */
export const NAV_CONTENT_COUNT: number = 3;
/** 内容窗格的清单(前三项:通信/日历/联系人)—— 宽屏侧栏与 currentIndex 分派都用它
* (第 4 项「我的」是外壳入口,不在内 —— 否则宽屏侧栏会出现两个 person 图标) */
export const NAV_CONTENT_ITEMS: NavItem[] = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT);
/** 外壳入口(第 4 项「我的」)的路由目标 —— 与 WideSidebar 的 onSettings 同一地址 */
export const NAV_SETTINGS_ROUTE: string = 'pages/SettingsPage';
/** 把任意下标归一化到内容窗格的合法范围(点击/外部传值都过这里,别直接赋值) */
export function normalizeNavIndex(index: number): number {
if (index >= 0 && index < NAV_ITEMS.length) {
if (index >= 0 && index < NAV_CONTENT_COUNT) {
return index;
}
return 0;
}
/** 第 index 项的键(内容按它挂载,判据也认它) */
/** 第 index 项的键(内容按它挂载,判据也认它) —— 越界回 0(内容窗格,不含外壳入口) */
export function navKeyAt(index: number): string {
return NAV_ITEMS[normalizeNavIndex(index)].key;
return NAV_CONTENT_ITEMS[normalizeNavIndex(index)].key;
}
/** 第 index 项的标签(给判据与无障碍文案用) */
/** 第 index 项的标签(给判据与无障碍文案用) —— 越界回 0(内容窗格,不含外壳入口) */
export function navLabelAt(index: number): string {
return NAV_ITEMS[normalizeNavIndex(index)].label;
return NAV_CONTENT_ITEMS[normalizeNavIndex(index)].label;
}

View File

@ -84,6 +84,19 @@ import {
const INBOX_PAGE_SIZE: number = 50;
const MAIL_DETAIL_ROUTE: string = 'mail-detail';
/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */
function compactMailTime(iso: string): string {
const value: Date = new Date(iso);
if (Number.isNaN(value.getTime())) {
return '';
}
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
const day: string = value.getDate().toString().padStart(2, '0');
const hour: string = value.getHours().toString().padStart(2, '0');
const minute: string = value.getMinutes().toString().padStart(2, '0');
return month + '/' + day + ' ' + hour + ':' + minute;
}
/**
* Navigation 目标页:按官方示例在 NavDestination.onReady 读取路径参数。
* 系统 Navigation 自动决定 Stack / Split;这里不读窗口宽度、不手搓分栏。
@ -337,32 +350,49 @@ struct InboxTab {
build() {
Column() {
Row() {
Column() {
Row() {
Text('收件箱').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
if (this.accountList.length > 1) {
Text(' ▾').fontSize(14).fontColor(Theme.textSubtle)
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
/*
* WebUI 的列表头只有一张紧凑卡:标题 +「会话组 · 邮件数」+ 账号切换。
* 不再重复堆「通信 / 收件箱 / 全部邮箱 / 已加载 / 未读」五层说明文字。
*/
/*
* ★ 卡片要真缩到 10vp,不能靠 `width('100%') + margin`:
* ArkUI 的 margin 加在宽度**外面**,100% 宽度再配左右 margin 会顶出父容器
* (实测卡片左缘只留 ~1px,列表卡却有 10vp 缩进)。改成外层带 padding 的容器。
*/
Column() {
Row() {
Text('收件箱').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Text(this.groups.length + ' 组 · ' + this.loaded + ' 封')
.fontSize(12).fontColor(Theme.textMuted).margin({ left: 10 })
Blank()
if (this.accountList.length > 1) {
Row() {
Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName)
.fontSize(11).fontColor(Theme.textMuted)
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtle })
.rotate({ angle: 90 })
.margin({ left: 3 })
}
.height(32)
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
if (this.unread > 0) {
Text(this.unread > 99 ? '99+' : this.unread.toString())
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.surface)
.backgroundColor(Theme.danger).borderRadius(9)
.constraintSize({ minWidth: 18 }).height(18)
.textAlign(TextAlign.Center).margin({ left: 8 })
}
Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName)
.fontSize(11).fontColor(Theme.textSubtle).margin({ top: 2 })
.onClick(() => { if (this.accountList.length > 1) { this.showAccountPicker = !this.showAccountPicker; } })
}
.alignItems(HorizontalAlign.Start)
Blank()
if (this.unread > 0) {
Text(this.unread + ' 未读')
.fontSize(13).fontColor(Theme.surface)
.backgroundColor(Theme.danger)
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
// ⚙ 不在这里了:它搬到了通信页的页头 —— 否则切到发件箱/授权就够不到设置
.width('100%').height(46)
.padding({ left: 14, right: 12 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
.backgroundColor(Theme.surface)
.width('100%')
.padding({ left: 10, right: 10 })
.margin({ top: 10 })
// 账号选择器下拉
if (this.showAccountPicker && this.accountList.length > 1) {
@ -423,26 +453,7 @@ struct InboxTab {
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
/*
* 本页取满了就当场说清楚"可能还有更多"。
*
* 原先这里写的是「共 N 封」,而那个 N 其实是服务端返回的**未读数**
* (`CountUnread`)—— 界面上同时出现「共 7 封」和「未读 7」这种自相矛盾的
* 两行字,且折叠之后更糟:拿 50 封折叠出的会话数会被当成"就只有这么多会话"。
*/
if (this.notice.length > 0) {
Row() {
Text(this.notice)
.fontSize(Theme.fontTiny)
.fontColor(Theme.warnFg)
.maxLines(2)
}
.width('100%')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(Theme.warnBg)
}
List({ space: 1 }) {
List({ space: 6 }) {
ForEach(this.groups, (g: SessionGroup) => {
ListItem() {
if (isFlatGroup(g)) {
@ -464,17 +475,8 @@ struct InboxTab {
}, (g: SessionGroup) => g.key)
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
.padding({ left: 10, right: 10, top: 10, bottom: 10 })
}
// 底部信息栏(悬浮加号不在这里:它属于**通信页**,三个栏都要能新建 —— 见 CommPage)
Row() {
Text('已加载 ' + this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle)
Blank()
Text('未读 ' + this.unread).fontSize(12).fontColor(Theme.textSubtle)
}
.width('100%').height(36).padding({ left: 16, right: 16 })
.backgroundColor(Theme.surface)
}
.width('100%').height('100%')
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
@ -497,15 +499,10 @@ struct InboxTab {
Row() {
this.MailItem(mail)
}
.width('100%').height(74)
/*
* **每项一张卡**(不是通栏):jianf 同步过来的语义 —— 列表项各自成卡/气泡,
* 卡与卡之间留缝,靠底色与圆角分开,不再用贯通整屏的分隔线。
* 底色用系统的"列表卡片底色",深浅主题由系统挑。
*/
.width('100%').height(64)
.backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 6 })
.border({ width: 1, color: Theme.border })
.clip(true)
.onClick(() => {
this.openMail(mail);
@ -521,42 +518,49 @@ struct InboxTab {
@Builder
GroupHeader(g: SessionGroup) {
Row() {
Text(this.isExpanded(g.key) ? '▾' : '▸')
.fontSize(12).fontColor(Theme.textMuted).width(18)
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtle })
.rotate({ angle: this.isExpanded(g.key) ? 90 : 0 })
.width(20)
Column() {
Row() {
Text(g.alias.length > 0 ? g.alias : '(未命名会话)')
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (g.unreadCount > 0) {
Text(g.unreadCount + '')
.fontSize(10).fontColor(Theme.surface)
.backgroundColor(Theme.danger)
.borderRadius(9).width(18).height(18)
.textAlign(TextAlign.Center)
}
Text(g.latest?.from_name ?? '')
.fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(compactMailTime(g.latest?.created_at ?? ''))
.fontSize(10).fontColor(Theme.textSubtle).margin({ left: 8 })
}
.width('100%')
Row() {
if (g.unreadCount > 0) {
Text(g.unreadCount > 99 ? '99+' : g.unreadCount.toString())
.fontSize(9).fontWeight(FontWeight.Bold).fontColor(Theme.surface)
.backgroundColor(Theme.accent).borderRadius(8)
.constraintSize({ minWidth: 16 }).height(16).textAlign(TextAlign.Center)
.margin({ right: 6 })
}
Text(g.subject.length > 0 ? g.subject : '(无主题)')
.fontSize(12).fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Blank()
Text(g.mails.length + ' 封').fontSize(11).fontColor(Theme.textSubtle)
.fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
}
.width('100%').margin({ top: 3 })
Row() {
Text(g.alias.length > 0 ? '.' + g.alias : '(未命名会话)')
.fontSize(10).fontColor(Theme.accent)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
Text(g.mails.length + ' 封').fontSize(10).fontColor(Theme.textSubtle).margin({ left: 8 })
}
.width('100%').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').height(60)
.padding({ left: 12, right: 12 })
.width('100%').height(78)
.padding({ left: 10, right: 12 })
.alignItems(VerticalAlign.Center)
.backgroundColor(Theme.surfaceMuted)
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 6 })
.border({ width: 1, color: Theme.border })
.clip(true)
.onClick(() => {
this.toggleExpanded(g.key);
@ -584,7 +588,7 @@ struct InboxTab {
.margin({ left: 6 })
}
Blank()
Text(mail.created_at).fontSize(11).fontColor(Theme.textSubtle)
Text(compactMailTime(mail.created_at)).fontSize(10).fontColor(Theme.textSubtle)
}
.width('100%')
@ -1112,28 +1116,6 @@ struct CommPage {
aboutToAppear(): void {
this.refreshCounts();
this.applyAppearance();
}
/**
* 外观(主题 + 壁纸)跟着**账号**走:这里进入主界面时先应用一次。
*
* 为什么主界面也要做一次、而不只在设置页里做:用户改了主题后如果只有设置页生效,
* 一进主界面就"变回去了"(WebUI 侧踩过:服务端存了外观、界面却毫无变化)。
* 没登录/离线时 store 会退回本地缓存并标 `local-only`(降级可见)。
*/
async applyAppearance(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const store: AppearanceStore = AppearanceStore.getInstance();
// 缓存**按账号**读:多账号共用一份是 WebUI 侧的原始缺陷
store.loadLocal(ctx, acctMgr.getActiveId());
const client: ApiClient = new ApiClient(ctx);
await store.syncFromServer(ctx, client);
}
/** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */
@ -1240,24 +1222,6 @@ struct CommPage {
Stack({ alignContent: Alignment.BottomEnd }) {
Navigation(this.navPathStack) {
Column() {
Row() {
Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
/*
* 设置入口。
* 图标用 `person`:与 WebUI 的「我的」(`NarrowNav.tsx` 的 `PersonIcon`)同一枚,
* 而设置页里正是账号 / 外观 / 密钥这些“属于这个人的东西”。
* 2026-09-15 之前这里是 emoji `⚙`(理由与替换过程见 `model/NavItems.ts`)。
*/
AmIcon({ iconName: 'person', iconSize: 20, iconColor: Theme.textMuted })
.width(36).height(36)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' });
})
}
.width('100%').height(52).padding({ left: 16, right: 8 })
.backgroundColor(Theme.surface)
this.CommTabBar()
if (this.commTab === 'sent') {
@ -1289,13 +1253,13 @@ struct CommPage {
* 悬浮的圆形加号(用户点名要的形状):56 圆、品牌色、右下角。
* 放在通信页这一层,所以三个栏里都在 —— 新建邮件这件事不挑栏。
*/
Text('+')
.fontSize(28).fontColor(Theme.surface)
Button() {
AmIcon({ iconName: 'compose', iconSize: 24, iconColor: Theme.surface })
}
.width(56).height(56)
.borderRadius(28)
.backgroundColor(Theme.accent)
.textAlign(TextAlign.Center)
.margin({ right: 20, bottom: 20 })
.margin({ right: 16, bottom: 16 })
.onClick(() => { this.openCompose(); })
}
.width('100%').height('100%')
@ -1672,7 +1636,19 @@ struct MainPage {
await acctMgr.load();
const store: AppearanceStore = AppearanceStore.getInstance();
store.loadLocal(ctx, acctMgr.getActiveId());
const client: ApiClient = new ApiClient(ctx);
/*
* ★ 必须用**单例**,且**不能**调 `init()`。
* 登录页的快速路径(已有账号)走 `client.setToken(active.token)`——
* 只写单例内存、**不** persistToken。如果这里再调 `init()`,
* 会从 preferences 读回可能空/旧的 token,把内存里正确的 token 覆盖掉
* ⇒ `/me/appearance` 恒 401(hilog 实测:response_code 401)。
* 单例由 LoginPage 先拿到,token 已在内存里;这里直接用即可。
* 只有冷启动直跳 MainPage(理论上不会发生)时才需要 init() 兜底。
*/
const client: ApiClient = ApiClient.getInstance(ctx);
if (client.getToken().length === 0) {
await client.init();
}
await store.syncFromServer(ctx, client);
const snap: AppearanceSnapshot = store.current();
this.wallpaperImage = store.wallpaper;
@ -1833,8 +1809,14 @@ struct MainPage {
.height(NAV_BAR_HEIGHT)
.constraintSize({ minHeight: NAV_ITEM_MIN_HIT, minWidth: NAV_ITEM_MIN_HIT })
.justifyContent(FlexAlign.Center)
// 点击:过 normalizeNavIndex 归一化(与内部页签同一套纪律:不直接赋值)
// 点击:外壳入口(route 非空)直接路由到 @Entry 页(与 WideSidebar 的设置按钮同一行为);
// 内容窗格才进 currentIndex 分派。两者不能混:设置页是 @Entry,在内容分派里占 index
// 会出现"点了没内容"或"每次重建都 pushUrl"(见 NavItem.route 的注释)。
.onClick(() => {
if (item.route !== undefined && item.route.length > 0) {
this.getUIContext().getRouter().pushUrl({ url: item.route });
return;
}
this.currentIndex = normalizeNavIndex(index);
})
}
@ -1902,7 +1884,11 @@ struct MainPage {
build() {
/*
* 底部三项:**通信** / 日历 / 联系人。
* 底部四枚图标:通信 / 日历 / 联系人 / 我的(与 WebUI `NarrowNav.tsx` 四入口一致)。
*
* 前三项是内容窗格(按 currentIndex 分派挂内容);第4项「我的」是外壳入口
* —— 点击路由到 `pages/SettingsPage`(见 `NavItem` 的 onClick,与 WideSidebar 的
* 设置按钮同一行为),不在 currentIndex 分派里占位(见 `NavItems.ts` 的 `route` 字段说明)。
*
* 通信不再等于收件箱:它内部有三栏(收件箱 / 发件箱 / 授权 + 徽标),
* 见 `CommPage` 与 `model/CommTabs.ts`。这也是 WebUI 现在的信息架构
@ -1971,12 +1957,17 @@ struct MainPage {
.width('100%')
.height('100%')
/*
* ★ 2026-09-16:宽屏内容面板的玻璃几何(对齐 WebUI app-shell)。
* 圆角 14 + clip(子内容被裁剪)+ 容器 padding/gap(面板缝隙露壁纸)。
* 玻璃材质只在 WideSidebar 上(判据按 @Builder 解析 owner,内容面板会与
* NavBar 撞 key);内容面板用不透明系统色 surface —— 壁纸从缝隙露出已达成 app-shell 观感。
* ★ 2026-09-17:窄屏壁纸可见化(对齐 WebUI 的 .narrow-shell)。
* WebUI 的正文面(卡片/气泡)不透明,但**容器**(.narrow-shell / .narrow-stack)
* 透明 —— 壁纸从卡片间健、从顶/底边缘透出。鸿蒙这边由内向外是:
* · 内容窗格 (CommPage 等) 已是 `bgActive ? Transparent : pageBg`(判据钉 ≥5 处);
* · 这里是它们的父容器,原来恒不透明 surface ⇒ 内层透明也被盖住 ⇒ 壁纸在窄屏上看不见。
* 所以窄屏 + bgActive 时这里也透明,壁纸才透得过。宽屏仍用 surface(玻璃面板从缝隙露壁纸
* 是既定的 app-shell 观感,不动)。
* 不加 backgroundBlurStyle:卡片是"正文面不透"的那一层(Theme 注释原话),
* 玻璃只留给浮层(NavBar),不是这里。
*/
.backgroundColor(Theme.surface)
.backgroundColor(this.isWide ? Theme.surface : (this.bgActive ? Color.Transparent : Theme.surface))
.borderRadius(this.isWide ? Theme.glassRadius : 0)
.clip(this.isWide)
.padding({ bottom: this.isWide ? 0 : NAV_CONTENT_RESERVE })

View File

@ -18,7 +18,7 @@
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
import { NAV_ITEMS, NavItem } from '../model/NavItems';
import { NAV_CONTENT_ITEMS, NavItem } from '../model/NavItems';
/** 侧栏宽度(vp)。WebUI 的 `Sidebar` 用 `w-[60px]` */
export const SIDEBAR_WIDTH: number = 60;
@ -55,8 +55,9 @@ export struct WideSidebar {
AmIcon({ iconName: 'brandMark', iconSize: 30, iconColor: Theme.accent })
.margin({ bottom: 16 })
// 导航项
ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
// 导航项:**只内容窗格**(前三项)—— 设置已单列在下面,
// 若遍历 NAV_ITEMS 会因第 4 项「我的」出现**两个 person 图标**。
ForEach(NAV_CONTENT_ITEMS, (item: NavItem, index: number) => {
this.NavItemBuilder(item.key, item.iconKey, index)
}, (item: NavItem) => item.key)