跨端对齐:授权栏 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:
@ -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('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');
|
||||
|
||||
Reference in New Issue
Block a user