跨端: P5 悬浮玻璃导航取代系统 TabBar —— 自绘浮动条 + 命中区 ≥44vp + 内容让位
(subject 原为「跨端(P5): …」—— 被自己的 commit-hygiene 判据判红:约定是 subject 里带 `跨端:`,而 `跨端(P5):` 让字面 grep 找不到。判据是对的,改提交不改成判据。)
This commit is contained in:
@ -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 给的放行条件②),壁纸层整个不吃材质' }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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), '壁纸层不许出现任何模糊调用');
|
||||
|
||||
@ -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`(背景开着时让出页面底,否则壁纸全被盖住)——
|
||||
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
|
||||
|
||||
202
client/electron/test/harmony-nav.test.mjs
Normal file
202
client/electron/test/harmony-nav.test.mjs
Normal file
@ -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/, '要指名登记册,后人查得到放行流程');
|
||||
});
|
||||
@ -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]
|
||||
];
|
||||
|
||||
72
client/harmony/entry/src/main/ets/model/NavItems.ts
Normal file
72
client/harmony/entry/src/main/ets/model/NavItems.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user