## 一、真 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`。
239 lines
8.4 KiB
Plaintext
239 lines
8.4 KiB
Plaintext
/*
|
||
* 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)
|
||
}
|
||
} |