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

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

① 兜底文案「暂无待办」→「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

@ -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;
}