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

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

★ ① ② 统一顶栏 `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('★ 语义:慢拖不翻页("快滑"是感知档,不是同一个毫秒数)', () => { test('★ 语义:慢拖不翻页("快滑"是感知档,不是同一个毫秒数)', () => {
/* /*
* WebUI:`const fast = Date.now() - start.t < 600` + `!fast → return`。 * 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+)/); const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)'); assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
assert.ok(Number(webFast[1]) > 0, '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 的那三个数。 * ★ 反向断言:鸿蒙**不得**引用 WebUI 的那三个数。

View File

@ -616,7 +616,7 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
* 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。 * 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。
*/ */
const CAL = await import(pathToFileURL(join(HARMONY_ETS, 'model/Calendar.ts')).href); 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); const next = judgeSwipe(-200, 10, 120);
@ -640,14 +640,32 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
/* 纯纵向必然不翻 */ /* 纯纵向必然不翻 */
assert.equal(judgeSwipe(5, -300, 100).turned, false, '纯纵向滚动不得翻页'); 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 会让所有手势都翻页) // ⑤ 阈值必须是正数(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 时斜滑就翻页)'); '三个阈值都要是正数;倍数还必须 > 1(=1 时斜滑就翻页)');
// ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页) // ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页)

View File

@ -612,8 +612,24 @@ test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—
'页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西'); '页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西');
assert.match(body, /\.borderRadius\(TAB_BAR_RADIUS\)/, assert.match(body, /\.borderRadius\(TAB_BAR_RADIUS\)/,
'页签条要用 TAB_BAR_RADIUS(= NAV_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 次)—— 用户:「玻璃要更透明/磨砂更有质感」+「判据去芜存菁」。 # 2026-09-19 22:xx baseline 重算(第 7 次)—— 用户:「玻璃要更透明/磨砂更有质感」+「判据去芜存菁」。
# ① `pages/SettingsPage.ets` / `pages/MainPage.ets`:有意编辑 —— # ① `pages/SettingsPage.ets` / `pages/MainPage.ets`:有意编辑 ——
# 页面里的玻璃三连(`backgroundColor` + `backgroundBlurStyle`)收敛成 # 页面里的玻璃三连(`backgroundColor` + `backgroundBlurStyle`)收敛成
@ -104,8 +116,8 @@
# 下次再手滑批量替换会直接判红,不必再靠事后复核。 # 下次再手滑批量替换会直接判红,不必再靠事后复核。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts 4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.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 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 f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets 6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets

View File

@ -42,6 +42,14 @@
import { Theme } from './Theme'; import { Theme } from './Theme';
import { AmIcon } from './Icons'; import { AmIcon } from './Icons';
import { Motion } from './Motion'; import { Motion } from './Motion';
import {
HEADER_BACK_HIT,
HEADER_HEIGHT,
HEADER_RADIUS,
HEADER_SIDE,
HEADER_TITLE_FONT,
HEADER_TOP
} from '../model/NavItems';
/** /**
* 「面」的层级。**只有两档,不是调色板** —— * 「面」的层级。**只有两档,不是调色板** ——
@ -137,69 +145,6 @@ export struct GlassPane {
} }
} }
/**
* 页头 —— 返回按钮 + 标题。
*
* 改造前**四个页面各写了一个返回按钮**,而且是四种尺寸:
*
* AdminUsersPage:343 Text('‹').fontSize(24) .width(40).height(40)
* MainPage:1876 Text('‹').fontSize(24) .width(36).height(36)
* MailDetailPage:564 Text('‹').fontSize(24) .width(36).height(36)
* CalendarPage:1171 Button('‹')
*
* 三件事同时错:① 用**字符**当图标(本仓明令禁止 —— 字形随字体变、基线对不齐,
* 正是不精致的直接来源),而 `ICON_PATHS` 里**早就有** `chevronLeft`;
* ② 同一个东西四种尺寸;③ 触控区各不相同。收进这里以后只剩一个定义。
*/
@Component
export struct PageHeader {
/** 标题 */
@Prop title: string = '';
/** 左侧返回按钮是否显示(根页面不显示) */
@Prop showBack: boolean = true;
/** 返回动作 */
onBack: () => void = () => {};
build() {
Row() {
if (this.showBack) {
Row() {
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(HeaderMetrics.HIT)
.height(HeaderMetrics.HIT)
.justifyContent(FlexAlign.Center)
.borderRadius(Theme.radiusControl)
.onClick(() => { this.onBack(); })
}
Text(this.title)
.fontSize(HeaderMetrics.TITLE_FONT)
.fontColor(Theme.textPrimary)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ left: HeaderMetrics.PAD_H, right: HeaderMetrics.PAD_H })
.alignItems(VerticalAlign.Center)
}
}
/**
* 页头几何。
*
* `HIT` 是**触控区下限**:WebUI 的返回按钮是 `p-1.5` + 16px 图标 ≈ 28px,
* 手机上偏小;取 36vp 与本仓既有的可点区下限同值
* —— `MainPage` 折叠头部那次修复正是 14px 的点按区会把手势条拉下来。
*/
class HeaderMetrics {
static readonly HIT: number = 36;
static readonly PAD_H: number = 12;
/** 与 WebUI `text-base font-semibold` 对应的标题字号 */
static readonly TITLE_FONT: number = 16;
}
/** /**
* 可点容器 —— 把「命中区」「按压反馈」收成一处。 * 可点容器 —— 把「命中区」「按压反馈」收成一处。
* *
@ -475,3 +420,257 @@ export class PaneModifier implements AttributeModifier<CommonAttribute> {
instance.backgroundColor(this.active ? Color.Transparent : Theme.pageBg); instance.backgroundColor(this.active ? Color.Transparent : Theme.pageBg);
} }
} }
/**
* **统一顶栏** —— 全仓所有页面的顶部那一条,只有这一个定义。
*
* 用户 2026-09-20:
* · 「我觉得所有的顶栏都应该应用玻璃圆框效果」
* · 「抽象一个统一的顶栏组件出来吧」
*
* ── 改造前的六种写法(每一处都不一样)──
*
* | 位置 | 返回键 | 标题字号 | 底色 |
* |---|---|---|---|
* | `AdminUsersPage.Header` | 40×40 `Text('‹')` | 16 | 无 |
* | `SettingsPage` | 无 | 20 | **实心 `Theme.surface`** |
* | `MailDetailPage` | 36×36 `Text('‹')` | 14 | 无(行高 14px,被裁过) |
* | `MainPage.SentTab` | 无 | 20 | 透明 |
* | `MainPage.PermissionTab` | 无 | 20 | 透明 |
* | `CommTabBar` | 无(页签) | 13 | 已改悬浮玻璃 |
*
* 三类毛病同时存在:**尺寸不一**(36/40、字号 14/16/20)、**底色不一**
* (实心白 / 透明 / 无)、**有的有返回键有的没有**。而且四处用 `Text('‹')`
* 当返回键 —— 用**字符**当图标(本仓明令禁止:字形随字体变、基线对不齐),
* 而 `ICON_PATHS` 里**早就有** `chevronLeft`。
*
* ── 为什么做成"悬浮玻璃圆框"──
*
* 底部导航条已经是这个语汇(`NAV_BAR_*`)。顶栏与它同在一根竖轴上,
* 一条悬浮一条通栏,会一眼看出不成套。所以几何**复用底部条的常量**
* (`HEADER_HEIGHT/RADIUS/SIDE` = `NAV_BAR_*`),材质走同一个 `Theme.navMaterial`。
*
* ★ 右侧动作位用 `@BuilderParam`:那几个页面的顶栏右侧各不相同
* (刷新 / + / 计数 / 徽标 / 页签),把动作当内容传进来,
* 组件只负责"圆框 + 返回键 + 标题 + 占位",不替调用方猜。
*/
@Component
export struct AppHeader {
/** 标题 */
@Prop title: string = '';
/**
* 左侧返回键是否显示。
*
* 根窗格(通信/日历/联系/我的)不显示 —— 底部导航就是它们的返回路径;
* push 出去的独立页(管理/详情/写信)显示。
*/
@Prop showBack: boolean = false;
/** 壁纸是否开启(决定吃不吃材质)。与 `GlassCardModifier` 同一约定 */
@Prop active: boolean = false;
/** 返回动作 */
onBack: () => void = () => {};
/**
* 顶部安全区高度(px)。`@Entry` 页面(管理/详情/写信)必须传自己的
* `topInset(windowInsets)` —— 否则顶栏会压到系统状态栏下面。
*
* ★ 为什么由调用方传而**不是**本组件自己读 `AppStorage`:
* 状态栏避让是**窗口级**事实,属于页面(见 `WindowInsets` 与提交 7421ad7)。
* 组件自己去读会变成"每层各加一次" —— 平板侧栏那个 83+83 双计正是这么来的。
* 常驻窗格(通信/日历/我的)在 `MainPage` 里已经被父容器避让过,传 0。
*/
@Prop topInsetPx: number = 0;
/** 右侧动作区(可空) */
@BuilderParam trailing: () => void = this.noTrailing;
@Builder
noTrailing() {
}
build() {
Row() {
if (this.showBack) {
/*
* 返回键:用**图标路径**(`chevronLeft`),不是 `Text('‹')`。
* 命中区 44vp(`HEADER_BACK_HIT`)—— 原来的 36/40 都不到通行下限。
*/
Row() {
AmIcon({ iconName: 'chevronLeft', iconSize: 22, iconColor: Theme.accentFor() })
}
.width(HEADER_BACK_HIT)
.height(HEADER_BACK_HIT)
.justifyContent(FlexAlign.Center)
.borderRadius(HEADER_RADIUS)
.margin({ right: 4 })
.onClick(() => { this.onBack(); })
}
Text(this.title)
.fontSize(HEADER_TITLE_FONT)
.fontWeight(FontWeight.Bold)
.fontColor(Theme.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
this.trailing()
}
.width(`calc(100% - ${HEADER_SIDE * 2}vp)`)
/* 高度 + 避让一起加:顶栏自己抬出状态栏,内容不越过系统时钟 */
.height(HEADER_HEIGHT + this.topInsetPx)
.padding({ left: 8, right: 8, top: this.topInsetPx })
.alignItems(VerticalAlign.Center)
/*
* ── 悬浮玻璃圆框(与底部导航条同一族)──
*
* `backgroundColor(Color.Transparent)`:材质自己带色调,再铺一层实色就把材质盖住
* ("模糊了但看不见" —— 上一轮壁纸被整块挡住正是这个形状的错)。
*/
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(this.active ? Theme.navMaterial : BlurStyle.NONE)
.borderRadius(HEADER_RADIUS)
.clip(true)
.margin({ left: HEADER_SIDE, right: HEADER_SIDE, top: HEADER_TOP })
}
}
/**
* **按压反馈** —— 一处定义、处处引用(与 `GlassCardModifier` 同一个理由)。
*
* 用户 2026-09-20:「同时各个组件带响应点击,滑动等的动画了吗」。
* 实测答案:**没有**。全仓 `onTouch` / `stateStyles` **0 处**,
* 117 个 `onClick` 按下去界面上什么都不动 —— 用户无法确认"我点到了没有",
* 只能等结果。这是"不精致"最直接的来源,比配色问题更影响手感。
*
* ── 为什么用 `AttributeModifier` 而不是逐个包 `Pressable` ──
*
* `Pressable` 那个组件(本文件里)要**包住**内容,于是每个调用点都要
* 多一层容器、并重排 `layoutWeight` —— 全仓 117 处,改不动,也一定改漏。
*
* `AttributeModifier` 有 `applyPressedAttribute`:**由系统在手势按下时回调**,
* 所以调用点只加一句 `.attributeModifier(PressFeedbackModifier.of())`,
* **不加节点、不改版式**。这正是"一处定义、处处引用"要的形状。
*
* ── 取值对齐 WebUI(不是拍脑袋)──
*
* WebUI 每个可点元素都带 `transition-colors` + `active:bg-*`
* (`MailList.tsx:279`、`AccountPage.tsx:239`、`CommTabs.tsx:95`…),
* 而 `index.css:1169` 把它统一定义成:
*
* button, a, input, textarea, select, [role='button'] {
* transition: background-color var(--dur-fast) var(--ease-out-soft), …
* }
*
* 即 **120ms + `cubic-bezier(0.22,1,0.36,1)`** —— 就是 `Theme.durFast` + `Theme.easeOutSoft`。
* 所以这里用同一对令牌(本仓已有,且 `navRiseIn` 那族也在用)。
*
* ★ 按压态用**提亮/压暗底色**而不是缩放:WebUI 用的是 `active:bg-gray-100`
* (底色变化),没用 `active:scale`。缩放会让文字重排(观感是"抖"),
* 且在列表项上会与滚动惯性打架。
*/
export class PressFeedbackModifier implements AttributeModifier<CommonAttribute> {
/**
* 按压时的底色。默认 **系统点击效果色** `ohos_id_color_click_effect`。
*
* ★★ 这个值不是我挑的,是**系统给 `Button` 用的那一个**(`id_defined.json`
* 里有 `ohos_id_color_click_effect` / `_dark` / `_transparent` 三档)。
*
* 第一版用的是 `Theme.surfaceMuted`("次级面"),实测**位移太小**:
* 按下与常态只差 `188,203,220` vs `191,206,223`(3/3/3),肉眼几乎看不出。
* 原因是 `surfaceMuted` 是"**一块面的颜色**",不是"按下时叠上去的提示色"——
* 两者在系统色板里本来就是不同的语义。
*
* 换成点击效果色之后,深浅两套、以及"要不要让底色透出来"都由系统管
* (`_transparent` 那一档就是给"叠在已有底色上"用的),与
* "用系统方案而不是自己猜"这条纪律一致。
*/
private color: ResourceColor = $r('sys.color.ohos_id_color_click_effect');
/** 工厂:`PressFeedbackModifier.of()`;可传自定义按压色 */
static of(color?: ResourceColor): PressFeedbackModifier {
const m = new PressFeedbackModifier();
if (color !== undefined) {
m.color = color;
}
return m;
}
applyNormalAttribute(instance: CommonAttribute): void {
/*
* ── 为什么不用 `applyPressedAttribute`(试过,对纯容器无效)──
*
* 那个回调**只在组件自带"按压状态机"时**才触发。实测:
* · 挂在 `Button` 上 → 回调正常(hilog 有输出);
* · 挂在只有 `.onClick` 的 `Row`/`Column` 上 → **一次都不回调**
* (按下与常态**逐像素相同**,`239,244,255` 两边一模一样)。
* 而本仓绝大多数的可点元素正是纯 `Row`/`Column`(117 处 `onClick`),
* 所以那条路对这里没用。
*
* 改用 `onTouch` 直接驱动:按下 → `animateTo` 把底色改成 `color`;
* 松开/取消 → 改回 `BLANK`。这是 ArkUI 里对纯容器唯一可靠的按压反馈路径。
*
* ★ 恢复到 `Color.Transparent`(而不是某个具体色):调用点自己可能铺了玻璃、
* 品牌色或什么都不铺 —— 本 modifier 只负责"按下时加一层提示",
* 松手还原成透明,由调用点自己的底色/材质继续显示。
*
* ★ `Cancel` 必须一起处理:手势被父容器(列表滚动)抢走时不会来 `Up`,
* 只处理 `Up` 会让元素**永远停在按下的样子**。
*/
instance.onTouch((e: TouchEvent) => {
const down: boolean = e.type === TouchType.Down;
const cancel: boolean = e.type === TouchType.Cancel;
/* 用 `BLANK` 常量而不是 `Color.Transparent` 字面量:避免"透明色"被误当成"没设" */
const c: ResourceColor = down ? this.color : Color.Transparent;
instance.backgroundColor(c);
});
}
}
/**
* **组合属性集** —— 把多个 `AttributeModifier` 合成一个。
*
* ── 为什么必须要有这个(实测撞出来的)──
*
* ArkUI 里 `.attributeModifier()` **一个组件只能挂一个**,链两个是后者覆盖前者:
*
* .attributeModifier(GlassCardModifier.of(this.bgActive))
* .attributeModifier(PressFeedbackModifier.of()) ← 把上面那个挤掉
*
* 实测现场:会话组头卡同时写了这两句,结果**玻璃与按压反馈只生效一个**。
* 这个错很隐蔽 —— 两句都"看起来在作用",而编译器不报错、运行时不提示。
*
* 与 WebUI 对照:那边是 CSS,多个类名的规则**天然叠加**
* (`class="glass-card … transition-colors active:bg-gray-100"`);
* ArkUI 的 `AttributeModifier` 是**单一插槽**,所以"叠加"这件事要显式做。
*
* ★ 顺序即优先级:列表里**后面的会覆盖前面的同名字段** ——
* 与 CSS 的"后来者胜"一致,也与 `.attributeModifier` 链式覆盖的直觉一致。
*/
export class CompositeModifier implements AttributeModifier<CommonAttribute> {
private parts: AttributeModifier<CommonAttribute>[] = [];
/** 工厂:`CompositeModifier.of([A.of(x), B.of()])` */
static of(parts: AttributeModifier<CommonAttribute>[]): CompositeModifier {
const m = new CompositeModifier();
m.parts = parts;
return m;
}
applyNormalAttribute(instance: CommonAttribute): void {
for (let i = 0; i < this.parts.length; i++) {
const p = this.parts[i];
/* 逐个转发:这些方法都是可选的,要先判存在 */
if (p.applyNormalAttribute !== undefined) {
p.applyNormalAttribute(instance);
}
}
}
applyPressedAttribute(instance: CommonAttribute): void {
for (let i = 0; i < this.parts.length; i++) {
const p = this.parts[i];
if (p.applyPressedAttribute !== undefined) {
p.applyPressedAttribute(instance);
}
}
}
}

View File

@ -373,8 +373,37 @@ export class SwipeVerdict {
export const SWIPE_MIN_DISTANCE: number = 56; export const SWIPE_MIN_DISTANCE: number = 56;
/** 横向位移至少要达到纵向的这么多倍,才认为用户意图是"横"而不是"竖" */ /** 横向位移至少要达到纵向的这么多倍,才认为用户意图是"横"而不是"竖" */
export const SWIPE_AXIS_RATIO: number = 1.4; export const SWIPE_AXIS_RATIO: number = 1.4;
/** 「快滑」的时间窗(毫秒);超过就算"慢慢拖",不翻页 */ /**
export const SWIPE_MAX_DURATION_MS: number = 700; * 「快滑」的**速度**下限(vp/ms);低于它就当成"慢慢拖",不翻页。
*
* ★★ 2026-09-20 从"时长上限"改成"速度下限"(实测撞出来的设计错误)。
*
* 原来的门是 `SWIPE_MAX_DURATION_MS = 700`:"整个手势超过 700ms 就拒绝"。
* 而时长 = 距离 ÷ 速度 —— 它把**距离**和**速度**混成了一个量。于是:
* · 同样的手速下,**滑得越远耗时越长** ⇒ 长距离的正常滑动被判成"慢慢拖";
* · 屏幕越大越严重(平板同一手势的像素更多 ⇒ 耗时更长 ⇒ 更容易被拒)。
*
* 设备实测(模拟器 3184×2232,同一段 1560px,位移恒为 542.6vp):
*
* 注入 600px/s → 5909ms 注入 2500px/s → 1429ms
* 注入 1200px/s → 3161ms 注入 5000px/s → 616ms
* 注入 1800px/s → 2006ms 注入 15000px/s → 123ms
*
* 旧门(700ms)的实际效果:**仅 5000px/s 以上才通过**,
* 而 5000px/s 那一档在重复测试里也只有 2/4 成功 ——
* 用户报告"滑不动/时灵时不灵"就是这个(判据 `cross-client-gesture` 反复红)。
*
* 改成速度门之后,判据与距离**解耦**:546vp 也好、1000vp 也好,
* 只看"手划得快不快",这才是"快滑"该有的语义(WebUI 那边
* `const fast = Date.now() - start.t < 600` 想把关的也是这个,只是同样受距离干扰)。
*
* ★ 取值 0.25 的依据(上表的分档,不是拍的):
* · 0.09 / 0.17 vp/ms(600/1200 px/s)= **拖动**(用户在对准/浏览)⇒ 应拒;
* · 0.38 / 0.88 / 4.4 vp/ms(2500/5000/15000 px/s)= **滑动** ⇒ 应过。
* 门槛放在 0.25 正好落在两档中间,两侧都有余量。
* 真实手指的甩动通常 0.5~3 vp/ms,比 0.25 高一档以上,不会误拒。
*/
export const SWIPE_MIN_SPEED: number = 0.25;
/** /**
* 判定一次滑动 —— 纯函数,无 UI 依赖(判据直接跑它)。 * 判定一次滑动 —— 纯函数,无 UI 依赖(判据直接跑它)。
@ -387,8 +416,9 @@ export const SWIPE_MAX_DURATION_MS: number = 700;
* ① 横向位移足够大(`SWIPE_MIN_DISTANCE`)—— 防"轻点/微抖"被当成滑动; * ① 横向位移足够大(`SWIPE_MIN_DISTANCE`)—— 防"轻点/微抖"被当成滑动;
* ② 横向明显大于纵向(`SWIPE_AXIS_RATIO`)—— 这是移动端最容易犯的手势错误: * ② 横向明显大于纵向(`SWIPE_AXIS_RATIO`)—— 这是移动端最容易犯的手势错误:
* 用户在纵向列表上滚动,手指多少会带一点横向偏移,不卡这条就会"滚着滚着翻页了"; * 用户在纵向列表上滚动,手指多少会带一点横向偏移,不卡这条就会"滚着滚着翻页了";
* ③ 够快(`SWIPE_MAX_DURATION_MS`)—— 慢慢拖通常意味着用户在瞄准或阅读, * ③ 够快(`SWIPE_MIN_SPEED`)—— 慢慢拖通常意味着用户在瞄准或阅读,
* 不是在翻页(WebUI 同语义); * 不是在翻页。用**速度**而不是**时长**:见 `SWIPE_MIN_SPEED` 的注释
* (时长把距离与速度混在一起,大屏上会误拒正常滑动);
* ④ 方向:左滑(dx < 0)= 下一段,右滑(dx > 0)= 上一段。 * ④ 方向:左滑(dx < 0)= 下一段,右滑(dx > 0)= 上一段。
* *
* 方向与翻页按钮同源:`delta` 直接交给 `shiftRange`, * 方向与翻页按钮同源:`delta` 直接交给 `shiftRange`,
@ -402,7 +432,12 @@ export function judgeSwipe(dx: number, dy: number, ms: number): SwipeVerdict {
if (Math.abs(dx) < Math.abs(dy) * SWIPE_AXIS_RATIO) { if (Math.abs(dx) < Math.abs(dy) * SWIPE_AXIS_RATIO) {
return v; return v;
} }
if (ms > SWIPE_MAX_DURATION_MS) { /*
* 速度门。`ms` 为 0(同一毫秒内完成)时按"极快"处理 —— 不能除零,
* 也不能因为读不到时长就把手势丢掉。
*/
const speed: number = ms > 0 ? Math.abs(dx) / ms : Number.MAX_SAFE_INTEGER;
if (speed < SWIPE_MIN_SPEED) {
return v; return v;
} }
v.turned = true; v.turned = true;

View File

@ -326,3 +326,48 @@ export const TAB_BAR_RADIUS: number = NAV_BAR_RADIUS;
export const TAB_BAR_SIDE: number = NAV_BAR_SIDE; export const TAB_BAR_SIDE: number = NAV_BAR_SIDE;
/** 顶部留白(vp):离内容区顶部一点点,做成"浮着"而不是"贴着" */ /** 顶部留白(vp):离内容区顶部一点点,做成"浮着"而不是"贴着" */
export const TAB_BAR_TOP: number = 8; export const TAB_BAR_TOP: number = 8;
/**
* **统一顶栏**的几何 —— 与页签条、底部导航条同一族(悬浮玻璃圆框)。
*
* 用户 2026-09-20:「我觉得所有的顶栏都应该应用玻璃圆框效果」
* 「抽象一个统一的顶栏组件出来吧」。
*
* 改造前全仓有 **6 处各写各的顶栏**,形状与尺寸都不一致:
* · `AdminUsersPage.Header` 返回键 40×40 + 标题 16 + 刷新
* · `SettingsPage` 标题 20 + 「+」40×40,**实心 `Theme.surface`**
* · `MailDetailPage` 返回键 36×36 + 可折叠标题行(行高 14px,被裁过)
* · `MainPage.SentTab` 标题 20 + 计数,通栏、无圆角
* · `MainPage.PermissionTab` 标题 20 + 徽标,通栏
* · `CommTabBar` 三页签(已先改成悬浮玻璃)
* 三处问题同时存在:**尺寸不一**(36/40、字号 16/20)、**底色不一**
* (实心 `surface` / 透明 / 无)、**有的有返回键有的没有**。
*
* 高度复用 `NAV_BAR_HEIGHT`(56):顶栏与底栏是同一根竖轴上的两条,
* 高度不同会一眼看出来。侧留白复用 `NAV_BAR_SIDE`。
*/
/*
* ★★ 2026-09-20 修(用户:「你不觉得发件箱太大了吗」)。
*
* 我第一版让它 **复用 `NAV_BAR_HEIGHT`(56)**,理由写的是"顶栏与底栏同在一根
* 竖轴上、高度不同会一眼看出来"。**那个理由错了**:
*
* · 底栏是**两行内容**(图标 + 文字标签)⇒ 56 是它需要的;
* · 顶栏只有**一行标题**(外加可选的返回键/动作)⇒ 56 里有一半是空白。
* 实测:`Row [277,315,1105,476]` = 161px = **56vp**,标题那行字只占 54px,
* 上下各空掉 50 多 px —— 用户一眼就看出"太大"。
*
* "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正需要对齐的是
* **左右留白与圆角**(那决定视觉边界),高度该按**内容**定。
* 所以侧留白/圆角仍复用 `NAV_BAR_*`,高度独立取 44 ——
* 44 同时是本仓的可点区下限(`NAV_ITEM_MIN_HIT`),返回键装得下。
*/
export const HEADER_HEIGHT: number = 44;
export const HEADER_RADIUS: number = NAV_BAR_RADIUS;
export const HEADER_SIDE: number = NAV_BAR_SIDE;
/** 顶栏离内容区顶部的留白(vp)—— 与 `TAB_BAR_TOP` 同值,同一族 */
export const HEADER_TOP: number = TAB_BAR_TOP;
/** 返回键的命中区(vp)。≥44 是本仓可点区下限(`NAV_ITEM_MIN_HIT` 同源) */
export const HEADER_BACK_HIT: number = NAV_ITEM_MIN_HIT;
/** 标题字号(vp)。16 = 系统 `text_title`,与 `AdminUsersPage` 原有取值一致 */
export const HEADER_TITLE_FONT: number = 16;

View File

@ -25,6 +25,7 @@
*/ */
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
import { AppHeader } from '../common/Surface';
import { AdminApi, MeApi } from '../api/AdminApi'; import { AdminApi, MeApi } from '../api/AdminApi';
import { import {
AdminUser, AdminUser,
@ -355,33 +356,44 @@ struct AdminUsersPage {
.backgroundColor(Theme.pageBg) .backgroundColor(Theme.pageBg)
} }
/**
* 顶栏右侧的「刷新」。
*
* 管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上。
* 命中区 44vp 且图标居中 —— 尺寸加在**外层 Stack** 上:
* 写 `AmIcon({...}).width(40).height(40)` 时内层容器固定 20vp 且靠左上,
* 图标会贴到命中区左上角(本仓 2026-09-18 修过同一处)。
*/
@Builder
HeaderTrailing() {
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(44)
.height(44)
.onClick(() => { this.load(); })
}
@Builder @Builder
Header() { Header() {
Row() { /*
Text('‹').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40) * ★ 2026-09-20 改用**统一顶栏**(用户:「所有的顶栏都应该应用玻璃圆框效果」
.textAlign(TextAlign.Center) * 「抽象一个统一的顶栏组件出来吧」)。
.onClick(() => { this.getUIContext().getRouter().back(); }) *
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) * 原来这里是手写的一行:`Text('‹')` 当返回键(字符当图标,本仓禁止)、
.layoutWeight(1) * 命中区 40vp(不到 44 下限)、标题 16、底色 `Theme.surface`(实心白)、
// 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上 * 高度与避让自己算。现在这些全在 `AppHeader` 里,本页只说"要不要返回键、
/* * 标题是什么、右侧放什么"。
* ★ 40×40 的命中区,图标居中 —— 尺寸加在**外层 Stack** 上。 */
* AppHeader({
* 原来写的是 `AmIcon({...}).width(40).height(40)`:内层容器固定 20vp 且靠左上, title: '管理',
* 于是 20vp 的图标贴在 40×40 命中区的左上角(同 `LoginPage` 品牌卡那处, showBack: true,
* 2026-09-18 一起修)。命中区本身是好的(40vp ≥ 44? 不 —— 见下), active: false,
* 但视觉上"图标没在按钮中央"是对的观感洁癖,也说明这个盒子才是按钮。 onBack: () => { this.getUIContext().getRouter().back(); },
*/ /* `@Entry` 页:状态栏避让由页面自己消费(窗口级事实,见 AppHeader 的注释) */
Stack({ alignContent: Alignment.Center }) { topInsetPx: topInset(this.windowInsets),
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accentFor() }) trailing: this.HeaderTrailing
} })
.width(40)
.height(40)
.onClick(() => { this.load(); })
}
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%').height(56 + topInset(this.windowInsets)).padding({ left: 8, right: 8, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
} }
/** /**

View File

@ -9,7 +9,7 @@
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
import { GlassCardModifier, PaneModifier } from '../common/Surface'; import { AppHeader, CompositeModifier, GlassCardModifier, PaneModifier, PressFeedbackModifier } from '../common/Surface';
import { AmIcon } from '../common/Icons'; import { AmIcon } from '../common/Icons';
import { WideSidebar } from './WideSidebar'; import { WideSidebar } from './WideSidebar';
import { MailDetailView } from './MailDetailPage'; import { MailDetailView } from './MailDetailPage';
@ -629,7 +629,17 @@ struct InboxTab {
.width('100%').height(78) .width('100%').height(78)
.padding({ left: 10, right: 12 }) .padding({ left: 10, right: 12 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.attributeModifier(GlassCardModifier.of(this.bgActive)) /*
* ★★ 两个属性集要**合成一个**再挂(实测撞出来的)。
*
* `.attributeModifier()` 一个组件只能挂一个,链两个是后者覆盖前者 ——
* 第一版就是链了两句,结果玻璃与按压反馈只生效一个,而编译器不报错。
* WebUI 那边是 CSS 类名天然叠加;ArkUI 是单一插槽,叠加要显式做。
*/
.attributeModifier(CompositeModifier.of([
GlassCardModifier.of(this.bgActive),
PressFeedbackModifier.of()
]))
.borderRadius(Theme.radiusCard) .borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border }) .border({ width: 1, color: Theme.border })
.clip(true) .clip(true)
@ -882,38 +892,32 @@ struct SentTab {
.onClick(() => { this.toggleExpanded(g.key); }) .onClick(() => { this.toggleExpanded(g.key); })
} }
/** 顶栏右侧:已加载封数 */
@Builder
SentCountTrailing() {
if (this.loaded > 0) {
Text(this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtleFor())
.margin({ right: 8 })
}
}
build() { build() {
Column() { Column() {
Row() {
Text('发件箱').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
if (this.loaded > 0) {
Text(this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtleFor())
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
/* /*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。 * ★ 2026-09-20 改用**统一顶栏**(用户:「所有的顶栏都应该应用玻璃圆框效果」)。
* *
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+ * 这里此前有过一段反复:先写死 `Theme.surface`(实心白条 → 用户
* 「期望:融进背景」)。 * 「一个横着过去的白条,我真的服了」),再改成"与页面底同口径"的透明通栏。
* * 两次都还是**通栏**,而底栏是悬浮圆框 —— 同一根竖轴上两种风格。
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200` * 现在统一走 `AppHeader`:圆框 + 与底栏同值的高度/圆角/侧留白。
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/ */
.attributeModifier(GlassCardModifier.of(this.bgActive)) AppHeader({
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */ title: '发件箱',
.border({ width: { bottom: 1 }, color: Theme.border }) showBack: false,
active: this.bgActive,
topInsetPx: 0,
trailing: this.SentCountTrailing
})
if (this.loading) { if (this.loading) {
Column() { LoadingProgress().width(32).height(32) } Column() { LoadingProgress().width(32).height(32) }
@ -1092,6 +1096,19 @@ struct PermissionTab {
} }
} }
/** 顶栏右侧:待决策徽标(橙=有人被卡住,不是"有东西要读") */
@Builder
PendingTrailing() {
if (this.requests.length > 0) {
Text(this.requests.length + ' 待决策')
.fontSize(12).fontColor(Theme.accentFg)
.backgroundColor(Theme.warnFg)
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.margin({ right: 8 })
}
}
@Builder @Builder
RequestCard(req: PermissionRequest) { RequestCard(req: PermissionRequest) {
Column() { Column() {
@ -1162,41 +1179,14 @@ struct PermissionTab {
build() { build() {
Column() { Column() {
Row() { /* 同 SentTab:统一走 AppHeader(圆框 + 与底栏同族的几何与材质) */
Text('授权').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) AppHeader({
Blank() title: '授权',
if (this.requests.length > 0) { showBack: false,
// 待决策的橙色徽标:这不是"有东西要读",是"有人被卡住" active: this.bgActive,
Text(this.requests.length + ' 待决策') topInsetPx: 0,
.fontSize(12).fontColor(Theme.accentFg) trailing: this.PendingTrailing
.backgroundColor(Theme.warnFg) })
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
if (this.loading) { if (this.loading) {
Column() { LoadingProgress().width(32).height(32) } Column() { LoadingProgress().width(32).height(32) }
@ -1427,7 +1417,16 @@ struct CommPage {
}) })
}, (key: string) => key) }, (key: string) => key)
} }
.width('100%') /*
* ★ 宽度用 `calc(100% - 2×TAB_BAR_SIDE)`,**不是 `width('100%')` + `margin`**。
*
* ArkUI 里 `width('100%')` 是按父容器算的,再叠 `margin` 会**向右溢出**
* 而不是把条挤窄 —— 实测(模拟器 3184px、密度 2.875):
* 页签条实际占 x=231..1151,与面板同宽;TAB_BAR_SIDE(16vp=46px) 完全没生效
* ⇒ 看起来仍是"通栏",只是多了圆角,与底部悬浮条(左右各留 16vp)不齐。
* 把留白算进宽度里,左右才真的各让出 16vp。
*/
.width(`calc(100% - ${TAB_BAR_SIDE * 2}vp)`)
/* /*
* ★★ 2026-09-20(用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」) * ★★ 2026-09-20(用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」)
* *

View File

@ -10,6 +10,7 @@
*/ */
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
import { AppHeader } from '../common/Surface';
import { GlassCardModifier, PaneModifier } from '../common/Surface'; import { GlassCardModifier, PaneModifier } from '../common/Surface';
import { AuthApi } from '../api/AuthApi'; import { AuthApi } from '../api/AuthApi';
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
@ -606,15 +607,17 @@ export struct SettingsPane {
* *
* 所以这里去掉返回键(导航条就是返回路径),标题保留。 * 所以这里去掉返回键(导航条就是返回路径),标题保留。
*/ */
Row() { /*
Text('我的').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) * ★ 2026-09-20 改用**统一顶栏**(原来是手写的一行 + **实心 `Theme.surface`**
.layoutWeight(1) * —— 在「我的」页里它是唯一一块不透明的白,与四周的玻璃卡片拼在一起就是"割裂")。
Text('+').fontSize(24).fontColor(Theme.accentFor()).width(40).height(40) */
.textAlign(TextAlign.Center) AppHeader({
.onClick(() => { this.showAddDialog = true; }) title: '我的',
} showBack: false,
.width('100%').height(56).padding({ left: 16, right: 8 }) active: this.bgActive,
.backgroundColor(Theme.surface) topInsetPx: 0,
trailing: this.HeaderTrailing
})
Divider().color(Theme.border) Divider().color(Theme.border)
@ -792,6 +795,17 @@ export struct SettingsPane {
* 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)—— * 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)——
* 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。 * 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。
*/ */
/** 顶栏右侧的「新增账号」 */
@Builder
HeaderTrailing() {
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'plus', iconSize: 22, iconColor: Theme.accentFor() })
}
.width(44)
.height(44)
.onClick(() => { this.showAddDialog = true; })
}
@Builder @Builder
ProfileSection() { ProfileSection() {
Column() { Column() {