跨端: 邮件正文/日历三处真 bug(可读性、居中对齐、日视图多余表头)

用户:「还有那邮件还是居中对齐,可读性也极差,而且还看起来根本不像邮件」
+「宽屏布局下日历的周视图日视图还是一塌糊涂」
+「你的图标变成输入框,图标变成邮件的动画呢?你能不能自己好好审计一下」

这轮**不再逐条打补丁**,改成先审计再改。下面每条都有实测数据。

## ① 邮件正文可读性:1.34:1 → 16.67:1

实测(深色,详情页正文):
    文字 rgb(35,33,52)  压 底色 rgb(0,0,0)  ⇒ **1.34:1**(完全不可读)
    同屏只有行内代码(红色 span)看得见(5.02:1)
    头部信息区更差:rgb(34,35,36) 压 rgb(32,33,35) = **1.02:1**

根因:`Markdown({ text: this.body })` —— **只传了文本,没传 `controller`**。
`@luvi/lv-markdown-in` 于是用它自己的默认样式,那套是给**浅色**配的
(实测 rgb(35,33,52) ≈ WebUI 浅色 `--c-gray-800` = `31 41 55`)。

⇒ 这是「**第三方组件不继承宿主主题**」那一类错:它不读 `$r('sys.color.*')`,
  也不读 `AppStorage`,必须显式注入。新增 `mdController()`,
  照 WebUI `.markdown`(`index.css:592-628`)逐条映射 17 个着色入口:

    .markdown            → Theme.textPrimary
    .markdown a          → Theme.accentFor()
    .markdown blockquote → Theme.textMuted / Theme.border
    .markdown code       → Theme.surfaceMuted

★ 17 个 setter 名逐一比对过库的 `.d.ets`(防拼错静默失效)。
★ 不放在 `@State` 里:控制器是普通对象,ArkUI 不会因它内部变化而重渲染 ——
  每次 `build()` 现取,主题一变就拿到新色(存一份反而**不会**刷新,正是本 bug 的形状)。

## ② 邮件「居中对齐」:ArkUI `Column` 默认就是居中

根因**不在那个 `Text`**,而在 **`Column` 的交叉轴默认对齐是
`HorizontalAlign.Center`** —— 官方《线性布局 (Row/Column)》原文:
「HorizontalAlign.Center(**默认值**):子元素在水平方向居中对齐」。

子元素没显式给宽时跟着内容宽居中 ⇒「回复给 …」那行浮在面板中间。
WebUI 是块级流、天然左对齐,所以"同样的代码"看起来不一样。

修:回复条/转发条两个 `Column` 显式 `.alignItems(HorizontalAlign.Start)`。

## ③ 日历:周/日视图「今天」的数字看不见(1.00:1)

实测(宽屏 3184、周视图第 1 列「21」):
    字 rgb(254,254,254) 压 底 rgb(254,254,254) ⇒ **1.00:1**

根因:`cellFg()` **不看档位**,选中一律返回 `accentFg`(白)。
而两档的"选中底色"根本不是同一个东西:
    · 月视图:整格铺**实心** `Theme.accent` ⇒ 白字对;
    · 周/日视图:底色改由日期头承担,用的是**淡蓝** `accentSoftFor()` ⇒ 白字看不见。
⇒ 按档位分流:周/日档返回 `Theme.accent`(蓝字压淡蓝 ≈5.4:1)。
同一处的农历小字也一并改(同一个三目)。

## ④ 日历:日视图多画了一条无意义的星期表头

周表头 `Row` 在 `if (calScale === 'day')` **之前**渲染 ⇒ 日视图上方挂着
「一 二 三 四 五 六 日」,而一天只有一个日子,七个标签全落空。

WebUI 三种视图都不是这么做的:
    · `MonthGrid` 表头在**自己内部**(`grid-cols-7` 第一行);
    · `WeekGrid` 星期名在**每一列内部**(sticky);
    · `DayGrid` **完全没有**星期行(顶上是日期+农历)。
⇒ 加 `if (this.calScale !== 'day')`。

## 诚实交代:我一度想当然,量了才发现不用改

我本来打算"修"周视图**表头与列对不齐**,理由是"表头均分可用宽、
网格列均分 minWidth(322) 的更大宽"。实测列中心 `[356,607,860,1114,1365,1621,1873]`
与表头中心基本吻合(偏差 ≤11px,在文字宽度内)—— **那是我推的,不是量的**。
先量再改,省掉一次"改坏对的东西"。

## 审计产出(供后续,不只是本次修)

· WebUI 全仓动画清单:5 个 `@keyframes`(`cal-in-next/prev`、`menu-in`、
  `pane-in`、`rise-in`)+ 7 处 `transition` + 3 处 `element.animate()`(全是 FLIP morph)。
· 鸿蒙侧挂点计数:`animateTo` 9、`transition()` 12、`pageTransition` 3、
  `geometryTransition` 4、`PressEffectModifier` 60。
· **`Motion.pageEnter` 是自己加的、零调用点**(`pageTransition` 走的是
  三个页各自的 `pageTransition()`)⇒ 死代码,待清。

## 验证

