跨端: 鸿蒙顶栏 —— 兜底改品牌名、一言只留句子、整批轮播(修"轮播不转")

用户三条裁定,逐条落地:

① 兜底文案「暂无待办」→「AgentMail」
   触发条件只是"三个计数为零",而"暂无待办"读起来像"没事可做"——
   是在**替用户下结论**,结论与事实不等价。品牌名不带判断。

② 一言**只显示句子,不显示出处**
   原来 `quote + ' —— ' + source`。出处占近 1/3 宽度、把句子本身
   挤到省略号;且 hitokoto 的出处格式杂(动漫角色/诗词/网名),
   窄带上排起来脏。⇒ 与签名对齐:都是"当前状态的一句话"。
   `topQuoteSource` 状态随之删除(只写不读的死字段)。

③ ★★ 修一个**不报错、不崩溃**的静默 bug:轮播根本不转
   `this.topQuote = content.quotes[0].text` —— 注释还振振有词
   「批次的意义是少请求,不是一次全显示」。那句话本身没错,
   但**只取第一句** ⇒ 本地永远只有 1 句 ⇒
   `startTopbarRotation` 里 `texts.length <= 1` 的守卫直接 `return`。
   实测:连拍 6 张(18 秒)**全是同一句**。

   ★ 更坏的是它先前是**假象**:看着"在转",靠的是兜底占了轮播位
     (一言 ↔ 暂无待办 交替)。等 ① 把兜底改成不占轮播位,真相立刻暴露。
     ⇒ 教训:**"看起来在工作"可能是另一个东西在工作**。
       做 ① 时顺手连拍验证,才撞出这个真 bug。

   修法:整批存进本地状态、本地在批内逐句轮。服务端一批给 10 句
   (每次顺序还随机),这正好落实用户最初那句「app 本地缓存一部分」
   ——缓存的是**一批**,不是一个。

   ★ 顺带:兜底**不占轮播位**(它只做"唯一候选")
     摘要要有真计数(unread/pending/contacts 至少一个 > 0)才占位。
     实测四张连拍得到 `一言 → 兜底 → 兜底 → 一言`,
     等于用户看到的内容里一半是废话。

实测凭据(2in1 模拟器 3120×2080)
  · 连拍 7 张(21s):6 句不同一言,全不带出处
  · 连拍 5 张(15s):4 句不同,无兜底占位
  · 顶栏文案与三键垂直中心差 0.0px(上一提交已校准)

判据(harmony-2in1,共 23 条)
  · 兜底必须存在(退回空串会让整块消失),且**不含判断词**
    ("暂无/没有/无"开头——正则断言,改回「暂无待办」即判红)
  · 兜底不占轮播位:`summaryIsReal` 守卫 + `out.length === 0` 才 push(两半都钉)
  · 一言存**整批**、逐句进候选、不许有单句形态 `topQuote`
  · 出处不许出现在代码里

