跨端对齐:授权栏 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,产物待重构建)
This commit is contained in:
2026-09-24 10:10:32 +08:00
parent 487c1c222b
commit 65de1c3884
31 changed files with 4147 additions and 1684 deletions

View File

@ -13,7 +13,7 @@ import { readdirSync } from 'node:fs';
import { code, prose, stripComments } from './lib/read.mjs';
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk,
noteBusySkip, noteRan, busyLimit, busyStreak,
noteBehavioralRan, behavioralRanAt, ourBundle } from './lib/harmony-device.mjs';
noteBehavioralRan, behavioralRanAt, ourBundle, backToMain } from './lib/harmony-device.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
@ -283,7 +283,22 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* 让位加在各滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
* 与 WebUI 同构:`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面。
*/
const contentOffsets = [...main.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
/*
* ★★ 2026-09-23 修:数让位时要把**抽出去的组件文件**一起算。
*
* 用户要求「按页面封装组件以便与 WebUI 一一对应」,于是 `PermissionTab`
* 与 `ContactsTab` 搬到了自己的文件里。原来只在 `main`(= `MainPage.ets`)
* 上数 ⇒ 从 5 处落到 2 处而红。
*
* ★ 这条判据**问的是"各滚动容器有没有在末尾让位"**,不是"它们在哪个文件"。
* 所以把抽出去的文件并进搜索面 —— 与 `harmony-logic` 的 `PAGE_SOURCES`
* 同一个做法(显式名单,不做"扫 pages/ 全部"那种过宽的作用域)。
*/
const navSrc = [main,
code(join(HARMONY_ETS, 'pages/PermissionTab.ets')),
code(join(HARMONY_ETS, 'pages/ContactsTab.ets'))
].join('\n');
const contentOffsets = [...navSrc.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
assert.ok(contentOffsets.length >= 5,
`各滚动容器都要在**内容末尾**让位(至少 5 处:收件箱/发件箱/授权/联系人×2/日历),实际 ${contentOffsets.length} 处`);
assert.ok(!/\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main),
@ -483,13 +498,14 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
* ⇒ `Motion.dur(Theme.durBase)` 这种"包一层来蒙混"**照样红**。
* (即:放宽的是**包装**,不是**令牌**。混起来的写法不会因为这次放宽而漏。)
*/
assert.match(body, /duration:\s*(?:Motion\.dur\()?Theme\.durRise/,
'★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' +
'写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。' +
'(外面允许包一层 `Motion.dur(...)`:那是无障碍动效开关,包了也仍是在引用 `durRise`。)');
assert.match(body, /curve:\s*Theme\.easeRise/,
'★ `paneRiseIn` 里曲线必须引用 `Theme.easeRise`(= rise-in 那条 0.22,0.61,0.36,1)。' +
'写成 `Theme.easeOutSoft` 是**另一根**曲线(那是给 transition 用的)。');
assert.match(body, /Motion\.effectAnim\(\s*Theme\.spring/,
'★ `paneRiseIn` 里必须走 `Motion.effectAnim(Theme.springIn)`。\n' +
' —— 2026-09-21 改用**系统弹簧曲线**(用户:「APP 存在大量系统预制动效,为什么不用?」)。\n' +
' 原来的断言钉的是 `duration: Motion.dur(Theme.durRise)` + `Theme.easeRise`;\n' +
' 弹簧曲线下 **duration 按 SDK 原文不生效**(`AnimateParam.duration`),\n' +
' 继续钉"有 duration"等于在钉一个不再成立的前提。\n' +
' ★ 仍然保留原判据的**区分力**:走 `Motion.effectAnim`(而不是自己写 curve)\n' +
' 是硬要求 —— 它负责"关动画时连曲线一起换",自己写会静默破掉无障碍开关。');
assert.ok(!/Theme\.durBase/.test(body),
'★ paneRiseIn 里不得出现 `durBase`(180ms 是壁纸淡入的时长,不是面板入场)');
assert.ok(!/Theme\.easeOutSoft/.test(body),
@ -607,12 +623,42 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不
* 名单显式列出,加了新的浮层就要一起改 —— 与 GLASS_REGISTRY 同一套纪律。
*/
const targets = [
{ file: 'pages/MainPage.ets', needle: 'if (this.showAccountPicker && this.accountList.length > 1) {', why: '账号选择器下拉' },
{ file: 'pages/SettingsPage.ets', needle: 'if (this.showAddDialog) {', why: '新增账号弹层' },
{ file: 'pages/AdminUsersPage.ets', needle: 'if (this.showCreate) {', why: '新建用户表单' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showReplyBox) {', why: '回复弹层' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' }
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' },
/*
* ★★ 2026-09-21 补:**两个地址候选下拉**。
*
* 它们一直是"条件挂载的浮层",却**从来不在上面这份名单里** ——
* 于是判据绿着看它们硬弹了很久。这正是 `Theme.menuIn()` 注释里
* 写着要覆盖"账号选择器、**下拉候选**"、而候选那一半从没兑现的原因:
* 没人把候选登记进这份名单,令牌自然也不会被挂上去。
*
* ── 怎么发现的 ──
* 不是靠人眼巡视,是"反查死代码"反出来的:两个账号选择器换成官方
* `bindMenu` 后 `menuIn()` 变成零调用,去问"要不要删掉这个死方法"时
* 拿它的注释对了一遍实际用法,才发现候选那一半是空的。
*
* ★ 这份名单是**枚举**("所有会被条件挂载的浮层")。枚举漏一项,
* 那一项就永久脱离守卫 —— 而且不会报任何错。
* WebUI 侧同一处挂着 `animate-menu-in`(`AddressInput.tsx:261`),
* 所以这不是"我们选择不做",是真实漏项。
*/
{ file: 'pages/ComposePage.ets',
needle: 'if (this.suggestOpen && this.suggestItems.length > 0) {',
why: '写信页地址候选下拉' },
{ file: 'pages/MailDetailPage.ets',
needle: 'if (this.fwdSuggestOpen && this.fwdSuggestItems.length > 0) {',
why: '转发弹层的地址候选下拉' }
];
/*
* ★★ 2026-09-21 改:`MainPage` 账号选择器与 `SettingsPage` 新增账号弹层
* 都已换成官方容器 —— 不再在 `if` 里自绘,所以从这份名单里移出,
* 改由下面 `migrated` 那份断言守"它们必须真的走官方容器"。
*
* ★ 不能直接删掉它们(那会留下空洞:改回自绘且忘挂过渡 ⇒ 全绿),
* 所以"换过去的那几个"单独有一份名单。
*/
const missing = [];
for (const t of targets) {
const src = code(join(HARMONY_ETS, t.file));
@ -638,6 +684,49 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不
if (!/\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/.test(window)) missing.push(t.why);
}
assert.deepEqual(missing, [], `这些位置会硬弹:${missing.join('、')}`);
/*
* ★★ 2026-09-21 新增:两个**换成官方容器**的弹层。
*
* 它们不再需要 `transition` —— 出入场由系统容器负责(那是系统的
* "半模态转场",比手写的 `paneRiseIn` 多了拖拽条、下滑关闭、
* 遮罩浓度、键盘避让)。但它们**仍然必须"有动画"**,
* 所以不能从名单里简单地删掉 —— 要换成断言"它走的是官方容器"。
*
* 否则会出现这个空洞:把 `bindSheet` 改回手写遮罩+固定高度、
* 且**忘了**挂 transition ⇒ 上面那个列表里已经没有它了 ⇒ 判据全绿。
*/
/*
* ★★ 2026-09-21 补:needle 从**字面串**改成**正则**。
*
* 原来写的是 `.bindMenu(this.showAccountPicker` —— 而我随后把两处菜单
* 换成了 **API 11 的 `bindMenu($$isShow, content)`**(显隐作为显式参数,
* `$$` 两向绑定让"点外部关闭"能自动回写状态)。
* `$` 一加,字面 needle 就不再匹配 ⇒ 判据报「它会硬弹」,
* 而它**明明走的是官方容器**。
*
* 这正是这类判据的典型老化方式:`needle` 钉的是**某个版本的写法**,
* 而它想守的是"这类东西走官方容器"。写法演进(API 7 → API 11)时,
* 它就从"守具"变成"绊脚石"。
*
* ⇒ 正则里把 `$$` 写成可选(`\$\$?`),两种重载都算数。
* 这样以后无论用哪一版绑定,判据都还在守**同一件事**。
*/
const migrated = [
{ file: 'pages/SettingsPage.ets', re: /\.bindSheet\(\$\$?this\.showAddDialog/, why: '新增账号弹层' },
{ file: 'pages/MailDetailPage.ets', re: /\.bindSheet\(\$\$?this\.showThread/, why: '对话树弹层' },
{ file: 'pages/MainPage.ets', re: /\.bindMenu\(\$\$?this\.showAccountPicker/, why: '账号选择器下拉' },
{ file: 'pages/ComposePage.ets', re: /\.bindMenu\(\$\$?this\.showAccountPicker/, why: '写信页账号选择' }
];
const lostAnim = [];
for (const t of migrated) {
const src = code(join(HARMONY_ETS, t.file));
if (!t.re.test(src)) lostAnim.push(t.why);
}
assert.deepEqual(lostAnim, [],
`这些弹层既不是官方容器也没有 transition,会硬弹:${lostAnim.join('、')}\n` +
' 两选一:要么用官方 `bindSheet`/`bindContentCover`(推荐,自带系统转场),' +
'要么回手写遮罩 + `.transition(Theme.paneRiseIn())`。两者都没有 = 硬弹。');
});
test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—— 一块实心白就是"割裂"', () => {
@ -954,17 +1043,32 @@ test('★ 滚动列表上下边缘渐隐:所有列表容器都挂 fadingEdge
'渐隐高度取 12vp:与 WebUI 的 `min(12px, 10%)` 同量级、也接近列表内边距(10vp)。' +
'改这个数要有理由(它直接决定"切得有交代"的观感强度)');
// 五个列表容器:收件箱 / 发件箱 / 授权 / 联系人卡片 / 联系人列表
const lists = main.match(/List\(\{[^)]*\}\)\s*\{/g) ?? [];
assert.ok(lists.length >= 5, `MainPage 里应当有 5 个列表容器,实际 ${lists.length} 个(判据前提要复核)`);
/*
* 五个列表容器:收件箱 / 发件箱 / 授权 / 联系人卡片 / 联系人列表。
*
* ★★ 2026-09-23 修:与上面那条让位计数同一个成因 —— `PermissionTab` 与
* `ContactsTab` 已从 `MainPage.ets` 抽成独立文件,这里只在 `main` 上数
* 就只剩 2 个。改用上面那份 `navSrc`(已并入抽出的文件)。
*/
/*
* 与上一条同一个搜索面:把从 `MainPage.ets` 抽出去的组件文件也并进来
* (`PermissionTab` / `ContactsTab` 已各自成文件)。
*/
const navSrc = [main,
code(join(HARMONY_ETS, 'pages/PermissionTab.ets')),
code(join(HARMONY_ETS, 'pages/ContactsTab.ets'))
].join('\n');
const lists = navSrc.match(/List\(\{[^)]*\}\)\s*\{/g) ?? [];
assert.ok(lists.length >= 5,
`应当有 5 个列表容器(收件箱/发件箱/授权/联系人卡片/联系人列表),实际 ${lists.length} 个(判据前提要复核)`);
const fades = main.match(/\.fadingEdge\(/g) ?? [];
const fades = navSrc.match(/\.fadingEdge\(/g) ?? [];
assert.equal(fades.length, lists.length,
`每个列表容器都要挂 fadingEdge(实际 ${fades.length} 个 / ${lists.length} 个列表)。` +
'漏掉的那个就是"上下还是硬截断" —— 用户 2026-09-17 原话。');
// 每个 fadingEdge 都要带上显式长度,且走 LengthMetrics.vp(不是裸数字)
const withLen = main.match(/\.fadingEdge\(true,\s*\{\s*fadingEdgeLength:\s*LengthMetrics\.vp\(LIST_FADE_LENGTH\)\s*\}\)/g) ?? [];
const withLen = navSrc.match(/\.fadingEdge\(true,\s*\{\s*fadingEdgeLength:\s*LengthMetrics\.vp\(LIST_FADE_LENGTH\)\s*\}\)/g) ?? [];
assert.equal(withLen.length, fades.length,
`每个 fadingEdge 都要写 \`{ fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }\`。` +
'不写长度会落回默认 32vp —— 在 58px 高的小容器里会把内容洗白' +
@ -1277,6 +1381,35 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
`(连续第 ${streak}/${busyLimit()} 轮,超过就要变红)`);
}
noteRan(CRIT); // 真跑成了 ⇒ 清掉连续计数
/*
* ★★ 2026-09-21 补:**先把应用摆回主界面再量**。
*
* ── 真 bug(套件里红、单独跑绿,查了半天的那个)──
*
* 本条原来直接 `dumpLayout` 量当前界面。它假设"此刻在主界面",
* 但**这个假设不成立**:`harmony-admin` 的设备判据会点进「管理」子页
* (`AdminUsersPage`),而那是个 **push 在 MainPage 之上的路由** ——
* 底栏/侧栏在那页上根本不存在。它跑完**不返回**,
* 于是本条的 dump 量到的是**管理页**:
*
* 实测:`navItems.length === 0`,报
* 「宽屏左侧栏要渲染出 ≥1 个可点的导航项(实际 0)」
* —— 侧栏明明好好的,只是量错了页面。
*
* `launchOurApp` 治不了这个(它只把应用切到前台,不弹栈)——
* 这正是 `backToMain` 存在的理由,它的注释里记着同一个根因,
* 只是本条的属主当时没顺手用上。
*
* ★ 判据自己建立起始状态,别继承上一条留下的现场。
* (`cross-client-gesture:309` 早就这么写了 —— 它今天就是绿的。)
*/
const fgNow = foregroundBundle(hdc);
if (fgNow === ourBundle()) {
/* 只有我们在前台时才按返回键 —— 别去动别人会话的界面 */
assert.ok(await backToMain(hdc),
'要能把应用摆回主界面(侧栏/底栏可见)才算量得准;' +
'上一条判据可能把它留在了子页上(那是判据间干扰,不是功能缺失)');
}
const root = dumpLayout(hdc);
assert.ok(screenHeightOf(root) > 100,
`要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`);
@ -1606,3 +1739,97 @@ test('★ 登录/退出必须用同一个原语(`replaceUrl`)—— 只改
assert.ok(!/pushUrl\(\{[^}]*MainPage[^}]*\}/.test(ok),
'★ 自检失败:正确写法(replaceUrl)被误判为违规');
});
test('★ 判据自检:设备判据「按形状找元素」必须排除会**改变设备状态**的邻居', () => {
/*
* ★★ 2026-09-23 加(真隐患 —— 调试期间设备**真的被登出过一次**)。
*
* ── 形状 ──
*
* 三处设备判据都用同一套"按形状找宽屏侧栏头像"的判据:
* `harmony-admin.test.mjs`(宽屏支)、
* `harmony-appearance.test.mjs`(两处)
*
* 原条件只有 `clickable && x2 <= scrW*0.08 && y1 > scrH*0.5 && (y2-y1) > 80`。
* 而侧栏下半部有**三个**可点方块 —— 设备实测(3184×2232,density 2.875):
*
* 头像 115×115px = 40×40vp ratio 1.000
* 主题切换 104× 81px = 36×28vp ratio 1.284
* 退出登录 104× 81px = 36×28vp ratio 1.284
*
* `(y2-y1) > 80` **三者全中**。它今天没出事,纯靠 `.find()` 返回 walk 序的
* **第一个**(恰好是头像)—— 也就是这条判据的正确性**不取决于形状判得准**,
* 而取决于"渲染树遍历顺序恰好把头像排在前面"。
*
* 后果不是"判据红",是**判据造成破坏**:
* `performLogout` → `AccountManager.clearAll()` ⇒ 设备被登出、账号清空
* (实测 `accounts_json` 变成 `[]`,要手工重建 preferences 才恢复)。
*
* ── 为什么值得单独一条判据 ──
*
* 这族问题的形状是:**判据的作用域比它声称的宽**,而"多出来的那部分"
* 恰好是有破坏性的动作。靠人记得"找头像时要排除退出按钮"不成立 ——
* 这次三个人都没想到。所以把它变成**结构约束**。
*
* ★ 判法用**显式名单 + 语法边界**,不做"通用模式扫描":
* 我第一版写了个扫描器,用固定字符窗口(900)判断约束在不在,
* 而**注释把 `w / h` 推出了窗口** ⇒ 对刚修好的代码假红。
* 那正是我在 `harmony-2in1` 里刚踩过的同一个坑
* ("拍脑袋的固定窗口会随时间失效")。名单是显式的,与
* `GLASS_REGISTRY`/弹层名单同一套纪律(加到第四处就一起改)。
*/
const LOOKUPS = [
{ file: 'test/harmony-admin.test.mjs', why: '宽屏侧栏头像(进「我的」页)' },
{ file: 'test/harmony-appearance.test.mjs', why: '宽屏侧栏头像(外观页)' }
];
const bad = [];
for (const t of LOOKUPS) {
/*
* 取该文件里"按位置+尺寸挑可点元素"的那段条件,**按 return 语句划边界**
* (不是按字符数)。`code()` 剥注释,所以判的是代码本身。
*/
const src = code(join(HERE, '..', t.file));
/*
* 取"过滤回调"的**整体**:从含 `clickable !== 'true'` 的那一行起,
* 到该回调的收尾 `})` 为止。
*
* ★ 不能用 `([\s\S]*?)return [^;]*;`:那是**非贪婪**的,
* 会停在回调里第一个 `return`(`if (!m) return false;`)上 ——
* 于是真正的过滤条件(在最后那个 `return` 里)根本不在窗口内,
* 判据对刚修好的代码假红(我实测撞到了)。
* ⇒ 按**回调结束**划边界,不按 return 划。
*/
const hits = [];
const anchor = /clickable\s*!==\s*'true'/g;
let a;
while ((a = anchor.exec(src)) !== null) {
const end = src.indexOf('});', a.index);
if (end < 0) continue;
hits.push(src.slice(a.index, end));
anchor.lastIndex = end; // 别在同一段里重复命中
}
if (hits.length === 0) {
bad.push(`${t.file}: 找不到「${t.why}」的过滤条件(改结构要一起改本判据)`);
continue;
}
for (const body of hits) {
/*
* 必须同时有 ① 宽高比(把「退出登录」那个扁矩形排除掉的那一条)
* 和 ② 位置/尺寸约束仍在(说明没被整个换掉)。
*/
const hasRatio = /w\s*\/\s*h|width\s*\/\s*height|aspectRatio/.test(body);
const hasPos = /x2\s*<=|y1\s*>/.test(body);
if (!hasRatio) {
bad.push(`${t.file} 的「${t.why}」缺少**宽高比**约束 —— ` +
'侧栏底部「头像(40×40, ratio 1.000) / 主题切换(36×28, 1.284) / 退出登录(36×28, 1.284)」' +
'三个都满足「够大、在左侧下半部」,`.find()` 只靠遍历顺序才取到头像;' +
'顺序一变就会点到**退出登录** ⇒ `clearAll()` ⇒ 设备被登出、账号清空');
}
if (!hasPos) {
bad.push(`${t.file} 的「${t.why}」缺少位置约束 —— ` +
'那就不再是"侧栏头像"的判据了(会挑到页面上任意地方的大方块)');
}
}
}
assert.deepEqual(bad, [], `★ 按形状找元素的判据必须能排除破坏性邻居:\n ${bad.join('\n ')}`);
});