跨端: B 的交付物(跨端纯逻辑一致性判据)+ 照 skill 回扫修掉两处隐形债

用户:「你为什么不加载鸿蒙开发相关skill?」—— 说得对。那份
`arkts-grammar-standards` 写着 "REQUIRED before writing the first .ets file of a
session",而我这轮一直在写 `.ets`。补加载后照它的规则表**逐条回扫**,
当场抓出两处此前没人管的违规。

══ ① 用户要做的 B:`cross-client-logic.test.mjs`(新,6 条判据)

背景:两套纯逻辑各写一份且已分叉(replyTarget 214/170 行、mailGroups 178/459、
appearance 187/324、calendar 208/446)。当天已**踩到**两处分叉
(`participantAddress` 的 `||`、`ThreadPage` 字段全错)。

做法:**同一张用例表喂给两边,逐条比结果**(`--experimental-strip-types`
直接在 node 里跑两侧源码 —— 两边的 model 层都是纯逻辑、无 SDK 依赖)。
不选"生成一份共享源码":harmony 不能 import 工程外文件,
且两边类型系统不同(ArkTS 禁解构/any/对象字面量要具名类型),
生成器要维护"两边都能过"的子集,是另一个大工程。

★ **首轮运行就报出两处真分叉,都不是我踩到才发现**:
  ① `formatAddress('dsh', undefined, undefined)`:electron 返回 `"dsh"`,
     harmony **抛** `Cannot read properties of undefined`。
     —— 又是 `omitempty` 那个坑(**第三次**),这次是判据先报的。
  ② `monthGrid`:electron **固定 6 行**(`grid-rows-6`),harmony **4~6 行**
     ⇒ 翻月时网格高度跳动。WebUI 的注释明写要避免这个("行数变化会让整个
     网格高度跳动,翻月时页面内容上下弹")。
  ③ 顺着 ② 又发现:WebUI 邻月格子**填真实日期并置灰、可点**
     (`CalendarView.tsx:545-556`),harmony 留**空白格**。

★ 判据自身的两次错,都留了档(判据的 bug 与代码的 bug 一样危险):
  · 第一版把 `args[0]` 当单个参数传,字符串被当可迭代对象展开 ⇒
    `formatAddress('d','s','h')` —— **判据自己造出假分叉**。
  · 第一版 `weekStart` 传 0(周日),而两端实际都是 1(周一)⇒ 又一处假分叉。
    差一点就去"修"一个不存在的问题。
  · `monthGrid` 的投影第一版按 `inMonth ? [y,m,day] : null`,
    把"邻月填不填真日期"这个**真分叉**抹平了 —— 投影只该换表示,不该替我看不看。

★ 三类"不同"要分清(写进文件头):**命名不同**(投影归一,不是分叉)、
  **签名不同**(ArkTS 没 Date 重载习惯;语义必须一样)、**行为不同**(是分叉,以 electron 为准)。

══ ② skill 回扫抓出的两处隐形债(编译器只告警、判据也不管)

· **正则字面量**(`arkts-no-regexp-literals`):`MailDetailPage.ets:615` 的
  `/^\d+$/`(从 2026-09-19 活到今天)。
· **废弃的全局 `router`**:`api/Logout.ets:76` 的 `router.replaceUrl(...)`。
  它是个独立函数(没有 `this`)⇒ 拿不到 `UIContext`,改成由调用方传
  (两个调用点都持有 `getUIContext()`,零成本)。

★ 这两条为什么能活这么久:**编译器对它们只告警、不挡构建**,
  全仓也**没有判据**管 ⇒ 规则事实上不存在。已补两条判据,都做了变异验证。

══ ③ 顺带修正一条**恒真的同义反复**断言

`harmony-calendar` 里 "today 不在本月:不许标在别的月" 那条:
它是在"邻月格子是空 `DayCell`(`iso` 为空串)"时写的 ⇒ `c.iso === today`
**永远不可能**匹配 ⇒ `count === 0` 恒真,**看起来守着一条规则,其实什么都没守**。
改成真不变量:**"被标为今天的那一格,iso 必须就是 today;至多一格"**,
并反向核对"2026-10-01 确实出现在 9 月网格里"(否则那段是空转)。
实测 WebUI `CalendarView.tsx:547` 是逐格 `isSameDay` ⇒ **它会标**,
所以原来那条"不许标"本身就窄了一半。

══ ④ 登记两处盘点发现(**没有**顺手改,因为需要人决定)

· `harmony-dead-pages`:`InboxPage.ets`(238 行) 不可达(不在页面表、无人导航),
  `SessionsPage.ets`(170 行) 唯一引用来自 InboxPage ⇒ 一起不可达。
  没删是因为 `HARMONY-ALIGN-PLAN.md:214` 把它当变异测试靶子用过 ——
  删掉会永久丢代码,是否只是"早期留存"我判断不了。
· `harmony-permission-history`:WebUI 授权栏显示**待决 + 已决策历史**两段
  (拿 inbox 自己分组,`PermissionList.tsx:27/174/182`);鸿蒙调专用端点
  `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ **只拿得到待决的**。
  已在 `cross-client-logic` 的 gaps 里如实登记,判据会盯着"不要再少"。

══ 判据状态

`files=33 ran=33 checks=530 pass=530 fail=0 skip=0 red=0 broken=0 unreported=0`;
`baseline=7/7✓`(底本第 9 次重算,已按规矩先 `git diff --quiet HEAD` 取证 + 记录理由)。
`verdict=red` 残余仍是 5 条静态判据的**设备到期提示**(既有机制)。

══ 环境

模拟器昨天起卡死(hdc 能连、shell 超时、CPU 150%、跑了 34 小时),
导致设备判据各跑 836 秒后失败 —— 看起来像"套件卡死"。用户批准后杀掉重启
(`Emulator -start HATriple -noWindow`,`devecocli` 那套因 x11 起不来),
现在**75~150 秒**跑完整套。
This commit is contained in:
2026-09-20 22:35:35 +08:00
parent 25e7d8f3bf
commit 5e4a1b616c
12 changed files with 681 additions and 20 deletions

View File

@ -17,7 +17,7 @@
*/
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { router } from '@kit.ArkUI';
import { UIContext } from '@kit.ArkUI';
import { ApiClient } from './ApiClient';
import { AuthApi } from './AuthApi';
import { AccountManager } from './AccountManager';
@ -31,7 +31,23 @@ import { PushService } from './PushService';
* 任何一步失败都必须继续把它清干净 —— 中途 return 会留下半退出的状态
* (最坏的是凭证没清、下次启动又自动登进去)。
*/
export async function performLogout(ctx: common.UIAbilityContext | common.Context | undefined): Promise<void> {
export async function performLogout(
ctx: common.UIAbilityContext | common.Context | undefined,
/*
* ★★ 2026-09-20 加:`UIContext` 必须由调用方传进来。
*
* 因为文件末尾那句 `router.replaceUrl(...)` 原本用的是**全局 `router`**,
* 而 ArkTS 的规范里 `router` / `promptAction` / `animateTo` 这些
* **全局 API 已废弃**,要走 `ui.getRouter()`。
* 而本函数是个**独立函数**(没有 `this`)⇒ 拿不到 `UIContext`,
* 只能由调用方给。两个调用点(`MainPage` 的侧栏退出、`SettingsPage` 的
* 「我的」页退出)**都已经持有 `getUIContext()`**,所以是零成本。
*
* 可选参数:传 `undefined` 时退化为"不导航"(凭证照清 —— 退出的语义是
* "这台设备不再持有这个账号",导航失败不该让前面几步白做)。
*/
ui?: UIContext
): Promise<void> {
const client: ApiClient | null = ctx === undefined ? null : ApiClient.getInstance(ctx);
/* ① 注销推送:失败不阻断退出(本地凭证该清还是要清) */
@ -72,6 +88,15 @@ export async function performLogout(ctx: common.UIAbilityContext | common.Contex
hilog.info(0x0001, 'Logout', 'disconnectAll 失败:%{public}s', JSON.stringify(e));
}
/* ④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页 */
router.replaceUrl({ url: 'pages/LoginPage' });
/*
* ④ `replaceUrl` 而不是 `pushUrl`:退出后不该还能"返回"到已登出的页。
*
* ★ 用 `ui.getRouter()`(`UIContext` 上的那个),不用全局 `router` ——
* 后者在 ArkTS 里是**已废弃 API**(`arkts-grammar-standards` 的规则表里
* `router.pushUrl` / `promptAction.showToast` / `animateTo` 那一条)。
* 它们在 SDK 里还能跑,所以 build 全绿、判据也不管 —— 是"隐形的债"。
*/
if (ui !== undefined) {
ui.getRouter().replaceUrl({ url: 'pages/LoginPage' });
}
}

View File

@ -67,8 +67,38 @@ export function monthGrid(year: number, month: number, weekStart: number, today?
const blanks: number = leadingBlanks(year, month, weekStart);
const len: number = daysInMonth(year, month);
const cells: DayCell[] = [];
for (let i = 0; i < blanks; i++) {
cells.push(new DayCell());
/*
* ★★ 2026-09-20 修 —— **跨端一致性判据抓出来的第二处真分叉**。
*
* 月首前面那几格原来是**空 `DayCell`**(`inMonth = false`、没有日期)。
* 而 WebUI 那边的格子是**真实的日期**,只是置灰:
* `CalendarView.tsx:545-556`
* const outside = d.getMonth() !== curMonth; ← 邻月
* className={`… ${ outside ? 'bg-gray-50/60' : 'bg-white' }`}
* onClick={() => onPickDay(d)} ← **而且可点**
* `calendar.ts:78-83` 的注释也写着:
* 「多出来的格子**显示邻月日期并置灰**」
*
* 所以两端的月网格长得不一样:WebUI 每一格都有日期(邻月的淡一点、能点过去),
* 鸿蒙首尾是空白格。用户翻月时看得到这个差别。
*
* 修法:前面补齐**上一个月的真实日期**,后面补齐**下一个月的真实日期**;
* 这些格子 `inMonth = false`(界面据此置灰),但 `day`/`iso` 都是真的。
*
* ★ 补法用**日期算术**(`Date` 自动处理跨月/跨年与闰年),不手写月份长度 ——
* 手写就多一份"每月几天"的实现,正是这类代码最容易出错的地方。
*/
const firstOfMonth: Date = new Date(year, month - 1, 1);
for (let i = blanks; i > 0; i--) {
const d: Date = new Date(firstOfMonth.getTime());
d.setDate(d.getDate() - i);
const c = new DayCell();
c.day = d.getDate();
c.label = d.getDate();
c.inMonth = false;
c.iso = isoOf(d.getFullYear(), d.getMonth() + 1, d.getDate());
c.isToday = today !== undefined && c.iso === today;
cells.push(c);
}
for (let d = 1; d <= len; d++) {
const c = new DayCell();
@ -79,9 +109,38 @@ export function monthGrid(year: number, month: number, weekStart: number, today?
c.isToday = today !== undefined && c.iso === today;
cells.push(c);
}
// 补齐最后一行(上月尾/下月头也填上日期,界面才好画连续的日子)
while (cells.length % 7 !== 0) {
cells.push(new DayCell());
/*
* ★★ 2026-09-20 修 —— **这是跨端一致性判据抓出来的真分叉**。
*
* 原来这里是"补齐到最后一行就停"⇒ 行数 **4~6 行**(由月长与月首星期决定)。
* 而 WebUI `CalendarView.tsx:543` 是**固定 6 行**:
* <div className="grid grid-cols-7 grid-rows-6 flex-1 min-h-[30rem]">
* 它旁边的注释(`calendar.ts:78-83`)写明了为什么:
* 「固定 6 行而不是按需 4~6 行:**行数变化会让整个网格高度跳动**,
* 翻月时页面内容上下弹。多出来的格子显示邻月日期并置灰。」
*
* 鸿蒙这边行数不一致 + `CELL_HEIGHT = 52` 固定 ⇒ 从 6 行的月份翻到 4 行的月份,
* 网格**矮下去 104vp**,整页内容跟着弹一下 —— 正是 WebUI 那条注释要避免的现象。
*
* 修法照抄:**永远 6 行(42 格)**,多出来的格子留空(`DayCell` 默认
* `inMonth=false`、无 `day`,界面按"邻月位"画空)。
*
* ★ 这条分叉是**判据先报的**,不是我在设备上看到的 ——
* 一致性表把 `(2026,9)` 与 `(2026,2)` 两个输入喂给两边:
* electron 都是 42 格,harmony 是 5 行 / 4 行 ⇒ 当场对不上。
* 这与本文件顶上那条"判据先报 `formatAddress(undefined)`"是同一次收获。
*/
const lastOfMonth: Date = new Date(year, month - 1, len);
while (cells.length < 42) {
const d: Date = new Date(lastOfMonth.getTime());
d.setDate(d.getDate() + (cells.length - (blanks + len) + 1));
const c = new DayCell();
c.day = d.getDate();
c.label = d.getDate();
c.inMonth = false;
c.iso = isoOf(d.getFullYear(), d.getMonth() + 1, d.getDate());
c.isToday = today !== undefined && c.iso === today;
cells.push(c);
}
const rows: DayCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {

View File

@ -43,12 +43,31 @@ export interface Counterpart {
* 给人类回信时若丢掉 `@`,整条地址就废了。
*/
export function formatAddress(name: string, path: string, session: string): string {
const n: string = name.trim();
/*
* ★★ 2026-09-20 修 —— **这是同一个 `omitempty` 坑的第三次**,
* 而且这次是**一致性判据当场抓出来的**(不是我又踩到才发现的)。
*
* electron 那一侧写的是 `(name || '').trim()` / `(path || '').trim()` /
* `(session || '').trim()`;鸿蒙这侧写的是裸 `.trim()`。
* 平时看不出来(调用方大多传的是 `''`),但**服务端 `omitempty` 缺失时
* 传进来的就是 `undefined`** —— 裸 `.trim()` 当场抛。
*
* 实测(`cross-client-logic.test.mjs` 的一致性表):
* formatAddress('dsh', undefined, undefined)
* electron → "dsh"
* harmony → THROW: Cannot read properties of undefined
*
* ★ 为什么这次值得单独记:前两次(`participantAddress` 的 `||`、
* `attachments.length` 的 `?? []`)都是**踩到之后**回头补的。
* 这次是**判据先报**——说明"两端跑同一张用例表比行为"这个做法是有效的,
* 它不需要我先知道有 bug。
*/
const n: string = (name || '').trim();
if (n.length === 0) {
return '';
}
const p: string = path.trim();
const s: string = session.trim();
const p: string = (path || '').trim();
const s: string = (session || '').trim();
if (s.length === 0) {
return p.length === 0 ? n : n + '@' + p;
}

View File

@ -611,8 +611,20 @@ export struct MailDetailView {
if (t.length === 0) {
return true;
}
/* 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒 */
return /^\d+$/.test(t);
/*
* 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒。
*
* ★★ 2026-09-20 修:原来这里写的是**正则字面量** `/^\d+$/`,
* 而 ArkTS **禁用正则字面量**(`arkts-no-regexp-literals`)——
* 要用 `new RegExp('...')`。
*
* 为什么一直没报错:编译器这条是**告警级**、不挡构建,
* 而全仓也**没有判据管它**(`harmony-arkts` 那条"词汇层硬坑"只查
* 解构 / any / unknown / 函数表达式四种)。⇒ 我这次是照着
* `arkts-grammar-standards` skill 的规则表逐条回扫才发现的。
* 已把正则字面量补进那条判据(见下)。
*/
return new RegExp('^[0-9]+$').test(t);
}
/** 徽标文案(对齐 WebUI 的三种写法) */

View File

@ -3392,7 +3392,7 @@ struct MainPage {
onToggleTheme: () => { this.toggleTheme(); },
/* 退出:与「我的」页那个按钮走**同一个** `performLogout` */
onLogout: () => {
performLogout(this.getUIContext().getHostContext()).catch(() => {});
performLogout(this.getUIContext().getHostContext(), this.getUIContext()).catch(() => {});
}
})
}

View File

@ -375,7 +375,7 @@ export struct SettingsPane {
*/
async doLogout(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
await performLogout(ctx);
await performLogout(ctx, this.getUIContext());
}
/**