diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 00dbc11..2d707d6 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -301,7 +301,7 @@ test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 */ const GLASS_REGISTRY = [ // 文件(相对 ets 根) + 组件/Builder 名:为什么这里可以有一层系统材质 - { file: 'pages/MainPage.ets', provider: 'TabBarBuilder', why: '底部导航栏要浮在内容之上(P5 的悬浮导航)' } + { file: 'pages/MainPage.ets', provider: 'NavBar', why: 'P5 悬浮玻璃条:浮在**会滚动的内容**之上(pi 给的放行条件②),壁纸层整个不吃材质' } ]; /** diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index f5fa4c8..255ca79 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -386,8 +386,18 @@ test('★ 页面真的把背景画出来了(这一条是补漏:P4 第一版 * 这一条与下面那条"遮盖色方向"是同一件事的两个面:这里钉"层在不在、浓度接没接上"。 */ assert.match(main, /\.backgroundColor\(Theme\.wallpaperScrim\)[\s\S]{0,80}?\.opacity\(this\.bgPlan\.scrim\)/, '遮盖层要用页面底色系令牌与算出来的浓度'); - // 背景在主界面这一层:Tabs 之上不该再有不透明的底色把背景盖死 - assert.match(main, /Stack\(\) \{[\s\S]{0,120}?this\.WallpaperLayer\(\)/, '背景要铺在内容之下(Stack 的底层)'); + /* + * 背景在主界面这一层:内容之前不该再有不透明的底色把背景盖死。 + * ⚠️ P5 起根 `Stack` 带了构造参数(`{ alignContent: Alignment.Bottom }`,浮动条贴底用), + * 所以这里不能写死 `Stack() {` —— 判据钉的是"壁纸是**第一个**子节点",不是 Stack 的写法。 + */ + const rootFrom = main.indexOf('this.WallpaperLayer()'); + assert.ok(rootFrom > 0, '根里要渲染壁纸层'); + const stackOpen = [...main.matchAll(/Stack\([^)]*\)\s*\{/g)].map(m => m.index).filter(i => i < rootFrom).pop(); + assert.ok(stackOpen !== undefined, '壁纸层要挂在某个 Stack 里(浮在最底)'); + const between = main.slice(stackOpen, rootFrom); + assert.ok(!/[A-Za-z]+\(/.test(between.replace(/Stack\([^)]*\)/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')), + `壁纸层必须是 Stack 的**第一个**子节点(中间不该有别的组件):${between.replace(/\s+/g, ' ').slice(0, 120)}`); }); test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统材质(互斥形式,pi 修正后的口径)', () => { @@ -403,7 +413,7 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统 /* * 取"壁纸层 builder 的正文"要**按行**截:用 `indexOf('build() {')` 两头夹, * 要么撞上文件里更早的那个 build()(切片成空串),要么一路跨到后面的 - * TabBarBuilder(那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。 + * 底栏 builder(P5 起叫 `NavBar`,那里**正当地**有 `.backgroundBlurStyle`)——于是判据会误报。 * 这是我自己的切片毛病,和 pi 指出 §三 那条是同一类。 */ const mainLines = main.split('\n'); @@ -414,7 +424,7 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统 if (/^ (@Builder|build\()/.test(mainLines[i])) { stop = i; break; } } const wallpaperBuilder = mainLines.slice(start, stop).join('\n'); - assert.ok(!/TabBarBuilder/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去'); + assert.ok(!/NavBar/.test(wallpaperBuilder), '自检:切片不该跨到别的成员上去'); assert.ok(wallpaperBuilder.length > 200, '要能取到壁纸层的 builder 正文'); assert.ok(!/backgroundBlurStyle/.test(wallpaperBuilder), '壁纸层不许再用材质(同一张底糊两遍 = 更脏更掉帧)'); assert.ok(!/blur\(/i.test(wallpaperBuilder), '壁纸层不许出现任何模糊调用'); diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 2b3fae2..6cb62c1 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -228,8 +228,16 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是 // 第一项 2026-09-14 从「收件箱」变成「通信」:收件箱/发件箱/授权现在是**通信页内部**的三栏 // (用户:「收件发件授权改为一个导航项,通过内部导航区分」),标签必须跟着改 —— // 否则底部写着"收件箱"、点进去却有发件箱和授权,比没有更让人困惑。 - const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); - assert.deepEqual(tabLabels, ['通信', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`); + /* + * P5 起底栏换成自绘的浮动玻璃条(`NavBar` + `NavItems.ts`),不再是系统 `Tabs` 的 + * `TabBarBuilder('标签')` 调用 —— 所以标签从**清单**里读,判的仍是同一件事: + * 平级项只剩两个、顺序不变。 + */ + const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length; + assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动'); + const navSource = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); + const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); + assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`); assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); }); @@ -500,7 +508,9 @@ test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐 test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => { // 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变) - const tabLabels = [...sendCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); + // P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数 + const navSource2 = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8'); + const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`); // 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)—— // 所以这里钉的是"带参数地渲染通信页",不是光有个名字 diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs new file mode 100644 index 0000000..34c5599 --- /dev/null +++ b/client/electron/test/harmony-nav.test.mjs @@ -0,0 +1,202 @@ +// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。 +// +// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动: +// 内容仍按 `currentIndex` 挂载,两个平级项仍是 通信 / 联系人。 +// 所以判据分四类: +// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick"); +// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变); +// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜; +// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质), +// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。 +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..', '..'); +const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); +const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts'); + +const read = p => readFileSync(join(HARMONY_ETS, p), 'utf8'); +/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */ +const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +/** 取某个 `@Builder` 的正文(按行切到下一个成员) */ +function builderBody(src, signature) { + const lines = src.split('\n'); + const start = lines.findIndex(l => l.includes(signature)); + assert.ok(start > 0, `要能找到 ${signature}`); + let stop = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^ (@Builder|build\()/.test(lines[i])) { stop = i; break; } + } + return lines.slice(start, stop).join('\n'); +} + +/** + * 取 `from` 处第一个 `{` 到**配对**的 `}` 之间的正文。 + * + * ⚠️ 我第一版用的是非贪婪正则 `\{([\s\S]*?)\}`:它在 + * `CommPage({ bgActive: this.bgActive })` 的**对象字面量** `}` 上就收尾了, + * 于是"else 分支里有没有 ContactsTab"被判成没有 —— 又是我自己那条 + * "判结构要配对/解析,不要窗口"(判据也被它咬)。这里按花括号配对取。 + */ +function braceBody(src, from) { + const at = src.indexOf('{', src.indexOf(from)); + assert.ok(at > 0, `要能找到 ${from} 后面的 {`); + let depth = 0; + for (let i = at; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); } + } + assert.fail(`${from} 的花括号没有闭合`); +} + +const N = await import(NAV_TS); +const main = read('pages/MainPage.ets'); + +test('① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"有 onClick")', () => { + /* + * 这条刻意不写成"存在 onClick" —— 那是最容易被满足、也最容易骗人的形状: + * 点哪个都赋 0 也能过。这里要求**每一项的点击目标与它的项下标配对**: + * 点击处理器里赋的值,必须来自这一项自己的 index(ForEach 的第二个参数), + * 并且过 `normalizeNavIndex` 归一化(与内部页签同一套纪律:不直接赋值)。 + */ + const item = builderBody(main, 'NavItem(item: NavItem, index: number) {'); + assert.ok(item.length > 100, '要能取到导航项的正文'); + /* + * 点击处理器是 `() => { this.currentIndex = normalizeNavIndex(index); }`。 + * 取赋值语句并断言它**同时**用到 `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} 次)`); + assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/, + `点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`); + // 处理器必须挂在**这一项**上(不是别的成员上) + assert.match(item, /\.onClick\(\(\)\s*=>\s*\{[\s\S]{0,120}?this\.currentIndex/, '点击处理器要挂在本项上'); + // ForEach 要真的把 index 传进来(否则上面那句 index 无从谈起) + const bar = builderBody(main, 'NavBar() {'); + assert.match(bar, /ForEach\(NAV_ITEMS,\s*\(item: NavItem,\s*index: number\)/, 'ForEach 要带 index 参数'); + assert.match(bar, /this\.NavItem\(item,\s*index\)/, '要把每一项(连同 index)交给导航项 builder'); +}); + +test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人)', () => { + /* + * 换了条之后最容易出的错是"点了没反应"——点击改了状态,但内容仍写死挂第一个页面。 + * 所以把 index → 页面的映射钉死:0 → CommPage,否则 → ContactsTab, + * 且两边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。 + */ + const stack = main.slice(main.indexOf('Stack({ alignContent: Alignment.Bottom })')); + // 只在**根 build()** 里数分派(页面内部也有 if/else,扫全文会数错) + const root = stack.slice(0, stack.indexOf('this.NavBar()') + 100); + const dispatch = [...root.matchAll(/if \(this\.currentIndex === 0\)/g)]; + assert.equal(dispatch.length, 1, `根里应当恰好一处"按 index 分派内容"(实际 ${dispatch.length} 处)`); + const first = braceBody(root, 'if (this.currentIndex === 0)'); + const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else'); + assert.match(first, /CommPage\(\{ bgActive: this\.bgActive \}\)/, 'index 0 要挂通信页'); + assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 1 要挂联系人页'); + /* + * 分派必须只有这两支,且必须**穷尽** NAV_ITEMS —— 现在只有两项,用 if/else 就够了; + * 一旦 NAV_ITEMS 变长,这条会红并提醒改成按清单挂载(否则第 3 项点了没内容)。 + */ + assert.equal(N.NAV_ITEMS.length, 2, + `NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,而内容分派只有 if/else 两支 —— 加了项必须同时加分派`); + // 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到 + const navAt = stack.indexOf('this.NavBar()'); + const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })'); + assert.ok(navAt > contentAt, '浮动条要在内容之后渲染(浮在上层)'); +}); + +test('③ 命中区 ≥44vp:判的是数值本身(从 NavItems.ts 导入,不猜源码)', () => { + assert.ok(N.NAV_ITEM_MIN_HIT >= 44, `命中区下限必须 ≥44vp(现在 ${N.NAV_ITEM_MIN_HIT})`); + assert.ok(N.NAV_BAR_HEIGHT >= N.NAV_ITEM_MIN_HIT, + `条高(${N.NAV_BAR_HEIGHT})不得小于命中区下限(${N.NAV_ITEM_MIN_HIT})`); + // 数值写了还要**用上**:项必须有下限约束,且高度取条高 + const item = builderBody(main, 'NavItem(item: NavItem, index: number) {'); + assert.match(item, /\.constraintSize\(\{\s*minHeight:\s*NAV_ITEM_MIN_HIT,\s*minWidth:\s*NAV_ITEM_MIN_HIT\s*\}\)/, + '导航项要显式声明命中区下限(minHeight 与 minWidth 都要)'); + assert.match(item, /\.height\(NAV_BAR_HEIGHT\)/, '导航项高度取条高'); + assert.match(item, /\.layoutWeight\(1\)/, '两项要等分条宽(否则命中区宽度靠内容撑,会小于下限)'); + // 条自身也要够高 + const bar = builderBody(main, 'NavBar() {'); + assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量'); +}); + +test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => { + const bar = builderBody(main, 'NavBar() {'); + /* + * "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。 + * 这三样缺一样就不再是 WebUI 那条玻璃条了(贴边的全宽条 = 又变回系统 bar 的样子)。 + */ + assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/, + '四周要留白(左右 + 离底),贴边就不是悬浮'); + assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)'); + assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '材质用系统档次,不手写 alpha'); + /* + * 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值", + * 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。 + * 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。 + */ + assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)), + `条上不许出现手写色值(深浅两套由系统给),实际:${(code(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`); + + // 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到) + assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM, + `内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`); + assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度'); + // 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡) + const contentPad = main.slice(main.indexOf('Column() {\n if (this.currentIndex === 0)')); + assert.match(contentPad.slice(0, 400), /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, + '让位要加在挂载内容的那层上'); +}); + +test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项仍是两项', () => { + /* + * 反面:如果只是"加了自绘条但仍留着系统 bar",界面上会出现两条导航(且系统那条 + * 依然贴边、依然占位),这正是这一期要消除的东西。所以断言根导航里**没有** + * `Tabs(` / `TabContent` / `tabBar(`。 + * 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)—— + * 所以这条只在"根组件"的范围内断言,别扫全文。 + */ + const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项')); + assert.ok(root.length > 300, '要能取到根 build() 的正文'); + const navCode = code(root); + assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs'); + assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent(内容改为按 index 挂载)'); + assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()'); + // 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效) + assert.ok(!/TabBarBuilder/.test(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里'); + + // 平级项仍是两项且顺序不变(信息架构这一期不动) + 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 与清单一致'); + // 归一化:越界/负数都退回第一项(点击与外部传值都过它) + assert.equal(N.normalizeNavIndex(-1), 0); + assert.equal(N.normalizeNavIndex(99), 0); + assert.equal(N.normalizeNavIndex(1), 1); + assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效'); +}); + +test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => { + /* + * pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次; + * ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓, + * 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。 + * 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。 + */ + const wallpaper = builderBody(main, 'WallpaperLayer() {'); + assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)'); + assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用'); + const bar = builderBody(main, 'NavBar() {'); + assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)'); + // 理由要写在**原文**(注释会被剥掉,而理由就在注释里) + const mainRaw = read('pages/MainPage.ets'); + const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {')); + assert.match(navDoc, /会滚动的内容|滚动内容/, '要写清"为什么这一处可以有材质"(背后是可变内容)'); + assert.match(navDoc, /GLASS_REGISTRY/, '要指名登记册,后人查得到放行流程'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 0bde5f2..07352b5 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -67,9 +67,11 @@ const SUITE = [ ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 23], + // P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位 + ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], // 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号 ['test/appearance-defaults.test.mjs', [], 3], - ['test/build-stamp.test.mjs', [], 5], + ['test/build-stamp.test.mjs', [], 6], ['test/packaging.test.mjs', [], 3], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2] ]; diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts new file mode 100644 index 0000000..16ab06e --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -0,0 +1,72 @@ +/* + * 底部导航(P5:悬浮玻璃条)的**清单与尺寸**。 + * + * 为什么数据放在 `.ts` 而不是写在 `MainPage.ets` 里: + * - 判据可以直接 `import` 它(`node --experimental-strip-types` 能跑纯逻辑 `.ts`), + * 于是"命中区 ≥44vp"这类要求判的是**数值本身**,而不是拿正则去源码里猜; + * - 「通信 → 联系人」的顺序与文案是信息架构,和 CommTabs 一样属于跨端要对齐的东西 + * (WebUI 侧同样是两项,撤掉会话 tab 的时机见计划文档 §7.15)。 + */ + +/** 一个底部导航项。字段与 WebUI `Sidebar.tsx` 的项一一对应 */ +export interface NavItem { + /** 稳定键(ForEach 的 key,也是判据认的名字) */ + key: string; + /** 显示文案(与 WebUI 逐字一致) */ + label: string; + /** 图标字形(鸿蒙侧用文本字形,不用猜 sys.media 名字) */ + icon: string; +} + +/** + * 平级导航项。**只有两项**:通信(内部三栏:收件箱/发件箱/授权)与联系人。 + * + * 「会话」原先是个平级 tab,已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法" + * (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由与时机见 `MainPage.build()` 的注释。 + * 「日历」等 P6 的内容做完再上入口 —— 不留点进去空着的页签(§7.15)。 + */ +export const NAV_ITEMS: NavItem[] = [ + { key: 'comm', label: '通信', icon: '✉️' }, + { key: 'contacts', label: '联系人', icon: '👤' } +]; + +/** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */ +export const NAV_ITEM_MIN_HIT: number = 44; + +/** 浮动条自身高度(vp)。≥ NAV_ITEM_MIN_HIT,否则命中区内边距会互相挤压 */ +export const NAV_BAR_HEIGHT: number = 56; + +/** 悬浮:左右留白(vp)—— 贴边就不是"悬浮"了,WebUI 的底栏同样浮在内容之上 */ +export const NAV_BAR_SIDE: number = 16; + +/** 悬浮:离屏幕底部的留白(vp) */ +export const NAV_BAR_BOTTOM: number = 12; + +/** 圆角(vp)。用"胶囊"半径(= 高度一半),与 WebUI 的 `rounded-2xl` 观感一致 */ +export const NAV_BAR_RADIUS: number = 28; + +/** + * 内容底部要让出的高度(vp):条高 + 离底留白 + 一点余量。 + * + * **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度, + * 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。 + */ +export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + 8; + +/** 把任意下标归一化到合法范围(点击/外部传值都过这里,别直接赋值) */ +export function normalizeNavIndex(index: number): number { + if (index >= 0 && index < NAV_ITEMS.length) { + return index; + } + return 0; +} + +/** 第 index 项的键(内容按它挂载,判据也认它) */ +export function navKeyAt(index: number): string { + return NAV_ITEMS[normalizeNavIndex(index)].key; +} + +/** 第 index 项的标签(给判据与无障碍文案用) */ +export function navLabelAt(index: number): string { + return NAV_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 b990447..dd5e391 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -48,6 +48,17 @@ import { emptyHint } from '../model/CommTabs'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; +import { + NAV_BAR_BOTTOM, + NAV_BAR_HEIGHT, + NAV_BAR_RADIUS, + NAV_BAR_SIDE, + NAV_CONTENT_RESERVE, + NAV_ITEM_MIN_HIT, + NAV_ITEMS, + NavItem, + normalizeNavIndex +} from '../model/NavItems'; /** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ const INBOX_PAGE_SIZE: number = 50; @@ -1674,15 +1685,52 @@ struct MainPage { } @Builder - TabBarBuilder(title: string, icon: string, index: number) { + NavItem(item: NavItem, index: number) { Column() { - Text(icon).fontSize(20) - Text(title) + Text(item.icon).fontSize(20) + Text(item.label) .fontSize(Theme.fontTiny) .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) } - .width('100%') + /* + * 命中区:**显式给下限**(44vp),不靠"看起来够大"。 + * 每个项 `layoutWeight(1)` 分到条宽的一半,高度就是条高 —— 两处都远大于 44, + * 但仍把下限写出来:以后有人把条调矮时,这里会挡住(判据也钉这个常量)。 + */ + .layoutWeight(1) + .height(NAV_BAR_HEIGHT) + .constraintSize({ minHeight: NAV_ITEM_MIN_HIT, minWidth: NAV_ITEM_MIN_HIT }) .justifyContent(FlexAlign.Center) + // 点击:过 normalizeNavIndex 归一化(与内部页签同一套纪律:不直接赋值) + .onClick(() => { + this.currentIndex = normalizeNavIndex(index); + }) + } + + /** + * 底部导航:**自绘的悬浮玻璃条**(P5),取代系统 `Tabs` 的 bar。 + * + * 为什么换成自绘的: + * - 系统 `Tabs` 的 bar 只能在 `barPosition` 上下两侧、贴边、按系统规矩排布, + * 摆不出 WebUI 那种"浮在内容之上、四周留白、圆角胶囊"的形状; + * - 内容仍按 `currentIndex` 挂载(状态机是同一个),所以这次换的是**条**,不是信息架构。 + * + * 玻璃**只在这一层**,而且这次是"**第二处**允许的模糊": + * 壁纸层整个不吃材质(同一张底只许糊一次),而这条背后是**会滚动的内容** —— + * 满足 pi 给的放行条件(§7.16),所以它在 `GLASS_REGISTRY` 里登记了并写了理由。 + */ + @Builder + NavBar() { + Row() { + ForEach(NAV_ITEMS, (item: NavItem, index: number) => { + this.NavItem(item, index) + }, (item: NavItem) => item.key) + } + .width('100%') + .height(NAV_BAR_HEIGHT) + // 悬浮:四周留白(贴边就不是悬浮了)+ 胶囊圆角 + .margin({ left: NAV_BAR_SIDE, right: NAV_BAR_SIDE, bottom: NAV_BAR_BOTTOM }) + .borderRadius(NAV_BAR_RADIUS) /* * 导航底:**系统材质**,不是手写 alpha。 * @@ -1690,8 +1738,6 @@ struct MainPage { * 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突, * 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial`), * 深浅两套颜色与模糊半径都由系统按主题给。 - * - * 玻璃**只出现在这一层**(内容面不透)—— 嵌套各加一层模糊是 pi 点名要避免的。 */ .backgroundBlurStyle(Theme.navMaterial) } @@ -1717,25 +1763,27 @@ struct MainPage { * (status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集, * 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。) */ - Stack() { + Stack({ alignContent: Alignment.Bottom }) { // 背景在最底下:内容面(系统色)盖在它上面 this.WallpaperLayer() - Tabs({ barPosition: BarPosition.End }) { - TabContent() { - CommPage({ bgActive: this.bgActive }) + /* + * 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。 + * 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的, + * 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。 + */ + Column() { + if (this.currentIndex === 0) { + CommPage({ bgActive: this.bgActive }) + } else { + ContactsTab({ bgActive: this.bgActive }) + } } - .tabBar(this.TabBarBuilder('通信', '✉️', 0)) - - TabContent() { - ContactsTab({ bgActive: this.bgActive }) - } - .tabBar(this.TabBarBuilder('联系人', '👤', 1)) - } - .onChange((index: number) => { - this.currentIndex = index; - }) .width('100%') .height('100%') + .padding({ bottom: NAV_CONTENT_RESERVE }) + + // 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明) + this.NavBar() } .width('100%') .height('100%')