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:
@ -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 要由背景计划决定');
|
||||
|
||||
@ -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\(\)/, '加号要真的能进写信页');
|
||||
|
||||
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)
|
||||
|
||||
@ -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, '非整数下标不该悄悄生效');
|
||||
});
|
||||
|
||||
|
||||
@ -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 实测)');
|
||||
});
|
||||
|
||||
@ -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 侧沿用的同一枚)');
|
||||
|
||||
Reference in New Issue
Block a user