From 20fc8a5800d7036a82fb3406c1fda49dbd02fd7c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 15:21:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E4=BF=AE=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=E5=B4=A9=E6=BA=83/=E5=A4=B1=E8=B4=A5=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20@BuilderParam=20=E4=B8=A2=20this=E3=80=81?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=90=8E=E9=80=80=E9=94=99=E9=A1=B5=E3=80=81?= =?UTF-8?q?=E6=BC=8F=E6=A0=A1=E9=AA=8C=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-09-21:「点击发送邮件直接闪退,点击授权也直接闪退,所有功能全部不可用」。 三个都是**真 bug**,逐个拿到证据后修的(不是猜的)。 ## ① 点「授权」必崩:`@BuilderParam` 把 `this` 换掉了 崩溃日志(`jscrash-…-20260921150832132.log`)给出的栈: Reason: TypeError Error message: Cannot read property length of undefined at anonymous entry (MainPage.ets:1458:23) ← this.requests.length at … Surface.ets:717:7 ← AppHeader 里 this.trailing() `MainPage.ets:1458` 是 `if (this.requests.length > 0)`, 而它住在 `PendingTrailing()` 这个 `@Builder` 里 —— **传给 `AppHeader` 的 `@BuilderParam` 之后,它执行时的 `this` 变成了 `AppHeader`**, 而 `AppHeader` 上当然没有 `requests` ⇒ `undefined.length` ⇒ 崩。 ★ 这是 ArkUI 的老坑:`@BuilderParam` 是**按值传递一个函数**, 调用方的 `this` 不会跟着过去。全仓**4 处**都踩了(`MainPage` 的 `SentCountTrailing`/`PendingTrailing`、`AdminUsersPage`/`SettingsPage` 的 `HeaderTrailing`)—— 它们各自读 `this.loaded`/`this.load()`。 修法:改成**尾随闭包**(`AppHeader({...}) { this.XxxTrailing() }`), 闭包捕获的是**定义处**的 `this`(本组件的),而不是 AppHeader 的。 ★ 为什么判据没抓到:那 4 处此前都只是"静态源码里有这个 builder", 而崩溃只发生在**运行时的 `this` 绑定**上 —— 形态判据看不见绑定。 这一条只能靠设备实测(我这次是靠真机崩溃日志)。 ## ② 发送成功后"闪退":其实是退错了页 `ComposePage.doSend()` 成功分支里是**无条件** `router.back()`。 而内嵌时(宽屏右栏 / 窄屏 `Navigation` 覆盖)写信只是 `MainPage` 的一个 **右栏状态** —— `router.back()` 退掉的是**整个 MainPage**,用户看到的就是 "发送之后 App 没了"(报成闪退)。 ★ 同一个文件里,顶栏「取消」键(上面几十行)**早就写对了**: if (this.embedded) { this.onBack(); return; } this.getUIContext().getRouter().back(); 我加 `doSend` 时没照着抄。`MailDetailView.goBack()` 也是这个正确形状 —— **只有 `doSend` 是那个异类**。已改成与取消键同一判据。 ## ③ 发送真的失败:校验漏了 `body`,且没 trim 日志里 `→ POST …/mail/send` 发出去了,但服务端 400。 直接打服务端复现: curl -d '{"to":"pi@root.new","subject":"t","body":""}' → {"error":"Missing to, subject, or body"} 而 WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**: to.trim() !== '' && subject.trim() !== '' && body.trim() !== '' && … 鸿蒙这边只校验了 `to` 与 `subject` —— **漏了 `body`**。 ⇒ 用户在"正文本来就是可选的"观感下不填正文,请求照样发出去、被拒。 同时补 `trim()`:WebUI 发的是 `to.trim()` / `subject.trim()`, 而 `pi@root.new ` 与 `pi@root.new` 在服务端是**两条不同地址**。 ## 设备验证(改前 → 改后) · 点「授权」:崩(进程消失,新增 jscrash) → **进程存活,页面正常渲染, 待决策徽标 "4" 正确显示**(证明 `this.requests` 绑定对了) · 发送邮件:POST 发出但服务端 400,且"闪退" → **回到收件箱, 发件箱里 `realtest` 已落库**(服务端实测 9 封) ## 另修:候选补全的菜单按 WebUI 补齐四件 用户:「收件人填充能力完全不可用,根本没有与 webui 对齐」。 实测后确认功能是通的(`pi` → `pi@` → 路径 → 会话 → 完整地址, 三段链逐段验过),但**行内渲染漏了 WebUI 的四个要素**(`AddressInput.tsx:186-231`): ① 别名 `font-mono`(地址类文本全仓等宽) ② 标题在**第二行**(原来挤在右边同一行) ③ `source` 三态视觉:`platform` 蓝胶囊 / `new` 灰字 / `mail` 无标 ④ `unread > 0` 红徽标(服务端 `SessionCandidate.Unread`,带 `omitempty`) `AddressSuggestion` 顺带补 `unread` 字段并守住 `omitempty` (缺键时裸 cast 是 `undefined`,不是类里的 `= 0` —— 与 `title` 同一个坑)。 ★ 另外修掉一个我自己写错的参数:`suggestAddress` 原来把 `'?name=…'` 传给 `ApiClient.get(path, query)`,而**问号是那个方法自己加的** ⇒ 会拼成 `??name=`。约定:`query` 只放 `k=v`,不含问号。 --- .../entry/src/main/ets/api/MailApi.ets | 18 +- .../entry/src/main/ets/model/Models.ets | 9 + .../src/main/ets/pages/AdminUsersPage.ets | 7 +- .../entry/src/main/ets/pages/ComposePage.ets | 191 ++++++++++++++++-- .../entry/src/main/ets/pages/MainPage.ets | 97 ++++++++- .../entry/src/main/ets/pages/SettingsPage.ets | 7 +- 6 files changed, 301 insertions(+), 28 deletions(-) diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index a9057ab..bd5f725 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -225,9 +225,25 @@ export class MailApi { * 不是"值是否为空"。省略会让它退回到上一层。 */ async suggestAddress(name: string, path: string): Promise { + /* + * ★★ 2026-09-21 修:原来这里返回的是 `'?name=…'` —— **自带问号**。 + * + * 而 `ApiClient.get(path, query)` 内部是: + * this.apiBase + opts.path + (opts.query.length > 0 ? '?' + opts.query : '') + * ^^^ 问号由**它**加 + * ⇒ 拼出来是 `/contacts/suggest??name=jianf`,服务端解析不到 `name`, + * 退回到"无参数"分支(回 name 层),于是**输入框里打了字却一个候选都不出现**。 + * + * 真实日志(`hilog`,14:54:53): + * → GET https://mail.jianfgit.xyz/api/v1/contacts/suggest + * 连 `?` 都没有 —— 说明 `query` 被当空串处理了(`'?name=…'.length > 0` 本应为真, + * 但那条路径下我传的其实没走到;无论如何,**带问号的 query 是错的写法**)。 + * + * ⇒ 约定:`query` 只放 `k=v&k2=v2`(**不含问号**)。见 `ApiClient.get` 的实现。 + */ let q: string = ''; if (name.length > 0) { - q = '?name=' + encodeURIComponent(name); + q = 'name=' + encodeURIComponent(name); if (path.length > 0) { q = q + '&path=' + encodeURIComponent(path); } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 9603c0e..317e3c6 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -390,6 +390,15 @@ export class AddressSuggestion { title: string = ''; /** 从哪来:mail(本侧线索,可直接送达)/ platform(平台侧镜像)/ new */ source: string = ''; + /** + * 未读数 —— 仅 `mail` 来源有意义。 + * + * ★ 服务端 `SessionCandidate.Unread` 带 `omitempty`(`platform_sessions.go:101`) + * ⇒ **缺键时是 `undefined`**,不是这里的 `= 0`。读它必须守一道 + * (见 `ComposePage.fetchSuggestions` 里那句 `typeof un === 'number'`)。 + * 与 `title` 同一个坑,本仓在 `MailDetail.normalize()` 上踩过整页白屏。 + */ + unread: number = 0; } /** 发信请求体 */ diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index e4e3d2c..a92bb7f 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -392,9 +392,10 @@ struct AdminUsersPage { active: false, onBack: () => { this.getUIContext().getRouter().back(); }, /* `@Entry` 页:状态栏避让由页面自己消费(窗口级事实,见 AppHeader 的注释) */ - topInsetPx: topInset(this.windowInsets), - trailing: this.HeaderTrailing - }) + topInsetPx: topInset(this.windowInsets) + }) { + this.HeaderTrailing() + } } /** diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index fe671af..786e725 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -97,6 +97,15 @@ export struct ComposeView { */ @State suggestItems: string[] = []; @State suggestTitles: string[] = []; + /* + * 候选的 `source`(`mail` | `platform` | `new`)与未读数。 + * + * ★ 对齐 WebUI `AddressInput.tsx:200-228`:这两者各有视觉 + * (`platform` 蓝胶囊 / `new` 灰字 / `unread>0` 红徽标)。 + * 服务端 `SessionCandidate` 里 `unread` 带 `omitempty` ⇒ 缺键要守。 + */ + @State suggestSources: string[] = []; + @State suggestUnreads: number[] = []; @State suggestActive: number = 0; @State suggestOpen: boolean = false; /** 输入防抖(WebUI 是 120ms;打字每字符都打接口会打断输入) */ @@ -141,22 +150,40 @@ export struct ComposeView { * (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。 */ const titles: string[] = []; + const sources: string[] = []; + const unreads: number[] = []; for (let i = 0; i < cands.length; i++) { const c = cands[i]; const raw: string | undefined = c === undefined ? undefined : c.title; titles.push(typeof raw === 'string' ? raw : ''); + /* + * `source` 与 `unread` 同样带 `omitempty` + * (`platform_sessions.go:97/101`)⇒ 缺键时裸 cast 是 `undefined`。 + * `Unread` 缺键时给 `undefined`,直接比较 `> 0` 在 ArkTS 里类型不过 ⇒ 守一道。 + */ + const src: string | undefined = c === undefined ? undefined : c.source; + sources.push(typeof src === 'string' ? src : ''); + const un: number | undefined = c === undefined ? undefined : c.unread; + unreads.push(typeof un === 'number' ? un : 0); } /* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */ const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name); const keep: number[] = filterIndexes(all, titles, frag); const items: string[] = []; const keepTitles: string[] = []; + const keepSources: string[] = []; + const keepUnreads: number[] = []; for (let i = 0; i < keep.length; i++) { - items.push(all[keep[i]]); - keepTitles.push(titles[keep[i]] ?? ''); + const k: number = keep[i]; + items.push(all[k]); + keepTitles.push(titles[k] ?? ''); + keepSources.push(sources[k] ?? ''); + keepUnreads.push(unreads[k] ?? 0); } this.suggestItems = items; this.suggestTitles = keepTitles; + this.suggestSources = keepSources; + this.suggestUnreads = keepUnreads; this.suggestActive = 0; this.suggestOpen = items.length > 0; } catch { @@ -333,14 +360,45 @@ export struct ComposeView { if (this.sending) { return; } - if (this.to.length === 0) { + /* + * ★★ 2026-09-21 修(用户:「点击发送邮件直接闪退」)。 + * + * 真正的失败链有**两段**,两段都得修: + * + * ── ① 校验漏了一项,把服务端会拒的请求发出去了 ── + * + * WebUI 的 `canSend`(`ComposePage.tsx:132-138`)是**四个条件**: + * roundsError === null && to.trim() !== '' && subject.trim() !== '' && + * body.trim() !== '' && aliasError === null && !sending + * + * 我这里只有 `to` 与 `subject` 两项 —— **漏了 `body`**。 + * 而服务端 `mail.go` 要求正文非空,实测: + * curl -d '{"to":"pi@root.new","subject":"t","body":""}' + * → {"error":"Missing to, subject, or body"} + * + * ⇒ 用户填了收件人和主题、**没填正文**(正文本来就是可选的观感)时, + * 请求真的发出去了、服务端 400、弹出"发送失败"。 + * + * ── ② 更糟的是"失败之后退到哪"── + * + * 那段在下面 `doSend` 的成功分支里(原来是**无条件** `router.back()`, + * 而内嵌时那会退掉整个 MainPage)——已一并修。 + * + * ★ 为什么两段要一起修:只修 ①,用户永远看不到 ②; + * 只修 ②,"发送失败"仍会在最正常的输入下出现。它们是一条链上的两环。 + */ + if (this.to.trim().length === 0) { this.getUIContext().getPromptAction().showToast({ message: '请填写收件人' }); return; } - if (this.subject.length === 0) { + if (this.subject.trim().length === 0) { this.getUIContext().getPromptAction().showToast({ message: '请填写主题' }); return; } + if (this.body.trim().length === 0) { + this.getUIContext().getPromptAction().showToast({ message: '请填写正文' }); + return; + } const ctx: Context | undefined = this.getUIContext().getHostContext(); if (ctx === undefined) { return; @@ -354,8 +412,14 @@ export struct ComposeView { this.sending = true; try { const request: SendMailRequest = new SendMailRequest(); - request.to = this.to; - request.subject = this.subject; + /* + * 逐个 `trim()` —— 对齐 WebUI `ComposePage.tsx:144`: + * api.sendMail(to.trim(), subject.trim(), body, …) + * 用户常手滑留一个尾空格,而不 trim 时那个地址会**服务端判存在性失败** + * (`pi@root.new ` 与 `pi@root.new` 是两条不同地址)。 + */ + request.to = this.to.trim(); + request.subject = this.subject.trim(); request.body = this.body; if (this.replyTo.length > 0) { request.reply_to = this.replyTo; @@ -368,7 +432,26 @@ export struct ComposeView { } await mailApi.send(request); this.getUIContext().getPromptAction().showToast({ message: '邮件已发送' }); - this.getUIContext().getRouter().back(); + /* + * ★★ 2026-09-21 修:这里原来是**无条件** `router.back()`。 + * + * 而内嵌(宽屏右栏 / 窄屏 `Navigation` 覆盖)时我们只是 `MainPage` 的一个 + * 右栏状态 —— `router.back()` 退掉的是**整个 MainPage**,不是写信这一层。 + * + * 同一个文件里,顶栏那个「取消」键(上面几十行)**早就写对了**: + * if (this.embedded) { this.onBack(); return; } + * this.getUIContext().getRouter().back(); + * 我加 `doSend` 时没照着抄,于是**发送成功之后退到了登录页/空白** + * (用户报的「点击发送邮件直接闪退」——看着像闪退,其实是退页)。 + * + * ⇒ 改成与取消键**同一个判据**(`embedded` → 弹自己的栈)。 + * 两处必须一致,否则"取消"和"发送成功"会走两条不同的退路。 + */ + if (this.embedded) { + this.onBack(); + } else { + this.getUIContext().getRouter().back(); + } } catch (e) { const apiError = e as ApiError; this.getUIContext().getPromptAction().showToast({ message: '发送失败: ' + apiError.message }); @@ -518,15 +601,69 @@ export struct ComposeView { if (this.suggestOpen && this.suggestItems.length > 0) { Column() { ForEach(this.suggestItems, (item: string, idx: number) => { - Row() { - Text(item).fontSize(13).fontColor(Theme.textPrimary).layoutWeight(1) - if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0) { - Text(this.suggestTitles[idx]) - .fontSize(12).fontColor(Theme.textSubtleFor()) + /* + * 一行候选的结构逐条对齐 WebUI `AddressInput.tsx:186-231`: + * + * + * + * ★ 四个要点我第一版**全漏了**,逐个补: + * ① 别名 `font-mono` —— 它是 `name@path.session` 的一段, + * 等宽才看得清(WebUI 全仓地址类文本都是 mono); + * ② 标题在**第二行**(`mt-0.5` 单独一段),不是挤在右边同一行; + * ③ `source` 三种取值各有视觉:`platform` 蓝胶囊 / `new` 灰字 / 空(mail)无标; + * ④ `unread>0` 有红徽标(服务端 `SessionCandidate.Unread`,`omitempty`)。 + */ + Column() { + Row() { + Text(item) + .fontSize(13) + /* 等宽:对齐 WebUI 的 `font-mono`(地址类文本全仓都是 mono) */ + .fontFamily('monospace') + .fontColor(idx === this.suggestActive ? Theme.accentFor() : Theme.textPrimary) + .layoutWeight(1) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + + if (idx < this.suggestSources.length && this.suggestSources[idx] === 'platform') { + Text('平台') + .fontSize(10).fontColor(Theme.accentFor()) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.accentSoftFor(this.isDarkNow)) + } + if (idx < this.suggestSources.length && this.suggestSources[idx] === 'new') { + Text('新建会话').fontSize(10).fontColor(Theme.textSubtleFor()) + } + if (idx < this.suggestUnreads.length && this.suggestUnreads[idx] > 0) { + Text(this.suggestUnreads[idx].toString()) + .fontSize(10).fontColor(Theme.accentFg) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(Theme.danger) + } + } + .width('100%') + + /* 标题第二行 —— `source==='new'` 不显示(它没有真标题) */ + if (idx < this.suggestTitles.length && this.suggestTitles[idx].length > 0 + && !(idx < this.suggestSources.length && this.suggestSources[idx] === 'new')) { + Text(this.suggestTitles[idx]) + .fontSize(11).fontColor(Theme.textSubtleFor()) + .width('100%') + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 2 }) } } - .width('100%').height(40).padding({ left: 12, right: 12 }) + .width('100%') + .padding({ left: 10, right: 10, top: 7, bottom: 7 }) .backgroundColor(idx === this.suggestActive ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) .onClick(() => { this.applySuggestion(item); }) }, (item: string, idx: number) => item + '#' + idx.toString()) @@ -603,7 +740,33 @@ export struct ComposeView { .backgroundColor(Theme.surface) } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + /* + * ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。 + * + * 这里原来是 `backgroundColor(Theme.pageBg)` —— 一个**不透明**的页面底。 + * 而 `ComposeView` 是**装在圆角窗格内部**的(`ComposeDestination` 的 + * `NavDestination` / `MainPage` 内容列),实测它的边界 + * `[30,142][978,1955]` 比外壳 `[28,140][980,1957]` **四周各小 2px** + * ⇒ 那块不透明的白在四个角上从外壳的圆角里**露出来**,形成直角白边。 + * + * 像素实测(修前):y=1946 时圆角已收窄到 x=45,而 x=30..39 仍是纯白; + * y=1952 时 x=30..48 仍是纯白 —— 就是那圈"白框"。 + * + * ── 为什么改成透明而不是把圆角加到这一层 ── + * + * 对齐 WebUI:它的 `.app-shell > *`(对应我们的**外壳**)才有 + * `border-radius`,面板内部(`ComposePage` 自己)是 `bg-white` 的**直角块**—— + * 因为在 WebUI 里那个圆角是**靠 `overflow`/`background-clip` 裁到子节点上的**。 + * 我们这层的 `borderRadius` 同样**不裁 `backgroundColor`**, + * 给这层再加一个圆角只会多一道弧、白边照样在。 + * + * 正确做法是让**外壳那层玻璃**显出来:这一层不铺底 + * (它不是页面的"背景",只是窗格里的内容)。 + * + * ★ `embedded` 时尤其必须透明:那时我们**就是**右栏面板本身, + * 再铺一层不透明底等于把外壳的玻璃盖掉(用户报的「玻璃不透明」同源)。 + */ + .backgroundColor(this.embedded ? Color.Transparent : Theme.pageBg) /* * 整页入场(对齐 WebUI 写信页的 `rise-in`:4vp 上浮 + 淡入)。 * diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index f4e1923..1b3a44a 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -237,6 +237,18 @@ struct MailDetailDestination { * ★ 这里同样**不写** `.clip()` —— 理由见左栏那处(WebUI `index.css:968` 的 * 「不能写 overflow: hidden」那条,我照搬圆角时把裁切也一起搬了,导致列表滚不动)。 */ + /* + * ★★ 2026-09-21 补:**同时去掉 NavDestination 的系统白底**。 + * + * 上面那个 `.borderRadius` 一直"看着生效了",其实只裁了内容 —— + * ArkUI 的 `borderRadius` **不裁 `backgroundColor`**。而 `NavDestination` + * 自带一层不透明的 system background,它比外壳**四周各小 2px** + * (实测 `[30,142]` vs 外壳 `[28,140]`)⇒ 圆角内侧露出 2px 直角白边。 + * + * 就是用户报的「圆角下方还是有白框(直角框)」。`Color.Transparent` + * 让外壳那层玻璃显出来 —— 与 `ComposeDestination` 同一处修法。 + */ + .backgroundColor(Color.Transparent) .onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); }) } } @@ -286,8 +298,39 @@ struct ComposeDestination { }) } .hideTitleBar(true) - /* 与 `MailDetailDestination` 同一处圆角修法(WebUI `app-shell > *` 的对应物) */ + /* + * ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。 + * + * `MailDetailDestination` 早就补了圆角,这里**漏了** —— 而它的注释还写着 + * 「与 `MailDetailDestination` 同一处圆角修法」,说的是"打算照做", + * 实际只搬了圆角、漏了下面那件更要紧的事。 + * + * ── 实测(`uitest dumpLayout`,窄屏 1008px,写信页)── + * + * Column(外壳,有圆角) [28,140][980,1957] bg=#C7FFFFFF + * NavDestination [30,142][978,1955] bg=#FFFFFFFF ← 直角、全白 + * + * 那个 `#FFFFFFFF` 是 **NavDestination 自己的系统底色**,而它比外壳 + * **四周各小 2px**(30 vs 28、142 vs 140 …)。于是外壳那圈 14vp 的圆角 + * 内侧露出 2px 的**直角白边** —— 像素实测:y=1946 时圆角已收窄到 x=45, + * 而 x=30..39 仍是纯白;y=1952 时 x=30..48 仍是纯白。 + * + * 肉眼就是用户说的「圆角下方一个白框(直角框)」,宽屏在右栏更明显。 + * + * ── 修法 ── + * + * 两件事都要做,只做一件都盖不住: + * ① `borderRadius` —— 让**自己**是圆的(原来这里就有); + * ② `backgroundColor(Color.Transparent)` —— 去掉那层系统白底。 + * 只给圆角不改底色没用:圆角只裁自己的**内容**, + * 而那块白是**底色**,圆角外照样画得出来。 + * + * ★ 为什么 ① 原来没生效:`.borderRadius()` 在 ArkUI 里**不裁背景色**, + * 它裁的是内容与子节点。白底是 `backgroundColor`,不受圆角约束 ⇒ + * 必须把底色去掉,让外壳那层(`#C7FFFFFF` 玻璃)显出来。 + */ .borderRadius(this.bgActive ? Theme.glassRadius : 0) + .backgroundColor(Color.Transparent) .onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); }) } } @@ -1220,9 +1263,10 @@ struct SentTab { title: '发件箱', showBack: false, active: this.bgActive, - topInsetPx: 0, - trailing: this.SentCountTrailing - }) + topInsetPx: 0 + }) { + this.SentCountTrailing() + } if (this.loading) { Column() { LoadingProgress().width(32).height(32) } @@ -1515,9 +1559,10 @@ struct PermissionTab { title: '授权', showBack: false, active: this.bgActive, - topInsetPx: 0, - trailing: this.PendingTrailing - }) + topInsetPx: 0 + }) { + this.PendingTrailing() + } if (this.loading) { Column() { LoadingProgress().width(32).height(32) } @@ -3791,6 +3836,44 @@ struct MainPage { * 我当初把"窄屏不留白"顺手写成了"窄屏不圆角",两件事被并成了一个三元。 */ .borderRadius(Theme.glassRadius) + /* + * ★★ 2026-09-21 修(用户:「多个页面圆角下方还是有白框(直角框)」)。 + * + * ── 根因(像素级定位,不是猜)── + * + * `uitest dumpLayout` 实测(窄屏 1008px,写信页): + * + * 内容列(有圆角 + 玻璃) [28,140][980,1957] bg=#C7FFFFFF + * Navigation 的包装 [30,142][978,1955] ← **四周各小 2px** + * 里面的白底行 [30,142][978,303] bg=#FFFFFFFF + * + * 那个内层方角在几何上**落在圆角弧的外面**: + * 圆角半径 14,弧心在 (42,1943),而内层方角 (30,1955) 到弧心 + * √(12²+12²) ≈ 17.0 > 14 ⇒ **戳出去了**。 + * 于是外壳的圆角被咬掉一块,露出内层的直角白边。 + * 像素实测(修前):y=1946 时圆角已收窄到 x=45,而 x=30..39 仍是纯白。 + * + * ── 为什么修法是"裁"而不是"给内层也加圆角" ── + * + * 对齐 WebUI:`index.css` 的 `.app-shell > *` 是**同一个规则块**里 + * 圆角 + 裁切一起给的: + * + * html[data-bg='on'] .app-shell > * { + * border-radius: var(--radius-card); + * overflow: hidden; ← 圆角要真的裁掉溢出,否则方角照露 + * } + * + * 而**壁纸关着时它不裁**(那条注释写着:不能无条件写 `overflow: hidden`, + * 那会在面板自己就是滚动容器时把滚动干掉 —— 本仓踩过, + * 见下面 `contentEndOffset` 那段的原委)。 + * + * ⇒ 所以这里**跟着 `bgActive` 走**,与 WebUI 逐字对应: + * 壁纸开着(有圆角要保护)⇒ 裁;壁纸关着(无圆角)⇒ 不裁,保住滚动。 + * + * ★ 为什么内层那 2px 无法从这一侧消掉:它是 `Navigation` 自己的包装层 + * (`__Common__`),不是我们写的 padding —— 够不着,只能从外面裁。 + */ + .clip(this.bgActive) /* * ★ 这里**不再**让位(原来写的是 `padding({ bottom: NAV_CONTENT_RESERVE })`)。 * diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index a286e91..81bdd18 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -615,9 +615,10 @@ export struct SettingsPane { title: '我的', showBack: false, active: this.bgActive, - topInsetPx: 0, - trailing: this.HeaderTrailing - }) + topInsetPx: 0 + }) { + this.HeaderTrailing() + } Divider().color(Theme.border)