用户(2026-09-17):「底栏不是玻璃质感,滑动内容无法穿过底栏」,
随后「还是在导航栏加上文字吧,没有文字还是不好看」。
── ① 底栏没浮起:`width('100%') + margin` 在 ArkUI 里不缩宽 ──
NavBar 原来是 `Row().width('100%').margin({left:16,right:16,bottom:12})`。
ArkUI 的 margin 加在宽度**外面**:100% 再配左右 margin 不会缩到
「100% − margin」,而是整个顶出父容器、两侧被裁。
实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px)⇒ 看起来是贴边通栏,
不是浮在壁纸上的胶囊。这与收件箱头卡片修过的是同一个坑。
改法:外层 Column 带 padding(与 WebUI `.narrow-nav` 的 margin 同一几何)。
实测修复后左缘 x=56 = **16.1vp**(期望 16)。
── ② 内容穿不过去:让位加错了层 ──
原来把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
那不只"让最后一行滚得出来",还**缩短了窗格本身** ⇒ 内容永远到不了条底下
⇒ 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊
⇒ 看起来就是一块普通浅色面板。**这就是"不是玻璃质感"的真正原因**。
改法:窗格满高(内容滑得到条底下,真正穿过),让位改到各滚动容器的
**内容末尾** `contentEndOffset(this.navReserve)`(5 个 List + 日历日程),
与 WebUI 同构(`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面)。
实测:条内高频细节 0.21 vs 条外正文 6.04 ⇒ **28.8×** 衰减,模糊确实生效。
连带修:FAB / 回复球原来靠窗格 padding 躲开条,现在自己按 navReserve 抬
(否则压在条上),实测 FAB 底距条顶 24.4vp。
── ③ 加回文字:并纠正一处**事实错误** ──
代码注释里写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
那句是**错的**:`NarrowNav.tsx:83-86` 每个导航项是
`flex flex-col items-center justify-center gap-0.5`,里面先 `<Icon />`
再 `<span className="text-3xs leading-none">{short}</span>`
—— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
那条判据把一个假事实固化成了规则,还反过来挡住了正确做法。
现在按真实契约钉:图标(24vp,与 WebUI 24×24 同几何)+ 文字,
两者过**同一个**选中三元式(只换颜色,不加背景/指示条)。
判据(全部含变异自检):
· ⑤ 重写:两处选中三元式(图标+文字各一)、必须有 Text(item.label)、
图标 24vp;变异自检覆盖"缺文字 / 文字不换色 / 图标不上色"。
· ④ 改:留白走外层 padding(并反向禁止 margin 做留白);
让位改为 ≥5 处 `contentEndOffset`,并禁止退回窗格 padding 让位。
· 连带更新 ②/⑤ 与 appearance/logic/widescreen 的组件签名断言
(组件多接一个 navReserve)。
· 删掉一段会假红的"从内容层 `}` 往后扫"的窗口式断言:它靠数花括号定界,
而注释里就有大量 `.padding({` 字面量,切片能长到 1700+ 字符,
一路扫进 `onAreaChange`(那里合法地出现 NAV_CONTENT_RESERVE)——
正是本仓库反复记的窗口式判据,故改为纯负向断言。
测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图与像素测量逐项核对。
147 lines
8.3 KiB
JavaScript
147 lines
8.3 KiB
JavaScript
// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。
|
||
//
|
||
// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列:
|
||
// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底)
|
||
// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。
|
||
//
|
||
// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是
|
||
// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
|
||
// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。
|
||
|
||
import { code, prose, stripComments } from './lib/read.mjs';
|
||
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 read = p => prose(join(HARMONY_ETS, p));
|
||
|
||
/** 取某个成员/方法正文(按行切到下一个成员声明) */
|
||
function memberBody(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\(|NavItemBuilder\()/.test(lines[i])) { stop = i; break; }
|
||
}
|
||
return lines.slice(start, stop).join('\n');
|
||
}
|
||
|
||
test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字)', () => {
|
||
const sidebar = read('pages/WideSidebar.ets');
|
||
const code_ = stripComments(sidebar);
|
||
assert.match(code_, /export const SIDEBAR_WIDTH: number = 60\s*;/, '要导出 60 这个常量');
|
||
const main = read('pages/MainPage.ets');
|
||
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
|
||
});
|
||
|
||
test('② 三项导航 + 设置按钮:内容与 WebUI Sidebar 对齐', () => {
|
||
const sidebar = read('pages/WideSidebar.ets');
|
||
// 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 侧沿用的同一枚)');
|
||
assert.match(sidebar, /onSettings/, '设置按钮要回调 onSettings(点击后由 MainPage 路由)');
|
||
});
|
||
|
||
test('③ 选中态只换颜色(与底部导航同一套纪律):纯图标、无文字', () => {
|
||
const sidebar = read('pages/WideSidebar.ets');
|
||
const code_ = stripComments(sidebar);
|
||
/*
|
||
* 2026-09-16 用户:「底部导航栏不允许有文字」⇒ 侧栏同样走纯图标。
|
||
* WebUI 的 `Sidebar.tsx` 本就是 60px 纯图标轨(无 label 文字)。
|
||
*/
|
||
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||
const hits = code_.match(active) || [];
|
||
assert.equal(hits.length, 1,
|
||
`侧栏导航项只应有 1 处选中三元式(现在 ${hits.length} 处)— 纯图标,无文字`);
|
||
// 无文字(label 的 Text)
|
||
assert.ok(!/Text\(label\)/.test(code_),
|
||
'侧栏是纯图标轨:不允许有 label 文字(WebUI Sidebar 无文字)');
|
||
// 图标不能太小(26vp)
|
||
assert.match(code_, /iconSize: 26/,
|
||
'侧栏导航图标要 26vp(WebUI 是 24,鸿蒙放大一档 —— 用户嫌 22 太小)');
|
||
// 不加背景块 / 指示条
|
||
assert.ok(!/currentIndex[^{;]{0,40}backgroundColor/.test(code_),
|
||
'选中态不许改背景色(与底部导航同一纪律:变色即可)');
|
||
});
|
||
|
||
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
|
||
const main = read('pages/MainPage.ets');
|
||
const code_ = stripComments(main);
|
||
// 宽屏分支:isWide 时才渲染 WideSidebar
|
||
assert.match(main, /if \(this\.isWide\) \{/, '侧栏要包在 isWide 分支里');
|
||
assert.match(code_, /WideSidebar\(\{/, '侧栏要真挂在 build 里(剥注释后仍存在)');
|
||
// 窄屏 x 宽屏:底部条只要窄屏有 —— 两半都是条件式的,不是同时出现
|
||
assert.match(main, /if \(!this\.isWide\) \{/, '底部导航条要包在 !isWide 分支里');
|
||
const both = /if \(this\.isWide\) \{[\s\S]*?if \(!this\.isWide\)/.test(main);
|
||
assert.ok(both, 'isWide 与 !isWide 两分支要在同一个结构里(互斥)');
|
||
// 断点检测
|
||
assert.match(code_, /onAreaChange/, '要用 onAreaChange 实时检测窗口宽度');
|
||
assert.match(code_, />=\s*768/s, '断点是 768(与 WebUI 的 lg 断点同一档)');
|
||
// let为0(宽屏没有底部条,内容 padding 归零)
|
||
/*
|
||
* ★ 2026-09-17:让位方式改了 —— 宽屏不再靠"内容 padding 归零",
|
||
* 而是宽屏时 navReserve 本身变成 0(没有底部条)。
|
||
* 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。
|
||
*/
|
||
assert.match(code_, /this\.navReserve = this\.isWide \? 0 : NAV_CONTENT_RESERVE/,
|
||
'宽屏时 navReserve 要归零(没有底部条,列表不需要为它让位)');
|
||
});
|
||
|
||
test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => {
|
||
const main = read('pages/MainPage.ets');
|
||
const code_ = stripComments(main);
|
||
/*
|
||
* phone / tablet / 2in1 的列表→详情行为交给系统:
|
||
* - 窄宽度自动 Stack(详情覆盖列表,可返回);
|
||
* - 宽窗口自动 Split(列表 + 详情并排)。
|
||
* 断点由 navBarWidthRange + minContentWidth 共同决定,不复制一套 onAreaChange。
|
||
*/
|
||
assert.match(code_, /private navPathStack: NavPathStack = new NavPathStack\(\)/,
|
||
'通信页必须持有 NavPathStack');
|
||
assert.match(code_, /Navigation\(this\.navPathStack\)/,
|
||
'通信页列表/详情必须由原生 Navigation 承载');
|
||
assert.match(code_, /\.navDestination\(this\.DestinationBuilder\)/,
|
||
'Navigation 必须注册邮件详情目标页 builder');
|
||
assert.match(code_, /\.mode\(NavigationMode\.Auto\)/,
|
||
'必须用 NavigationMode.Auto,让系统自动选择 Stack / Split');
|
||
assert.match(code_, /\.navBarWidth\(320\)/,
|
||
'宽屏列表栏应与 WebUI MailList 的 320px 同档');
|
||
assert.match(code_, /\.navBarWidthRange\(\[280, 360\]\)/,
|
||
'列表栏应允许系统在 280–360vp 内适配,而不是固定死布局');
|
||
assert.match(code_, /\.minContentWidth\(360\)/,
|
||
'详情栏最小宽度参与 Auto 断点计算');
|
||
assert.match(code_, /this\.navPathStack\.pushPath\(\{ name: MAIL_DETAIL_ROUTE, param: params \}\)/,
|
||
'选中邮件必须进入 NavPathStack,而不是绕回 router.pushUrl');
|
||
});
|
||
|
||
test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比一复刻的骨架)', () => {
|
||
const main = read('pages/MainPage.ets');
|
||
const code_ = stripComments(main);
|
||
/*
|
||
* WebUI 的 `.app-shell`(index.css):
|
||
* padding: var(--pane-gap) → 10px
|
||
* gap: var(--pane-gap) → 10px
|
||
* > * { border-radius: var(--radius-card) } → 14px
|
||
* 宽屏面板之间留缝(壁纸从缝隙露出)、各面板圆角 14。窄屏贴合全屏(无 padding / 无圆角)。
|
||
*/
|
||
// 令牌值要和 WebUI 同值(14/10)
|
||
const theme = read('common/Theme.ets');
|
||
assert.match(theme, /static readonly glassRadius: number = 14/, 'glassRadius = 14(与 WebUI --radius-card 同值)');
|
||
assert.match(theme, /static readonly paneGap: number = 10/, 'paneGap = 10(与 WebUI --pane-gap 同值)');
|
||
// Row 用 space=paneGap(gap)
|
||
assert.match(code_, /Row\(\{ space: Theme\.paneGap \}\)/, '宽屏 Row 用 space=paneGap(与 WebUI gap 同值)');
|
||
// 内容面板圆角只在宽屏启用(窄屏贴合全屏)
|
||
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/, '内容面板圆角宽屏 14 / 窄屏 0');
|
||
assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
|
||
// 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出)
|
||
assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)');
|
||
}); |