跨端: 修三个真崩溃/失败 —— @BuilderParam 丢 this、发送后退错页、漏校验 body
用户 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`,不含问号。
This commit is contained in:
@ -225,9 +225,25 @@ export class MailApi {
|
||||
* 不是"值是否为空"。省略会让它退回到上一层。
|
||||
*/
|
||||
async suggestAddress(name: string, path: string): Promise<AddressSuggestionResponse> {
|
||||
/*
|
||||
* ★★ 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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/** 发信请求体 */
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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`:
|
||||
*
|
||||
* <button class="px-2.5 py-1.5 {active ? bg-blue-50 : hover:bg-gray-50}">
|
||||
* <div class="flex items-center gap-1.5">
|
||||
* <span class="text-sm font-mono truncate">{别名}</span>
|
||||
* <div class="flex-1" />
|
||||
* {source==='platform' && <span class="bg-blue-100 text-blue-700">平台</span>}
|
||||
* {source==='new' && <span class="text-gray-400">新建会话</span>}
|
||||
* {unread>0 && <span class="bg-red-600 text-white">{unread}</span>}
|
||||
* </div>
|
||||
* {title && source!=='new' && <p class="text-3xs text-gray-400 mt-0.5">{title}</p>}
|
||||
* </button>
|
||||
*
|
||||
* ★ 四个要点我第一版**全漏了**,逐个补:
|
||||
* ① 别名 `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 上浮 + 淡入)。
|
||||
*
|
||||
|
||||
@ -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 })`)。
|
||||
*
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user