跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"

用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
  ① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
  ③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
  ⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」

★ ① ② 统一顶栏 `AppHeader`
  改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
  `SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
  `MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
  `MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
  尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
  且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
  基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
  ⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
    几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
  ★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
    组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。

★ ③ 发件箱"太大"—— 我的理由错了
  我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
  竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
  底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
  实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
  "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
  ⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。

★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
  · **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
    会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
    WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
    ⇒ 新增 `CompositeModifier`。
  · **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
    实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
    → **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
    `onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
    改用 `onTouch` + `animateTo`。
  · 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
    第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
    因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
  设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。

★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
  旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
  而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
  **滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
  设备实测(3184×2232,位移恒 542.6vp):
      600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
  ⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
    —— 用户报的"滑不动/时灵时不灵"就是这个。
  改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
      1200px/s → 0/3(正确拒绝:那是拖动)    2500px/s → **0/4 → 3/3**
      5000px/s → **2/4 → 3/3**
  两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
  · `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
    (旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
  · `harmony-logic` 的行为判据加了两条**回归钉**:
    `542.6vp/1429ms`(实测的正常甩动)必须过;
    `1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。

★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
  WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
  ("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
  所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
  · `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
    写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
  · 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
  所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
  理由记在这里,免得下次又当成遗漏。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
This commit is contained in:
2026-09-20 10:51:28 +08:00
parent c0ab3f57b2
commit 6ca0113fd2
10 changed files with 556 additions and 184 deletions

View File

@ -128,18 +128,40 @@ test('★ 语义:纵向优先 —— 横向位移必须明显大于纵向,
test('★ 语义:慢拖不翻页("快滑"是感知档,不是同一个毫秒数)', () => {
/*
* WebUI:`const fast = Date.now() - start.t < 600` + `!fast → return`。
* 鸿蒙:`if (ms > SWIPE_MAX_DURATION_MS) return v;`。
* 鸿蒙:`if (speed < SWIPE_MIN_SPEED) return v;`。
*
* 两端都要有这道门;**毫秒数不比**。
* ★★ 2026-09-20:鸿蒙侧从"**时长**上限"改成"**速度**下限",
* 本判据跟着改形态(**语义不变**:都是"够快才翻页")。
*
* 改的原因是实测撞出的**设计错误**,不是调参:时长 = 距离 ÷ 速度,
* 它把距离与速度混成一个量 ⇒ 同样的手速下**滑得越远越容易被拒**,
* 大屏(平板)尤其严重。设备实测(3184×2232,位移恒 542.6vp):
*
* 600px/s → 5909ms | 2500px/s → 1429ms
* 1200px/s → 3161ms | 5000px/s → 616ms
* 1800px/s → 2006ms | 15000px/s → 123ms
*
* 旧门 700ms 意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
* ⇒ 用户报"滑不动/时灵时不灵"。速度门与距离解耦,才是"快滑"该有的形状。
*
* 两端都要有这道门;**数值不比**((b) 口径)。
*/
const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
assert.ok(Number(webFast[1]) > 0, 'WebUI 的时间窗应为正数');
const hFast = harmonyLogic.match(/ms\s*>\s*SWIPE_MAX_DURATION_MS/);
assert.ok(hFast, '鸿蒙要有"快滑"时间窗这道门');
assert.match(harmonyLogic, /export const SWIPE_MAX_DURATION_MS:\s*number\s*=\s*\d+/,
'鸿蒙的时间窗要走具名常量');
/*
* 鸿蒙侧:门必须存在、且**必须按速度算**。
*
* 「按速度」是本判据要钉的形状 —— 只断言"有个门"会漏掉这次的错:
* 旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,
* 而它在真机上把正常滑动拒掉了。所以要断言"算的是 dx/ms"。
*/
assert.match(harmonyLogic, /export const SWIPE_MIN_SPEED:\s*number\s*=\s*[\d.]+/,
'鸿蒙的"快滑"门要走具名常量(裸字面量会让"这个数为什么是它"无从追溯)');
assert.match(harmonyLogic, /Math\.abs\(dx\)\s*\/\s*ms/,
'鸿蒙的快滑门要按**速度**(dx/ms)算,不是按时长 —— ' +
'时长把距离与速度混在一起,大屏上会误拒正常滑动(实测 700ms 门只有 ≥5000px/s 能过)');
/*
* ★ 反向断言:鸿蒙**不得**引用 WebUI 的那三个数。

View File

@ -616,7 +616,7 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
* 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。
*/
const CAL = await import(pathToFileURL(join(HARMONY_ETS, 'model/Calendar.ts')).href);
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MAX_DURATION_MS } = CAL;
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MIN_SPEED } = CAL;
// ① 正常快滑:左滑 = 下一段
const next = judgeSwipe(-200, 10, 120);
@ -640,14 +640,32 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
/* 纯纵向必然不翻 */
assert.equal(judgeSwipe(5, -300, 100).turned, false, '纯纵向滚动不得翻页');
// ④ 慢拖不翻页
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS + 1).turned, false,
'超过时间窗 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS).turned, true,
'正好在时间窗内 ⇒ 应翻页');
/*
* ④ 慢拖不翻页 —— **按速度判,不是按时长**。
*
* ★★ 2026-09-20 改:原来是 `judgeSwipe(-300, 0, MAX_DURATION + 1)`,
* 即"时长超窗就拒"。那道门把**距离与速度混成一个量**(时长 = 距离 ÷ 速度),
* 于是同样的手速下滑得越远越容易被拒 —— 大屏上会误拒正常滑动。
* 设备实测(3184×2232、位移恒 542.6vp):旧门 700ms 意味着**只有 ≥5000px/s 才过**,
* 而用户正常甩动大约 2500px/s ⇒ "滑不动/时灵时不灵"。
*
* 现在按速度判,且要看**两侧**:慢的一定拒、快的一定过。
*/
assert.equal(judgeSwipe(-300, 0, 5000).turned, false,
'速度 0.06 vp/ms(300/5000)远低于阈值 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
/* ★ 这条是本次的**回归钉**:2500px/s 档在新门之前是 0/4 成功 */
assert.equal(judgeSwipe(-542.6, 0, 1429).turned, true,
'实测的正常甩动(542.6vp / 1429ms ≈ 0.38 vp/ms)**必须过** —— 旧门在这里判红过');
/* 同一距离、更快 ⇒ 更该过(证明判的是速度而不是距离) */
assert.equal(judgeSwipe(-300, 0, 600).turned, true, '同样 300vp、600ms(0.5 vp/ms)⇒ 应翻页');
/* 同样速度、更远 ⇒ 也该过(旧门会在这里误拒) */
assert.equal(judgeSwipe(-1200, 0, 3000).turned, true,
'同样 0.4 vp/ms 但滑得更远(1200vp)⇒ 仍应翻页(旧门会因时长超窗误拒)');
/* 时长为 0 不能除零,也不能被当成"慢" */
assert.equal(judgeSwipe(-300, 0, 0).turned, true, '时长为 0(同一毫秒)不得除零,应按极快处理');
// ⑤ 阈值必须是正数(0 会让所有手势都翻页)
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MAX_DURATION_MS > 0,
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MIN_SPEED > 0,
'三个阈值都要是正数;倍数还必须 > 1(=1 时斜滑就翻页)');
// ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页)

View File

@ -612,8 +612,24 @@ test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—
'页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西');
assert.match(body, /\.borderRadius\(TAB_BAR_RADIUS\)/,
'页签条要用 TAB_BAR_RADIUS(= NAV_BAR_RADIUS)—— 自己拍一个数会让上下两条不齐');
assert.match(body, /\.margin\(\{[^}]*left: TAB_BAR_SIDE[^}]*right: TAB_BAR_SIDE/,
'页签条的左右留白要用 TAB_BAR_SIDE(= NAV_BAR_SIDE)—— 悬浮条的侧边必须与底部条同值');
/*
* ★★ 2026-09-20:留白必须**算进宽度**,不能只写 `width('100%')` + `margin`。
*
* ArkUI 的 `width('100%')` 是按父容器算的,再叠 `margin` 会**向右溢出**
* 而不是把条挤窄 —— 实测(模拟器 3184px、密度 2.875):页签条占 x=231..1151,
* 与面板**同宽**,`TAB_BAR_SIDE` 完全没生效;看起来仍是"通栏 + 圆角",
* 与底部悬浮条不齐。
*
* 这条断言是**从这次实测反推出来的**:单看代码"有 margin"会以为留白生效了,
* 而只有形如 `calc(100% - N vp)` 的宽度才真的让出那一段。
*/
assert.ok(!/\.width\('100%'\)[\s\S]{0,200}?\.margin\(\{[^}]*left: TAB_BAR_SIDE/.test(body),
"页签条不能写 `width('100%')` + `margin` —— ArkUI 里那是向右溢出、留白不生效。\n" +
' 要用 `width(`calc(100% - ${TAB_BAR_SIDE * 2}vp)`)` 把留白算进宽度(设备实测的结论)');
assert.match(body, /\.width\(`calc\(100% - \$\{TAB_BAR_SIDE \* 2\}vp\)`\)/,
'页签条宽度要 `calc(100% - 2×TAB_BAR_SIDE)` —— 悬浮条的左右留白必须真的让出来');
assert.match(body, /\.margin\(\{[^}]*top: TAB_BAR_TOP/,
'页签条要留顶部间距(TAB_BAR_TOP)—— "浮着"而不是"贴着"内容区顶');
/*
* 自检:把页签条改回实心面,上面第一条必须判红。

View File

@ -3,6 +3,18 @@
#
# 取基线是**有意的动作**,不是随手重算 —— 重算会把"某次变异没还原"永久掩盖掉。
# 每次重算都要在此记一行"为什么":
# 2026-09-20 10:xx baseline 重算(第 8 次)—— 用户:「所有顶栏统一玻璃圆框」「抽象统一顶栏组件」
# 「发件箱太大了」「各组件带响应点击动画了吗」「转场动画呢」「还有其他行为要一一对齐」。
# ① 有意编辑:`MainPage.ets`(SentTab/PermissionTab 顶栏改 AppHeader、挂按压反馈)、
# `SettingsPage.ets`(标题行改 AppHeader)、`AdminUsersPage.ets`(Header 改 AppHeader)。
# ② 两个都跑过 `git diff --quiet HEAD -- <f>` 取证。
# ★ 本轮最大的收获是**两个「看起来做了、其实没生效」的坑**:
# · `.attributeModifier()` **一个组件只能挂一个**,链两个是后者覆盖前者 ——
# 会话组头卡同时挂了玻璃与按压反馈,结果只生效一个,编译器不报错。
# 修法:`CompositeModifier` 显式合成(CSS 的类名天然叠加,ArkUI 是单一插槽)。
# · `applyPressedAttribute` **只对自带按压状态机的组件**(Button)回调;
# 纯 `Row`/`Column`(全仓 117 处 onClick 的主体)一次都不触发 ——
# 实测:挂上去后按下与常态逐像素相同。改用 `onTouch` + 系统点击效果色。
# 2026-09-19 22:xx baseline 重算(第 7 次)—— 用户:「玻璃要更透明/磨砂更有质感」+「判据去芜存菁」。
# ① `pages/SettingsPage.ets` / `pages/MainPage.ets`:有意编辑 ——
# 页面里的玻璃三连(`backgroundColor` + `backgroundBlurStyle`)收敛成
@ -104,8 +116,8 @@
# 下次再手滑批量替换会直接判红,不必再靠事后复核。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
dce63e1b847fb0656afcac0ed034f3d084859ce8246f3051cbc24a1101baa545 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
5778eb9cc2af326b9f51ef9d53a66efa058fd0b5cfbef2881b603f9d367ac2a5 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
73c66c7045f579c3eb8b8e0aa07803e7c9363b7ae8375972b251633d3ce969be client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
beb55081a550d833c4a35b75cc69498d028a0ae0591eb8861c799c1700616d40 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
1c8f848ee2d6817beeefd05325cb3b0cdc1d425717769533688019c1e59f2299 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets