Files
MailUI4Agents/client/electron/test/animation-audit.test.mjs
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

344 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
*
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
*
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
*
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
*
* # 判据
*
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
* 找不到就是死动画(① 就是这么被抓住的)。
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
*/
import { readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { check, finish } from './lib/checks.mjs';
import { code, PKG } from './lib/read.mjs';
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
/*
* 仓库根 —— 鸿蒙侧那几个路径要从仓库根算。
* 本文件原先只读 WebUI 的 CSS(相对 `PKG` 就够),所以没有 ROOT;
* 加鸿蒙那四条之后需要它。`PKG` 仍用于 WebUI(它自带正确基准)。
*/
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const css = code('src/index.css');
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
function componentsSrc() {
const out = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = `${dir}/${e.name}`;
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
}
};
/* ★ 基准用 PKG(= client/electron),**不是**相对 cwd:
* `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。
* 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */
walk(join(PKG, 'src'));
return out.join('\n');
}
const src = componentsSrc();
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
sel: m[2].trim().replace(/\s+/g, ' '),
body: m[3]
}));
/**
* 一条 selector 在**最终**要不要靠某个类名才能命中?
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
*/
function wearerClasses(sel) {
return sel
.split(',')
.map(part => part.trim().split(/\s+/).pop() || '')
.filter(last => last.startsWith('.'))
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
.filter(Boolean);
}
const dead = [];
const unwrapped = [];
for (const k of keyframes) {
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
if (users.length === 0) {
dead.push(`${k}(没有任何规则用它)`);
continue;
}
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
const worn = users.some(r => {
const cls = wearerClasses(r.sel);
if (cls.length === 0) return true; // 元素选择器/通配:不算死
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
});
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
}
check(
'每个 @keyframes 都有人穿(没有死动画)',
dead.length === 0 && unwrapped.length === 0,
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
);
check(
'弹层菜单带着菜单入场类',
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
);
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
check(
'没有"整档控件一起动"的切视图规则',
!/html\.view-switch\s+\.glass-control/.test(topLevel),
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
);
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
check(
'挂载即播那档被 reduced-motion 显式关掉',
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
);
/*
* ══════════════════════════════════════════════════════════════════════
* 鸿蒙侧:**共享元素转场**(`geometryTransition`)的接线
* ══════════════════════════════════════════════════════════════════════
*
* 用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,
* 你做的啥?」—— WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成面板",
* 鸿蒙侧的对应能力是 `geometryTransition`。
*
* 它有三种**静默失效**的写法(都只会表现为"动画没播",不报错):
* ① 只绑一端 —— 没有 in/out 配对,系统无处可插值;
* ② 只绑 `geometryTransition` 却**没有 `animateTo`** ——
* 官方原文:「**必须配合 animateTo 使用**才有动画效果,
* 动效时长、曲线跟随 animateTo 中的配置,**不支持 animation 动画**」;
* ③ 同一个 id 绑了**三个以上**组件 —— 官方原文:
* 「同一个 id 只能有**两个**组件绑定…**不能多个组件绑定同一个 id**」。
*
* 判据就钉这三条。它们全是"接线"性质,与时长/曲线无关(那两样由人看着定)——
* 与本文件既有四条的分寸一致(`harmony-nav` 那份注释里写过同一条理由)。
*/
const HARMONY_PAGES = join(ROOT, 'client/harmony/entry/src/main/ets/pages');
function harmonySources() {
const out = [];
for (const e of readdirSync(HARMONY_PAGES, { withFileTypes: true })) {
if (e.isFile() && e.name.endsWith('.ets')) out.push({ name: e.name, src: code(join(HARMONY_PAGES, e.name)) });
}
return out;
}
const hs = harmonySources();
const allHarmony = hs.map(h => h.src).join('\n');
/* 每个 id(按文件统计,因为 id 是**字符串**,跨文件同名是合法但危险的) */
const geomIds = new Map(); // id -> [file, ...]
for (const h of hs) {
for (const m of h.src.matchAll(/geometryTransition\(\s*'([^']+)'/g)) {
const id = m[1];
if (!geomIds.has(id)) geomIds.set(id, []);
geomIds.get(id).push(h.name);
}
}
check(
'鸿蒙|共享元素转场的每个 id 恰好绑**两处**(一 in 一 out)',
[...geomIds.values()].every(v => v.length === 2),
'官方约束:「同一个 id 只能有两个组件绑定,且分别作为 in(新视图)和 out(旧视图)' +
'两种不同类型角色,不能多个组件绑定同一个 id」;实际:' +
[...geomIds.entries()].map(([k, v]) => `${k}→${v.length}处(${v.join(',')})`).join(' ')
);
/*
* ★★ 这三条**第一版写错了**,记在这里以免重蹈(变异实测发现的)。
*
* 第一版是全仓 `any()`:
* /animateTo\(/.test(allHarmony) /durMorph/.test(allHarmony) /easeRise/…
* 变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
* ——**三条全绿**。因为全仓**别处**还有这些名字,所以"删掉这一处"永远命中不了。
*
* 第二版改成"逐站点看邻域",又**假红**:`geometryTransition(id)` 绑在
* **组件树**上,而 `animateTo` 写在**另一个方法**里 —— 文本邻域取不到隔壁的方法。
*
* ⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
* 把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
* (`animateTo` + 时长 + 曲线都在里面),调用点只剩"我要改哪个状态"。
* 这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
* 把"每处都得记得写"收敛成"一处定义、处处引用")。
*
* 现在判据钉的就是**这个结构**,逐条都能被变异打红:
*/
const MOTION = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Motion.ets'));
const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'));
check(
'鸿蒙|morph 只有**一个**入口且它内部有 animateTo(时长/曲线同处)',
/static morph\(ui: UIContext, mutate: \(\) => void\): void \{/.test(MOTION) &&
/ui\.animateTo\(/.test(MOTION),
'`Motion.morph` 的形状变了(少了 animateTo 或统一入口)—— ' +
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
'时长/曲线与 animateTo 必须在**同一处**,散开就必然有人漏'
);
/*
* ★★ 2026-09-21 改:从"钉 `duration: Motion.dur(Theme.durMorph)` + `curve: Theme.easeRise`"
* 改为钉**官方弹簧曲线的契约**。
*
* 为什么旧钉法必须改(而不是把字面量改成新字面量):
* 用户指出「webui 是 webui,app 是 app…APP 存在大量系统预制动效,为什么不用?」
* —— 我此前把 WebUI 的 CSS 数值(220ms + cubic-bezier)当成了规格,
* 而那只是它的**带宽约束下的上限**。改用系统弹簧曲线后,
* `duration` 按 SDK 原文**根本不再生效**:
* 「The **duration** parameter does not take effect when springMotion /
* responsiveSpringMotion / interpolatingSpring are configured for **curve**.」
* ⇒ 继续断言"有 duration"等于在钉一个**不再成立的前提**。
*
* 新钉法看**三件真的事**:
* ① 走官方弹簧曲线(`Theme.springResponsive` / `springMotion`);
* ② 经过 `Motion.anim` 那道统一入口(它负责"关动画时连曲线一起换");
* ③ 不自己写 `duration`(弹簧曲线下那是**静默无效**的写法)。
* ③ 单独可变异:把 `Motion.anim(...)` 换回 `{ duration: 220, curve: ... }` ⇒ 红。
*/
check(
'鸿蒙|morph 走**官方弹簧曲线**且经过 `Motion.anim` 统一入口',
/ui\.animateTo\(Motion\.anim\(\s*Theme\.spring/.test(MOTION),
'`Motion.morph` 不再用官方弹簧曲线(或没走 `Motion.anim`)—— '
);
check(
'鸿蒙|弹簧曲线下**不得**自己写 duration(SDK:它不生效)',
!/ui\.animateTo\(\{/.test(MOTION),
'`animateTo` 直接收字面对象 `{ duration: … }` —— 若配的是弹簧曲线,' +
'按 SDK 原文 duration **不生效**;若配的是 cubic-bezier,则丢掉系统预制动效。' +
'两种都不对 ⇒ 必须走 `Motion.anim(spring)`'
);
check(
'鸿蒙|`Motion.anim` 在"减弱动效"时同时换掉曲线(不是只把 duration 折 0)',
/*
* ★ 这里必须断言"换成了**不是** spring 参数的曲线"。
* 第一版写成 `…duration: 0,…curve:` —— 那个 `curve:` 后面接什么都行,
* 于是变异"把 curl: Curve.Linear 换成 curve: spring"**测不出来**
* (实测:变异后仍然 13/13 绿)。
* 判据自己对变异不敏感 = 它实际没在守卫那件事。
*
* 现在钉两件事同时成立:
* ① 减弱分支里存在 `duration: 0`;
* ② 同一分支的 `curve:` **不是** `spring`(即真的换掉了)。
*/
/static anim\(spring: ICurve\): AnimateParam/.test(MOTION) &&
/duration: 0,[\s\S]{0,80}?curve: (?!spring\b)/.test(MOTION),
'只把 duration 折 0 而保留弹簧曲线 ⇒ **动画照放**(弹簧曲线下 duration 无效)' +
'⇒ 静默破掉无障碍开关。必须连曲线一起换回可时长控制的那个'
);
check(
'鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo',
!/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')),
'全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' +
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
);
/*
* ★★ 2026-09-23 改:从「**同文件**共址」改为「按 morph **id** 找驱动」。
*
* 旧判据:`geomFiles ⊆ filesUsingHelper` —— 绑了 `geometryTransition` 的
* 文件自己必须也有 `Motion.morph` 调用。
*
* ★ 为什么它现在必须改(不是判据变宽,是它守的东西变了):
* 一个共享元素转场的 **in/out 两端天然在两处**——`compose-morph` 的 out 端
* 是列表里的加号(`MainPage.ets:1760`),in 端是全屏写信页。用户要求
* 「把组件按页面封装以便与 WebUI 一一对应」,于是 in 端随写信页搬进了
* `NavDestinations.ets`,而**驱动(唯一的 `Motion.morph`)合理地仍留在
* `MainPage.ets:1512`** —— 它就在 out 端旁边。
* ⇒ 旧判据报 `NavDestinations.ets`「没走统一入口」,而事实是它**根本不需要**
* 自己驱动:它只是终点。这是判据的代理失效,不是代码退化。
*
* ★ 新判据仍按原先的**安全目标**:每个 morph 都必须由 `Motion.morph` 驱动
* (不能谁自己写 `animateTo`,否则就漏掉「时长/曲线/reduced-motion 折 0」)。
* 改问的是:**每个 id 的所有绑定文件里,至少有一个含 `Motion.morph`**。
* 这样:
* · 驱动与它绑的那一端同文件 ✓(仍是原来的意图);
* · 另一端随组件搬家不会误报 ✓;
* · 若有人新绑一个 id 却**哪里都没驱动**,或把驱动换成裸 `animateTo`,仍会红 ✓
* —— 没有变宽:它照旧要求每个 id 有且只有一处统一入口的驱动。
*/
const idHasDriver = new Map(); // id -> 是否有任一绑定文件含 Motion.morph
for (const [id, files] of geomIds) {
idHasDriver.set(id, files.some(f => {
const h = hs.find(x => x.name === f);
return h !== undefined && /Motion\.morph\(/.test(h.src);
}));
}
const undrivenIds = [...idHasDriver.entries()].filter(([, ok]) => !ok).map(([id]) => id);
check(
'鸿蒙|每个共享元素转场 id 都有 `Motion.morph` 驱动(不是只绑不驱、或自己写 animateTo)',
undrivenIds.length === 0,
'这些 id 的两端都不在含 `Motion.morph` 的文件里 —— 没人用统一入口驱动它:' +
undrivenIds.join(' ') +
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
);
/*
* ★★ 2026-09-21 改:从「页面里不再直接出现 `Theme.durMorph`」改为
* 钉**弹簧曲线令牌**不得下沉到页面。
*
* 原来那条守的是 `durMorph` —— 令牌已删(morph 改用 `springResponsive`,
* 弹簧曲线下 duration 不生效,保留那是误导)。
*
* ★ 我第一版改成了"所有曲线令牌都不得在页面出现" —— **过宽**,实测当场红:
* · `easeOutSoft` 在页面里是**正当**的:控件状态变色
* (`.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })`)
* 本来就该按控件写,它没有"统一入口"也不需要弹簧(微交互要短、可控)。
* · `easeRise` 在 PageTransitionEnter/Exit 里也是正当的:那是**每页自己的**
* 路由转场声明,只能写在页面里。
*
* ⇒ 真正必须禁止的是**弹簧曲线**:它们让 `duration` 失效,必须走
* `Motion.effectAnim` / `Motion.anim`(那里统一处理"关动画时连曲线一起换")。
* 页面里直接写弹簧曲线 = 绕开了无障碍开关。
*/
check(
'鸿蒙|页面里不得直接写**弹簧曲线**(必须走 Theme 的过渡方法或 Motion)',
!/Theme\.(springIn|springResponsive)\b/.test(hs.map(h => h.src).join('\n')),
'弹簧曲线出现在**页面**里 = 绕开了 `Motion.effectAnim`(它负责"关动画时连曲线一起换")\n' +
'⇒ 系统里开了"减弱动效"时动画照放 —— 静默破掉无障碍开关'
);
/*
* ★★ 2026-09-21 删除两条陈旧断言:
* · 「页面里不再直接出现 `Theme.durMorph`」
* · 「`Theme.durMorph` 确实存在且 = 220」
*
* 令牌**已被删除**(morph 改用 `responsiveSpringMotion`,弹簧曲线下
* duration 不生效,保留那是误导)⇒ 这两条现在守的是一个不存在的东西。
* 上面那条改为钉**曲线令牌**的"不得下沉到页面",与原来的意图同一件事。
*/
finish('动画盘点');