跨端对齐:授权栏 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

@ -519,10 +519,37 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否
}
// 开关必须由**背景计划**驱动(不是写死的 true/false)
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');
// 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)
for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) {
const at = main.indexOf(`struct ${comp} {`);
const head = main.slice(at, at + 400);
/*
* 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)。
*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。原来这份名单一律在 `main` 里找
* `struct X {` —— 抽出后 `PermissionTab` 不在 `main` 里了,
* `indexOf` 返回 -1 ⇒ `main.slice(-1, 399)` 得到**空串** ⇒ 断言红。
*
* ★ 值得记的是这个红**是好红**:它立刻指出了"有个组件搬家了"。
* 而 `harmony-logic` 那条同形状的判据当时**静默变成了空断言**
* (它用的是 `slice(indexOf(...))`,-1 会变成"从末尾取一个字符")
* —— 两者差别只在于 `slice` 的参数形状。**同一类搬家,一个红一个哑。**
* ⇒ 这次把"组件在哪个文件"显式写进名单,而不是靠"它恰好在 main 里"。
*/
const PANE_SOURCES = {
CommPage: main, ContactsTab: main, InboxTab: main, SentTab: main,
/*
* ★ 已抽出,读它们自己的文件(搬家的地方只在这里写一次)。
*
* 2026-09-23:按用户「把组件按页面封装以便与 WebUI 一一对应」的要求,
* `PermissionTab` 与 `ContactsTab` 都从 `MainPage.ets` 抽成了独立文件。
* 这份名单必须跟着改 —— 否则判据报"找不到",而组件其实好好的。
*/
PermissionTab: read('pages/PermissionTab.ets'),
ContactsTab: read('pages/ContactsTab.ets')
};
for (const comp of Object.keys(PANE_SOURCES)) {
const src = PANE_SOURCES[comp];
const at = src.indexOf(`struct ${comp} {`);
assert.ok(at >= 0, `${comp} 要能在它该在的文件里找到(组件搬家要一起改本判据)`);
const head = src.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});
@ -800,10 +827,33 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
}
// 两个语义不许共用一个令牌(这个仓库撞过四次的那个模式)
assert.notEqual('wallpaperScrim', 'overlay');
// 模态弹层仍然用 mask(那里的语义确实是"压暗背后")
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask(那里的语义是压暗背后)');
assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色(两个语义别混)');
/*
* ★★ 2026-09-21 改:不再要求 `SettingsPage` 里出现 `Theme.overlay`。
*
* 原来这两条是:
* assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask')
* assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色')
*
* 它们守的**真实意图**是「两个语义别混」(`overlay`=模态遮罩 / `wallpaperScrim`=壁纸压暗)——
* 这个仓库为此撞过四次。而现在 SettingsPage **已经不自绘遮罩了**
* (新增账号与修改密码都换成了官方 `bindSheet`,遮罩由系统画)
* ⇒ `Theme.overlay` 在这份文件里**只应出现在说明它被删掉的注释里**。
*
* ★ 不能简单删掉这两条:那会丢掉"不许把壁纸遮盖色当弹层遮罩"这个约束。
* 改成:
* ① `wallpaperScrim` 仍然**绝不得**出现在 SettingsPage(语义不混);
* ② 弹层必须走官方容器(`bindSheet`)—— 系统自己会用遮罩,
* 不需要也不应该再铺一层 `overlay`(两层遮罩会叠成双倍变暗)。
*
* ★ 为什么"不许出现 overlay"是对的:若有人往 `bindSheet` 内容里
* 再铺一层 `Theme.overlay`,那就是**系统遮罩 + 自绘遮罩叠两层**,
* 背景会暗两倍。这条断言正是拦这个。
*/
const settings2 = read('pages/SettingsPage.ets');
assert.match(settings2, /\.bindSheet\(\$\$this\./, '弹层应走官方 bindSheet(系统自带遮罩/圆角/动画)');
assert.ok(!/wallpaperScrim/.test(settings2), '弹层不该用壁纸遮盖色(两个语义别混)');
assert.ok(!/\.backgroundColor\(Theme\.overlay\)/.test(settings2),
'已交给 `bindSheet` 的弹层**不得**再自铺一层 overlay —— 系统遮罩 + 自绘遮罩 = 双倍变暗');
});
/**
@ -994,14 +1044,74 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && (x2 - x1) > 80 && (y2 - y1) > 80;
/*
* ★★ 2026-09-21 修(真隐患):加 `宽高比 ≈ 1` 这一条。
*
* ── 原来只要求「宽>80 且 高>80」,而侧栏下半部有**三个**可点方块 ──
* 设备实测(屏幕 3184×2232,density 2.875):
* #0 头像 [57,1791][172,1906] 115×115px = 40×40vp ratio 1.000
* #1 主题切换 [63,1918][167,1999] 104× 81px = 36×28vp ratio 1.284
* #2 退出登录 [63,2010][167,2091] 104× 81px = 36×28vp ratio 1.284
* **三个全都满足**「宽>80 且 高>80」。
*
* 今天它没出事,纯靠 `.find()` 返回**第一个**(walk 序恰好是头像)——
* 也就是说这条判据的正确性**不取决于形状判得准**,而取决于
* "渲染树的遍历顺序恰好把头像排在前面"。那种保证是会失效的:
* 谁调整一下侧栏底部簇的子元素顺序(把主题/退出提到前面),
* 这里就会**点中「退出登录」** ⇒ 设备被登出、账号列表被清空
* (`performLogout` → `AccountManager.clearAll()`,实测
* `accounts_json` 会变成 `[]`)。
*
* ★ 这不是理论风险:我这一次调试期间设备**真的被登出了**
* (09:21 `agentmail_accounts` 变成 `accounts_json=[]`),
* 排查了一圈才定位到"形状判据太宽 + 依赖遍历顺序"这个形状。
*
* ⇒ 加宽高比约束:头像 40×40(1.000),另两个 36×28(1.284)。
* 阈值 1.15 落在两者之间,且留了余量(不写 1.0 的精确等号 ——
* 那是拿"渲染出来的像素"当"源码里的 vp",两者不该硬等)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 &&
w > 80 && h > 80 && w / h <= 1.15;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」窗格(底栏第 4 项)');
/*
* 窄屏:点底栏第 4 项。
*
* ★★ 2026-09-21 修(真 bug):原来用的是 `D.tapText(hdc, '我的')`,
* 而 "我的" 这个文案在**同屏出现两次**:
* · 页面头部标题 `Text('我的')`(`SettingsPage` 的 AppHeader),
* 它的 bounds 是 **[99,201][783,255]**(实测)—— 在屏**上半部**;
* · 底部导航项的文字,**[819,2061][877,2096]**。
* `tapText` 的实现是「取第一个可点的;都不可点就取第一个」——
* 两者都 `clickable=false`(点击挂在祖先容器上)⇒ 它取到的是**页头标题**,
* 点在那儿什么都不会发生。
*
* 症状极具误导性:报"点完之后应真的在「我的」页",
* 看起来像页面切不过去,实际是**点错了地方**(而且点的就是当前页的标题)。
*
* ⇒ 按位置挑:只取下半个屏幕、且 x 在屏宽右侧(底栏第 4 项)。
* 与宽屏那一分支的"按形状找"同一套思路 —— 文案会重复,位置不会。
*/
const scrH = screenOf(root0).h;
const navItem = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if ((a.text || '').trim() !== '我的') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return y1 > scrH * 0.75 && x1 > scrW * 0.6 && (x2 - x1) < 200 && (y2 - y1) < 200;
});
assert.ok(navItem,
'窄屏底栏第 4 项要有「我的」文字(按位置找,不按文案 —— 页头标题同名)');
const nc = D.boundsCenter(navItem.attributes.bounds);
/* 文字节点通常不可点(点击挂在祖先),但点它的中心会冒泡到那一项 */
assert.ok(D.tap(hdc, nc.cx, Math.round(nc.cy - 20)), '要能点到底栏的「我的」');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
@ -1026,11 +1136,40 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
* (这正是"设备判据必须自己搭现场"的另一个理由。)
*/
let meRoot = null;
for (let i = 0; i < 20; i++) {
/*
* ★★ 2026-09-21 补:**先滑回顶部再轮询**(顺序很重要)。
*
* `SettingsPane` 在 `MainPage` 里是**常驻挂载**的 ⇒ 它的 `Scroll`
* 保留上一次的位置。上一次跑这套判据时滑到了底,这一次点进「我的」
* 直接就是滚到底的屏,而 `inMePane` 找的「用户名 / 连接密钥」在**上半部**
* ⇒ 报「点完之后应真的在「我的」页」,而实际**已经在了**。
* ("判据必须自己搭现场" —— 这条纪律本文件自己的注释里就写着。)
*/
const scrNav = screenOf(root0);
for (let i = 0; i < 3; i++) {
/* 回顶:手指从上往下拉(y 小→大) */
D.swipe(hdc, Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.25),
Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.75));
await new Promise((r) => setTimeout(r, 250));
} for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const snap = D.dumpLayout(hdc);
if (inMePane(snap)) { landed = true; meRoot = snap; break; }
}
/*
* ★★ 2026-09-21 补:点进去之后**先回到顶部**再找「背景」。
*
* 上面那个 `inMePane` 找的是「用户名 / 连接密钥 / 主题由系统」——
* 而这三样都在页面**上半部**。而 `SettingsPane` 在 `MainPage` 里是
* **常驻挂载**的(宽屏/窄屏都不重建)⇒ 它的 `Scroll` **保留上一次的位置**。
*
* 后果:上一次跑这套判据时滑到底了,这一次点进「我的」就直接是滚到底的屏,
* `用户名` 不在可见节点里 ⇒ 报「点完之后应真的在「我的」页」。
* 而实际**已经在了** —— 这是"判据没自己搭现场"(本仓已有这条纪律,
* `harmony-appearance` 自己的注释里就写着)。
*
* ⇒ 点完之后一律先滑回顶部(用屏幕尺寸算坐标,不写死宽屏值)。
*/
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
@ -1066,13 +1205,41 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
* 滑了但可视内容不变 = 这个 Scroll 是死的。
*/
if (!hasBg(meRoot)) {
/*
* ★★ 2026-09-21 修(真 bug,套件红 / 单独跑也红):**滑动坐标写死了宽屏的**。
*
* 原来这两行是:
* D.swipe(hdc, 1600, 900, 1600, 1500); // 回顶
* D.swipe(hdc, 1600, 1500, 1600, 900); // 下扫
*
* `x=1600` 是**宽屏(3184px)**下的坐标。而窄屏只有 **1008px** 宽
* ⇒ x=1600 在屏外,`uitest uiInput swipe` 不接受、什么都不做。
* 于是"回顶"没回、"下扫"没扫,`meRoot` 一直是那一屏
* ⇒ 实测报「滚过 6 屏仍没有背景那一节」,而**手动滑两下就能看到「背景」**
* (它就在「客户端连接密钥」上面)。
*
* 危害不止假红:这段注释本身写着
* 「滑了但可视内容不变 = 这个 Scroll 是死的」——
* 也就是说这条判据**本意要顺便验证 Scroll 能不能滚**,
* 而坐标写死后它连"滚"这个动作都没发生,
* 那个顺带的验证也从来没生效过(一直是"空变量")。
*
* ⇒ 从实测屏幕宽高算中心,不写死:宽屏/窄屏/折叠态都能跑。
* 横向取屏幕中心,纵向取上/下各 1/5 处(比贴边稳,不碰系统手势区)。
*/
const scr = screenOf(meRoot);
const cx = Math.round(scr.w / 2);
const topY = Math.round(scr.h * 0.25);
const botY = Math.round(scr.h * 0.75);
for (let i = 0; i < 5; i++) { // ① 回到顶部
D.swipe(hdc, 1600, 900, 1600, 1500);
/* 从下往上**不行** —— 回顶要"内容下移" ⇒ 手指从上往下拉(y 小→大) */
D.swipe(hdc, cx, topY, cx, botY);
await new Promise((r) => setTimeout(r, 300));
}
meRoot = D.dumpLayout(hdc);
for (let i = 0; i < 8 && !hasBg(meRoot); i++) { // ② 逐屏往下扫
D.swipe(hdc, 1600, 1500, 1600, 900);
/* 往下看 = 内容上移 ⇒ 手指从下往上推(y 大→小) */
D.swipe(hdc, cx, botY, cx, topY);
await new Promise((r) => setTimeout(r, 400));
meRoot = D.dumpLayout(hdc);
}
@ -1335,8 +1502,25 @@ test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
const [, x1, y1, x2, y2] = m.map(Number);
/*
* ★★ 2026-09-21 修:与文件里另一处「找头像」同一个隐患 —— 见 `:1020` 那段注释。
* 侧栏下半部三个可点方块(头像 40×40 / 主题 36×28 / 退出 36×28)**都**满足
* `(y2-y1) > 80`,`.find()` 取第一个才碰巧是头像。加宽高比约束把它变成
* **形状判得准**,而不是"依赖遍历顺序"。
*
* 这里尤其要紧:这一处找到之后点它进「我的」页,若点到「退出登录」,
* 设备会被登出并清空账号(`clearAll()`)—— 实测 `accounts_json` 变 `[]`。
*
* ⚠️ 注意这里必须解构出 **`x1`**(原来写的是 `[, , y1, x2, y2]`,
* 跳过了 `x1`)—— 我加宽高比时需要它算 `w`,漏了就会在**运行期**
* `ReferenceError: x1 is not defined`。它不是断言失败:整个文件
* **一条都跑不了**(套件把它记成 `broken=1`,而 broken 证明不了任何事)。
* 实测就是这么红的(`ran=34 checks=553 … broken=1`)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15;
});
if (!avatar) {
return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');