跨端: 鸿蒙顶栏文案(摘要/一言/签名轮播)在三键左边 —— 位置与字号按实测校准
用户四条需求,逐条落地:
· 「可以在服务器集成一言与签名,同时 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
· 沉浸保留(装饰仍隐藏)
This commit is contained in:
@ -564,3 +564,142 @@ test('2in1|右键菜单的破坏性动作(归档)必须先确认', () => {
|
|||||||
/* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */
|
/* 真正的请求在确认回调之后(另一个方法里)——确保不是"点了就发" */
|
||||||
assert.match(body, /doArchiveRow/, '确认的回调里才发请求');
|
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)');
|
||||||
|
});
|
||||||
|
|||||||
@ -152,7 +152,7 @@ const SUITE = [
|
|||||||
* 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 /
|
* 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 /
|
||||||
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
|
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
|
||||||
*/
|
*/
|
||||||
['test/harmony-2in1.test.mjs', [], 16],
|
['test/harmony-2in1.test.mjs', [], 23],
|
||||||
['test/harmony-contacts.test.mjs', [], 5],
|
['test/harmony-contacts.test.mjs', [], 5],
|
||||||
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
|
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
|
||||||
//
|
//
|
||||||
|
|||||||
135
client/harmony/entry/src/main/ets/common/TopbarStore.ets
Normal file
135
client/harmony/entry/src/main/ets/common/TopbarStore.ets
Normal file
@ -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<TopbarContent | null> {
|
||||||
|
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<TopbarContent>('/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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,6 +41,11 @@ import { AccountManager, AccountInfo } from '../api/AccountManager';
|
|||||||
import { SseService, SseEvent } from '../api/SseService';
|
import { SseService, SseEvent } from '../api/SseService';
|
||||||
import { AppearanceStore } from '../common/AppearanceStore';
|
import { AppearanceStore } from '../common/AppearanceStore';
|
||||||
import { MailStore, MailSnapshot, AccountError, INBOX_PAGE_SIZE } from '../common/MailStore';
|
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 { AppearanceApi } from '../api/AppearanceApi';
|
||||||
import { performLogout } from '../api/Logout';
|
import { performLogout } from '../api/Logout';
|
||||||
import { ComposeIntent, ReplyIntent, PopIntent } from '../common/ComposeIntent';
|
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';
|
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
|
@Component
|
||||||
struct InboxTab {
|
struct InboxTab {
|
||||||
@ -1613,6 +1650,15 @@ struct CommPage {
|
|||||||
@State commTab: string = 'inbox';
|
@State commTab: string = 'inbox';
|
||||||
@State unreadCount: number = 0;
|
@State unreadCount: number = 0;
|
||||||
@State pendingCount: number = 0;
|
@State pendingCount: number = 0;
|
||||||
|
/**
|
||||||
|
* 当前栏**取回来**的封数(摘要文案用)。
|
||||||
|
*
|
||||||
|
* ★ 为什么不读各 pane 的 `loaded`:三个 pane 是**条件挂载**的,
|
||||||
|
* 切走就销毁 ⇒ 它们的 `loaded` 在别的栏里读不到(也读不准)。
|
||||||
|
* 而摘要是"这一栏有多少东西"的展示,必须在栏切换时都有值。
|
||||||
|
* 所以由 `refreshCounts()`(那一层拉徽标数据时)顺手记下来。
|
||||||
|
*/
|
||||||
|
@State loaded: number = 0;
|
||||||
/*
|
/*
|
||||||
* 徽标数的 AppStorage 键 —— 由刚才那个窗格写、由导航栏读(见发布处注释)。
|
* 徽标数的 AppStorage 键 —— 由刚才那个窗格写、由导航栏读(见发布处注释)。
|
||||||
* 常量而不是字面量:拼错 `AppStorage.get('xxx')` 不报错、只会恒为 undefined,
|
* 常量而不是字面量:拼错 `AppStorage.get('xxx')` 不报错、只会恒为 undefined,
|
||||||
@ -1738,6 +1784,8 @@ struct CommPage {
|
|||||||
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
||||||
let unread: number = 0;
|
let unread: number = 0;
|
||||||
let pending: number = 0;
|
let pending: number = 0;
|
||||||
|
/* 摘要文案要的"这一栏有多少封"(同一次遍历里就有数,不另发请求) */
|
||||||
|
let loadedHere: number = 0;
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
const acct: AccountInfo = accounts[i];
|
const acct: AccountInfo = accounts[i];
|
||||||
try {
|
try {
|
||||||
@ -1753,6 +1801,7 @@ struct CommPage {
|
|||||||
unread += 1;
|
unread += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadedHere += split.normal.length;
|
||||||
const pend: PendingResponse = await api.pendingPermissions();
|
const pend: PendingResponse = await api.pendingPermissions();
|
||||||
pending += pend.requests.length;
|
pending += pend.requests.length;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1761,6 +1810,8 @@ struct CommPage {
|
|||||||
}
|
}
|
||||||
this.unreadCount = unread;
|
this.unreadCount = unread;
|
||||||
this.pendingCount = pending;
|
this.pendingCount = pending;
|
||||||
|
/* 摘要文案要的"这一栏有多少封" —— 顺手记下(同一个循环里已经有数) */
|
||||||
|
this.loaded = loadedHere;
|
||||||
/*
|
/*
|
||||||
* ★ 同时**发布到 AppStorage** —— 底栏与侧栏(MainPage 的兄弟分支)要读它。
|
* ★ 同时**发布到 AppStorage** —— 底栏与侧栏(MainPage 的兄弟分支)要读它。
|
||||||
*
|
*
|
||||||
@ -1839,6 +1890,21 @@ struct CommPage {
|
|||||||
this.publishStackDepth();
|
this.publishStackDepth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶栏那块文案(页签条右侧)。
|
||||||
|
*
|
||||||
|
* ★★ 用户三条硬要求,逐条对应:
|
||||||
|
* · 「纯文字不要加底」→ **不设** backgroundColor / 不加玻璃材质。
|
||||||
|
* 它就在页签条那行上,页签条本身是玻璃族 —— 再叠一层底会变成
|
||||||
|
* "条里又一块小条"(本仓 `bindSheet` 那次同一条教训:自绘会多一层)。
|
||||||
|
* · 「显示摘要不显示一言,显示一言不显示摘要」→ 一次只渲染一条
|
||||||
|
* (`topbarTexts()[topIndex % len]`),不是并排两个。
|
||||||
|
* · 「自动轮播,要有消失出现动画」→ `topOpacity` 由定时器驱动。
|
||||||
|
*
|
||||||
|
* ★ 宽度用 `layoutWeight(1)` + 右对齐,而不是固定宽:
|
||||||
|
* 文案长短差很多(「已发 3 封」vs 一句一言),固定宽会截断或留大片空。
|
||||||
|
*/
|
||||||
@Builder
|
@Builder
|
||||||
CommTabBar() {
|
CommTabBar() {
|
||||||
Row() {
|
Row() {
|
||||||
@ -1876,6 +1942,13 @@ struct CommPage {
|
|||||||
this.refreshCounts();
|
this.refreshCounts();
|
||||||
})
|
})
|
||||||
}, (key: string) => key)
|
}, (key: string) => key)
|
||||||
|
/*
|
||||||
|
* 顶栏文案(摘要 / 一言 / 签名轮播)。
|
||||||
|
*
|
||||||
|
* ★ 放在页签条的**同一个 Row** 里、排在三个页签之后 ——
|
||||||
|
* 这样它天然落在页面右上(三键的下方),与左边三个页签形成左右平衡,
|
||||||
|
* 而不是另起一行(那会多一条横带,用户要的是"纯文字不要加底")。
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* ─────────────────────── 页签条造型:三次反转记在这里 ───────────────────────
|
* ─────────────────────── 页签条造型:三次反转记在这里 ───────────────────────
|
||||||
@ -2398,6 +2471,25 @@ struct MainPage {
|
|||||||
* 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
|
* 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
|
||||||
*/
|
*/
|
||||||
@State accountLabel: string = '';
|
@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` 摧掉)。
|
* App 级 SSE 监听(`aboutToAppear` 注册 / `aboutToDisappear` 摧掉)。
|
||||||
*
|
*
|
||||||
@ -2498,9 +2590,179 @@ struct MainPage {
|
|||||||
private gridSettings: RenderingContextSettings = new RenderingContextSettings(true);
|
private gridSettings: RenderingContextSettings = new RenderingContextSettings(true);
|
||||||
private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings);
|
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 {
|
aboutToAppear(): void {
|
||||||
this.applyAppearance();
|
this.applyAppearance();
|
||||||
this.watchEnvironment();
|
this.watchEnvironment();
|
||||||
|
/*
|
||||||
|
* 顶栏文案(摘要 / 一言 / 签名)—— 用户 2026-09-24:
|
||||||
|
* 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」+
|
||||||
|
* 「自动轮播,要有消失出现动画」+「在三键的左边」。
|
||||||
|
*
|
||||||
|
* ★ 为什么由 **MainPage** 起(而不是 CommPage):
|
||||||
|
* 文案要画在**整窗右上**(三键左边),那是 `MainPage` 的根 Stack 层;
|
||||||
|
* 而 `CommPage` 只是通信那一栏、只占左边 608px。
|
||||||
|
* 数据来源不冲突:摘要读的是 CommPage 发布到 AppStorage 的那三个计数。
|
||||||
|
*/
|
||||||
|
this.loadTopbar();
|
||||||
|
this.startTopbarRotation();
|
||||||
/*
|
/*
|
||||||
* ★★ 2026-09-24:**把 SSE 监听提到 App 级**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
|
* ★★ 2026-09-24:**把 SSE 监听提到 App 级**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
|
||||||
*
|
*
|
||||||
@ -2529,6 +2791,8 @@ struct MainPage {
|
|||||||
|
|
||||||
aboutToDisappear(): void {
|
aboutToDisappear(): void {
|
||||||
this.unwatchEnvironment();
|
this.unwatchEnvironment();
|
||||||
|
/* 停轮播 —— 不清的话页面销毁后它还在跑(白耗电,且会改不可见组件的状态) */
|
||||||
|
this.stopTopbarRotation();
|
||||||
if (this.sseService !== null) {
|
if (this.sseService !== null) {
|
||||||
this.sseService.removeListener(this.onGlobalSseEvent);
|
this.sseService.removeListener(this.onGlobalSseEvent);
|
||||||
}
|
}
|
||||||
@ -3717,6 +3981,78 @@ struct MainPage {
|
|||||||
.opacity(this.themeFadeOpacity)
|
.opacity(this.themeFadeOpacity)
|
||||||
.hitTestBehavior(HitTestMode.None)
|
.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%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
|
|||||||
Reference in New Issue
Block a user