用户:「你自己看看宽屏的侧边栏和webui有哪怕一丁点的相似之处嘛?」
并排截图(WebUI 1100×700 @2x vs 三折叠展开态 3184×2232)之后,差异一眼可见:
| | WebUI | 鸿蒙(改前) |
|---|---|---|
| 文字标签 | **有**(通信/日历/联系) | 没有 |
| 选中态 | **浅蓝底块** | 只换颜色 |
| 「我的」 | 底部头像按钮进入 | 甩给 `onSettings` → **pushUrl 推页** |
| 品牌标颜色 | `#475569` 石板灰 | 品牌蓝 |
| 项间距 | 48px 项 + 4px gap,**贴顶一簇** | `layoutWeight(1)` 等分铺满(395px/项) |
## 根因:`WideSidebar` 里那段"复刻 WebUI"的注释是**编的**
```
* WebUI 的 `Sidebar`(60px 宽)是**纯图标轨**(无 label 文字)……
* 选中态:图标变色(`navFgActive`),**不加背景块、不加指示条、不加文字**
* (用户 2026-09-16:「底部导航栏不允许有文字」⇒ 侧栏同样按纯图标走)
```
两条都错,而且都能在源码里当场证伪:
- `Sidebar.tsx:110` 明明有 `<span className="text-3xs leading-none">{short}</span>`
—— 通信/日历/联系三个标签一直都在;
- `index.css:1590` 的 `.nav-item[data-active='true'] { background-color: … }`
就是底块,而且 CSS 注释**专门**说了侧栏必须有它:
「宽屏侧栏是 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
所以"只变色"不能无差别推广到所有 `.nav-item`。」
我犯的错是**把底栏那条纪律套到了侧栏上**:用户 2026-09-14 说「底部导航栏选中
对应的文字和图标变色即可」、2026-09-16 说「底部导航栏不允许有文字」——
两句都针对**底部导航栏**,而侧栏是另一种东西(`index.css:1595-1606` 把这个区别
写得很清楚)。更糟的是我把这个错误**写进了判据**(`harmony-widescreen` ②③ 与
`harmony-nav` 的宽屏分支),于是判据锁住的是我编的理由,一路全绿。
## 修
- 侧栏项 = **图标 + 文字标签 + 选中底块**(`navActiveBg` = `--nav-active-bg` #DBEAFE,
判据**直接读 WebUI 的 CSS** 取值,不写死、更不引自己的注释)。
- 品牌标:`navBrandFg` = `#475569`(**像素取证**:2x 截图里品牌标附近最常见的墨色
是 `rgb(71,85,105) ×206` = `--nav-fg-muted`,即中性石板灰,**不是**品牌蓝);
尺寸/圆角按 WebUI `w-10 h-10 rounded-xl`(40×40、圆角 16);点它回收件箱。
- 项**贴顶一簇**(`Column({ space: 4 })` = WebUI 的 `gap-1`),不再 `layoutWeight(1)`。
- 删掉单列的"设置"入口(`onSettings` 回调一并删除)—— 那正是用户 2026-09-17 报过的
「我的页面完全没有遵守 nav 的导航规则」(push 页 ⇒ 侧栏整条消失)。
「我的」由 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(该常量**含第 4 项**,
走 `onSelect(3)` = 窗格,与底栏同一套)。
- 补避让:侧栏原先**完全没有** `topInset` ⇒ 全屏之后品牌标被状态栏时钟压住。
## 判据(并修掉它们锁住的错误)
- `harmony-widescreen` ②③ **重写**:从"纯图标 / 只变色"改成
"有文字标签 / 有选中底块 / 不许留 `onSettings`",并读 WebUI `index.css` 拿真实色值。
- `harmony-nav` 宽屏分支:原来断言「侧栏项**不该有文字**」—— 同一条编造。
改成"图标(Path)画出来了 **且** 文字命中源码 `NAV_ITEMS`"。
- `harmony-nav` 宽屏形状阈值 `boxH > screenH*0.08` 是**错的**:48vp 项在密度 2.875 下
是 138px,而阈值要求 >178px ⇒ 四项全被滤掉(当时"通过"只是因为项被另一个 bug
压成了 39vp)。改成 `*0.04`,并补一条"必须有文字"把**品牌标**(40vp 无文字的可点方块)
排除在外。
**变异测试 3 个方向全咬**:去掉文字标签 ⇒ 红;去掉选中底块 ⇒ 红;Theme 色值写错 ⇒ 红。
★ 顺带记一条**我差点犯的错**:我一度按 density 3.5 换算,算出"60vp 侧栏被压成 49.4vp",
去查 flex 压缩、加 `.flexShrink(0)` —— 全是假的。实测密度是 **2.875**
(`138px ÷ 48vp = 2.875`),侧栏 173px ÷ 2.875 = **60.2vp**,与声明完全一致。
**没有压缩,是我除错了。** 已撤回那笔改动并把口径写进注释。
harmony-widescreen 6/6、harmony-nav 18/18、harmony-window 9/9、harmony-arkts 5/5、
harmony-contacts 5/5、harmony-calendar 30/30、harmony-system-api 5/5、harmony-logic 28/28。
205 lines
12 KiB
JavaScript
205 lines
12 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 { 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 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('② 四项导航(与底栏同源):不再有单列的"设置"入口', () => {
|
||
/*
|
||
* ★★ 2026-09-18 重写。原来这条判的是「三项导航 + 设置按钮」,
|
||
* 依据是"设置单列在下面、遍历 NAV_ITEMS 会出现两个 person 图标"。
|
||
* 那个依据**两半都错**:
|
||
* · `NAV_CONTENT_ITEMS = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT)` 而
|
||
* `NAV_CONTENT_COUNT = 4` ⇒ **四项全在**(含「我的」),不存在"只有前三项";
|
||
* · 单列的"设置"走 `onSettings → pushUrl('pages/SettingsPage')`,
|
||
* 而那正是用户 2026-09-17 报过的「我的页面完全没有遵守 nav 的导航规则」
|
||
* (底栏那一支改成了窗格,侧栏这一支漏了)。
|
||
*
|
||
* WebUI `Sidebar.tsx` 的真实形状:`navItems` 三项 + **底部一簇**
|
||
* (账号头像 / 主题切换 / 退出)。「我的」在 WebUI 是 `viewMode === 'account'`,
|
||
* 由侧栏底部那个**头像按钮**进入 —— 也就是说它是**导航项**,不是"推出去的页"。
|
||
* 鸿蒙的对应物是第 4 项「我的」窗格(与底栏一致)。
|
||
*/
|
||
const sidebar = read('pages/WideSidebar.ets');
|
||
const code_ = stripComments(sidebar);
|
||
assert.match(code_, /ForEach\(NAV_CONTENT_ITEMS,/,
|
||
'侧栏要遍历 NAV_CONTENT_ITEMS(与底栏同一个清单 —— 两处各留一份会漂移)');
|
||
// 品牌标:WebUI 是 `<button data-testid="brand-mark"><BrandMarkIcon/>` —— 可点 + brandMark 图标
|
||
assert.match(code_, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
|
||
// 「我的」必须在侧栏里作为一个**导航项**出现,且走 onSelect 而不是推页
|
||
assert.ok(!/onSettings/.test(code_),
|
||
'侧栏不许留 onSettings(它推 `pages/SettingsPage` ⇒ 侧栏整条消失,正是用户报过的形状)');
|
||
/*
|
||
* 「我的」由上面那个 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(`NAV_CONTENT_ITEMS`
|
||
* 含第 4 项)—— 所以这里判的是"没有另起一个推页入口",
|
||
* 以及 onSelect 能把 index 传到第 4 项(`normalizeNavIndex` 不截到 3 项)。
|
||
*/
|
||
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(index\); \}\)/,
|
||
'导航项点击要原样传 index(第 4 项「我的」因此走 onSelect(3) = 窗格)');
|
||
// 品牌标点它要回第一项(WebUI `onClick={() => setViewMode('inbox')}`)
|
||
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
|
||
'品牌标点击要回收件箱(WebUI brand-mark 的一致习惯:点左上角 logo 回家)');
|
||
});
|
||
|
||
test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是两条纪律)', () => {
|
||
/*
|
||
* ★★ 2026-09-18 重写。原来这条写着「选中态只换颜色……不加背景块」,
|
||
* 并引"WebUI Sidebar 本就是纯图标轨"为依据 —— **依据是编的**。
|
||
*
|
||
* `client/electron/src/index.css:1595-1606` 的原话:
|
||
* 「底部导航的选中态**只换颜色**……只作用在 `.narrow-nav` 上:宽屏侧栏是
|
||
* 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
|
||
* 所以"只变色"不能无差别推广到所有 `.nav-item`。」
|
||
* 而 `.nav-item[data-active='true'] { background-color: rgb(var(--nav-active-bg)) }`
|
||
* 就是那块底色(`--nav-active-bg: 219 234 254` = #DBEAFE)。
|
||
*
|
||
* ★ 这条判据现在**直接读 WebUI 的 CSS 拿真实值**,而不是引自己写的注释 ——
|
||
* 上一版之所以能一直绿,就是因为"依据"是一句话,没人拿它跟 WebUI 对。
|
||
*/
|
||
const sidebar = read('pages/WideSidebar.ets');
|
||
const code_ = stripComments(sidebar);
|
||
|
||
// 从 WebUI 的真实源码里取侧栏选中底色(不写死,改了两边一起动)
|
||
const css = readFileSync(
|
||
join(ROOT, 'client', 'electron', 'src', 'index.css'), 'utf8');
|
||
const m = css.match(/--nav-active-bg:\s*(\d+ \d+ \d+)/);
|
||
assert.ok(m, 'WebUI index.css 要有 --nav-active-bg(侧栏选中底色)');
|
||
const [r, g, b] = m[1].split(' ').map(Number);
|
||
const hex = '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
|
||
assert.match(themeSrc, new RegExp(`navActiveBg: string = '${hex}'`, 'i'),
|
||
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
|
||
|
||
// 选中态必须**有底块**(WebUI 侧栏唯一的选中线索)
|
||
assert.match(code_, /backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/,
|
||
'导航项选中要有底块(WebUI `.nav-item[data-active]` 的 --nav-active-bg)—— 侧栏不是底栏,不能只变色');
|
||
|
||
// 文字标签:WebUI `Sidebar.tsx:110` 有 <span>{short}</span>,所以侧栏**有** label
|
||
assert.match(code_, /Text\(label\)/,
|
||
'侧栏导航项要有文字标签(WebUI Sidebar 有 <span>{short}</span>)');
|
||
|
||
// ★ 自检:这三条都必须能判红
|
||
assert.ok(!/backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/.test(
|
||
'backgroundColor(this.currentIndex === index ? Color.Transparent : Theme.navActiveBg)'),
|
||
'自检:三元式写反了必须判红');
|
||
});
|
||
|
||
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(没有底部条)。
|
||
* 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。
|
||
*
|
||
* ★ 2026-09-18:窄屏那一支多加了 `windowInsets.navIndicator`(全屏后要让开系统
|
||
* 手势条,见 `harmony-window.test.mjs`)。所以断言改为:**宽屏一定是 0**,
|
||
* 窄屏是 `NAV_CONTENT_RESERVE` **加上避让**(不是写死那个字面表达式)。
|
||
* 写死的话,"加一个正当的避让"与"宽屏忘了归零"会红得一模一样 ——
|
||
* 而这条判据要守的是后者。
|
||
*/
|
||
const reserve = /this\.navReserve = this\.isWide \? 0 : ([^;]+);/.exec(code_);
|
||
assert.ok(reserve, '要按 `isWide ? 0 : <窄屏值>` 的开关式写 navReserve');
|
||
const narrowExpr = reserve[1].trim();
|
||
assert.notEqual(narrowExpr, '0', '窄屏不能归零(窄屏有底部条,列表要让位)');
|
||
assert.match(narrowExpr, /NAV_CONTENT_RESERVE/,
|
||
'窄屏要让条高那么大的位(至少含 NAV_CONTENT_RESERVE)');
|
||
});
|
||
|
||
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 贴合全屏)');
|
||
}); |