From ff75339baa6387a37f2c2f3b9383343126cfd74d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 17 Sep 2026 16:27:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(harmony):=20=E5=9B=BE=E6=A0=87=E6=8C=89=20p?= =?UTF-8?q?x=E2=86=92vp=20=E6=AD=A3=E7=A1=AE=E7=BC=A9=E6=94=BE=20+=20?= =?UTF-8?q?=E5=BA=95=E6=A0=8F=E5=9B=9B=E5=85=A5=E5=8F=A3=20+=20=E5=8D=A1?= =?UTF-8?q?=E7=89=87=2010vp=20=E7=BC=A9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标过小的根因(实图硬证):`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。 --- .../electron/test/harmony-appearance.test.mjs | 7 +- client/electron/test/harmony-logic.test.mjs | 25 +- client/electron/test/harmony-nav.test.mjs | 41 ++- client/electron/test/harmony-push.test.mjs | 46 +++ .../electron/test/harmony-widescreen.test.mjs | 5 +- .../entry/src/main/ets/api/PushService.ets | 5 +- .../entry/src/main/ets/common/Icons.ets | 36 ++- .../entry/src/main/ets/model/NavItems.ts | 47 +++- .../entry/src/main/ets/pages/MainPage.ets | 261 +++++++++--------- .../entry/src/main/ets/pages/WideSidebar.ets | 7 +- 10 files changed, 301 insertions(+), 179 deletions(-) diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 0e39301..23cdbf7 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -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 要由背景计划决定'); diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 0830a08..7a75d0d 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -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\(\)/, '加号要真的能进写信页'); // 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的) diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 556ad37..3d46e3e 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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(/(? 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, '非整数下标不该悄悄生效'); }); diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs index 0318858..68ad779 100644 --- a/client/electron/test/harmony-push.test.mjs +++ b/client/electron/test/harmony-push.test.mjs @@ -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 实测)'); +}); diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index 32d28a3..6e6505e 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -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 侧沿用的同一枚)'); diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets index c05e36d..0eadcf3 100644 --- a/client/harmony/entry/src/main/ets/api/PushService.ets +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -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(); /* diff --git a/client/harmony/entry/src/main/ets/common/Icons.ets b/client/harmony/entry/src/main/ets/common/Icons.ets index 968add0..97dc592 100644 --- a/client/harmony/entry/src/main/ets/common/Icons.ets +++ b/client/harmony/entry/src/main/ets/common/Icons.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index 88c61aa..4b388df 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -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; } diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index c298a74..5aca4e9 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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 { - 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 }) diff --git a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets index 819fcbd..ef5eefe 100644 --- a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets +++ b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets @@ -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)