From 9d50352e7e34957bd1e4d26a427b94f2d9be5beb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 18:24:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E9=B8=BF=E8=92=99?= =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E6=96=87=E6=A1=88=EF=BC=88=E6=91=98=E8=A6=81?= =?UTF-8?q?/=E4=B8=80=E8=A8=80/=E7=AD=BE=E5=90=8D=E8=BD=AE=E6=92=AD?= =?UTF-8?q?=EF=BC=89=E5=9C=A8=E4=B8=89=E9=94=AE=E5=B7=A6=E8=BE=B9=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=BD=8D=E7=BD=AE=E4=B8=8E=E5=AD=97?= =?UTF-8?q?=E5=8F=B7=E6=8C=89=E5=AE=9E=E6=B5=8B=E6=A0=A1=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户四条需求,逐条落地: · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」 · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」 · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」 · 「我要的效果是在退出,最大化,最小化三个按键的左边」 客户端(服务端那半见 31939f2) · 新增 `common/TopbarStore.ets`:一言 + 签名的取数与**账号级**缓存 (键 = 前缀 + accountId,本仓纪律;共用一份会让多账号串台)。 本地缓存先出(秒开、离线可用),再后台拉一次更新; 拉失败**保留缓存**、不抛异常 —— 装饰性内容不该成为失败点。 · 轮播:摘要 / 一言 / 签名三选一轮着显示,5.5s 一条、 淡出淡入各 260ms(停顿明显长于动画,否则观感是"一直在闪")。 · 纯文字:不设 background、不加玻璃(用户点名「不要加底」), `hitTestBehavior(None)` 不吃事件。 ★ 位置:为什么自绘而不 `setWindowTitle` 官方 `setWindowTitle` **实测确实**能在那一行显示文字(截图验过), 但它三条硬伤:① 必须保持窗口装饰可见 ⇒ 标题栏横带回来,与刚修好的 「顶栏沉浸」冲突;② 瞬时替换,做不了用户要的消失出现动画; ③ 字号颜色跟随系统。⇒ 装饰仍隐藏,文字自绘在装饰带原位。 ★★ 两个"按实测校准"的修正(都是用户看出来的) 1. **位置**:我先做成"右对齐、贴住三键左缘"——用户纠正 「我要求的是与三键同行,但是在左边啊」。改成靠左(FlexAlign.Start)。 2. **对齐与字号**(用户:「行没有对齐,大小也偏小」): 实测(1px ≈ 1.91vp): 三键 y 290..343 高 53px、中心 316.5 我原来 y 296..323 高 27px、中心 309.5 ⇒ **中心差 7px、字号小一档** 改法:字号 12 → 14vp;垂直对齐从写死的 `y: 8` 改成 `height(windowInsets.windowDecor)` + `VerticalAlign.Center`。 改后实测:**中心差 0.0px**、文案高 31px(与三键图标同量级)。 ★ 顺带修掉一个逻辑漏洞 摘要三项计数都是 0 时我返回了空串 ⇒ 整块**不渲染**,顶栏右上什么都没有。 而"没有未读"恰恰是常态(收件箱清干净了)。加兜底文案「暂无待办」。 那句"三项都是 0 就不显示"是我按"有信息才显示"想当然写的, 没考虑"零"本身也是信息。 判据(harmony-2in1 新增 4 条 → 19→23,全部变异验证过) · 在三键左边且不破坏沉浸 —— 钉的是**两个约束同时成立** (只看一件会放过错误的那版:为了三键左边而恢复标题栏) · 纯文字:不许 backgroundColor / backgroundBlurStyle,且不吃事件 · 摘要为零也要有文案(把兜底改回空串即判红) · 一言/签名缓存要账号级、失败要吞掉 ★ 判据自己的两个坑(都在注释里记了) ① 切片锚点不能用常量的**名字**:`TOPBAR_STRIP_VPAD` 先在文件顶部常量区 出现一次,从那里往后切会一路包进 `InboxTab`(那里有 backgroundColor), 于是报"文案加了底色"——报的其实是**别人的代码**。改成锚**使用点**。 ② 位置断言跟着事实改过一轮:第一版给"贴三键"那个错版背书, 用户纠正后改成断言靠左。 实测凭据(2in1 模拟器 3120×2080) · 文案 x 545..652、y 301..332;三键 x 2362..2567、y 290..343 · 垂直中心差 0.0px · 沉浸保留(装饰仍隐藏) --- client/electron/test/harmony-2in1.test.mjs | 139 ++++++++ client/electron/test/run-all.mjs | 2 +- .../entry/src/main/ets/common/TopbarStore.ets | 135 +++++++ .../entry/src/main/ets/pages/MainPage.ets | 336 ++++++++++++++++++ 4 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 client/harmony/entry/src/main/ets/common/TopbarStore.ets diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index bb8294c..93974f5 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -564,3 +564,142 @@ test('2in1|右键菜单的破坏性动作(归档)必须先确认', () => { /* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */ assert.match(body, /doArchiveRow/, '确认的回调里才发请求'); }); + +/* ──────────────────────────────────────────────────────────────── + * ⑦ 顶栏文案(摘要 / 一言 / 签名轮播)—— 2026-09-25 + * + * 用户四条: + * · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」 + * · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」 + * · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」 + * · 「我要的效果是在退出,最大化,最小化三个按键的左边」 + * ──────────────────────────────────────────────────────────────── */ + +test('2in1|顶栏文案在三键左边,且不破坏窗口沉浸', () => { + /* + * ★ 这条钉的是**两个约束同时成立**,因为它们会互相顶: + * 要"在三键左边"最省事的做法是官方 `setWindowTitle`(实测确实能显示), + * 但那个**必须保持窗口装饰可见** —— 于是标题栏那条浅色横带回来, + * 与用户先前要的「顶栏沉浸」直接冲突(那是刚修好的)。 + * + * 所以正确解法是:**装饰仍隐藏**(沉浸保留)+ 文字自绘在装饰带原位。 + * 判据必须同时看到这两件事,只看一件就会放过错误的那版。 + * + * 改坏会红: + * · 去掉 setWindowDecorVisible(false) ⇒ 第一段红(沉浸丢了) + * · 去掉自绘的 position/padding ⇒ 第二段红(文案不在三键左边) + */ + const ABILITY = code(join(ETS, 'entryability/EntryAbility.ets')); + const MAIN = code(join(ETS, 'pages/MainPage.ets')); + + /* ① 沉浸必须保留(装饰隐藏) */ + assert.match(ABILITY, /win\.setWindowDecorVisible\(false\)/, + '窗口装饰要保持隐藏 —— 否则标题栏横带回来,与「顶栏沉浸」冲突'); + + /* + * ② 文案自绘,且在**左边**(与三键同一行、左右相称)。 + * + * ★★ 这里**改过一次**:第一版我断言的是"贴住三键左缘" + * (右对齐 + TOPBAR_TRIPLE_BTN_WIDTH),用户纠正: + * 「我要求的是与三键同行,但是在左边啊」 + * ⇒ 断言随之改成靠左。判据要跟事实走,不是给错版背书。 + */ + assert.match(MAIN, /\.justifyContent\(FlexAlign\.Start\)/, + '文案要**靠左**(与右边三键同一行、左右相称)'); + assert.match(MAIN, /\.position\(\{ x: 0, y: 0 \}\)/, + '文案要绝对定位到窗口顶部(整窗根 Stack 的最上层)'); + assert.match(MAIN, /padding\(\{ left: TOPBAR_STRIP_LEFT \}\)/, + '左侧留出与窗口边缘的距离'); + /* + * ★ 垂直对齐要**用装饰带自己的高度**,不许写死 y 偏移。 + * + * 我第一版写 `.position({ y: 8 })`(凭感觉给的 8vp), + * 实测文案中心 309.5、三键中心 316.5 —— **差 7px**(用户看出「行没有对齐」)。 + * 改成 `height(windowInsets.windowDecor)` + `VerticalAlign.Center` 后 + * 实测中心差 **0.0px**。 + * + * ⇒ 判据钉"高度取自 windowDecor"这条做法,而不是某个具体数值: + * 它同时保证"带高变了也不跑偏"。 + */ + assert.match(MAIN, /\.height\(this\.windowInsets\.windowDecor\)/, + '垂直对齐要用装饰带自己的高度(写死 y 偏移会差几个 px —— 实测过)'); + assert.match(MAIN, /\.alignItems\(VerticalAlign\.Center\)/, + '带内垂直居中'); +}); + +test('2in1|顶栏文案必须是纯文字(不加底、不吃事件)', () => { + /* + * 用户原话:「同时注意,是纯文字不要加底」。 + * + * ★ 为什么这条要单独钉:给它加个底是最容易的"看起来更清楚"的冲动, + * 而那条会在沉浸顶栏上再压一块色 —— 正是用户点名不要的。 + * 同理不能加玻璃材质(那也会形成一块可见的面)。 + * + * 改坏会红:给那个 Row 加 backgroundColor / backgroundBlurStyle。 + */ + const MAIN = code(join(ETS, 'pages/MainPage.ets')); + /* + * ★ 切片锚点取**使用点**(`.position({ x: 0, y: TOPBAR_STRIP_VPAD })`), + * 不是那个常量的**名字**。 + * + * 我第一版就用了名字 —— 而它在**文件顶部的常量区**先出现一次, + * 从那里往后切会一路包进 `InboxTab`(那里有 `backgroundColor`), + * 于是判据报"文案加了底色",报的是**别人的代码**。 + * 本仓纪律:判据匹配到的东西必须就是它声称的那个。 + */ + const at = MAIN.indexOf('.position({ x: 0, y: 0 })'); + assert.ok(at > 0, '顶栏文案块要存在(找不到定位语句)'); + /* 往前取到该块的起点、往后取到它的收口,覆盖整块 */ + const start = Math.max(0, at - 1200); + const body = MAIN.slice(start, at + 900); + assert.ok(body.length > 0, '要能切出顶栏文案块'); + + assert.ok(!/\.backgroundColor\(/.test(body), + '顶栏文案不许加底色(用户:「是纯文字不要加底」)'); + assert.ok(!/\.backgroundBlurStyle\(/.test(body), + '顶栏文案不许加玻璃材质(同样是"加了一层可见的面")'); + assert.match(body, /hitTestBehavior\(HitTestMode\.None\)/, + '文案是装饰性的,不许吃掉底下的点击(三键就在它右边)'); +}); + +test('2in1|顶栏摘要"零"时也要有文案(否则整块消失)', () => { + /* + * ★ 这条钉的是一个**实测撞出来的逻辑漏洞**。 + * + * 我第一版写的是"三项计数都是 0 就返回空串"——想当然的"有信息才显示"。 + * 实测:那封邮件已读 ⇒ 三项全 0 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**, + * 顶栏右上什么都没有。 + * + * 而"没有未读"恰恰是**常态**(收件箱清干净了)。那时该说"暂无待办", + * 而不是整块消失让人以为坏了。 + * + * 改坏会红:把 `return '暂无待办'` 改回 `return ''`。 + */ + const MAIN = code(join(ETS, 'pages/MainPage.ets')); + assert.match(MAIN, /暂无待办/, + '摘要为零时要有兜底文案 —— "零"本身也是信息,不该让整块消失'); +}); + +test('2in1|一言与签名要在客户端缓存(账号级),离线也能轮播', () => { + /* + * 用户:「同时 app 本地缓存一部分」。 + * + * ★ 缓存键必须**账号级**(`kind + accountId`)—— 本仓纪律。 + * 共用一份会让换个账号看到上一个人的签名;`user_appearance` 当初就是 + * 全局键,多账号互相覆盖。 + * + * ★ 还要验"拉失败不致命":装饰性内容不该成为失败点 + * (服务端那半也有对称的一条判据)。 + * + * 改坏会红:去掉 prefKey 的 accountId 拼接、或让 refresh 把异常抛出去。 + */ + const STORE = code(join(ETS, 'common/TopbarStore.ets')); + assert.match(STORE, /private prefKey\(accountId: string\)/, + '缓存键要按账号拼(共用一份会让多账号串台)'); + assert.match(STORE, /KEY_PREFIX \+ accountId/, + '键的实际拼法要含 accountId'); + assert.match(STORE, /catch \(e\)/, + '拉取/缓存失败要吞掉 —— 顶栏是装饰性的,不该把异常抛给调用方'); + assert.match(STORE, /'\/me\/topbar'/, + '要打服务端那个端点(/me/topbar)'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index a12d537..91106af 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -152,7 +152,7 @@ const SUITE = [ * 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 / * 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。 */ - ['test/harmony-2in1.test.mjs', [], 16], + ['test/harmony-2in1.test.mjs', [], 23], ['test/harmony-contacts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // diff --git a/client/harmony/entry/src/main/ets/common/TopbarStore.ets b/client/harmony/entry/src/main/ets/common/TopbarStore.ets new file mode 100644 index 0000000..7e1b596 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/TopbarStore.ets @@ -0,0 +1,135 @@ +/* + * AgentMail 鸿蒙客户端 — 顶栏内容(一言 + 签名)的取数与缓存 + * + * ★★ 2026-09-24 新增。用户裁定: + * · 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」 + * · 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」 + * · 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」 + * + * ## 为什么客户端还要再缓一层(服务端已经有了) + * + * 服务端那次缓存解决的是「别每次打外网」;这一层解决的是**别每次打服务端**: + * · 顶栏是**启动即显示**的东西,等一次网络往返会让它先空着再突然冒出来; + * · 轮播是秒级的,每次换条都问服务端既浪费又会在断网时停住; + * · 「离线也照转」是这句需求的隐含前提 —— 用户没说,但顶栏转着转着 + * 因为没有网而卡住是最难看的失败方式。 + * + * ## 缓存键必须是**账号级**(本仓纪律) + * + * 与 `AppearanceStore.prefKey(accountId)` 同一条:多账号共用一份缓存会让 + * 换个账号看到上一个人的签名。本仓在这上面栽过(`user_appearance` 当初就是 + * 全局键,多账号互相覆盖)。 + * + * ## 缓存的是什么 + * + * `{ quotes: [...], signature: "..." }` 整个存 —— 与 `MailStore` 那条 + * 「不要手工挑字段拼序列化」同一纪律:手工挑字段会在加字段时静默丢数据。 + */ + +import { preferences } from '@kit.ArkData'; +import { common } from '@kit.AbilityKit'; +import { ApiClient } from '../api/ApiClient'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const DOMAIN = 0x0001; +const TAG = 'TopbarStore'; + +const PREF_STORE: string = 'agentmail_topbar'; +/** 缓存键前缀(后接 accountId)—— 账号级,见文件头 */ +const KEY_PREFIX: string = 'topbar.'; + +/** 一条一言 */ +export class TopbarQuote { + text: string = ''; + source: string = ''; +} + +/** 顶栏内容 */ +export class TopbarContent { + quotes: TopbarQuote[] = []; + signature: string = ''; +} + +export class TopbarStore { + private static instance: TopbarStore | null = null; + + static getInstance(): TopbarStore { + if (TopbarStore.instance === null) { + TopbarStore.instance = new TopbarStore(); + } + return TopbarStore.instance; + } + + private prefKey(accountId: string): string { + return KEY_PREFIX + accountId; + } + + /** + * 读本地缓存(同步、不打网络)。 + * + * 拿不到就返回空内容 —— **不是**抛错:顶栏拿不到内容只是不轮播那一半, + * 不该让调用方崩。 + */ + loadLocal(ctx: common.Context, accountId: string): TopbarContent { + const out: TopbarContent = new TopbarContent(); + if (accountId.length === 0) { + return out; + } + try { + const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE }); + const raw = store.getSync(this.prefKey(accountId), '') as string; + if (raw.length === 0) { + return out; + } + /* + * ★ 存的是整个 `TopbarContent` 的 JSON,读回来直接赋给同形对象。 + * 不逐字段赋值:那样加字段时会静默丢掉(本仓数据层的既定纪律)。 + */ + const parsed = JSON.parse(raw) as TopbarContent; + out.quotes = parsed.quotes ?? []; + out.signature = parsed.signature ?? ''; + return out; + } catch (e) { + /* 缓存坏了就丢掉 —— 源数据在服务端,下一次拉取就能重建 */ + hilog.warn(DOMAIN, TAG, '读顶栏缓存失败(忽略):%{public}s', JSON.stringify(e)); + return out; + } + } + + /** + * 从服务端拉一批并写缓存。 + * + * 拉失败返回 null(调用方继续用本地那份)—— 这是"装饰性内容不该成为失败点" + * 在客户端这半边的落实,与服务端 `GetTopbar` 的取舍一致。 + */ + async refresh(ctx: common.Context, accountId: string, server: string, token: string): Promise { + if (accountId.length === 0 || server.length === 0) { + return null; + } + try { + const client: ApiClient = new ApiClient(ctx); + client.setBase(server); + client.setToken(token); + const remote = await client.get('/me/topbar'); + const next: TopbarContent = new TopbarContent(); + next.quotes = remote.quotes ?? []; + next.signature = remote.signature ?? ''; + this.saveLocal(ctx, accountId, next); + return next; + } catch (e) { + hilog.info(DOMAIN, TAG, '拉顶栏内容失败(保留本地缓存):%{public}s', JSON.stringify(e)); + return null; + } + } + + /** 写缓存。失败静默(缓存写不进去不影响功能,只是下次还得拉)。 */ + private saveLocal(ctx: common.Context, accountId: string, content: TopbarContent): void { + try { + const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE }); + store.putSync(this.prefKey(accountId), JSON.stringify(content)); + store.flush(); + } catch (e) { + hilog.warn(DOMAIN, TAG, '写顶栏缓存失败(忽略):%{public}s', JSON.stringify(e)); + } + } +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 2ef415e..b506569 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -41,6 +41,11 @@ import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; import { MailStore, MailSnapshot, AccountError, INBOX_PAGE_SIZE } from '../common/MailStore'; +/* + * 顶栏文案(摘要 / 一言 / 签名)—— 用户 2026-09-24: + * 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」。 + */ +import { TopbarStore, TopbarContent } from '../common/TopbarStore'; import { AppearanceApi } from '../api/AppearanceApi'; import { performLogout } from '../api/Logout'; import { ComposeIntent, ReplyIntent, PopIntent } from '../common/ComposeIntent'; @@ -141,6 +146,38 @@ const COMPOSE_ROUTE: string = 'compose'; */ const THEME_FADE_ROOT_ID: string = 'theme-fade-root'; +/* + * 顶栏文案的轮播节奏(2026-09-24 用户:「自动轮播,要有消失出现动画」)。 + * + * 停顿 5.5s、淡入淡出各 260ms:停顿要**明显长于**动画,否则观感是 + * "一直在闪"而不是"隔一会儿换一句"。5.5s 是按"一行短句读完"给的。 + */ +const TOPBAR_ROTATE_MS: number = 5500; +const TOPBAR_FADE_MS: number = 260; +/* + * 顶栏文案的**绝对定位**参数 —— 与三键**同一行、靠左**。 + * + * 实测(3120×2080 窗口,dumpLayout): + * 装饰带 y 281→351(高 70px ≈ 24vp),三键在同一行、靠右(x 2340→2605)。 + * ⇒ 文案 y 取装饰带内的居中偏移;x 从窗口左缘起 —— + * 左边一句话说当前状态、右边三个系统按钮,一条线上左右相称。 + * + * ★★ 2026-09-25 这里**改过一次**,记下来免得再走回头路: + * 第一版我按"贴住三键左缘"做(右对齐 + padding 三键宽 92), + * 用户纠正:「我要求的是与三键同行,但是在左边啊」。 + * ⇒ 靠左才是对的。三键宽度那个常量随之**删除**(它只为那个错版存在)。 + */ +/* 文案与窗口左缘的距离(vp)—— 与内容区起始留白同档 */ +const TOPBAR_STRIP_LEFT: number = 16; +/* + * 顶栏文案字号(vp)。 + * + * ★ 实测定的,不是随手取:三键图标 38px、按钮 53px 高(1px ≈ 1.91vp), + * 12vp 的文案只有 27px ⇒ 比旁边的按钮小一档,看着"没长齐"(用户:「大小也偏小」)。 + * 14vp 的文本行高约 33px,与三键图标同量级。 + */ +const TOPBAR_FONT_SIZE: number = 14; + @Component struct InboxTab { @@ -1613,6 +1650,15 @@ struct CommPage { @State commTab: string = 'inbox'; @State unreadCount: number = 0; @State pendingCount: number = 0; + /** + * 当前栏**取回来**的封数(摘要文案用)。 + * + * ★ 为什么不读各 pane 的 `loaded`:三个 pane 是**条件挂载**的, + * 切走就销毁 ⇒ 它们的 `loaded` 在别的栏里读不到(也读不准)。 + * 而摘要是"这一栏有多少东西"的展示,必须在栏切换时都有值。 + * 所以由 `refreshCounts()`(那一层拉徽标数据时)顺手记下来。 + */ + @State loaded: number = 0; /* * 徽标数的 AppStorage 键 —— 由刚才那个窗格写、由导航栏读(见发布处注释)。 * 常量而不是字面量:拼错 `AppStorage.get('xxx')` 不报错、只会恒为 undefined, @@ -1738,6 +1784,8 @@ struct CommPage { const accounts: AccountInfo[] = acctMgr.getAccounts(); let unread: number = 0; let pending: number = 0; + /* 摘要文案要的"这一栏有多少封"(同一次遍历里就有数,不另发请求) */ + let loadedHere: number = 0; for (let i = 0; i < accounts.length; i++) { const acct: AccountInfo = accounts[i]; try { @@ -1753,6 +1801,7 @@ struct CommPage { unread += 1; } } + loadedHere += split.normal.length; const pend: PendingResponse = await api.pendingPermissions(); pending += pend.requests.length; } catch (e) { @@ -1761,6 +1810,8 @@ struct CommPage { } this.unreadCount = unread; this.pendingCount = pending; + /* 摘要文案要的"这一栏有多少封" —— 顺手记下(同一个循环里已经有数) */ + this.loaded = loadedHere; /* * ★ 同时**发布到 AppStorage** —— 底栏与侧栏(MainPage 的兄弟分支)要读它。 * @@ -1839,6 +1890,21 @@ struct CommPage { this.publishStackDepth(); } + + /** + * 顶栏那块文案(页签条右侧)。 + * + * ★★ 用户三条硬要求,逐条对应: + * · 「纯文字不要加底」→ **不设** backgroundColor / 不加玻璃材质。 + * 它就在页签条那行上,页签条本身是玻璃族 —— 再叠一层底会变成 + * "条里又一块小条"(本仓 `bindSheet` 那次同一条教训:自绘会多一层)。 + * · 「显示摘要不显示一言,显示一言不显示摘要」→ 一次只渲染一条 + * (`topbarTexts()[topIndex % len]`),不是并排两个。 + * · 「自动轮播,要有消失出现动画」→ `topOpacity` 由定时器驱动。 + * + * ★ 宽度用 `layoutWeight(1)` + 右对齐,而不是固定宽: + * 文案长短差很多(「已发 3 封」vs 一句一言),固定宽会截断或留大片空。 + */ @Builder CommTabBar() { Row() { @@ -1876,6 +1942,13 @@ struct CommPage { this.refreshCounts(); }) }, (key: string) => key) + /* + * 顶栏文案(摘要 / 一言 / 签名轮播)。 + * + * ★ 放在页签条的**同一个 Row** 里、排在三个页签之后 —— + * 这样它天然落在页面右上(三键的下方),与左边三个页签形成左右平衡, + * 而不是另起一行(那会多一条横带,用户要的是"纯文字不要加底")。 + */ } /* * ─────────────────────── 页签条造型:三次反转记在这里 ─────────────────────── @@ -2398,6 +2471,25 @@ struct MainPage { * 而账号列表只有主框架持有(与徽标走同一套"父算子读")。 */ @State accountLabel: string = ''; + /* + * ── 顶栏文案轮播(2026-09-24 用户裁定)── + * 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」 + * 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」 + * + * 三种文案轮着显示,一次只显示一种: + * · 摘要 —— 当前栏的统计(如「7 封 · 2 未读 · 1 待决」) + * · 一言 —— 服务端句库(本地有缓存,离线也照转) + * · 签名 —— 用户个人签名(没设过就不进轮播) + */ + @State topQuote: string = ''; + @State topQuoteSource: string = ''; + @State topSignature: string = ''; + /** 当前显示到第几个(对"可用文案列表"取模) */ + @State topIndex: number = 0; + /** 淡出/淡入用:0 = 不可见、1 = 可见(驱动 opacity,见 TopbarText) */ + @State topOpacity: number = 1; + private topTimer: number = 0; + /** * App 级 SSE 监听(`aboutToAppear` 注册 / `aboutToDisappear` 摧掉)。 * @@ -2498,9 +2590,179 @@ struct MainPage { private gridSettings: RenderingContextSettings = new RenderingContextSettings(true); private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings); + /** + * 顶栏文案的**候选列表**(摘要 / 一言 / 签名)。 + * + * ★ 返回数组而不是三个布尔:轮播要对"可用项"取模, + * 而某些项会缺席(没设签名、句库为空)—— 用布尔写会绕一圈还容易错位。 + * 数组天然只含**真的能显示的**那些。 + */ + private topbarTexts(): string[] { + const out: string[] = []; + /* 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释 */ + const summary: string = this.topbarSummary(); + if (summary.length > 0) { + out.push(summary); + } + if (this.topQuote.length > 0) { + out.push(this.topQuoteSource.length > 0 + ? this.topQuote + ' —— ' + this.topQuoteSource + : this.topQuote); + } + if (this.topSignature.length > 0) { + out.push(this.topSignature); + } + return out; + } + + /** + * 摘要文案。 + * + * ── 数据从哪来(这是把它从 CommPage 提到 MainPage 时改掉的那处)── + * `unread`/`pending`/`contacts` 三个计数**本来就由 `CommPage` 发布到 + * AppStorage**(底栏与侧栏的徽标读的就是这三个键,见 `navUnread` 那组 + * `@StorageProp`)。摘要要的是同一份数据 ⇒ 直接读同一组键, + * **不另发请求、也不把 CommPage 的状态搬上来**。 + * + * ★ 口径与徽标**逐字一致**:同一个数在两处显示成不同的值是最难解释的 bug。 + * 徽标只显示数字,这里补上单位(「7 封」「2 未读」)—— + * 单位不同、数字同源。 + * + * ★ 为什么不带"哪一栏"(收件箱/发件箱/授权):那个状态(`commTab`) + * 在 CommPage 里,而顶栏在整窗层。为它再建一条跨层通道不值得 —— + * 这三项本来就是"这个账号下待处理的东西",一起说也是对的信息。 + */ + private topbarSummary(): string { + const parts: string[] = []; + if (this.navUnread > 0) { + parts.push(this.navUnread + ' 未读'); + } + if (this.navPending > 0) { + parts.push(this.navPending + ' 待决'); + } + if (this.navContacts > 0) { + parts.push(this.navContacts + ' 会话'); + } + /* + * ★★ 2026-09-25 加兜底(实测撞出来的): + * 三项都是 0 时返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**。 + * 而"没有未读"恰恰是**常态**(收件箱清干净了)—— + * 那时顶栏该说"都清完了",而不是整块消失让人以为坏了。 + * + * ★ 这与我先前那版"三项都是 0 就不显示"的判断相反 —— + * 那句话是我按"有信息才显示"想当然写的,没考虑"零"本身也是信息。 + */ + if (parts.length === 0) { + return '暂无待办'; + } + return parts.join(' · '); + } + + /** + * 启动轮播。 + * + * ── 为什么用 `setInterval` 而不是 `animateTo` 的完成回调 ── + * 轮播是**永续的**(只要页面在)。用回调串起来会在某次不可见时断链 + * (切到别的栏 `aboutToDisappear` 清了定时器,回来就再也不转了)。 + * 定时器 + 显式的启停更直白。 + * + * ── 为什么在 `aboutToAppear` 里启、`aboutToDisappear` 里停 ── + * 不清的话,用户切到日历页之后这个定时器还在跑(白耗电、且会改一个 + * 已不可见组件的状态)。 + */ + private startTopbarRotation(): void { + this.stopTopbarRotation(); + this.topTimer = setInterval(() => { + const texts: string[] = this.topbarTexts(); + if (texts.length <= 1) { + /* 只有一条(甚至没有)就没什么可轮的 —— 别浪费一次淡入淡出 */ + return; + } + /* + * 消失 → 出现。★ 两步而不是一步换字: + * 用户要的是「消失出现动画」,而直接换字只是闪烁。 + * 用 `animateTo` 包住 opacity 的变化(主题时长见 Theme)。 + */ + const ui = this.getUIContext(); + ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => { + this.topOpacity = 0; + }); + setTimeout(() => { + this.topIndex = (this.topIndex + 1) % this.topbarTexts().length; + ui.animateTo({ duration: TOPBAR_FADE_MS, curve: Theme.easeOutSoft }, () => { + this.topOpacity = 1; + }); + }, TOPBAR_FADE_MS); + }, TOPBAR_ROTATE_MS); + } + + private stopTopbarRotation(): void { + if (this.topTimer !== 0) { + clearInterval(this.topTimer); + this.topTimer = 0; + } + } + + /** + * 拉一次顶栏内容(一言 + 签名)。 + * + * 先读**本地缓存**(秒出、离线可用),再后台拉一次更新 —— 与服务端那层 + * 缓存的分工见 `TopbarStore` 的文件头。 + * 拉失败什么都不做(保留缓存那份):顶栏是装饰性的,不该弹错。 + */ + private loadTopbar(): void { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + const acctMgr: AccountManager = AccountManager.getInstance(ctx); + acctMgr.load().then(() => { + const acct: AccountInfo | null = acctMgr.getActiveAccount(); + if (acct === null) { + return; + } + const store: TopbarStore = TopbarStore.getInstance(); + /* ① 先用缓存(同步) */ + const local: TopbarContent = store.loadLocal(ctx, acct.id); + this.applyTopbar(local); + /* ② 再拉一次(异步,失败就留着缓存那份) */ + store.refresh(ctx, acct.id, acct.server, acct.token).then((fresh: TopbarContent | null) => { + if (fresh !== null) { + this.applyTopbar(fresh); + } + }); + }); + } + + /** 把顶栏内容接进 @State(`Array` 取第一条做轮播的一句话) */ + private applyTopbar(content: TopbarContent): void { + /* + * ★ 只取**第一条**做轮播:服务端一次给一批是给客户端"够轮播一阵子"的, + * 而我们这里已经有本地定时轮播 —— 一次显示多句反而乱。 + * 批次的存在意义是"少请求",不是"一次全显示"。 + */ + if (content.quotes.length > 0) { + this.topQuote = content.quotes[0].text; + this.topQuoteSource = content.quotes[0].source; + } + this.topSignature = content.signature; + } + aboutToAppear(): void { this.applyAppearance(); this.watchEnvironment(); + /* + * 顶栏文案(摘要 / 一言 / 签名)—— 用户 2026-09-24: + * 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」+ + * 「自动轮播,要有消失出现动画」+「在三键的左边」。 + * + * ★ 为什么由 **MainPage** 起(而不是 CommPage): + * 文案要画在**整窗右上**(三键左边),那是 `MainPage` 的根 Stack 层; + * 而 `CommPage` 只是通信那一栏、只占左边 608px。 + * 数据来源不冲突:摘要读的是 CommPage 发布到 AppStorage 的那三个计数。 + */ + this.loadTopbar(); + this.startTopbarRotation(); /* * ★★ 2026-09-24:**把 SSE 监听提到 App 级**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。 * @@ -2529,6 +2791,8 @@ struct MainPage { aboutToDisappear(): void { this.unwatchEnvironment(); + /* 停轮播 —— 不清的话页面销毁后它还在跑(白耗电,且会改不可见组件的状态) */ + this.stopTopbarRotation(); if (this.sseService !== null) { this.sseService.removeListener(this.onGlobalSseEvent); } @@ -3717,6 +3981,78 @@ struct MainPage { .opacity(this.themeFadeOpacity) .hitTestBehavior(HitTestMode.None) } + + /* + * ★★ 2026-09-25 顶栏文案(摘要 / 一言 / 签名轮播)—— 放这里,理由如下。 + * + * ── 用户要的位置(原话)── + * 「我要的效果是在退出,最大化,最小化三个按键的左边」 + * 即**窗口右上、三键左侧**那一带,不是页签条(我先做错了一次: + * 页签条所在的列表栏只有 608px 宽,塞不下)。 + * + * ── 为什么自绘而不 `setWindowTitle` ── + * 官方 `setWindowTitle` 实测**确实**能在那一行显示文字(已验证: + * 截图里 `探针:一言在标题栏 —— 出自本地句库` 就显示在三键左边)。 + * 但它有三条硬伤: + * ① 必须**保持窗口装饰可见** ⇒ 标题栏那条浅色横带回来, + * 与用户先前要的「顶栏沉浸」直接冲突(那个是刚修好的); + * ② 是瞬时替换,**做不了**用户要的「消失出现动画」; + * ③ 字号/颜色跟随系统,我们控制不了。 + * ⇒ 自绘:装饰仍隐藏(沉浸保留),文字画在**装饰带原来的位置**。 + * + * ── 位置怎么算 ── + * 实测(dumpLayout,3120×2080 窗口): + * 装饰带 y 281→351(高 70px) + * 三键区 x 2340→2605 + * ⇒ 文案右边界对齐到三键左缘,垂直居中于装饰带。 + * 用 `position` 绝对定位(这个 Stack 是整窗根层)。 + * + * ★ `hitTestBehavior(None)`:它是装饰性的, + * 绝不能吃掉底下的点击(尤其三键区就在它右边)。 + * 点它换下一条的交互也因此去掉 —— 那与"不吃事件"矛盾, + * 而用户要的是自动轮播,不需要手动入口。 + */ + /* + * 只在**有装饰带**(2in1/PC)且**有内容**时显示: + * 手机形态 windowDecor=0(没有三键、也就没有那条带),此时不该出现 —— + * 否则它会浮在状态栏上,而手机状态栏本来就窄。 + */ + if (this.topbarTexts().length > 0 && this.windowInsets.windowDecor > 0) { + Row() { + Text(this.topbarTexts()[this.topIndex % this.topbarTexts().length]) + /* + * ★★ 2026-09-25 字号 12 → 14(用户:「大小也偏小」)。 + * + * 实测对照(1px ≈ 1.91vp): + * 三键图标 38px 高、按钮 53px 高; + * 我原来的文案只有 27px 高(fontSize 12)—— 明显比旁边小一档。 + * 14vp 的文本行高约 33px,与三键图标同量级,不与两侧失衡。 + */ + .fontSize(TOPBAR_FONT_SIZE) + .fontColor(Theme.textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .opacity(this.topOpacity) + } + /* + * ── 垂直居中:**用装饰带自己的高度**,不猜 y 偏移 ── + * + * 实测(dumpLayout):装饰带 y 281..351 = 70px = **37vp**, + * 三键 y 290..343 **居中**于带内(中心 316.5)。 + * 我原来写 `.position({ y: 8 })`(凭感觉给 8vp), + * 文案中心落在 309.5 ⇒ **差 7px**,肉眼能看出"没对齐"。 + * + * 改成 `height(windowDecor)` + `VerticalAlign.Center`: + * 带多高就多高,文字自己居中 —— 以后带高变了也不用重算那个偏移。 + */ + .height(this.windowInsets.windowDecor) + .justifyContent(FlexAlign.Start) + .alignItems(VerticalAlign.Center) + .position({ x: 0, y: 0 }) + .width('100%') + .padding({ left: TOPBAR_STRIP_LEFT }) + .hitTestBehavior(HitTestMode.None) + } } .width('100%') .height('100%')