跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"
用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」
★ ① ② 统一顶栏 `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:
@ -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 的那三个数。
|
||||||
|
|||||||
@ -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 去翻页)
|
||||||
|
|||||||
@ -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)—— "浮着"而不是"贴着"内容区顶');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 自检:把页签条改回实心面,上面第一条必须判红。
|
* 自检:把页签条改回实心面,上面第一条必须判红。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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(用户:「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user