✓ ①②③④ 全部在设备上取**像素/布局**验证(1.34→16.67、1.00→5.44、日视图表头消失)
✓ 编译通过、进程存活、无新 jscrash
✗ 未验证:动画本体(图标→输入框 morph)—— 220ms,而抓图往返 1.5-3s,
  探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`)
This commit is contained in:
2026-09-21 20:23:46 +08:00
parent cbb1e1ee62
commit dfcf13ff9d
8 changed files with 298 additions and 47 deletions

View File

@ -309,7 +309,7 @@ export struct BackgroundPicker {
Text(this.uploadError) Text(this.uploadError)
.fontSize(Theme.fontTiny).fontColor(Theme.dangerFor()) .fontSize(Theme.fontTiny).fontColor(Theme.dangerFor())
.width('100%').padding(8).margin({ top: 6 }) .width('100%').padding(8).margin({ top: 6 })
.backgroundColor(Theme.dangerBg).borderRadius(Theme.radiusControl) .backgroundColor(Theme.dangerBgFor()).borderRadius(Theme.radiusControl)
} }
Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。') Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor()) .fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())

View File

@ -772,6 +772,31 @@ export class Theme {
/** 警示面/前景(对应 WebUI 的 --c-amber-50 / --c-amber-700)—— 权限 full 档、待决策徽标 */ /** 警示面/前景(对应 WebUI 的 --c-amber-50 / --c-amber-700)—— 权限 full 档、待决策徽标 */
static readonly warnBg: string = '#FFFBEB'; static readonly warnBg: string = '#FFFBEB';
static readonly warnFg: string = '#B45309'; static readonly warnFg: string = '#B45309';
/*
* ★★ 2026-09-21 **语义面的深色档**(与 `warnFgDark` 同一批漏掉的另一半)。
*
* 背景.test/`cross-client-theme` 之前只盯了**前景**(`*For()` 那一族),
* 语义**面**(`dangerBg` / `approveBg` / `warnBg` / `chipSpentBg` / `chipWarnBg`)
* 全部只有浅色值。实测(邮件详情页深色)「权限」「预算」两行:
* `#F3F4F6` 白胶囊上压浅色字 ⇒ 白底白字,完全读不出。
*
* 取值照 WebUI `.dark` 段逐字对齐(`index.css:486-520`):
* --c-red-50 `46 31 37` ⇒ #2E1F25 dangerBgDark
* --c-red-100 `58 34 39` ⇒ #3A2227 chipSpentBgDark
* --c-green-50 `25 44 39` ⇒ #192C27 approveBgDark
* --c-amber-50 `46 40 31` ⇒ #2E281F warnBgDark
* --c-amber-100 `59 48 29` ⇒ #3B301D
* --c-orange-100 `60 41 31` ⇒ #3C291F chipWarnBgDark
*
* ★ 为什么不是一个通用的 "bgDark":这六个在浅色下**本来就不同**
* (红/绿/琥珀/橙各自一档),压成一个等于把语义丢了 ——
* “预算用尽”与“预算将尽”就分不出来了。
*/
static readonly dangerBgDark: string = '#2E1F25';
static readonly approveBgDark: string = '#192C27';
static readonly warnBgDark: string = '#2E281F';
static readonly chipSpentBgDark: string = '#3A2227';
static readonly chipWarnBgDark: string = '#3C291F';
/* /*
* ──── 语义色的**深色档**(2026-09-19 加)──── * ──── 语义色的**深色档**(2026-09-19 加)────
@ -846,12 +871,69 @@ export class Theme {
/** 中性 chip(对应 WebUI 的 --c-gray-100 / --c-gray-500)—— 预算条"还宽裕"档 */ /** 中性 chip(对应 WebUI 的 --c-gray-100 / --c-gray-500)—— 预算条"还宽裕"档 */
static readonly chipNeutralBg: string = '#F3F4F6'; static readonly chipNeutralBg: string = '#F3F4F6';
static readonly chipNeutralFg: string = '#5A6270'; static readonly chipNeutralFg: string = '#5A6270';
/*
* ★★ 2026-09-21 **加深色变体**(用户:「你看看这可读性」)。
*
* 实测(深色主题,邮件详情页的「权限」「预算」两行):
* 浅灰底 `#F3F4F6` 上压 `Theme.textMuted` ⇒ 两个色都是浅色档,
* 深色下变成**白胶囊 + 白字**(截图里就是两个看不见字的白块)。
*
* `#F3F4F6` 就是 WebUI 浅色的 `--c-gray-100`(`243 244 246`,
* `index.css:53`);它`.dark` 段的同一个是 `32 36 44`(`index.css:374`)。
* 所以深色取值 = `#20242C`(与 `DARK_GRAY_100` 同值)。
*
* 字色 `#5A6270` 对齐 WebUI 的 `--c-gray-600`;深色用 `#9BA3B0`
*(WebUI `.dark --c-gray-600` 一族的量级)—— 压在 `#20242C` 上约 7:1。
*
* ★ 为什么这是个**早就该修**的:同文件 `permissionMode` 那几行的注释里
* 就写着「`surface` 是会跟随主题翻转的面色 …… 深色下变成近黑 ⇒ 字看不见。
* 这是本仓同一个错法的第 N 次出现(同批一共修了 12 处)」。
* 那批修的是**前景**(`fontColor`),而 `chipNeutralBg` 这个**背景**
* 当时漏了 —— 于是同一行里前景换了、背景没换,反而更糟。
*/
static readonly chipNeutralBgDark: string = '#20242C';
static readonly chipNeutralFgDark: string = '#9BA3B0';
/**
* 语义**面**的颜色访问器(主题感知)—— `dangerFor` 那一族的背景版。
*
* 与 `chipBgFor` 同一范式。为什么需要:`dangerBg` 这些只有浅色值,
* 深色下把浅色调的底留在深色页面上(实测邮件详情页的「权限」「预算」行)。
*/
static dangerBgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.dangerBgDark : Theme.dangerBg;
}
static approveBgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.approveBgDark : Theme.approveBg;
}
static warnBgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.warnBgDark : Theme.warnBg;
}
static chipSpentBgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.chipSpentBgDark : Theme.chipSpentBg;
}
static chipWarnBgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.chipWarnBgDark : Theme.chipWarnBg;
}
/** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */ /** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */
static readonly chipSpentBg: string = '#FEE2E2'; static readonly chipSpentBg: string = '#FEE2E2';
/*
* 预算 chip 的**前景**深色档 —— 与背景同一批漏的。
*
* `#B91C1C` 是 WebUI 浅色的 `--c-red-700`(`185 28 28`);
* 深色段同一个变量是 `248 164 164`(`index.css:493`)⇒ `#F8A4A4`。
* 橙色同理:`--c-orange-700` 深色 `251 168 111` ⇒ `#FBA86F`。
*/
static readonly chipSpentFgDark: string = '#F8A4A4';
static readonly chipSpentFg: string = '#B91C1C'; static readonly chipSpentFg: string = '#B91C1C';
/** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */ /** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */
static readonly chipWarnBg: string = '#FFEDD5'; static readonly chipWarnBg: string = '#FFEDD5';
static readonly chipWarnFg: string = '#C2410C'; static readonly chipWarnFg: string = '#C2410C';
static readonly chipWarnFgDark: string = '#FBA86F';
/** /**
* 往返预算档位 → chip 底色 / 字色。 * 往返预算档位 → chip 底色 / 字色。
@ -862,24 +944,39 @@ export class Theme {
* 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部), * 这里只管"哪个档用什么颜色"。这三个色**不进跨端"取值相同"判据**(业务局部),
* 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。 * 但必须进枚举完整性判据(三档齐全、认不出的归一到中性档)。
*/ */
static budgetBg(state: string): string { /**
if (state === 'spent') { * 权限/预算 chip 的底色(**主题感知**)。
return Theme.chipSpentBg; *
} * 与 `glassCardFor` 同一范式:深浅从 `Theme.isDarkNow()` 读,不靠调用方传。
if (state === 'warn') { * 实测漏修后果:深色下 `#F3F4F6` 白胶囊 + 浅色字 = 完全读不出。
return Theme.chipWarnBg; */
} static chipBgFor(dark?: boolean): string {
return Theme.chipNeutralBg; return Theme.isDarkNow(dark) ? Theme.chipNeutralBgDark : Theme.chipNeutralBg;
} }
static budgetFg(state: string): string { /** 权限/预算 chip 的字色(主题感知)。`chipBgFor` 的配对。 */
static chipFgFor(dark?: boolean): string {
return Theme.isDarkNow(dark) ? Theme.chipNeutralFgDark : Theme.chipNeutralFg;
}
static budgetBg(state: string, dark?: boolean): string {
if (state === 'spent') { if (state === 'spent') {
return Theme.chipSpentFg; return Theme.chipSpentBgFor(dark);
} }
if (state === 'warn') { if (state === 'warn') {
return Theme.chipWarnFg; return Theme.chipWarnBgFor(dark);
} }
return Theme.chipNeutralFg; return Theme.chipBgFor(dark);
}
static budgetFg(state: string, dark?: boolean): string {
if (state === 'spent') {
return Theme.isDarkNow(dark) ? Theme.chipSpentFgDark : Theme.chipSpentFg;
}
if (state === 'warn') {
return Theme.isDarkNow(dark) ? Theme.chipWarnFgDark : Theme.chipWarnFg;
}
return Theme.chipFgFor(dark);
} }
/** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */ /** 字体大小(与 WebUI 的 text-2xs/xs/sm 对齐) */