★ 判据自己踩的坑,记下来免得重犯:
  一言那条我第一版写成反向正则 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/`
  —— **只匹配单引号**。变异时我把拼接写成双引号 `" —— "`,正则没命中
  ⇒ **假绿**。变异验证当场抓到。改成**正向断言**
  `out.push(this.topQuote);`(必须原样推进、不许在此处拼接):
  正向比反向窄,且不依赖引号风格。

全量:577/577 绿。
This commit is contained in:
2026-09-25 19:09:48 +08:00
parent 9d50352e7e
commit ab1c856ffe
2 changed files with 186 additions and 25 deletions

View File

@ -670,15 +670,97 @@ test('2in1|顶栏摘要"零"时也要有文案(否则整块消失)', () =>
* 实测:那封邮件已读 ⇒ 三项全 0 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**,
* 顶栏右上什么都没有。
*
* 而"没有未读"恰恰是**常态**(收件箱清干净了)。那时该说"暂无待办",
* 而不是整块消失让人以为坏了。
* 而"没有未读"恰恰是**常态**(收件箱清干净了)。
*
* 改坏会红:把 `return '暂无待办'` 改回 `return ''`。
* ★★ 文案改过两轮,判据跟着改(用户裁定):
* 第 0 版 空串 → 整块消失(顶栏右侧空着、与三键失衡)
* 第 1 版 「暂无待办」→ 补上了"零也是信息",但这句话**信息量为 0 且会误导**:
* 它读起来像"没事可做",实际只表示"三个计数是 0"。
* 第 2 版 「AgentMail」(品牌名)—— 不带任何判断。
*
* ⇒ 判据钉**两条**:① 兜底必须存在(别退回空串让整块消失);
* ② 兜底文案**不含判断词**("暂无/没有/无"开头都是在替用户下结论)。
*
* 改坏会红:把 `TOPBAR_FALLBACK` 改回 `''`,或改回 `'暂无待办'`。
*/
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
assert.match(MAIN, /暂无待办/,
'摘要为零时要有兜底文案 —— "零"本身也是信息,不该让整块消失');
});
assert.match(MAIN, /const TOPBAR_FALLBACK: string = '[^']+';/,
'兜底文案要存在(退回空串会让整块消失、顶栏右侧空着)');
const fallback = MAIN.match(/const TOPBAR_FALLBACK: string = '([^']+)';/);
assert.ok(fallback, '要能取到兜底文案');
assert.doesNotMatch(fallback[1], /^暂无|^没有|^无/,
'兜底文案不许下判断("暂无X"是在替用户下结论,而触发条件只是计数为零)');
/*
* ③ 兜底**不占轮播位** —— 它只做"唯一候选"。
*
* 实测:四张连拍(3s 一张)得到 `一言 → 兜底 → 兜底 → 一言`,
* 轮播位有一半被废话吃掉(那时一言只有 2 条)。
* ⇒ 摘要有真计数才配占位;兜底只在**三类都空**时才作为唯一候选出现。
*
* 结构上是两半,都要钉住(少一半就退回旧行为):
* a) 摘要入口有 `summaryIsReal` 守卫(假摘要不进数组)
* b) 数组为空时才 push 兜底(不是无条件 push)
*
* 改坏会红:删掉任一半。
*/
assert.match(MAIN, /const summaryIsReal: boolean = this\.navUnread > 0 \|\| this\.navPending > 0 \|\| this\.navContacts > 0;/,
'摘要要有"是否为真计数"的判断');
assert.match(MAIN, /if \(summaryIsReal\) \{\s*\n\s*out\.push\(summary\);\s*\n\s*\}/,
'假摘要(兜底)不许进候选数组 —— 否则它会被轮播到、占掉一半时长');
assert.match(MAIN, /if \(out\.length === 0\) \{\s*\n\s*out\.push\(TOPBAR_FALLBACK\);\s*\n\s*\}/,
'兜底只在数组为空时作为唯一候选进入');
/*
* ④ 一言**只显示句子**,不带出处(用户裁定)。
*
* 原形状 `quote + ' —— ' + source` 的问题:
* 出处占近 1/3 宽度、把句子本身挤到省略号;
* 且 hitokoto 的出处格式杂(动漫角色/诗词/网名),窄带上排起来脏。
* ⇒ 与签名对齐:两者都是"当前状态的一句话",都不带出处。
*
* 判据钉**两半**:① 拼装里不含 `——` + source;
* ② `topQuoteSource` 这个状态整个不存在了(留着就是只写不读的死字段)。
*
* 改坏会红:把拼接改回去,或把 `topQuoteSource` 状态加回来。
*/
/*
* ★ 这里我第一版写漏了,记下来:
* 正则写成 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/` ——
* **只匹配单引号**。而变异时我把拼接写成双引号 `" —— "`,
* 正则没命中 ⇒ 判据**假绿**(变异验证的意义就在这:它当场抓到了)。
*
* ⇒ 改成**正向断言**:必须存在"把 topQuote 原样推进数组"这一句。
* 正向比反向窄,且不依赖引号风格。
*/
assert.match(MAIN, /@State topQuotes: string\[\] = \[\];/,
'一言要存**整批**(单个字符串装不下一批,见下条)');
assert.match(MAIN, /this\.topQuotes\.push\(q\)|out\.push\(q\);/,
'一言要**逐句**进候选数组');
assert.doesNotMatch(MAIN, /this\.topQuote\b(?!s)/,
'不许再有单句形态的 topQuote(它是"只取第一句"那个 bug 的载体)');
assert.doesNotMatch(MAIN, /this\.topQuoteSource/,
'出处不许出现在代码里(只写不读的死字段也要删)');
/*
* ⑤ **整批都要存下来** —— 只取第一句会让轮播静默失效。
*
* 这是本轮最值钱的一条判据,因为那个 bug **不报错、不崩溃**:
* `this.topQuote = content.quotes[0].text` ⇒ 本地只有 1 句 ⇒
* `startTopbarRotation` 的 `texts.length <= 1` 守卫直接 return ⇒ 不轮播。
* 我连拍 6 张(18s)全是同一句才发现。
*
* ★ 更坏的是它是**假象**:先前看着"在转",是因为兜底文案占了位
* (一言 ↔ 暂无待办 交替);等兜底改成不占轮播位,转的其实是兜底。
* ⇒ 判据必须钉"服务端给的那一批**全都进**本地状态"。
*
* 改坏会红:把赋值改回 `quotes[0]`。
*/
assert.match(MAIN, /for \(let i = 0; i < content\.quotes\.length; i\+\+\)/,
'要遍历服务端返回的整批(只取 [0] 会让轮播静默失效)');
assert.match(MAIN, /this\.topQuotes = qs;/,
'整批赋给本地状态');
assert.doesNotMatch(MAIN, /@State topQuoteSource/,
'出处状态要删干净,不留只写不读的死字段');});
test('2in1|一言与签名要在客户端缓存(账号级),离线也能轮播', () => {
/*

View File

@ -169,6 +169,13 @@ const TOPBAR_FADE_MS: number = 260;
*/
/* 文案与窗口左缘的距离(vp)—— 与内容区起始留白同档 */
const TOPBAR_STRIP_LEFT: number = 16;
/*
* 兜底文案(没有任何真内容时显示)。
*
* ★ 用户裁定用品牌名,不用「暂无待办」—— 理由见 `topbarSummary()` 的注释:
* "暂无X"是在替用户下结论,而触发条件只是"三个计数为零",两者不等价。
*/
const TOPBAR_FALLBACK: string = 'AgentMail';
/*
* 顶栏文案字号(vp)。
*
@ -2481,8 +2488,25 @@ struct MainPage {
* · 一言 —— 服务端句库(本地有缓存,离线也照转)
* · 签名 —— 用户个人签名(没设过就不进轮播)
*/
@State topQuote: string = '';
@State topQuoteSource: string = '';
/*
* ★★ 2026-09-25:**整批存下来,本地在批内轮播**(原来是 `topQuote: string` 单句)。
*
* ── 原来的错 ──
* 我写成 `this.topQuote = content.quotes[0].text`,注释还振振有词:
* 「批次的意义是'少请求',不是'一次全显示';一次显示多句反而乱」
* —— 那句话本身没错,但我**只取第一句** ⇒ 本地永远只有 1 句 ⇒
* `startTopbarRotation` 里 `texts.length <= 1` 的守卫直接 `return`
* ⇒ **轮播根本不转**(6 张连拍 18 秒全是同一句)。
*
* 之前看着"在转",靠的是兜底文案占了位(一言 ↔ 暂无待办 交替)——
* 那是**假象**。等我把兜底改成不占轮播位,真相立刻暴露。
*
* ── 正确做法 ──
* 服务端一批给 10 句(每次顺序还随机)⇒ 整批存进本地缓存,
* 让本地轮播在**批内**逐句换。这正好落实用户最初那句
* 「app 本地缓存一部分」——缓存的是**一批**,不是一个。
*/
@State topQuotes: string[] = [];
@State topSignature: string = '';
/** 当前显示到第几个(对"可用文案列表"取模) */
@State topIndex: number = 0;
@ -2599,19 +2623,57 @@ struct MainPage {
*/
private topbarTexts(): string[] {
const out: string[] = [];
/* 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释 */
/*
* 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释。
*
* ★★ 2026-09-25:**兜底不占轮播位**。
* 实测四张连拍(3s 一张):`一言 → 摘要 → 摘要 → 一言` ——
* 轮播位有一半被兜底文案吃掉,而那时一言只有 2 条、
* 等于用户看到的内容里**一半是废话**。
*
* 规则:摘要**有真计数**(unread/pending/contacts 至少一个 > 0)才占位;
* 落到兜底(品牌名)说明没有任何待办 —— 那就不必用一整个轮播位去说它。
* ⇒ 兜底只在**其它文本一条都没有**时才作为唯一的候选显示(见下面 return 前那段)。
*/
const summary: string = this.topbarSummary();
if (summary.length > 0) {
const summaryIsReal: boolean = this.navUnread > 0 || this.navPending > 0 || this.navContacts > 0;
if (summaryIsReal) {
out.push(summary);
}
if (this.topQuote.length > 0) {
out.push(this.topQuoteSource.length > 0
? this.topQuote + ' —— ' + this.topQuoteSource
: this.topQuote);
/*
* ★★ 2026-09-25:一言**只显示句子本身,不带出处**(用户裁定)。
*
* ── 原来长这样 ──
* `this.topQuote + ' —— ' + this.topQuoteSource`
* ⇒「青山相待,白云相爱,梦不到紫罗袍共黄金带。 —— 宋方壶」
*
* ── 为什么不带出处 ──
* ① 顶栏是**一句话的位置**,不是引文区。出处(`—— 宋方壶`)占了
* 近三分之一宽度,把句子本身挤到省略号。
* ② 这些句子来自 hitokoto,出处杂(动漫角色、诗词、网络段子),
* 格式也不统一(有的带书名号、有的就是个人名)——
* 在一条 14vp 的窄带上排版起来很脏。
* ③ 与签名对齐:签名也没有"出处"。两者都是"当前状态的一句话"。
*/
for (let i = 0; i < this.topQuotes.length; i++) {
const q: string = this.topQuotes[i];
if (q.length > 0) {
out.push(q);
}
}
if (this.topSignature.length > 0) {
out.push(this.topSignature);
}
/*
* 兜底的**唯一**用途:上面三类**一条都没有**时别让整块消失
* (否则顶栏右侧空着,与三键之间失衡 —— 用户最初就是为这个才要这块)。
*
* ⇒ 它进的是"唯一候选",不是"轮播位之一":
* 只要真内容存在(哪怕只有一条一言),兜底就不会出现。
*/
if (out.length === 0) {
out.push(TOPBAR_FALLBACK);
}
return out;
}
@ -2644,16 +2706,23 @@ struct MainPage {
parts.push(this.navContacts + ' 会话');
}
/*
* ★★ 2026-09-25 加兜底(实测撞出来的):
* 三项都是 0 时返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**。
* 而"没有未读"恰恰是**常态**(收件箱清干净了)——
* 那时顶栏该说"都清完了",而不是整块消失让人以为坏了。
* ★★ 2026-09-25 兜底 = **品牌名**(用户裁定:「AgentMail」)。
*
* ★ 这与我先前那版"三项都是 0 就不显示"的判断相反 ——
* 那句话是我按"有信息才显示"想当然写的,没考虑"零"本身也是信息。
* ── 这里改过两轮,记下为什么 ──
* 第 0 版:三项都是 0 ⇒ 返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ **整块不渲染**。
* "没有未读"是常态,那时顶栏不该整块消失(看着像坏了)。
* 第 1 版:「暂无待办」—— 先把"零也是信息"补上了,但这句话
* **信息量是 0 且会误导**:它读起来像"这个 App 里没事可做",
* 而实际上它只表示"这三个计数当前都是 0"。
* 第 2 版(本版):「AgentMail」。
*
* ★ 判据:兜底文案要**不含判断**。
* 任何"暂无/没有/无"开头的句子都是在替用户下结论,
* 而这句话的触发条件只是"三个计数为零" —— 结论与事实不等价。
* 品牌名没有这个毛病:它只是标识,且与右边三键在视觉上相称。
*/
if (parts.length === 0) {
return '暂无待办';
return TOPBAR_FALLBACK;
}
return parts.join(' · ');
}
@ -2741,9 +2810,19 @@ struct MainPage {
* 而我们这里已经有本地定时轮播 —— 一次显示多句反而乱。
* 批次的存在意义是"少请求",不是"一次全显示"。
*/
if (content.quotes.length > 0) {
this.topQuote = content.quotes[0].text;
this.topQuoteSource = content.quotes[0].source;
/*
* 整批存(服务端一批给 10 句),本地在批内轮播。
* 只取句子、不取出处 —— 理由见 `topbarTexts()`。
*/
const qs: string[] = [];
for (let i = 0; i < content.quotes.length; i++) {
const t: string = content.quotes[i].text;
if (t.length > 0) {
qs.push(t);
}
}
if (qs.length > 0) {
this.topQuotes = qs;
}
this.topSignature = content.signature;
}