From 36ef15a8f2b2c6320c14414d2ef8532ce605986b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 12:30:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=A1=B6=E6=A0=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E9=93=BA=E7=99=BD=E6=9D=A1?= =?UTF-8?q?=20+=20=E6=97=A5=E5=8E=86=E6=94=B9=E5=B7=A6=E5=8F=B3=E4=B8=A4?= =?UTF-8?q?=E6=A0=8F=20+=20=E5=B8=B8=E9=A9=BB=E7=AA=97=E6=A0=BC=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E7=9C=9F=E7=9A=84=E4=BC=9A=E6=92=AD=EF=BC=88=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=B8=89=E5=A4=84=E5=AE=9E=E6=B5=8B=E6=8C=87=E5=87=BA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户三条反馈,逐条对应: ① 「底栏数字为什么显示在图标下面?」 WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角), 我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。 改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。 (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。) ② 「一个横着过去的白条,我真的服了」/「期望:融进背景」 WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200` (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`), 底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。 鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开, 顶上都是一条不通明白带。改成与**页面底**同一口径 (`bgActive ? Transparent : surface`)+ 补下边框。 ③ 「日历页面和webui布局完全不同」 WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、 右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。 鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange` 量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截); 编辑器改占右栏位置(不再整页盖掉正在看的那个月)。 ④ 「最严重的动画问题你一点也不该改」 日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、 也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发 (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的 `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。 WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320` (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」), 它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是 `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。 ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵, 渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。 顺带修: · 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出), 与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与 NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。 新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。 · 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页 两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。 · 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569), 我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。 · 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时 落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。 · 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。 判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 / arkts 5 / contacts 5 / calendar 30 / system-api 5): · `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数** 要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` —— 第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在, 正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。 · `harmony-nav` 动画条重写:改判**机制真的存在且被驱动** (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug —— 判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外 这条断言(否则动画退化成瞬移)。 · `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6" 改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子 (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。 新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。 · `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽, 得到 7 个(4+3 混在一起),任何一边改对了它都会红。 设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到 图标右上角 / 日历左右两栏与 WebUI 并排同构。 server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。 --- .gitignore | 1 + client/electron/test/CRITERIA.md | 54 +++ client/electron/test/harmony-logic.test.mjs | 34 +- client/electron/test/harmony-nav.test.mjs | 126 ++++- .../electron/test/harmony-widescreen.test.mjs | 166 +++++-- client/electron/test/run-all.mjs | 32 +- .../harmony/entry/src/main/ets/api/Logout.ets | 77 +++ .../src/main/ets/common/BackgroundPicker.ets | 21 +- .../entry/src/main/ets/common/Theme.ets | 29 ++ .../entry/src/main/ets/model/NavItems.ts | 58 ++- .../entry/src/main/ets/pages/CalendarPage.ets | 457 +++++++++++------- .../entry/src/main/ets/pages/MainPage.ets | 254 +++++++++- .../entry/src/main/ets/pages/SettingsPage.ets | 32 +- .../entry/src/main/ets/pages/WideSidebar.ets | 202 ++++++-- server/go.mod | 4 +- 15 files changed, 1216 insertions(+), 331 deletions(-) create mode 100644 client/harmony/entry/src/main/ets/api/Logout.ets diff --git a/.gitignore b/.gitignore index fb1fb62..12ea887 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ plugins/homeagent-mail-bridge/build/ # 构建产物(曾误提交) server/server plugins/zcode-mail-bridge/test/manual/gate-e2e-evidence.json +.hvigor/ diff --git a/client/electron/test/CRITERIA.md b/client/electron/test/CRITERIA.md index e12343a..98277fb 100644 --- a/client/electron/test/CRITERIA.md +++ b/client/electron/test/CRITERIA.md @@ -911,6 +911,58 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一 | ② 让自检断言 `staticDetail` **变量**内容完整 | 只删掉那句 `console.log` | **4b 照样报 ok**,而真实输出里播报段 **0 次** | | ③ 断言源码里有 `${staticDetail}` 这个形状 | 同上 | **仍报 ok** —— 因为**锚点写在这段自检自己的注释里**,`includes` 命中的是注释 | | ④ 剥注释后再数 `+ staticDetail);` 的命中 | 删 print | **4b 红 ✓**("可执行代码里没有任何一处把 `staticDetail` 拼进输出") | +| ⑤ **同一处改成"记录那次调用真的送了什么"(4c)** | 删 print | **4c 红 ✓** | +| ⑤ | **把 print 包进恒假条件**(`if (false)` / `while (false)` / `false ? … : 0`) | **4c 红 ✓,而 4b 仍报 ok**(三种写法都实测过) | + +#### ★★ 4b 的漏格:它扫的是**文本**,而"那一跳"是**一次调用**(pi 报,dsh 2026-09-19 复现并修) + +④ 只解决了"print 被删"。但 4b 的证据是**源码里存在那个锚文本** —— 于是**锚文本还在、 +而那次调用根本不发生**时,它照样报 ok。**pi 报的正是这一格**,我完整跑复现: + +``` +baseline : 播报段出现 1 次 4b = ok +变异体 : 播报段出现 0 次 4b = ok ← 红清单与 baseline **逐条相同** +``` + +根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前), +打印在 `:3129` ⇒ 它**物理上到不了**那次调用,只能读文本。 +⇒ **"验了那个形状"与"验了那件事发生"仍然是两件事**(§16.1 那条缝的又一格)。 + +**修法(4c)**:不再猜源码,改成**记录这次调用真的往 `console.log` 送了什么** —— +锚点落在「**实际发生的那次调用**」上,这是源码文本够不着的东西: + +```js +const staticBroadcastSeen = []; +{ const realLog = console.log; + console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); }; + console.log(`…` + staticDetail); + console.log = realLog; } +if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why)))) + reds.push('(自检)4c:…没有真的进 console.log…'); +``` + +实测(完整跑,每次一个变异体): + +| 变异 | 播报段 | 4b | 4c | +|---|---|---|---| +| 无(对照) | 1 | ok | 不红 ✓(红清单与改前**逐条相同**) | +| `if (false)` 包裹 | 0 | **仍 ok** | **红 ✓** | +| `while (false)` 包裹 | 0 | **仍 ok** | **红 ✓** | +| `false ? … : 0` | 0 | 红 | **红 ✓** | +| 删掉 print | 0 | **红 ✓** | **红 ✓**(不回归) | + +★ 两条可复用教训: + +1. **"文本里有" ≠ "那次调用发生了"** —— 只要判据读源码,`if (false)` 就是一个 + **既保留文本、又取消行为**的通用逃逸。凡"必须真的发生"的那一跳,锚点要落在 + **运行时观测**上,不能落在源码形状上。 +2. **自检的位置本身就是判据的一部分**:4b 读的那个值定义在它之前、而**使用**在它之后 + ⇒ 它对"使用"这一跳永远只能读文本。**"自检跑在它守的那件事之前"是一类结构性盲区**, + 不是这一条的偶然。 + +⚠️ 4c **不能**做成 `SELFTESTS` 的一条:快入口 `--only-selftest=<名>` 在 +`:2503` 就 `process.exit` 了,那条路径**永远到不了** `:3129` 的打印 ⇒ +它只会"看不见"而**不会红**(把它挂在那里等于又造一个 4b)。 ★ 三条教训(都可复用): @@ -928,6 +980,8 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一 (Y 变 0 而 X 仍是 6 ⇒ 一眼看得出)。 - **每次运行都全表播报** `STATIC_ONLY`(文件 + 原因 + 是否到期**并排**)⇒ 第 2 列有了读者。 - **自检 4b** 钉住"那一跳真的存在"(剥注释后数锚点 + 内容完整 + 反空转长度)。 +- **自检 4c** 钉住"**那一跳真的发生了**"(运行时记录 `console.log` 的实际参数)—— + 4b 管"源码里有",4c 管"调用发生了";**两条合起来才等价于"到达"**。 ★ **通用规则**(本节定稿): diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index fead4a5..61e083d 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -243,11 +243,24 @@ test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不 const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length; assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动'); const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts')); - const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); - // 2026-09-14:日历页(P6 第 1 步)落地后入口上架 ⇒ 三项。撤掉「会话」这条判断本身没变: - // 它不是第三个地方,而是"同一批数据的另一种看法"(收件箱折叠 + 联系人卡片视图)。 - // 2026-09-17:与 WebUI NarrowNav 对齐 ⇒ 四项(前三内容窗格 + 外壳入口「我的」)。 - assert.deepEqual(labels, ['通信', '日历', '联系人', '我的'], `平级项应为 通信/日历/联系人/我的,实际:${labels.join('、')}`); + /* + * ★★ 2026-09-19 修:原先这里把 `NavItems.ts` 里**所有** `label:` 一网打尽 + * ⇒ 得到 7 个(底栏 4 项 **加上** 侧栏 3 项)—— 因为这两份清单现在是**分开的**: + * · `NAV_ITEMS`(底栏)= 通信/日历/联系人/我的(对齐 WebUI `NarrowNav.tsx:37-40`+143) + * · `NAV_SIDEBAR_ITEMS`(侧栏)= 通信/日历/**联系**(对齐 WebUI `Sidebar.tsx:26-44`, + * 「我的」是底部头像,不在这份清单里) + * 把它们混着数,会得到一个既不是底栏、也不是侧栏的“第三份清单”, + * 于是任何一边改对了这一条都会红。⇒ 改成**分别从各自的数组里取**。 + */ + const itemsOf = (name) => { + const m = navSource.match(new RegExp(`export const ${name}: NavItem\\[\\] = \\[([\\s\\S]*?)\\];`)); + assert.ok(m, `NavItems.ts 要有 ${name}`); + return [...m[1].matchAll(/label:\s*'([^']+)'/g)].map(x => x[1]); + }; + assert.deepEqual(itemsOf('NAV_ITEMS'), ['通信', '日历', '联系人', '我的'], + '底栏四项(对齐 WebUI NarrowNav:收件/发件/授权合并为「通信」,末项「我的」)'); + assert.deepEqual(itemsOf('NAV_SIDEBAR_ITEMS'), ['通信', '日历', '联系'], + '侧栏三项(对齐 WebUI Sidebar.tsx 的 navItems:「我的」是底部头像,不在导航轨里)'); assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); }); @@ -520,9 +533,16 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + // 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变) // P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数 const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts')); - const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); + /* + * ★★ 2026-09-19 修(同文件另一处同一个错):从 `NAV_ITEMS`(**底栏那份**)里取 label, + * 不能把全文件的 `label:` 一网打尽 —— 那样会把 `NAV_SIDEBAR_ITEMS`(三项)也数进来, + * 得到 7 个。两份清单现在是分开的(底栏 4 / 侧栏 3),见 `harmony-widescreen` ②。 + */ + const navItemsBlock = navSource2.match(/export const NAV_ITEMS: NavItem\[\] = \[([\s\S]*?)\];/); + assert.ok(navItemsBlock, 'NavItems.ts 要有 NAV_ITEMS(底栏那份清单)'); + const tabLabels = [...navItemsBlock[1].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); // 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人) - // 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」是外壳入口(见 NavItem.route) + // 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」也是内容窗格(currentIndex === 3) assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`); // 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)—— // 所以这里钉的是"带参数地渲染通信页",不是光有个名字 diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 837eead..35bc396 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -460,9 +460,45 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵 assert.ok(branchRoots >= 2, `★ transition 要挂在会被换掉的子树根上(if/else 分支),实际只找到 ${branchRoots} 处。` + '挂在两级之上的稳定父容器上等于没挂(实测过:6 秒取样仍是硬切)。'); - // 日历那一支(常驻 + visibility)也要有 - assert.match(mainCode, /\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/, - '★ 日历窗格(常驻挂载 + visibility 控制)也要挂过场过渡'); + + /* + * ★★ 2026-09-19 重写日历那一条(用户:「最严重的动画问题你一点也不该改」)。 + * + * 原断言:`.visibility(...)` 后面跟 `.transition(Theme.paneRiseIn())` —— + * 它锚的是**写法**,而不是“动画真的会播”。而那个写法**一帧也不会播**: + * `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it **appears and + * disappears**"),可日历是**常驻**的(用 `visibility` 控制,因为它里面 + * `today` 要随时间重算、也要保住“正在看哪个月”)—— 永不重挂载。 + * 所以旧断言是**把 bug 锁住了**(与我上次给侧栏编理由同一个错法)。 + * + * 新断言改成断**机制真的存在且被驱动**: + * · 常驻窗格不能用 TransitionEffect,要用可插值的属性 + * (`opacity` + `translate`); + * · 那个属性要有一个 @State 支撑; + * · 切窗格时要**真的把它从 0 推到 1**(且 reset 在 animateTo 外 —— + * 否则同一帧内 0→1,起点终点都是 1,动画退化成瞬移)。 + */ + assert.ok(!/\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/.test(mainCode), + '★ 日历窗格是**常驻挂载**(visibility 控制)—— 它上面的 `.transition()` 永远不会触发。' + + '那条断言锁的就是这个 bug,不得恢复。'); + // 日历容器的入场:用可插值属性,且由 @State 支撑 + assert.match(mainCode, /calPaneIn/, + '★ 日历窗格的入场进度要有一个 @State(`calPaneIn`)支撑 —— 常驻窗格只能靠属性插值做入场'); + const calContainer = /\.visibility\(this\.currentIndex === 1[\s\S]{0,400}?\.opacity\(this\.calPaneIn\)/.exec(mainCode); + assert.ok(calContainer, + '★ 日历容器要真的用 `calPaneIn` 驱动透明度(这才是常驻窗格能播的入场)'); + assert.match(mainCode, /\.translate\(\{[^}]*calPaneIn[^}]*\}\)/, + '★ 日历容器要同时驱动位移(对齐 WebUI `rise-in` 的 translateY)'); + // reset 必须在 animateTo **外** —— 否则起点=终点,动画退化 + const resetIdx = mainCode.indexOf('this.calPaneIn = 0'); + const animIdx = mainCode.indexOf('getUIContext().animateTo('); + assert.ok(resetIdx > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)'); + assert.ok(resetIdx < animIdx || animIdx < 0, + '★ `calPaneIn = 0` 必须在 `animateTo` **之前**:写进回调里会与同帧的 1 相抵,' + + '渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)'); + const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(mainCode); + assert.ok(intoAnim, + '★ 推到 1 要在 `animateTo` 窗口里(与其余窗格的 transition 同时长、同曲线)'); // animateTo 走 getUIContext(全局 animateTo 已废弃,另行有判据钉) assert.match(mainCode, /getUIContext\(\)\.animateTo\(/, @@ -933,24 +969,55 @@ function navItemsOf(root) { const boxW = x2 - x1; const boxH = y2 - y1; /* - * ★ 阈值用**比例**而不是绝对 px,且必须容得下真实的 48vp 项。 - * 实测(密度 2.875):项 138×138px、屏 3184×2232 ⇒ - * x1=45 < 3184/6=531 ✓、boxW=138 < 3184/4=796 ✓、 - * 但 boxH=138 需要 > 2232*0.08=178 ✗ ⇒ 四项**全被滤掉**。 - * 也就是说这个阈值我第一版写大了,只是当时项被压成 39vp 才"恰好"通过 - * (那是别的 bug,不是阈值对)。 - * 改成 `screenH * 0.04`(=89px):48vp 项(138px)过得去, - * 而内容区那些横向长条(高 43px < 89)仍然被排除。 + * ★★ 2026-09-19 修(被判据自己抳到):过滤条件从 “靠左 1/6” 改成 + * “**整个盒子在侧栏轨道内**”。 * - * ★ 还要**要求有文字标签**:品牌标也是左上的可点方块(实测 `Stack [57,174][172,289]` - * 40vp、无文字),不加这一条它会混进来,而它不是导航项 —— - * 它是"点它回家"的品牌按钮(WebUI 的 `brand-mark`)。 + * 原条件 `x1 < screenW / 6` 只要求**左边缘**靠左 —— 而日历网格的格子实测是 + * `Column [229,511][366,794]`:x1=229 < 531 ✓、高 283 > 89 ✓、带文字 ✓ + * ⇒ **被当成导航项**。于是一屏日历数出 11~12 个“导航项”, + * 而侧栏实际只有 3 项。 + * + * 换成 “`x2`(右边缘)也在轨道内”: + * · 导航项 `[45,312][183,450]` ⇒ x2=183 ≤ 255 ✓ + * · 品牌标 `[57,174][172,289]` ⇒ x2=172 ✓(但无文字,另行排除) + * · 日历格子 `[229,511][366,794]` ⇒ x2=366 > 255 ✗ **排除** + * + * 为什么用比例 `screenW * 0.08`(展开态 = 255px)而不是写死 183/200: + * 侧栏是 60vp,px 值随密度变(教训:**密度是第二个真相**,见 `isWideLayout`)。 + * 0.08 在展开态的 3184px 上给 255,在单屏 1008px 上给 80(而单屏不是宽屏,不走这支)。 */ - return x1 < screenW / 6 && boxW < screenW / 4 && boxH > screenH * 0.04 + return x2 <= screenW * 0.08 && boxH > screenH * 0.04 && textsUnder(n).length > 0; }); } +/** + * 宽屏侧栏里的**导航轨**(不含底部那一簇)。 + * + * ★★ 2026-09-19 新增(用户:「你写的app和webui大面积不符」)。 + * + * 侧栏底部那一簇(头像 / 主题 / 退出)也是可点、也有文字/图标,形状与导航项相近 —— + * `navItemsOf` 会把它们一起数进来(实测数出 12 个,而导航项应为 3 个)。 + * 而 WebUI 确实有这一簇(`Sidebar.tsx:183-212`),所以**不能拿它当“多出来的项”判红**; + * 也不能因此放宽总项数(那会让“我的又回到侧栏”那个 bug 漏过去)。 + * + * 所以改按**位置**切:导航项一簇**贴顶**(WebUI 实测 y = 70/122/174,`gap-1`), + * 下面用 `Blank()` 推到屏幕底部才是那一簇。取两者之间的空档切一刀即可。 + * + * ★ 阈值用“屏幕高度的一半”:导航项在顶部 1/3 以内,底部簇在最后 1/4 以内, + * 中间有大段空白(实测展开态导航项在 y≈985-1380、底部簇在 y≈2000+)。 + * 用比例而不是绝对 px,避开密度那第二个真相(同 `isWideLayout` 的教训)。 + */ +function navRailItemsOf(root) { + const items = navItemsOf(root); + const screenH = screenHeightOf(root); + return items.filter(n => { + const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); + if (!m) return false; + return Number(m[2]) < screenH * 0.5; + }); +} + test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => { const CRIT = 'harmony-nav/底栏可点项'; const hdc = findHdc(); @@ -1041,8 +1108,33 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout * * 现在按两侧的**共同事实**判:侧栏项 = 图标(Path)+ 文字标签。 * 图标画出 + 文字命中源码清单,两条都要。 + * + * ★★ 2026-09-19 修第二处(与 harmony-widescreen ② 同一个错): + * 这一段原来拿 `N.NAV_ITEMS`(**四项**,含「我的」)的 label 去比侧栏渲染出来的文字 + * —— 但侧栏是**三项**(通信/日历/**联系**),第三项对不上(“联系” ≠ “联系人”), + * 而且它反过来证明不了“侧栏多了我的”那个 bug。 + * 侧栏要比的是 `NAV_SIDEBAR_ITEMS`;两项前两项相同只是巧合,不能因此混用。 */ - const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label)); + const sourceLabels = new Set(N.NAV_SIDEBAR_ITEMS.map(i => i.label)); + /* + * 侧栏**总共**应该正好是 `NAV_SIDEBAR_ITEMS` 那个数。 + * 只判“每项命中”不够:多出来的一项(例如「我的」)也可能命中一个 label, + * 而“多一个我的”本来就是这次报的 bug。所以同时判**个数**。 + */ + /* + * ★★ 2026-09-19:改判**导航轨**(`navRailItemsOf`)而不是全部可点项 —— + * 侧栏底部那一簇(头像/主题/退出)是 WebUI 就有的(`Sidebar.tsx:183-212`), + * 把它数进来会得到 12,而那并不是“多了一个我的”。 + * 数量仍要卡死(不然“我的回到侧栏”那个 bug 会漏过去),只是卡在**轨道**这一层。 + */ + const railItems = navRailItemsOf(root); + assert.equal(railItems.length, N.NAV_SIDEBAR_ITEMS.length, + `宽屏侧栏导航轨应有 ${N.NAV_SIDEBAR_ITEMS.length} 项(与 WebUI Sidebar.tsx 的 navItems 同数),` + + `实际 ${railItems.length} 项(多出来很可能就是「我的」又回到了侧栏);` + + `(全部可点项 ${navItems.length} 个,含底部那一簇)`); + /* 后续逐项断言作用在导航轨上 */ + navItems.length = 0; + navItems.push(...railItems); for (const it of navItems) { const paths = [...walk(it)].filter(x => x.attributes?.type === 'Path'); assert.ok(paths.length >= 1, @@ -1051,7 +1143,7 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout const texts = textsUnder(it); const matched = texts.filter(l => sourceLabels.has(l)); assert.ok(matched.length >= 1, - `宽屏侧栏项要带文字标签且命中源码 NAV_ITEMS(WebUI Sidebar 有 {short});` + + `宽屏侧栏项要带文字标签且命中源码 NAV_SIDEBAR_ITEMS(WebUI Sidebar 有 {short});` + `实际文字:${texts.join('、')}`); } } diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index eb10daa..04f236c 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -1,12 +1,18 @@ -// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。 +// 宽屏侧栏(WideSidebar)判据 —— 逐项复刻 WebUI 的 `Sidebar`。 // -// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列: -// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底) -// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。 +// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾): +// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/ +// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**: +// · `Sidebar.tsx:26-44` 是**三项**(通信/日历/**联系**)——「我的」不在导航轨里, +// 而是 **底部头像按钮**(`Sidebar.tsx:186`); +// · `Sidebar.tsx:110` 有 `{short}`(有文字); +// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**, +// 而且 CSS 注释专门说明侧栏**必须有**(“图标底下那一块底色是它唯一的选中线索”)。 +// 更糟的是:判据当时**引用了这段自编的描述当依据**,于是把错误锁死 —— 全绿。 +// 现在的口径:每一项都回读 WebUI 源码取值,不再引“我上次写的那句话”。 // -// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是 -// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。 -// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。 +// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半: +// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。 import { code, prose, stripComments } from './lib/read.mjs'; import { readFileSync } from 'node:fs'; @@ -18,8 +24,12 @@ 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'); +/** WebUI 源码根:判据要**回读**它取值,而不是引注释里的话 */ +const WEBUI_SRC = join(ROOT, 'client/electron/src'); const read = p => prose(join(HARMONY_ETS, p)); +/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */ +const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8'); /** 取某个成员/方法正文(按行切到下一个成员声明) */ function memberBody(src, signature) { @@ -41,41 +51,84 @@ test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字 assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar'); }); -test('② 四项导航(与底栏同源):不再有单列的"设置"入口', () => { +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 的导航规则」 - * (底栏那一支改成了窗格,侧栏这一支漏了)。 + * ★★ 2026-09-19 重写(用户:「你写的app和webui大面积不符,问题特别大」)。 * - * WebUI `Sidebar.tsx` 的真实形状:`navItems` 三项 + **底部一簇** - * (账号头像 / 主题切换 / 退出)。「我的」在 WebUI 是 `viewMode === 'account'`, - * 由侧栏底部那个**头像按钮**进入 —— 也就是说它是**导航项**,不是"推出去的页"。 - * 鸿蒙的对应物是第 4 项「我的」窗格(与底栏一致)。 + * 上一版这条判的是「遍历 `NAV_CONTENT_ITEMS`」—— 而 `NAV_CONTENT_ITEMS` 是 + * **四项**(含「我的」)。那正是**错的形状**:四项只对**底栏**。 + * + * WebUI 两套导航**本来就不同**(同一份源码里两个文件): + * · `NarrowNav.tsx:37-40` items = 通信/日历/**联系人**, + * 再加第 4 个「我的」按钮(`NarrowNav.tsx:143`)⇒ 底栏 **4 项**; + * · `Sidebar.tsx:26-44` navItems = 通信/日历/**联系** ⇒ 侧栏 **3 项**, + * 「我的」是底部**头像按钮**(`Sidebar.tsx:186`,旁边的注释就说它是 + * `title=...点击管理账号`)。 + * + * 这条件判据的写法变了:不再只查鸿蒙源码的字符串,而是 + * **回读 `Sidebar.tsx` 数出 `short:` 的个数**,要求鸿蒙那一份也是同一数字 —— + * 否则下次 WebUI 改了项数,这里只会继续绿。 */ - const sidebar = read('pages/WideSidebar.ets'); - const code_ = stripComments(sidebar); - assert.match(code_, /ForEach\(NAV_CONTENT_ITEMS,/, - '侧栏要遍历 NAV_CONTENT_ITEMS(与底栏同一个清单 —— 两处各留一份会漂移)'); + const sidebarSrc = readWeb('components/Sidebar.tsx'); + const narrowSrc = readWeb('components/NarrowNav.tsx'); + + // 从 WebUI 源码里数导航项:`short: 'xx'` 的行(两处各数一遍) + const shortOf = (src) => [...src.matchAll(/short:\s*'([^']+)'/g)].map(m => m[1]); + const webSidebarShort = shortOf(sidebarSrc); + const webNarrowShort = shortOf(narrowSrc); + + assert.deepEqual(webSidebarShort, ['通信', '日历', '联系'], + `WebUI 侧栏应为 3 项(读到的是 ${JSON.stringify(webSidebarShort)})——若这里变了,下面的期望值要跟着改`); + assert.deepEqual(webNarrowShort, ['通信', '日历', '联系人'], + `WebUI 底栏内容项应为 3 项(读到的是 ${JSON.stringify(webNarrowShort)})——与侧栏的第三项措辞**故意不同**`); + + // 鸿蒙侧:侧栏清单必须与 WebUI 侧栏逐字一致(项数与文案) + const navItems = read('model/NavItems.ts'); + const sidebarList = navItems.match(/export const NAV_SIDEBAR_ITEMS[\s\S]*?\];/); + assert.ok(sidebarList, 'model/NavItems.ts 要有 NAV_SIDEBAR_ITEMS(侧栏专用的三项清单)'); + const harmonySidebarShort = [...sidebarList[0].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); + assert.deepEqual(harmonySidebarShort, webSidebarShort, + `NAV_SIDEBAR_ITEMS 的 label 要与 WebUI 侧栏逐字一致(现在读到 ${JSON.stringify(harmonySidebarShort)})`); + + // 侧栏**不许**用四项那份清单(那正是这次报的“多出一个我的”) + const sidebar = stripComments(read('pages/WideSidebar.ets')); + assert.match(sidebar, /ForEach\(NAV_SIDEBAR_ITEMS,/, + '侧栏要遍历 NAV_SIDEBAR_ITEMS(三项)'); + assert.ok(!/ForEach\(NAV_CONTENT_ITEMS,/.test(sidebar), + '侧栏不许再遍历 NAV_CONTENT_ITEMS(那是**底栏**的四项清单,含「我的」)'); + // 品牌标:WebUI 是 `