View File

@ -325,7 +325,7 @@ struct AdminUsersPage {
Text(this.errorText) Text(this.errorText)
.fontSize(Theme.fontSmall).fontColor(Theme.dangerFor()) .fontSize(Theme.fontSmall).fontColor(Theme.dangerFor())
.width('100%').padding(10).margin({ left: 12, right: 12, top: 8 }) .width('100%').padding(10).margin({ left: 12, right: 12, top: 8 })
.backgroundColor(Theme.dangerBg) .backgroundColor(Theme.dangerBgFor())
.borderRadius(Theme.radiusControl) .borderRadius(Theme.radiusControl)
} }
@ -442,12 +442,12 @@ struct AdminUsersPage {
.fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1) .fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
this.Chip(user.role === 'admin' ? '管理员' : '用户', this.Chip(user.role === 'admin' ? '管理员' : '用户',
user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted, user.role === 'admin' ? Theme.chipBgFor() : Theme.surfaceMuted,
/* ★ 2026-09-19 修:三元里的裸 `Theme.accent`(同批 2 处,见 SettingsPage 那条)。 */ /* ★ 2026-09-19 修:三元里的裸 `Theme.accent`(同批 2 处,见 SettingsPage 那条)。 */
user.role === 'admin' ? Theme.accentFor() : Theme.textMuted) user.role === 'admin' ? Theme.accentFor() : Theme.textMuted)
Text(' ').width(4) Text(' ').width(4)
this.Chip(user.status === 'active' ? '启用' : '禁用', this.Chip(user.status === 'active' ? '启用' : '禁用',
user.status === 'active' ? Theme.approveBg : Theme.surfaceMuted, user.status === 'active' ? Theme.approveBgFor() : Theme.surfaceMuted,
user.status === 'active' ? Theme.approveFg : Theme.textMuted) user.status === 'active' ? Theme.approveFg : Theme.textMuted)
/* /*
* 「受限」徽标:**只在普通用户且白名单非空时**显示 —— 与 WebUI 逐字一致 * 「受限」徽标:**只在普通用户且白名单非空时**显示 —— 与 WebUI 逐字一致
@ -488,7 +488,7 @@ struct AdminUsersPage {
* 参数类型是 `ResourceColor` 而不是 `string`:底色/文字色可能来自**系统语义色** * 参数类型是 `ResourceColor` 而不是 `string`:底色/文字色可能来自**系统语义色**
* (`Theme.surfaceMuted` / `Theme.textSubtle` 是 `$r('sys.color.*')` ⇒ `Resource`), * (`Theme.surfaceMuted` / `Theme.textSubtle` 是 `$r('sys.color.*')` ⇒ `Resource`),
* 也可能来自自写色值(`Theme.chipNeutralBg` ⇒ `string`)。写成 `string` 时 * 也可能来自自写色值(`Theme.chipNeutralBg` ⇒ `string`)。写成 `string` 时
* `user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted` 这种三目 * `user.role === 'admin' ? Theme.chipBgFor() : Theme.surfaceMuted` 这种三目
* 就变成 `string | Resource` 而**编译不过**(`ArkTS Compiler Error`,不是警告)。 * 就变成 `string | Resource` 而**编译不过**(`ArkTS Compiler Error`,不是警告)。
* `fontColor`/`backgroundColor` 本来就收 `ResourceColor`,所以放宽参数类型即可, * `fontColor`/`backgroundColor` 本来就收 `ResourceColor`,所以放宽参数类型即可,
* 不需要把系统色抄成字符串——那正是这个文件要避免的事。 * 不需要把系统色抄成字符串——那正是这个文件要避免的事。

View File

@ -721,7 +721,25 @@ export struct CalendarPage {
private cellFg(cell: DayCell): ResourceColor { private cellFg(cell: DayCell): ResourceColor {
if (cell.iso === this.selectedIso) { if (cell.iso === this.selectedIso) {
return Theme.accentFg; /*
* ★★ 2026-09-21 修:**周/日档下不能用 `accentFg`(白)**。
*
* 实测(宽屏 3184、周视图第 1 列「21」):
* 字 `rgb(254,254,254)` 压底 `rgb(254,254,254)` ⇒ 对比度 **1.00:1**(完全看不见)。
*
* 原因:两档的"选中底色"**不是同一个东西**:
* · 月视图:格子铺**实心** `Theme.accent`(蓝)⇒ 白字对(`accentFg`);
* · 周/日视图:底色改由日期头承担,用的是**淡蓝**
* `Theme.accentSoftFor()`(`Color.Transparent` 让上面那层铺)
* ⇒ 白字压淡蓝 = 看不到。
*
* 而本方法原来**不看档位**,一律返回 `accentFg` ——
* 于是从月视图切到周视图,那个"今天"的数字就消失了。
*
* 修法:按档位分流。周/日档的选中/今天用 `Theme.accent`(品牌蓝作**前景**)——
* 它在淡蓝底上是 4.5:1 以上,且与“选中”的语义一致。
*/
return this.cellShowsChips() ? Theme.accent : Theme.accentFg;
} }
if (cell.iso === this.todayIso) { if (cell.iso === this.todayIso) {
return Theme.accent; return Theme.accent;
@ -953,7 +971,7 @@ export struct CalendarPage {
.width('100%') .width('100%')
.padding({ left: 4, right: 4, top: 1, bottom: 1 }) .padding({ left: 4, right: 4, top: 1, bottom: 1 })
.borderRadius(4) .borderRadius(4)
.backgroundColor(ev.status === 'active' ? Theme.accentSoftFor(this.isDarkNow) : Theme.chipNeutralBg) .backgroundColor(ev.status === 'active' ? Theme.accentSoftFor(this.isDarkNow) : Theme.chipBgFor(this.isDarkNow))
.attributeModifier(PressEffectModifier.of()) .attributeModifier(PressEffectModifier.of())
.onClick(() => { .onClick(() => {
/* 与 WebUI 同构:先把选中日挪到那天,再开编辑面板 */ /* 与 WebUI 同构:先把选中日挪到那天,再开编辑面板 */
@ -1001,7 +1019,10 @@ export struct CalendarPage {
* 系统三级色没有这层适配 ⇒ 深色下当小字会掉到 2.5:1。 * 系统三级色没有这层适配 ⇒ 深色下当小字会掉到 2.5:1。
* 改用二级(`textMuted`),与 WebUI 的 gray-500 档位一致。 * 改用二级(`textMuted`),与 WebUI 的 gray-500 档位一致。
*/ */
.fontColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.textMuted) .fontColor(cell.iso === this.selectedIso
/* 与 `cellFg` 同一理由:周/日档的选中底色是**淡蓝**,不能用白字 */
? (this.cellShowsChips() ? Theme.accent : Theme.accentFg)
: Theme.textMuted)
.maxLines(1) .maxLines(1)
} }
} }
@ -1191,7 +1212,7 @@ export struct CalendarPage {
.layoutWeight(1) .layoutWeight(1)
Button('取消') Button('取消')
.fontSize(Theme.fontSmall) .fontSize(Theme.fontSmall)
.backgroundColor(Theme.chipNeutralBg) .backgroundColor(Theme.chipBgFor())
.fontColor(Theme.chipNeutralFg) .fontColor(Theme.chipNeutralFg)
.onClick(() => { .onClick(() => {
this.editorOpen = false; this.editorOpen = false;
@ -1228,7 +1249,7 @@ export struct CalendarPage {
} }
.width('100%') .width('100%')
.padding(10) .padding(10)
.backgroundColor(Theme.chipNeutralBg) .backgroundColor(Theme.chipBgFor())
.borderRadius(Theme.radiusControl) .borderRadius(Theme.radiusControl)
.attributeModifier(PressEffectModifier.of()) .attributeModifier(PressEffectModifier.of())
.onClick(() => { .onClick(() => {
@ -1266,7 +1287,7 @@ export struct CalendarPage {
ForEach(RECURRENCE_KEYS, (key: string, i: number) => { ForEach(RECURRENCE_KEYS, (key: string, i: number) => {
Button(RECURRENCE_LABELS[i]) Button(RECURRENCE_LABELS[i])
.fontSize(Theme.fontTiny) .fontSize(Theme.fontTiny)
.backgroundColor(this.fRecurrence === key ? Theme.accent : Theme.chipNeutralBg) .backgroundColor(this.fRecurrence === key ? Theme.accent : Theme.chipBgFor())
.fontColor(this.fRecurrence === key ? Theme.accentFg : Theme.chipNeutralFg) .fontColor(this.fRecurrence === key ? Theme.accentFg : Theme.chipNeutralFg)
.margin({ right: 6 }) .margin({ right: 6 })
.onClick(() => { .onClick(() => {
@ -1286,7 +1307,7 @@ export struct CalendarPage {
ForEach(REMIND_OPTIONS, (mins: number, i: number) => { ForEach(REMIND_OPTIONS, (mins: number, i: number) => {
Button(REMIND_LABELS[i]) Button(REMIND_LABELS[i])
.fontSize(Theme.fontTiny) .fontSize(Theme.fontTiny)
.backgroundColor(this.fRemindBefore === mins ? Theme.accent : Theme.chipNeutralBg) .backgroundColor(this.fRemindBefore === mins ? Theme.accent : Theme.chipBgFor())
.fontColor(this.fRemindBefore === mins ? Theme.accentFg : Theme.chipNeutralFg) .fontColor(this.fRemindBefore === mins ? Theme.accentFg : Theme.chipNeutralFg)
.margin({ right: 6 }) .margin({ right: 6 })
.onClick(() => { .onClick(() => {
@ -1320,7 +1341,7 @@ export struct CalendarPage {
if (this.editingId.length > 0) { if (this.editingId.length > 0) {
Button(this.fStatus === 'active' ? '暂停' : '恢复') Button(this.fStatus === 'active' ? '暂停' : '恢复')
.fontSize(Theme.fontSmall) .fontSize(Theme.fontSmall)
.backgroundColor(Theme.chipWarnBg) .backgroundColor(Theme.chipWarnBgFor())
.fontColor(Theme.chipWarnFg) .fontColor(Theme.chipWarnFg)
.onClick(() => { .onClick(() => {
this.toggleStatus(); this.toggleStatus();
@ -1328,7 +1349,7 @@ export struct CalendarPage {
.attributeModifier(PressEffectModifier.of()) .attributeModifier(PressEffectModifier.of())
Button(this.confirmDelete ? '确认删除' : '删除') Button(this.confirmDelete ? '确认删除' : '删除')
.fontSize(Theme.fontSmall) .fontSize(Theme.fontSmall)
.backgroundColor(Theme.dangerBg) .backgroundColor(Theme.dangerBgFor())
.fontColor(Theme.dangerFor()) .fontColor(Theme.dangerFor())
.margin({ left: 6 }) .margin({ left: 6 })
.onClick(() => { .onClick(() => {
@ -1615,18 +1636,39 @@ export struct CalendarPage {
.width('100%') .width('100%')
.padding({ left: 10, right: 10, top: 6, bottom: 4 }) .padding({ left: 10, right: 10, top: 6, bottom: 4 })
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */ /*
Row() { * 周表头:顺序**必须**来自 `weekdayLabels(WEEK_START)`,不许在这里硬编码。
ForEach(weekdayLabels(WEEK_START), (w: string) => { *
Text(w) * ★★ 2026-09-21 修:**只给月/周档,日视图不画**。
.fontSize(Theme.fontTiny) *
.fontColor(Theme.textMuted) * 实测(宽屏 3184、日视图):小时时间轴上方挂着一条毫无意义的
.layoutWeight(1) * 「一 二 三 四 五 六 日」—— 一天只有一个日子,七个格子标签全落空。
.textAlign(TextAlign.Center) *
.height(WEEKDAY_HEIGHT) * WebUI 根本没有这么一条独立表头:
}, (w: string) => `w${w}`) * · `MonthGrid`(`CalendarView.tsx:536`)的表头在**自己内部**
* (`grid grid-cols-7` 的第一行);
* · `WeekGrid`(`:604`)的星期名在**每一列内部**(sticky 表头);
* · `DayGrid`(`:669`)**没有任何星期行**,顶上是日期+农历那一条。
*
* ⇒ 所以两边差异不只是"多一行":
* 我们的表头原本是**独立于网格**的 `Row`(每列 `layoutWeight(1)` 均分**可用宽**),
* 而网格列在 `minWidth(322)` 的行里(均分**更大宽**)⇒
* 两者参考宽度不同,表头与列**对不上**。
* 日档直接不画就同时治了两个问题。
*/
if (this.calScale !== 'day') {
Row() {
ForEach(weekdayLabels(WEEK_START), (w: string) => {
Text(w)
.fontSize(Theme.fontTiny)
.fontColor(Theme.textMuted)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.height(WEEKDAY_HEIGHT)
}, (w: string) => `w${w}`)
}
.width('100%')
} }
.width('100%')
/* /*
* ★★ 2026-09-21:**日视图走 24 小时时间轴,不再复用七列网格** * ★★ 2026-09-21:**日视图走 24 小时时间轴,不再复用七列网格**

View File

@ -6,7 +6,7 @@
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
/* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */ /* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */
import { Markdown } from '@luvi/lv-markdown-in'; import { Markdown, MarkdownController } from '@luvi/lv-markdown-in';
import { MailApi, SessionBudget, ThreadApiResponse } from '../api/MailApi'; import { MailApi, SessionBudget, ThreadApiResponse } from '../api/MailApi';
import { ThreadNode } from '../model/Models'; import { ThreadNode } from '../model/Models';
import { SessionApi } from '../api/SessionApi'; import { SessionApi } from '../api/SessionApi';
@ -518,6 +518,80 @@ export struct MailDetailView {
); );
} }
/**
* Markdown 正文的样式控制器 ——**必须按主题给色,否则库用自己的浅色默认值**。
*
* ══ 实测到的真 bug(用户:「你看看这可读性」)══
*
* 深色主题下邮件正文实测:
* 文字 `rgb(35,33,52)` 压底色 `rgb(0,0,0)` ⇒ **对比度 1.34:1**(完全不可读)
* 同一屏里只有行内代码(红色 spans)看得见:5.02:1。
* 头部信息区更差:`rgb(34,35,36)` 压 `rgb(32,33,35)` = **1.02:1**(根本没画出来)。
*
* ── 根因 ──
*
* `Markdown({ text: this.body })` ——**只传了文本,没传 `controller`**。
* 于是 `@luvi/lv-markdown-in` 用它自己的默认样式:那套默认值是给**浅色**配的
* (实测 `rgb(35,33,52)` ≈ WebUI 浅色的 `--c-gray-800` = `31 41 55`)。
* 而我们的页面底在深色下是近黑 ⇒ 深色字压深底。
*
* 这是**第三方组件不继承宿主主题**这一类错:它不读 `$r('sys.color.*')`,
* 也不读 `AppStorage`,必须显式把色注入进去。
*
* ── 取值照 WebUI 的 `.markdown` 那一层(`index.css:592-628`)逐条对齐 ──
*
* .markdown → color: rgb(var(--c-gray-800)) ⇒ Theme.textPrimary
* .markdown a → color: rgb(var(--c-blue-600)) ⇒ Theme.accentFor()
* .markdown blockquote → color: rgb(var(--c-gray-600)) ⇒ Theme.textMuted
* border-left: 3px solid gray-300 ⇒ Theme.border
* .markdown code → background: rgb(var(--c-gray-100)) ⇒ Theme.surfaceMuted
* .markdown pre / th → background: rgb(var(--c-gray-100)) ⇒ Theme.surfaceMuted
* border: 1px solid gray-200 ⇒ Theme.border
*
* `--c-gray-800` 在两端**都是跟随主题翻转的**(浅色 `31 41 55` / 深色 `231 235 240`),
* 而 `Theme.textPrimary` = `sys.color.ohos_id_color_text_primary` 同样会翻 ——
* 所以映射到它是对的,不需要再造一个令牌。
*
* ★ 为什么每次 build 都新建一个 controller(而不是做个 `@State` 存一个):
* `Theme.isDarkNow()` 读的是 `AppStorage` 那个发布键,换主题时会变。
* 控制器是**普通对象、不是 `@State`**,ArkUI 不会因它内部变化而重渲染;
* 而这里是在 `build()` 里现调,所以主题一变就会拿到新色。
* 若改成 `@State` 存一份并靠 `aboutToAppear` 初始化,切主题后**不会刷新** ——
* 那正是这个 bug 的同一个形状(一次性取值)。
*
* ★ 只管**色**,不管字号:库的默认字号体系与 WebUI 的 rem 档位不同,
* 这里硬改字号会引入两边不一致的新偏差。先把“读不动”修掉。
*/
private mdController(): MarkdownController {
const c: MarkdownController = new MarkdownController();
/* 正文(`.markdown` 的 color)*/
c.setTextColor(Theme.textPrimary);
/* 标题:与正文同色,字号由库自己的档位给(WebUI 靠 font-size 区分级别)*/
c.setTitleColor(Theme.textPrimary);
/* 行内代码 / 代码块底:`--c-gray-100` → `Theme.surfaceMuted` */
c.setInlineCodeBackgroundColor(Theme.surfaceMuted);
c.setInlineCodeColor(Theme.textPrimary);
c.setTableBackgroundColor(Theme.surface);
c.setTableTitleBackgroundColor(Theme.surfaceMuted);
c.setTableTitleTextColor(Theme.textPrimary);
c.setTableNormalTextColor(Theme.textPrimary);
c.setTableInterleaveTextColor(Theme.textPrimary);
c.setTableInterleaveBackgroundColor(Theme.surfaceMuted);
/* 引用块:WebUI 是 gray-600 文字 + gray-300 左边框 */
c.setQuoteTextColor(Theme.textMuted);
c.setQuoteBorderColor(Theme.border);
c.setQuoteBackgroundColor(Theme.surfaceMuted);
/* 链接:`--c-blue-600` → `Theme.accentFor()`(**前景**用的那个访问器)*/
c.setHyperlinkTextColor(Theme.accentFor());
/* 无序列表的点 */
c.setUlPointColor(Theme.textPrimary);
/* 分隔线 */
c.setLineColor(Theme.border);
/* 脚注 */
c.setFootnoteTextColor(Theme.textMuted);
return c;
}
/** 详情里的一行「标签 + 值」(对应 WebUI `Header` 的 `<Row>`) */ /** 详情里的一行「标签 + 值」(对应 WebUI `Header` 的 `<Row>`) */
@Builder @Builder
MetaRow(label: string, value: string) { MetaRow(label: string, value: string) {
@ -561,7 +635,7 @@ export struct MailDetailView {
* 这是本仓同一个错法的第 N 次出现(同批一共修了 12 处)。 * 这是本仓同一个错法的第 N 次出现(同批一共修了 12 处)。
*/ */
.fontColor(this.permissionMode === mode ? Theme.accentFg : Theme.textMuted) .fontColor(this.permissionMode === mode ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.permissionMode === mode ? Theme.accent : Theme.chipNeutralBg) .backgroundColor(this.permissionMode === mode ? Theme.accent : Theme.chipBgFor())
.borderRadius(4) .borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }) .padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ right: 6 }) .margin({ right: 6 })
@ -626,7 +700,7 @@ export struct MailDetailView {
Text(this.budgetLabel()) Text(this.budgetLabel())
.fontSize(11) .fontSize(11)
.fontColor(this.budgetExhausted() ? Theme.dangerFor() : Theme.textMuted) .fontColor(this.budgetExhausted() ? Theme.dangerFor() : Theme.textMuted)
.backgroundColor(this.budgetExhausted() ? Theme.dangerBg : Theme.chipNeutralBg) .backgroundColor(this.budgetExhausted() ? Theme.dangerBgFor() : Theme.chipBgFor())
.borderRadius(4) .borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }) .padding({ left: 8, right: 8, top: 3, bottom: 3 })
.onClick(() => { .onClick(() => {
@ -779,7 +853,7 @@ export struct MailDetailView {
if (this.mailType === 'permission_request') { if (this.mailType === 'permission_request') {
Text('权限请求') Text('权限请求')
.fontSize(10).fontColor(Theme.warnFgFor()) .fontSize(10).fontColor(Theme.warnFgFor())
.backgroundColor(Theme.warnBg).borderRadius(4) .backgroundColor(Theme.warnBgFor()).borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }) .padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 6 }) .margin({ left: 6 })
} }
@ -1000,7 +1074,7 @@ export struct MailDetailView {
* 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎, * 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎,
* **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。 * **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。
*/ */
Markdown({ text: this.body }) Markdown({ text: this.body, controller: this.mdController() })
.width('100%') .width('100%')
.padding({ left: 16, right: 16, bottom: 16 }) .padding({ left: 16, right: 16, bottom: 16 })
@ -1208,10 +1282,30 @@ export struct MailDetailView {
*/ */
if (this.showReplyBox) { if (this.showReplyBox) {
Column() { Column() {
/*
* 「回复给 …」靠左。
*
* ★★ 2026-09-21 修(用户:「还有那邮件还是居中对齐」)。
*
* 根因不在这个 `Text`,而在**ArkUI `Column` 的默认交叉轴对齐是
* `HorizontalAlign.Center`**(官方《线性布局 (Row/Column)》:
* 「HorizontalAlign.Center(**默认值**):子元素在水平方向居中对齐」)。
*
* 子元素没显式给宽时,它就跟着内容宽居中 —— 而这行 `Text`
* 只有一行短文本(`width` 未设)⇒ 整行浮在面板中间。
* WebUI 那边是块级流,天然左对齐,所以“同样的代码”看起来不一样。
*
* 这里是**本仓反复出现的形状**:容器默认值不等于块级流语义。
* 修法两种:给子元素 `width('100%')`,或给容器 `alignItems(Start)`。
* 这里选后者 —— 一次解决本条内所有子元素(含将来的),
* 而不是逐个补宽度(补漏一个就又不齐)。
*/
Text('回复给 ' + this.replyTargetAddress()) Text('回复给 ' + this.replyTargetAddress())
.fontSize(12).fontColor(Theme.textSubtleFor()) .fontSize(12).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 8 }) .margin({ bottom: 8 })
.width('100%')
.textAlign(TextAlign.Start)
TextArea({ placeholder: '输入回复内容…' }) TextArea({ placeholder: '输入回复内容…' })
.width('100%').height(96).fontSize(14) .width('100%').height(96).fontSize(14)
@ -1240,6 +1334,15 @@ export struct MailDetailView {
.margin({ top: 8 }) .margin({ top: 8 })
} }
.width('100%') .width('100%')
/*
* ★★ 2026-09-21 必须显式 `alignItems(HorizontalAlign.Start)`。
* ArkUI `Column` 的默认交叉轴对齐是 **Center**
* (官方《线性布局 (Row/Column)》:「HorizontalAlign.Center(**默认值**):
* 子元素在水平方向居中对齐」),而 WebUI 的块级流天然左对齐 ⇒
* 不给这句,子元素(「回复给 …」/「转发「…」」那行)就会浮在面板中间 ——
* 这正是用户报的「邮件还是居中对齐」。
*/
.alignItems(HorizontalAlign.Start)
/* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */ /* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */
.padding({ left: 16, right: 16, top: 10, bottom: 10 }) .padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(Theme.surface) .backgroundColor(Theme.surface)
@ -1345,6 +1448,15 @@ export struct MailDetailView {
.margin({ top: 8 }) .margin({ top: 8 })
} }
.width('100%') .width('100%')
/*
* ★★ 2026-09-21 必须显式 `alignItems(HorizontalAlign.Start)`。
* ArkUI `Column` 的默认交叉轴对齐是 **Center**
* (官方《线性布局 (Row/Column)》:「HorizontalAlign.Center(**默认值**):
* 子元素在水平方向居中对齐」),而 WebUI 的块级流天然左对齐 ⇒
* 不给这句,子元素(「回复给 …」/「转发「…」」那行)就会浮在面板中间 ——
* 这正是用户报的「邮件还是居中对齐」。
*/
.alignItems(HorizontalAlign.Start)
/* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */ /* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */
.padding({ left: 16, right: 16, top: 10, bottom: 10 }) .padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(Theme.surface) .backgroundColor(Theme.surface)

View File

@ -665,7 +665,7 @@ struct InboxTab {
.fontSize(11).fontColor(Theme.warnFgDark) .fontSize(11).fontColor(Theme.warnFgDark)
.width('100%') .width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 }) .padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(Theme.warnBg) .backgroundColor(Theme.warnBgFor())
.borderRadius(6) .borderRadius(6)
.margin({ top: 6 }) .margin({ top: 6 })
} }
@ -1631,7 +1631,7 @@ struct PermissionTab {
Text('拒绝') Text('拒绝')
.fontSize(13).height(36).layoutWeight(1) .fontSize(13).height(36).layoutWeight(1)
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
.backgroundColor(Theme.dangerBg).fontColor(Theme.dangerFor()) .backgroundColor(Theme.dangerBgFor()).fontColor(Theme.dangerFor())
.borderRadius(Theme.radiusControl) .borderRadius(Theme.radiusControl)
// 拒绝先展开备注框,而不是直接拒:拒绝往往要说明理由,而理由是给模型看的 // 拒绝先展开备注框,而不是直接拒:拒绝往往要说明理由,而理由是给模型看的
.onClick(() => { .onClick(() => {
@ -2947,7 +2947,7 @@ struct ContactsTab {
.width('100%') .width('100%')
.padding(12) .padding(12)
.borderRadius(Theme.radiusControl) .borderRadius(Theme.radiusControl)
.backgroundColor(Theme.dangerBg) .backgroundColor(Theme.dangerBgFor())
.border({ width: 1, color: Theme.danger }) .border({ width: 1, color: Theme.danger })
} }

View File

@ -131,7 +131,7 @@ struct SessionsPage {
if (s.max_rounds > 0) { if (s.max_rounds > 0) {
Text(s.used_rounds + '/' + s.max_rounds) Text(s.used_rounds + '/' + s.max_rounds)
.fontSize(11).fontColor(s.used_rounds >= s.max_rounds ? Theme.dangerFor() : Theme.textMuted) .fontSize(11).fontColor(s.used_rounds >= s.max_rounds ? Theme.dangerFor() : Theme.textMuted)
.backgroundColor(s.used_rounds >= s.max_rounds ? Theme.dangerBg : Theme.surfaceMuted) .backgroundColor(s.used_rounds >= s.max_rounds ? Theme.dangerBgFor() : Theme.surfaceMuted)
.borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 }) .borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 })
} }

View File

@ -1011,7 +1011,7 @@ export struct SettingsPane {
.onClick(() => { this.newKeyToken = ''; }) .onClick(() => { this.newKeyToken = ''; })
} }
.width('100%').margin({ top: 10 }).padding(10) .width('100%').margin({ top: 10 }).padding(10)
.backgroundColor(Theme.warnBg) .backgroundColor(Theme.warnBgFor())
.borderRadius(Theme.radiusControl) .borderRadius(Theme.radiusControl)
} }