Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/InboxPage.ets
JianFeeeee 13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00

239 lines
8.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* AgentMail 鸿蒙客户端 — 收件箱页
* GET /me/inbox 分页,列表展示邮件摘要
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { MailApi } from '../api/MailApi';
import { MailSummary, Me } from '../model/Models';
import { router } from '@kit.ArkUI';
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct InboxPage {
/*
* 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。
*
* ★ 2026-09-19:深色主题下这些"浅蓝底"(未读行 / 选中项 / 分段选中)
* 在深色页面上刺眼。根因是 `Theme.accentSoft` 写死浅色
* (WebUI 靠 CSS 的 `.dark` 段反转发解决,ArkTS 静态常量没有那层机制)。
* 窗格自己算不出深浅色 ⇒ 从 AppStorage 读。
*/
@StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false;
@State mails: MailSummary[] = [];
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State loading: boolean = false;
@State limit: number = 20;
@State total: number = 0;
@State unread: number = 0;
@State me: Me = new Me();
@State error: string = '';
private client: ApiClient | null = null;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const client: ApiClient = ApiClient.getInstance(ctx);
this.client = client;
this.mailApi = new MailApi(client);
this.loadInbox();
}
}
async loadInbox(): Promise<void> {
const c: ApiClient | null = this.client;
const m: MailApi | null = this.mailApi;
if (c === null || m === null) {
return;
}
this.loading = true;
this.error = '';
try {
const resp = await m.inbox('all', this.limit);
this.mails = resp.mails;
this.total = resp.total;
// 客户端统计未读(API 未提供顶层 unread)
let unreadCount: number = 0;
for (let i = 0; i < this.mails.length; i++) {
if (this.mails[i].status === 'unread') {
unreadCount++;
}
}
this.unread = unreadCount;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
build() {
Column() {
// 顶栏
Row() {
Text('收件箱')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
if (this.unread > 0) {
Text(this.unread + ' 未读')
.fontSize(13).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger)
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%')
.height(56 + topInset(this.windowInsets))
.padding({ left: 16, right: 16, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
// 列表
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
Text('加载中…').fontSize(14).fontColor(Theme.textSubtle).margin({ top: 8 })
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor(Theme.danger)
Button('重试').margin({ top: 12 }).onClick(() => { this.loadInbox(); })
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) {
Column() {
Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.mails, (mail: MailSummary) => {
ListItem() {
this.MailItem(mail)
}
.height(80)
.backgroundColor(mail.status === 'unread' ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({
url: 'pages/MailDetailPage',
params: { mail_id: mail.mail_id }
});
})
}, (mail: MailSummary) => mail.mail_id)
}
.width('100%')
.layoutWeight(1)
/* 上下边缘渐隐(2026-09-17 补:原先漏了,同一次滚动里只有部分是渐隐) */
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
}
// 底部 Tab 栏(预留系统导航栏空间)
Column() {
Row() {
Column() {
AmIcon({ iconName: 'inbox', iconSize: 20, iconColor: Theme.accentFor() })
Text('收件箱').fontSize(11).fontColor(Theme.accentFor())
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
Column() {
AmIcon({ iconName: 'chatBubble', iconSize: 20, iconColor: Theme.textMuted })
Text('会话').fontSize(11).fontColor(Theme.textMuted)
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/SessionsPage' });
})
Column() {
AmIcon({ iconName: 'contacts', iconSize: 20, iconColor: Theme.textMuted })
Text('联系人').fontSize(11).fontColor(Theme.textMuted)
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%').height(56)
.backgroundColor(Theme.surface)
}
.width('100%')
}
.width('100%').height('100%')
.backgroundColor(Theme.pageBg)
}
@Builder
MailItem(mail: MailSummary) {
Row() {
// 未读圆点
Column() {
if (mail.status === 'unread') {
Circle({ width: 8, height: 8 }).fill(Theme.accent)
}
}
.width(20).height('100%')
.justifyContent(FlexAlign.Center)
// 正文
Column() {
Row() {
Text(mail.from_name).fontSize(14).fontWeight(mail.is_read ? FontWeight.Normal : FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
Text(mail.created_at).fontSize(11).fontColor(Theme.textSubtle)
}
.width('100%')
Text(mail.subject).fontSize(13).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
Text(mail.body_preview).fontSize(12).fontColor(Theme.textSubtle)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
// 附件图标
if (mail.has_attachments) {
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted }).margin({ right: 8 })
}
// 权限档位徽标
if (mail.permission_mode.length > 0) {
Text(mail.permission_mode)
.fontSize(10).fontColor(Theme.textMuted)
.backgroundColor(Theme.border).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.width('100%').height('100%')
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
}