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

@ -200,12 +200,62 @@ const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme
check(
'鸿蒙|morph 只有**一个**入口且它内部有 animateTo(时长/曲线同处)',
/static morph\(ui: UIContext, mutate: \(\) => void\): void \{/.test(MOTION) &&
/ui\.animateTo\(/.test(MOTION) &&
/duration:\s*Motion\.dur\(Theme\.durMorph\)/.test(MOTION) &&
/curve:\s*Theme\.easeRise/.test(MOTION),
'`Motion.morph` 的形状变了(少了 animateTo / 时长令牌 / 曲线)—— ' +
/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(
@ -215,33 +265,79 @@ check(
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
);
/* 每一个绑了 geometryTransition 的组件,它**同一文件**里必须有 Motion.morph 调用 */
const geomFiles = new Set();
for (const h of hs) {
if (/geometryTransition\(/.test(h.src)) geomFiles.add(h.name);
/*
* ★★ 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 filesUsingHelper = new Set();
for (const h of hs) {
if (/Motion\.morph\(/.test(h.src)) filesUsingHelper.add(h.name);
}
const missing = [...geomFiles].filter(f => !filesUsingHelper.has(f));
const undrivenIds = [...idHasDriver.entries()].filter(([, ok]) => !ok).map(([id]) => id);
check(
'鸿蒙|每个用到 geometryTransition 的文件都走 Motion.morph(不是自己 animateTo)',
missing.length === 0,
'这些文件绑了共享元素转场却没用统一入口:' + missing.join(' ') +
'鸿蒙|每个共享元素转场 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.durMorph(它只属于 Motion.morph)',
!/Theme\.durMorph/.test(hs.map(h => h.src).join('\n')),
'`Theme.durMorph` 出现在页面里 = 又有人绕开 `Motion.morph` 自己写动画参数了'
'鸿蒙|页面里不得直接写**弹簧曲线**(必须走 Theme 的过渡方法或 Motion)',
!/Theme\.(springIn|springResponsive)\b/.test(hs.map(h => h.src).join('\n')),
'弹簧曲线出现在**页面**里 = 绕开了 `Motion.effectAnim`(它负责"关动画时连曲线一起换")\n' +
'⇒ 系统里开了"减弱动效"时动画照放 —— 静默破掉无障碍开关'
);
check(
'鸿蒙|Theme.durMorph 确实存在且 = 220(WebUI FLIP 的原值)',
/static readonly durMorph: number = 220/.test(themeSrc),
'durMorph 不见了或被改成别的数 —— 上面几条会因为"引了一个不存在的名字"而失去意义'
);
/*
* ★★ 2026-09-21 删除两条陈旧断言:
* · 「页面里不再直接出现 `Theme.durMorph`」
* · 「`Theme.durMorph` 确实存在且 = 220」
*
* 令牌**已被删除**(morph 改用 `responsiveSpringMotion`,弹簧曲线下
* duration 不生效,保留那是误导)⇒ 这两条现在守的是一个不存在的东西。
* 上面那条改为钉**曲线令牌**的"不得下沉到页面",与原来的意图同一件事。
*/
finish('动画盘点');

View File

@ -463,6 +463,72 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
navFgLum !== null && navFgLum > 180,
navFgLum === null ? '缺 --nav-fg' : `--nav-fg 平均亮度 ${navFgLum}(应 >180)`
);
/*
* ★★ 2026-09-23 **堵上两条逾期欠债**(`docs/DEBTS.json` 的
* `nav-dark-route-b-unguarded` 与 `nav-blur-route-c-unguarded`)。
*
* ── 为什么现在堵 ──
* 这两条欠债的 `due` 写的是「**引入深色主题(或第一次给导航组件加 dark: 变体)时**
* 必须一并堵」。而深色主题 **2026-09-17 就落地了**(见上面那段 `.dark` 的注释 ——
* 那一次把判据从「.dark 里不许有 --nav-*」翻转成「必须有深色导航令牌」)。
* ⇒ 到期条件**在当时就成立了**,但那一次只修了令牌这条路,A/B/C 三条逃逸路
* 一直没堵 —— 欠债逾期到现在。
*
* ── 三条逃逸路的现状(判据逐条看)──
* · A `.dark .nav-rail { background-color: … }`(选择器作用域)—— **上一条已堵**:
* 它搜 `.dark{}` 块体,而 A 是**另一条选择器**,落在块体之外 ⇒ 逃掉。
* 等等 —— 那一条现在也堵不了 A。见下面 A 条新判据。
* · B `className="nav-item dark:bg-slate-900"`(Tailwind 的 dark: 变体)——
* 产出的不是 `.dark{--nav-*}`,**当前完全无判据**。
* · C `className="nav-item backdrop-blur-lg"`(元素级工具类模糊)——
* 不在 `html[data-bg='on'] .nav-rail{…}` 那条选择器下,**当前完全无判据**。
*
* ── 堵法为什么是「窄豁免」而不是一刀切(欠债里点名警告过)──
*
* 欠债原文:「堵法按**文件窄豁免**写,不许写成『导航目录不许出现 dark:』
* (`bg-chrome-600` plain 档徽标那个先例我踩过一次)」。
*
* 那个先例的形状是:一条"某目录不许出现 X"的宽断言,会把**合法的**
* 非导航用途一起判红 —— 判据一红,下一个人就会去放宽它,最后什么都守不住。
*
* ⇒ 这两条**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些字符串**。
* 换句话说:问的不是"这个文件里有没有 dark: 背景",而是
* **"挂在导航那个元素上的那个类串里有没有"** —— 与逃逸路的形状同构。
* 同一个文件里的别的元素(徽标、头像…)写什么都不影响。
*/
const navComponents = ['../src/components/Sidebar.tsx', '../src/components/NarrowNav.tsx']
.map((p) => read(p));
/** 把所有 className 字符串里的"导航类串"取出来(支持 "…" 与 `…` 两种写法) */
const navClassStrings = [];
for (const src of navComponents) {
for (const m of src.matchAll(/className=(?:"([^"]*)"|\{`([^`]*)`\})/g)) {
const cls = m[1] || m[2] || '';
if (/\bnav-(?:rail|item)\b/.test(cls)) navClassStrings.push(cls);
}
}
check(
'B 条已堵:挂在导航元素上的类串里不许出现 `dark:bg-*`(Tailwind 深色变体绕过 --nav-bg)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bdark:bg-/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了(组件改名要一起改本判据)'
: `有导航类串写了 dark:bg-* ⇒ 深色下导航会绕过 --nav-bg 令牌自己变色:` +
navClassStrings.filter((c) => /\bdark:bg-/.test(c)).join(' | ')
);
check(
'C 条已堵:挂在导航元素上的类串里不许出现 `backdrop-blur-*`(元素级模糊叠在壁纸模糊上)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bbackdrop-blur/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了'
: `有导航类串写了 backdrop-blur-* ⇒ 壁纸已经模糊过,导航再糊一次(更脏更掉帧):` +
navClassStrings.filter((c) => /\bbackdrop-blur/.test(c)).join(' | ')
);
check(
'A 条已堵:不许用 `.dark .nav-rail` / `.dark .nav-item` 这类**选择器**给导航单独换色',
!/\.dark\s+[^{]*\.nav-(?:rail|item)/.test(css),
'深色下导航的底色只有一个来源(`.dark` 块里的 `--nav-bg` 令牌);' +
'另开一条 `.dark .nav-rail{…}` 选择器会让它绕过令牌 —— 那正是「导航黑、正文白」那个老问题的形状'
);
check(
'导航自叠模糊之一已堵:壁纸模式下 .nav-rail 规则里不许写 backdrop-filter(元素级工具类那条路未覆盖)',
/\.app-backdrop \{[\s\S]*?filter: blur\(/.test(css) &&

View File

@ -152,6 +152,36 @@ const PAIRS = [
latestMailId: g.latest?.mail_id ?? null,
mailIds: (g.mails ?? []).map(m => m.mail_id),
unreadCount: g.unreadCount
})),
/*
* ★★ 2026-09-23 新增:`groupPermissions` 的双边投影。
*
* ── 为什么必须加这一条 ──
* 上面那段注释(原文)说 "`groupPermissions` 不是改名了……
* **已决策的授权记录在鸿蒙上完全看不到**",然后把
* `harmony-permission-history` 登记进 `gaps`。2026-09-21 鸿蒙补上了
* 这个函数,`gaps` 清空了 —— 但**从没有人把两边的 `groupPermissions`
* 真正比过一次**:`gaps: []` 只意味着"名字都在",
* 不意味着"行为一致"。
*
* 实际后果:我刚要对比时才发现,两边都在算 `path`,
* 而鸿蒙那边是**硬编码空串**(`g.path = ''`,理由写着"MailLike 没这个字段"),
* electron 是真取 `latest.session_workspace`。
* ⇒ 鸿蒙的授权栏**永远显示不出 `agent@path`**。
* 这不是"少一个导出",是**同一个函数两边算得不一样** ——
* 而现有判据的形状只能抓前者。
*
* ⇒ 投影里**保留 `path`**(不归一掉),并补上用例(见下面 cases)。
* 它现在是这条判据的守具:谁再把它写成空串,这里会红。
*/
groupPermissions: (v) => (v ?? []).map(g => ({
sessionId: g.sessionId ?? g.session_id,
alias: g.alias,
agentName: g.agentName,
path: g.path,
pending: (g.pending ?? []).map(m => m.mail_id),
settled: (g.settled ?? []).map(m => m.mail_id),
latestMailId: g.latest?.mail_id ?? null
}))
},
cases: [
@ -174,7 +204,28 @@ const PAIRS = [
['拆分:空列表', 'splitByPermission', [[]]],
['未决权限计数', 'countPendingPermissions', [[mkMail({ mail_type: 'permission_request' })]]],
['未决权限计数(已决策)', 'countPendingPermissions',
[[mkMail({ mail_type: 'permission_request', permission_result: 'allow' })]]]
[[mkMail({ mail_type: 'permission_request', permission_result: 'allow' })]]],
/*
* ── groupPermissions:两边逐字段算得一样吗 ──
*
* 用例特意覆盖 `path`(会话工作目录):
* 这是它与 WebUI 真分叉过的那个字段(鸿蒙曾硬编码空串)。
* 若只拿"有权限请求"的普通用例测,两组都不报 path 差异也看不出来。
*/
['权限分组:单会话待决', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:待决 + 已决策同会话', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' }),
mkMail({ mail_id: 'p2', mail_type: 'permission_request', permission_result: 'allow' })]]],
['★ 权限分组:path 取会话的 workspace', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: '/home/program/agentmail' })]]],
['★ 权限分组:workspace 缺失时 path 为空(不是 undefined)', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: undefined })]]],
['权限分组:非权限邮件被过滤', 'groupPermissions',
[[mkMail({ mail_id: 'm1' }), mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:空列表', 'groupPermissions', [[]]]
]
},
{

View File

@ -936,8 +936,31 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
* 由那个方法自己的使用点担保)。
*/
const themeSrc = code(themePath);
const declared = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
assert.ok(declared.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${declared.length} 个)`);
/*
* ★★ 2026-09-21 补:清单从"只有 `static readonly` 字段"扩到 **`static` 方法**。
*
* ── 怎么发现的 ──
* 我把两个账号选择器换成官方 `bindMenu`(系统自带转场)之后,
* `Theme.menuIn()` 就**没有任何调用点了** —— 它是个 `static` 方法,
* 而这份清单当时只匹配 `static readonly NAME`,于是它**从来不在这份清单里**,
* 这条判据一路绿着看它变成死代码。
*
* 讽刺的是本条判据的出身正是同一个形状:pi 2026-09-15 那一刀是为了
* "`navMaterial` 没人用了却没被抓"(一个**字段**)。今天同一个洞
* 长在**方法**上 —— 覆盖面按"声明种类"划,就必然漏掉另一种声明。
* ⇒ 清单改成"字段 + 方法",两者用同一套可达性规则。
*
* ★ 方法名不带 `()`:比对用的是 `Theme.<name>\b`,
* 而调用点写 `Theme.menuIn()` —— `\b` 在 `n` 与 `(` 之间成立,匹配得上。
*/
const fieldTokens = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
const methodTokens = [...themeSrc.matchAll(/^\s*static (\w+)\(/gm)].map(m => m[1]);
const declared = [...new Set([...fieldTokens, ...methodTokens])];
assert.ok(fieldTokens.length > 20,
`要从 Theme.ets 里读到令牌清单(读到 ${fieldTokens.length} 个)`);
assert.ok(methodTokens.length > 10,
`也要读到 static 方法清单(读到 ${methodTokens.length} 个)—— ` +
'少了它,`menuIn` 那种"方法型的死代码"就永远不在射程内');
const others = collectEts(etsRoot).filter(f => f !== themePath);
const othersSrc = others.map(f => ({ f: f.slice(etsRoot.length + 1), src: code(f) }));
/*

View File

@ -261,17 +261,46 @@ test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按
assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件');
});
test('2in1 快捷键|候选列表内联渲染,不用 bindPopup/bindMenu', () => {
test('2in1 快捷键|地址候选列表内联渲染,不用 bindPopup/bindMenu', () => {
/*
* 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉,
* "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。
*
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*
* 改坏会红:把候选改成 `bindPopup(...)`。
* ★★ 2026-09-21 改:原来这两条断言扫的是**整个文件**
* (`!/\.bindMenu\(/.test(COMPOSE)`)—— 那把**不属于候选列表**的菜单
* 也一并禁了。我在账号选择器上用了官方 `bindMenu`(那是正确的:
* 它是个"点开选一个"的菜单,没有 ↑↓ 候选导航),结果被判据误报。
*
* 两者性质完全不同:
* · 地址候选(`suggestOpen`):**要 ↑↓/Enter 导航** ⇒ 必须内联,
* 这是无障碍与键盘可达的硬要求;
* · 账号选择器(`showAccountPicker`):**没有键盘导航**,就是个下拉
* ⇒ 官方 `bindMenu` 正合适(还自带点外部关闭/边缘避让)。
*
* ⇒ 断言改为**只看候选那一段**(从 `suggestOpen` 条件挂载处取到下一段结束)。
* 这样它仍然拦得住"把候选改成 bindMenu"(真正要防的那件事)。
*/
assert.ok(!/\.bindPopup\(/.test(COMPOSE), '不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(COMPOSE), '不得用 bindMenu(会吃掉按键)');
const suggestAt = COMPOSE.indexOf('if (this.suggestOpen && this.suggestItems.length > 0)');
assert.ok(suggestAt > 0, '要能找到地址候选的挂载点(它必须内联渲染)');
/*
* ★ 窗口要**从候选的开头往前多取一段**,不能从 `if` 那个位置开始。
*
* 实测(变异验证时发现的):我第一版从 `suggestAt` 开始切,
* 而"把候选改成 `bindMenu`"最自然的写法是把 `.bindMenu(...)` 写在
* **那个 `if` 之前**(作为一个链式修饰符)—— 于是它落在窗口之外,
* 变异**测不出来**(实测确实绿)。
*
* 判据自己对变异不敏感 = 它没在守卫那件事。
* ⇒ 往前取 600 字符(够包住同一节点上的修饰符链),往后到该块结束。
*/
const from = Math.max(0, suggestAt - 600);
const tail = COMPOSE.slice(suggestAt);
const cut = tail.indexOf('Divider()');
const suggestBlock = COMPOSE.slice(from, suggestAt + (cut > 0 ? cut : 3000));
assert.ok(!/\.bindPopup\(/.test(suggestBlock), '地址候选不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(suggestBlock), '地址候选不得用 bindMenu(会吃掉按键)');
assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载');
});

View File

@ -621,8 +621,26 @@ 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 修:加宽高比约束(与 `harmony-appearance` 两处同一个隐患)。
*
* 侧栏下半部有**三个**可点方块,实测(3184×2232,density 2.875):
* 头像 40×40vp(115×115px)ratio 1.000
* 主题切换 36×28vp(104× 81px)ratio 1.284
* 退出登录 36×28vp(104× 81px)ratio 1.284
* `(y2-y1) > 80` **三者全中**,`.find()` 靠遍历顺序才取到头像。
*
* 一旦顺序变了就会点到**退出登录** ⇒ 设备被登出、账号被清空
* (`performLogout` → `clearAll()`,实测 `accounts_json` 变 `[]`)。
* 我这次调试期间设备真的被登出过一次。
*
* ⇒ 用宽高比把"正方形头像"与"扁矩形按钮"分开。阈值 1.15 落在
* 1.000 与 1.284 之间,两边都有余量。
*/
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('宽屏侧栏头像找不到 —— 无法进「我的」');
const c = D.boundsCenter(avatar.attributes.bounds);
@ -747,6 +765,87 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
assert.ok(userRows.length > 0,
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
/*
* ★★ 2026-09-21 补:**把应用放回主界面再退出**。
*
* ── 真 bug(连着两轮套件都因此红)──
*
* 本条爬到「管理」子页(`AdminUsersPage`,push 在 `MainPage` 之上的路由)
* 之后**就地结束**,应用就停在那里。下一个跑的设备判据
* (`harmony-nav` 的「底栏真渲染了可点的导航项」)直接 `dumpLayout` 量现场
* ⇒ 量到的是**管理页**,那一页上根本没有底栏/侧栏 ⇒ 报
* 「宽屏左侧栏要渲染出 ≥1 个可点的导航项(实际 0)」
* 侧栏明明好好的。**那不是功能缺失,是我的判据留下的现场。**
*
* `backToMain` 的注释里早写着同一个根因("宽屏侧栏找不到 ⇒ 双双 skip"),
* 只是本条的属主当时没顺手收尾。
*
* ★ 判据有义务**清理自己造成的状态**,不能指望"下一条会自己归位" ——
* 那等于把顺序耦合藏进设备状态里(谁先跑谁后跑决定了绿红)。
* `harmony-nav` 那边我也补了自保(它现在会先 `backToMain`),
* 但两边都做才是对的:一条产生脏状态、一条消化脏状态,
* 任何一边缺失都只是"碰巧还能过"。
*/
await D.launchOurApp(hdc, { settle: 400, tries: 20 }).catch(() => {});
await D.backToMain(hdc).catch(() => {});
});
test('★ 两个表单型半模态必须**同形**(同类交互不能一个能叉、一个不能)', () => {
/*
* ★★ 2026-09-21 加:用户「修改密码也应该做成弹窗吧」之后,
* `SettingsPage` 里出现了**两个同类**的表单型半模态:
* · 新增账号(`AddAccountSheet`)
* · 修改密码(`PasswordSheet`)
*
* 第一版我给新增账号写了 `showClose: false`(理由是"内容里已经有「取消」按钮"),
* 给修改密码写了 `true` —— 于是两个**同类**一个能叉掉、一个不能。
* 用户看到的是"为什么这个能关、那个不能"。
*
* ── 为什么这条值得单独钉 ──
*
* 这是用户这一路反复指出的那个形状:**同类交互必须是同一种形状**。
* (「修改密码也应该做成弹窗吧」的 `也应该` 本身就是这个意思 ——
* 不是"这个要弹窗",而是"两个表单别一个内联一个弹窗"。)
*
* 靠人记得"改一个要顺手改另一个"是不成立的 —— 这条判据把它变成**结构约束**:
* 两份 sheet 的选项必须逐字相同。以后谁只改其中一个,这里立刻红。
*
* ★ 为什么用「逐字对比两份选项块」而不是逐项 assert:
* 逐项 assert 只能钉我**今天想到的那几项**(height / showClose),
* 而"同形"的含义是**整块相同** —— 明天加个 `dragBar: false`
* 只加在一个上,逐项 assert 照样绿。对比整块才真的守住"同形"。
*/
const src = code(SETTINGS_PAGE);
const grabOpts = (marker) => {
const at = src.indexOf(marker);
if (at < 0) return null;
/* 取 `.bindSheet(...)` 的**最后一个参数**(选项对象):从 marker 起配对 `{}` */
const open = src.indexOf('{', src.indexOf(',', at + marker.length));
if (open < 0) return null;
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open, i + 1); }
}
return null;
};
const add = grabOpts('.bindSheet($$this.showAddDialog');
const pw = grabOpts('.bindSheet($$this.showPasswordSheet');
assert.ok(add, '要能找到「新增账号」半模态(`.bindSheet($$this.showAddDialog`)');
assert.ok(pw, '要能找到「修改密码」半模态(`.bindSheet($$this.showPasswordSheet`)');
/* 选项里的值各自不同(回调名),所以只对比**键**与**非回调字面量** */
const normalize = (o) => o
.replace(/\/\*[\s\S]*?\*\//g, '') // 去注释(两份注释必然不同)
.replace(/onDisappear\s*:\s*\(\)\s*=>\s*\{[^}]*\}/g, 'onDisappear:<cb>')
.replace(/\s+/g, ' ')
.trim();
assert.equal(normalize(pw), normalize(add),
'★ 两个表单型半模态的选项必须**整块相同** —— 同类交互同一种形状。\n' +
` 新增账号:${normalize(add)}\n` +
` 修改密码:${normalize(pw)}\n` +
' 只改其中一个会让两个同类面板长得不一样(用户看到的"割裂"就是这么来的)。');
});
test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => {
@ -882,17 +981,22 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
'★ 不要显式设成 `OFFSET`(那就是"整页上移",正是要避免的那个模式)');
/*
* 逐个**仍然存在的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
* 逐个**仍然自绘的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
*
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层),这道检查现在只剩
* **对话树弹层**一个对象。它仍然该有明确高度 —— 理由没变:
* 尺寸由内容决定的覆盖层,键盘弹起时内容会被挤出可视区。
* (内联底栏那半改用上面的 `KeyboardAvoidMode.RESIZE` 承担。)
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层)。
* ★★ 2026-09-21:**对话树弹层已换成官方 `bindSheet`**(用户:「现在最割裂的
* 就是弹出效果」)⇒ 自绘的覆盖式弹层现在是 **0 个**。
*
* 「明确高度」这条要求**依然成立、且被满足得更好**:
* · 旧自绘版:`height('60%')` —— 拍脑袋的百分比,内容多时会被挤出可视区;
* · 官方 `bindSheet`:高度档位由系统定义(`SheetSize.LARGE`),
* 并有 `keyboardAvoidMode` 专门做键盘避让。
*
* ⇒ 所以这里不再要求"至少 1 个自绘弹层"(那会把回退到自绘当成优点),
* 改为:**若还有自绘弹层,它必须有明确高度**;同时断言换过去的那一个
* 确实还在官方容器里(否则就是"既没自绘也没官方"= 硬弹且无避让)。
*/
const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)];
assert.ok(sheets.length >= 1,
`详情页应还有覆盖式弹层(对话树)—— 实际找到 ${sheets.length} 个。\n` +
' 若回复/转发已全变内联底栏,至少对话树那一个还在;一个都没有说明结构被改坏了。');
const noHeight = [];
for (const m of sheets) {
const before = src.slice(Math.max(0, m.index - 1200), m.index);
@ -906,6 +1010,13 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
'内容一多就会被挤出可视区(旧版回复/转发弹层实测过:按钮点不到)。\n' +
`缺高度的弹层:\n ${noHeight.join('\n ')}`);
/* 换到官方容器的那一个必须真的在(不能"两边都不在") */
assert.match(src, /\.bindSheet\(\$\$this\.showThread/,
'★ 详情页的**覆盖式弹层**(对话树)必须二选一:\n' +
' · 官方 `bindSheet`(推荐:自带拖拽条/下滑关闭/遮罩/键盘避让),或\n' +
' · 自绘 `borderRadius({topLeft})` + 明确高度 + `transition`。\n' +
' 两者都没有 = 旧弹层被删了却什么都没换上去。');
/*
* ② 底栏内部各元素用**内容尺寸/百分比**,不得再用 `layoutWeight(1)` 抢空间。
*

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('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');

View File

@ -566,7 +566,27 @@ test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读
* 归一被删 ⇒ 前提不成立 ⇒ 豁免失效 ⇒ 这一行重新报警。
*/
...(normalizeCoversOmitemptyFields
? [/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/]
? [
/*
* ★★ 2026-09-23 修:这条豁免原来写的是 `^MainPage\.ets: …`,
* 而那段代码**已从 `MainPage.ets` 抽成 `pages/PermissionTab.ets`**
* (用户要求"按页面封装组件以便与 WebUI 一一对应")。
*
* 抽出之后豁免不再命中 ⇒ 三行重新报警(判据报的是三个 `permission_result.length`)。
* ★ 这个红是**对的**,而且值得记:豁免写的是**文件 + 行内容**,
* 组件搬家就会失效 —— 而失效的方向是**变红**(不是变哑),
* 这正好与那条豁免注释里担心的方向相反、也都可接受:
* 宁可多报一次让人来看,也不要静默致盲。
*
* ★ 注意这里**同时保留了旧路径**:`permission_result` 的同类读法
* 在 `MainPage.ets` 里也还有(`MailRow` / `SentRow` 的 chip 文案),
* 两条路径都要豁免,删掉哪一条都会让另一处假红。
*/
/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
/^PermissionTab\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
/^PermissionTab\.ets: \.fontColor\(m\.permission_result\.length/,
/^PermissionTab\.ets: \.fontWeight\(m\.permission_result\.length/
]
: [])
];
const real = hits.filter(h => !EXEMPT.some(re => re.test(h)));

View File

@ -44,7 +44,16 @@ const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
*/
/*
* ★★ 2026-09-23 修:`ContactsTab` 已从 `MainPage.ets` 抽出
* (`pages/ContactsTab.ets`)。下面三个判据原来只在 `MAIN` 上找,
* 抽出后落在 `ContactsTab` 的代码全部失踪 ⇒ 静默变绿(判了空串)。
* 改成把两边并起来 —— 与 `harmony-logic`/`harmony-nav` 的 `PAGE_SOURCES`
* 同一个做法(显式名单,不扫整个目录,避免作用域过宽)。
*/
const MAIN = join(ETS, 'pages', 'MainPage.ets');
const CT = join(ETS, 'pages', 'ContactsTab.ets');
const ctAndMain = (src) => src; // 占位,下面直接用 code(MAIN)+code(CT)
const API = join(ETS, 'api', 'MailApi.ets');
const LOGIN = join(ETS, 'pages', 'LoginPage.ets');
const GROUP = join(ETS, 'model', 'MailGrouping.ts');
@ -79,7 +88,7 @@ test('★ 「写信 / 归档」两个动作在**两种视图**里都有(WebUI
* 换个视图就换套确认 UI 只会让人对「自己点了点什么」更没底)。
* 鸿蒙两视图都要有 —— 只加一边的话,切到另一边就像"这个功能没了"。
*/
const src = code(MAIN);
const src = code(MAIN) + '\n' + code(CT);
const writes = [...src.matchAll(/this\.CardAction\('写信'[\s\S]{0,80}?composeTo\(c\)/g)].length;
const archives = [...src.matchAll(/this\.CardAction\('归档'[\s\S]{0,80}?requestArchive\(c\)/g)].length;
assert.equal(writes, 2, `「写信」要在卡片视图与列表视图各一处,实际 ${writes} 处`);
@ -97,7 +106,7 @@ test('★ 归档要**先确认**,且两种视图共用同一个确认框(不
* 文案就会各自漂移,而漂移之后没人会同时看两边。
* 判据:确认框的 builder 只有一个定义,两个视图都调它。
*/
const src = code(MAIN);
const src = code(MAIN) + '\n' + code(CT);
const defs = [...src.matchAll(/@Builder\s+ArchiveConfirmCard\(/g)].length;
assert.equal(defs, 1, `ArchiveConfirmCard 只应有 1 个定义,实际 ${defs} 个`);
const calls = [...src.matchAll(/this\.ArchiveConfirmCard\(c\)/g)].length;
@ -122,7 +131,7 @@ test('★ 时间戳要按 WebUI 的口径格式化(不是把 ISO 串原样印
* —— 不许 `.slice()` 切字符串(那是拿 UTC 的月/日当本地时刻用,
* UTC+8 的 09-15 00:30 会显示成 09-14 16:30,而格子/表头全都正常)。
*/
const main = code(MAIN);
const main = code(MAIN) + '\n' + code(CT);
assert.doesNotMatch(main, /\+ c\.last_activity\b/,
'last_activity 不许原样拼接显示(会印出 ISO 串),要走 shortTimeOf');
assert.match(main, /shortTimeOf\(c\.last_activity\)/, '要用 shortTimeOf 格式化');

View File

@ -41,7 +41,33 @@ const ATT_TS = join(HARMONY_ETS, 'model/Attachment.ts');
const ATT = await import(pathToFileURL(ATT_TS).href);
const C = await import(pathToFileURL(COMM_TS).href);
const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
/*
* ★★ 2026-09-23 修:把**从 MainPage 抽出去的组件文件**也并进来。
*
* 用户要求「把鸿蒙的组件按页面封装,以便与 WebUI 一一对应」,
* 于是 `PermissionTab` / `ContactsTab` / `NavDestinations` / `NavShared`
* 都从 `MainPage.ets` 抽成了独立文件。而本文件的 `pageCode` 只看
* `MainPage.ets` ⇒ 那些断言("卡片上要显示预算"、"切换按钮要走这条判据"…)
* 全部落空变红 —— 它们问的是**渲染层有没有这个调用**,
* 而那个渲染层已经搬家了。
*
* ⇒ 判据该问的是"这个应用里有没有",不是"它在哪个文件里"。
* 把抽出去的那几个一起读进来。
*
* ★ 有意**不**做成"扫描 pages/ 下所有文件":那会让断言变得过宽
* (任何文件里出现过一次就算数),而本仓反复在消的正是"作用域比声称的宽"。
* 这份名单是显式的 —— 组件再搬家就一起改,与 `harmony-appearance` 里
* 那份 `PANE_SOURCES` 同一个做法。
*/
const PAGE_SOURCES = [
'pages/MainPage.ets',
/* ★ 2026-09-23 抽出的组件(与 WebUI 的 ContactPanel / PermissionList 一一对应) */
'pages/ContactsTab.ets',
'pages/PermissionTab.ets',
'pages/NavDestinations.ets',
'pages/NavShared.ets'
];
const page = PAGE_SOURCES.map((f) => code(join(HARMONY_ETS, f))).join('\n');
/**
* 断言一律读**剥掉注释的源码**。
*
@ -663,12 +689,53 @@ test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段
assert.ok(new RegExp(`payload\\.${field} =`).test(decide), `决策体要带 ${field}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteText(WebUI 踩过"界面能填、其实没发出去"的坑)
const permTab = sendCode.slice(sendCode.indexOf('struct PermissionTab'));
assert.match(permTab, /this\.decide\(req, 'deny', this\.noteText\)/, '拒绝要把备注送出去');
assert.match(permTab, /this\.noteText = v;/, '备注框要真的收集输入');
/*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。
*
* 原来这里写的是 `sendCode.slice(sendCode.indexOf('struct PermissionTab'))`
* —— 而 `sendCode` 是 `MainPage.ets` 的源码。抽出之后 `indexOf` 返回 -1,
* `slice(-1)` 是**末一个字符**,于是下面四条断言全部落在空串上 ⇒
* **整条判据静默失效**(不是红,是变成永远绿的空断言)。
*
* ★ 这正是本仓反复在消的形状:**判据盯着一个会移动的位置**。
* 组件一旦搬家,它不会红,只会不再检查任何东西。
* ⇒ 改成读**那个组件自己的文件**,并把"搬家"这件事也钉住
* (下面先断言它在新位置,否则给一句能看懂的失败)。
*/
const permTabSrc = code(join(HARMONY_ETS, 'pages/PermissionTab.ets'));
assert.ok(permTabSrc.includes('struct PermissionTab'),
'PermissionTab 应在 pages/PermissionTab.ets —— 它若再次搬家,本条要一起改');
/*
* ★★★ 2026-09-23 重大修:`navigator_only` 后,**决策不在授权栏了**。
*
* 用户裁定授权栏走 WebUI 架构(纯导航器):点一条 → 详情页决策。
* 于是原来断言在 `PermissionTab` 里的「拒绝要送备注 / 备注框收集输入 /
* 过期要说清后果」全部搬到了详情页的 `PermissionPanel`(新组件)。
*
* ⇒ 判据对象从 `PermissionTab` 换成 `PermissionPanel`。
* 断言**没变宽**:还是那三件必须如实做的事(备注送出、输入收集、过期说清)。
* 只是位置跟组件一起搬了 —— 这正是本文件上面那条注释反复在消的形状。
*/
const panelSrc = code(join(HARMONY_ETS, 'pages/PermissionPanel.ets'));
assert.ok(panelSrc.includes('struct PermissionPanel'),
'PermissionPanel 应在 pages/PermissionPanel.ets —— 决策面板搬到详情页了');
assert.match(panelSrc, /this\.submit\(decision, noteText\)|this\.submit\(label, this\.note\)/,
'决策要带着备注一起送出(拒绝往往要说明理由,理由要真的到 Agent 手里)');
assert.match(panelSrc, /this\.note = v;/, '备注框要真的收集输入');
// 过期必须当场说清:审批不会让那次调用继续
assert.match(permTab, /resp\.expired/, '过期分支要处理');
assert.match(permTab, /不会让那次调用继续/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
assert.match(panelSrc, /resp\.expired/, '过期分支要处理');
assert.match(panelSrc, /staleWarning|已超过等待窗口/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
/*
* ★ `navigator_only` 的正形状:授权栏**不再有**内联决策,只导航。
* 漏删一个按钮/备注框就是"一半改了"(用户裁定最恨的形状)。
*/
assert.ok(!/Button\('同意'\)/.test(permTabSrc),
'授权栏不许再有内联「同意」按钮 —— 决策在详情页(navigator_only)');
assert.ok(!/this\.decide\(/.test(permTabSrc),
'授权栏不许再有 decide() 调用 —— 那是内联决策时代的残留');
assert.match(permTabSrc, /this\.onOpenMail\(req\.mail_id, req\.source_account_id\)/,
'卡片要能把点按变成导航(onOpenMail 带 mail_id + 来源账号)—— navigator_only 的形状');
});
test('★ 判据自检:把状态机的默认页签改错必须判红', () => {

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 ')}`);
});

View File

@ -145,10 +145,20 @@
# ⇒ 这是同一形状的第 N 次:**"清单没跟上代码"不会自己报警**,
# 得靠 `hits=0` 那条判据;而它本次确实报出来了(`diag=mutant-anchor-stale`)。
# 2026-09-23 09:5x baseline 重算(第 11 次)—— 用户:「修改密码也应该做成弹窗吧」一批弹层迁移。
# ① 三个文件漂移,**两类成因已分别取证**(这一步不能省 —— 底本过期与变异残留在 sha256 眼里一样):
# · AdminUsersPage.ets、BackgroundPicker.ets —— `git diff --quiet HEAD` ⇒ **空**(与 HEAD 逐字节相同),
# 最近提交 `aa10427` / `9064ead` ⇒ **底本过期(stale)**,不是残留。
# · SettingsPage.ets —— `git diff --quiet HEAD` ⇒ **非空** ⇒ **我的有意编辑**,逐块核过 diff 只有重构、无半截/占位。
# ② 有意编辑的内容(用户:「修改密码也应该做成弹窗吧」):
# 修改密码 / 新增账号 → 官方 `bindSheet`(`FIT_CONTENT` + `showClose: true`,两者同形);
# 删掉自绘遮罩(该文件里 `Theme.overlay` 只剩注释引用);
# 候选列表补 `Theme.menuIn()`(WebUI `AddressInput.tsx:261` 同款,鸿蒙此前是硬弹)。
# ③ 复算后 `sha256sum -c baseline.sha` 应全 OK(11/11)。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
d774fc494c21d4f68314b2a4953ba5a369744d75574c7f88f679497ff27a5ce0 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
73c66c7045f579c3eb8b8e0aa07803e7c9363b7ae8375972b251633d3ce969be client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
399d3a67ac78cb02bd6fafe73c0aec27122a2b02154d30f9f94e7d20ce942a19 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
54458e8010b10dc8b057fd399f0ad2b33149c5f051ee120580faa154a6ad0771 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
a41ffefd6c4ba383075ccfcc8c80d8cb1fadb2cb03177601d81061a03f6c2b6e client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
b64d09d2e29c10d2361e5eb9ade7fbf4d78631ca94db5d07d25cfd8d9685f566 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets

View File

@ -36,8 +36,8 @@
},
{
"file": "client/harmony/entry/src/main/ets/common/Theme.ets",
"pat": " return TransitionEffect\\.opacity\\(0\\)\\.combine\\(\\n TransitionEffect\\.translate\\(\\{ y: Theme\\.riseInOffset \\}\\)\\n \\)\\.animation\\(\\{ duration: Motion\\.dur\\(Theme\\.durRise\\), curve: Theme\\.easeRise \\}\\);",
"repl": " return TransitionEffect.asymmetric(TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: Theme.riseInOffset })).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }), 0, 0, 0, 0);",
"pat": " return TransitionEffect\\.opacity\\(0\\)\\.combine\\(\\n TransitionEffect\\.translate\\(\\{ y: Theme\\.riseInOffset \\}\\)\\n \\)\\.animation\\(Motion\\.effectAnim\\(Theme\\.springIn\\)\\);",
"repl": " return TransitionEffect.asymmetric(TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: Theme.riseInOffset })).animation(Motion.effectAnim(Theme.springIn)), 0, 0, 0, 0);",
"test": "nav",
"why": "★ paneRiseIn 改回 asymmetric(与 WebUI 的 both 不一致,判据该红)"
},

View File

@ -68,11 +68,11 @@ const SUITE = [
* 的接线判据 —— 用户「webui 行为是按钮变成对应的写邮件页面或输入框吧」。
* 详细理由见该文件里那段「这三条第一版写错了」的注释(两版错法都记了)。
*/
['test/animation-audit.test.mjs', [], 10], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
['test/animation-audit.test.mjs', [], 12], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion/系统弹簧曲线
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 44],
['test/background.test.mjs', [], 47],
['test/cross-client-theme.test.mjs', [], 21],
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
@ -108,7 +108,7 @@ const SUITE = [
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 21],
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);
@ -140,7 +140,7 @@ const SUITE = [
['test/criteria-hygiene.test.mjs', [], 7],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],

View File

@ -61,6 +61,43 @@ export class Motion {
return Motion.reduced() ? 0 : want;
}
/**
* ★★ 2026-09-21 新增:给**弹簧曲线**用的减弱处理。
*
* ── 为什么不能直接用 `dur(0)` ──
* SDK `AnimateParam.duration` 原文:
* 「The **duration** parameter **does not take effect** when
* springMotion / responsiveSpringMotion / interpolatingSpring
* are configured for **curve**.」
* ⇒ 传 `duration: 0` 配上弹簧曲线 = **完全没有效果**,动画照放。
* 也就是说:只把时长换成弹簧曲线,会**静默地破掉无障碍开关** ——
* 系统里关了动画,我们这套照样弹。
*
* ⇒ 正确形状:两条都换 —— 关动画时同时换回 `duration: 0` + 线性曲线。
* 这正是 `Motion.dur()` 那段注释说的"收成一个入口,让人写不出
* '忘了判断' 的代码";弹簧曲线在这一点上必须走**同一个入口**。
*
* @param spring 正常情况用的弹簧曲线
* @param fallback 关动画时要退回的时长版参数(`duration` 必须能生效)
*/
static anim(spring: ICurve): AnimateParam {
if (Motion.reduced()) {
return { duration: 0, curve: Curve.Linear };
}
/* 弹簧曲线:**不传 duration**(传了也不生效,留着只会让人误以为它有用) */
return { curve: spring };
}
/**
* `Motion.anim` 的 `TransitionEffect.animation()` 版本(同一个判断)。
*
* 存在的理由与 `anim` 完全相同:`TransitionEffect.animation()` 与
* `AnimateParam` 是不同的类型,但"关动画必须连曲线一起换"这条约束一样。
*/
static effectAnim(spring: ICurve): AnimateParam {
return Motion.anim(spring);
}
/**
* **共享元素转场**(`geometryTransition`)的动画参数。
*
@ -178,9 +215,21 @@ export class Motion {
* 而静态方法里拿不到 `this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。
* ⇒ 由调用方把它的 `UIContext` 传进来。调用点本来就在组件里,拿得到。
*/
ui.animateTo({
duration: Motion.dur(Theme.durMorph),
curve: Theme.easeRise
}, mutate);
/*
* ★★ 2026-09-21 改:固定 220ms + cubic-bezier → **`responsiveSpringMotion`**。
*
* 官方对该曲线的定位(`arkts-spring-curve.md` 原文):
* 「一般用于**跟手做成动画**的场景,离手时可用 springMotion 创建动画,
* 此时离手阶段动画将自动继承跟手阶段动画速度,**完成动画衔接**。」
*
* `geometryTransition` 干的正是"衔接":一个组件从 A 位置/尺寸
* 续接到 B 位置/尺寸。原来那个 220ms 抄自 WebUI 的 FLIP 常数
* (那是 JS 手算 transform 的产物),而系统弹簧由合成器按物理跑,
* 不需要我从别处抄一个毫秒数。
*
* ★ `Motion.anim` 会处理"关动画":弹簧曲线让 `duration` 失效,
* 所以减弱动效时必须连曲线一起换(否则无障碍开关静默失效)。
*/
ui.animateTo(Motion.anim(Theme.springResponsive), mutate);
}
}

View File

@ -1051,32 +1051,42 @@ export class Theme {
* 别拿 `durBase`(180) 或 `durFast`(120) 来替它。
*/
static readonly durRise: number = 200;
/**
* **共享元素转场**(`geometryTransition`,即"按钮长成面板")的时长。
/*
* ★★ 2026-09-21 删除 `durMorph`(220) —— 它已无消费者。
*
* 取 WebUI FLIP 的原值 **220ms**(`ComposePage.tsx:75`)——
* 就是那个"球长成写信页 / 回复框"的动画。曲线用 `Theme.easeRise`,
* 它的三次贝塞尔正是 WebUI 那行 `cubic-bezier(0.22, 0.61, 0.36, 1)`。
* 它原意是「共享元素转场的时长,取 WebUI FLIP 原值 220ms」。
* 现在 morph 改用 `Theme.springResponsive`(官方 `responsiveSpringMotion`,
* 定位就是"跟手/衔接"场景)—— 而弹簧曲线下 **duration 不生效**
* (SDK `AnimateParam.duration` 原文),所以那个 220 **不再参与任何计算**。
*
* ★ 为什么单独一个令牌而不是复用 `durRise`:「元素出入场」与
* 「共享元素在两个位置间续接」是两种动效,WebUI 那边也是两个值
* (rise-in 150 / morph 220)。合一个的话以后想单独调其中一个
* 就得先把它拆开 —— 拆的时候一定会漏掉某处调用。
* ★ `cross-client-theme` 的「令牌不得变孤儿」判据报红了它,**报得对**:
* 一个只被注释提到、没有任何代码读的令牌,下一个人调它以为会生效 ——
* 而实际上动画由弹簧物理参数决定。那比没有它更坏。
*
* 曲线仍由 `Theme.springResponsive` 提供;不再需要时长令牌。
*/
static readonly durMorph: number = 220;
/**
* **主题切换**交叉淡出的时长。
*
* 取值理由:主题切换是**整屏**变化,比单个组件的入场要慢一点才不刺眼
* (260ms 落在"能看清是个过渡"与"不让人觉得卡"之间)。
*
* ★ 与 `durRise`(200) / `durMorph`(220) 分开:三者是三种不同的动效。
* 本仓为此已经写过一次教训 —— **时长令牌合并后,想单独调一个就得先拆开,
* 而拆的时候一定会漏掉某处调用点**。
* ★★ 2026-09-21:原来这里写的是「与 `durRise`(200) / `durMorph`(220) 分开」,
* 而现在 `durMorph` **已被删掉**(morph 改用 `responsiveSpringMotion`,
* 弹簧曲线下毫秒数不生效,保留一个没人读的令牌只会误导)。
* 与 `durRise` 仍然分开:两者是两种动效。
*/
static readonly durThemeFade: number = 260;
/** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */
static readonly durMenu: number = 140;
/*
* ★★ 2026-09-21 删除 `durMenu`(140) —— 它已无消费者。
*
* `menuIn()` 改用 `Theme.springIn`(系统弹簧曲线)后,
* 原来那句 `duration: Motion.dur(Theme.durMenu)` 不在了(弹簧曲线下
* duration 按 SDK 原文**不生效**,留着只会让人以为它在起作用)。
*
* `cross-client-theme` 的「令牌不得变孤儿」判据当场报红了这个 —— **报得对**:
* 一个只出现在注释里、没有任何代码读的令牌,下一个人会以为改它能生效。
*/
/** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */
static readonly durCal: number = 200;
@ -1085,9 +1095,77 @@ export class Theme {
*
* 用 `curves.cubicBezierCurve` 而不是 `Curve.EaseOut` 之类的枚举:
* 枚举是系统预设的**另一根**曲线,观感与 WebUI 对不上。
*
* ★ 保留它的位置:**控件状态变化**(hover / active / 颜色)—— 那类变化
* 需要"秒级可控"的时长(120ms),而不是弹簧的物理时长。
*/
static readonly easeOutSoft: ICurve = curves.cubicBezierCurve(0.22, 1, 0.36, 1);
/*
* ══════════════════ 系统预制动效:阻尼弹簧曲线 ══════════════════
*
* ★★ 2026-09-21 新增(用户:「webui 是 webui,app 是 app。webui 为了保证
* 低带宽流畅性与降低 http 传输体积,动效肯定是够用就好,而 APP 慢慢存在
* 大量系统预制动效,为什么不用?这部分动效又不需要占用带宽」)。
*
* ── 我之前错在哪 ──
* 我把 **WebUI 的 CSS 数值当成了目标**:`durRise=200` 是
* 「WebUI 150ms 与鸿蒙规范 200-300ms 的折中」,morph 直接抄
* WebUI FLIP 的原值 220ms,曲线都是 `cubicBezierCurve`。
*
* 但 WebUI 那些数字是**带宽与通用性的产物**,不是动效设计的最优解:
* · 浏览器要照顾低端设备与弱网 ⇒ 动效只能"够用就好"(短、少、无物理);
* · 而 App 的动效由系统合成器做,**不占带宽、不传字节** ⇒ 没有理由将就。
* ⇒ 拿 WebUI 的上限当 App 的标准,是把约束当成了规格。
*
* ── 官方原文(`arkts-spring-curve.md`)──
* 「采用弹簧曲线的动画在达终点时动画速度为 0,**不会产生动画"戛然而止"
* 的观感**,以避免影响用户体验。」
*
* 这正是我那些 `cubic-bezier + 固定 ms` 的毛病:220ms 到点**硬停**。
* 而弹簧曲线是物理模型(质量-弹簧-阻尼),末速度为 0 ⇒ 自然停住。
*
* ── 关键约束(SDK `AnimateParam.duration` 原文)──
* 「The **duration** parameter **does not take effect** when
* springMotion / responsiveSpringMotion / interpolatingSpring
* are configured for **curve**.」
* ⇒ 用这四个曲线时**不能再传 duration**(传了也不生效),
* 时长由"曲线参数 + 属性变化量 + 弹簧初速度"自动算。
* 所以下面用 `springMotion` 的地方,`durXxx` 令牌**不再参与**。
*/
/**
* **入场/弹层升起**的系统弹性曲线。
*
* 参数取官方示例同源:`springMotion(0.6, 0.8)`
* (`arkts-modal-transition.md` 的 `bindContentCover` 示例就是这么用的:
* `.transition(TransitionEffect.translate({ y: 1000 })
* .animation({ curve: curves.springMotion(0.6, 0.8) }))`)。
*
* 语义:`response=0.6s`(周期,越大越慢)、`dampingFraction=0.8`
* (阻尼比,<1 会回弹一下,0.8 是"几乎不过冲"的手感)。
* 取官方示例的默认量级而不是自己调:系统动效的"预制"价值正在于
* 与系统其它转场手感一致,自己调反而会不一样。
*/
static readonly springIn: ICurve = curves.springMotion(0.6, 0.8);
/**
* **跟手/共享元素续接**的弹性曲线(`responsiveSpringMotion`)。
*
* 官方定位(原文):「是 springMotion 动画的一种特例,仅默认参数不同。
* **一般用于跟手做成动画的场景**,离手时可用 springMotion 创建动画,
* 此时离手阶段动画将自动继承跟手阶段动画速度,完成动画衔接。」
*
* 用在哪:`Motion.morph`(球长成面板)—— 那个动画本质是
* 「一个元素从 A 位置续接到 B 位置」,正属它描述的"衔接"场景;
* 而官方也建议 `responsiveSpringMotion` **保留默认参数**
* 「To apply custom settings for a spring animation, you are advised to use
* **springMotion**. When using **responsiveSpringMotion**, you are advised
* to retain the default settings.」
* ⇒ 这里不传参。
*/
static readonly springResponsive: ICurve = curves.responsiveSpringMotion();
/**
* **@keyframes 动画**用的缓动:`cubic-bezier(0.22, 0.61, 0.36, 1)`。
*
@ -1159,9 +1237,20 @@ export class Theme {
* 换窗格那几处如果将来发现"闪",应该用各自更合适的过渡去解,
* 而不是让**所有**用到 rise 的地方一起背这个不对称。
*/
/*
* ★★ 2026-09-21 改:固定 200ms + cubic-bezier → **系统弹簧曲线**。
*
* 理由见 `springIn` 令牌那段(用户:「APP 存在大量系统预制动效,为什么不用」)。
* 一句话:cubic-bezier 到点**硬停**,而弹簧曲线末速度为 0 ⇒ 自然停住。
*
* ★ `Motion.effectAnim` 而不是直接写 `{ curve: ... }`:
* 弹簧曲线会让 `duration` **失效**(SDK 原文),所以"关动画"时必须
* 连曲线一起换回去 —— 否则无障碍开关会被静默破掉。
* 这个判断只有一处(`Motion.anim`),调用点想漏都漏不了。
*/
return TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: Theme.riseInOffset })
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise });
).animation(Motion.effectAnim(Theme.springIn));
}
/**
@ -1177,11 +1266,15 @@ export class Theme {
* 从上方“落”下来。与 `rise-in` 的**上浮**方向相反,别混。
*/
static menuIn(): TransitionEffect {
/*
* ★★ 2026-09-21 改:同上,改用系统弹簧曲线。
* 弹层/下拉是"出现并落位",弹簧的收尾比 cubic-bezier 更像系统原生菜单。
*/
return TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: -4 })
).combine(
TransitionEffect.scale({ x: 0.985, y: 0.985 })
).animation({ duration: Motion.dur(Theme.durMenu), curve: Theme.easeRise });
).animation(Motion.effectAnim(Theme.springIn));
}
/**
@ -1208,8 +1301,12 @@ export class Theme {
* 所以不会出现"从右边进、从右边出"的别扭感:退出时它往同一侧滑走,
* 与 WebUI 那条 `both` 的行为一致。
*/
/*
* ★★ 2026-09-21 改:翻月是**水平位移**,正是弹簧曲线最擅长的场景
* (有明确的物理方向与终止位置)。用官方示例的量级 `springMotion(0.6, 0.8)`。
*/
return TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise });
).animation(Motion.effectAnim(Theme.springIn));
}
}

View File

@ -26,6 +26,31 @@ export interface MailLike {
mail_id: string;
session_id: string;
session_alias: string;
/**
* **会话的**工作目录(`sessions.workspace`)—— 不是 `from_workspace`。
*
* ★★ 2026-09-23 补(跨端对齐的一个真差异)。
*
* ── 为什么必需 ──
* Agent 的可投递地址是 `name@path.session` **三段**,其中的 `path` 就是它。
* WebUI 在 **两处**用它拼地址(`MailList.tsx:167` 会话组头 / `:273` 行),
* 而鸿蒙的 `MailLike` **没有这个字段** ⇒ `groupPermissions` 只能把
* `g.path` 写成空串(那段注释还写着"本仓为它踩过白屏")。
*
* 后果是:鸿蒙授权栏里**永远显示不出 `agent@path`**,
* 而同一屏的 `MailDetailPage` 却拿得到(它读的是 `MailSummary.session_workspace`,
* 那个字段 `Models.ets:243` **一直都有**)—— 同一份数据、两个模型,
* 一个有一个没有。这正是"跨端分叉"最典型的形状:
* **不是没实现,是有两套模型,而只有一套带这个字段。**
*
* ── 为什么可以放心读它 ──
* 服务端 `SessionWorkspace` 带 `json:"session_workspace,omitempty"`
* (`models.go:181`)⇒ **空值时整个 key 不出现**,客户端会拿到 `undefined`。
* 而 `MailSummary.normalize()` 已经用 `str(...)` 把它归一成空串
* (`Models.ets:264`),所以这里声明为 `string` 是安全的、与那两个模型一致。
* (本仓为漏了这一步真的白屏过,见 `ReplyTarget.participantAddress` 的注释。)
*/
session_workspace: string;
from_name: string;
/** 收件人显示名 —— 发件箱那一栏行上显示的是它(收件箱显示 from_name) */
to_name: string;
@ -250,12 +275,34 @@ export function groupPermissions(mails: MailLike[]): PermissionGroup[] {
g.alias = all[0].session_alias;
g.agentName = all[0].from_name;
/*
* 路径取组内最新一封的。★ `MailLike` **没有** `session_workspace`(本仓
* 为它踩过白屏:缺失时客户端拿到 `undefined`)⇒ 这里不做猜测,
* 保持空串;授权页要显示路径的话得先给 `MailLike` 补字段并守 `omitempty`。
* 本轮只做"待决/历史两段"这一件事,不顺手扩字段(顺手加是本仓反复出现的错法)。
* ★★ 2026-09-23 修:不再硬写空串 —— 真读 `session_workspace`。
*
* 原来这里写的是 `g.path = '';`,理由是「`MailLike` 没有这个字段
* (本仓为它踩过白屏)⇒ 不做猜测,保持空串」。
* 那个理由**有一半是错的**:白屏那次踩的是"字段存在但值为 `undefined`"
* (即**没有归一化**),而不是"不该有这个字段"。
*
* ⇒ 给 `MailLike` 补上声明,这里直取。与 WebUI
* `mailGroups.ts:162` 的 `latest.session_workspace || ''` 同义。
*
* ⚠️ ⚠️ **必须防 `undefined`,不能直接 `.length`** ——
* 我第一版写的是 `all[0].session_workspace.length > 0 ? ... : ''`,
* 而 `session_workspace` 带 `json:"...,omitempty"`:
* 值为空时 Go **根本不输出这个 key** ⇒ 这里拿到的是 `undefined`
* ⇒ **读 `.length` 直接抛** `Cannot read properties of undefined`。
*
* 这是本次**新加的跨端判据当场抓到的**(`cross-client-logic`
* 的 `★ 权限分组:workspace 缺失时 path 为空` 用例):
* electron 给 `path:''`,harmony 给 `THROW:...`。
*
* ★ 教训:类里的 `= ''` 默认值**只管"整个对象缺失"**,
* 不管"JSON 里没有这个 key" —— 本仓为这个区别已经白屏过一次
* (`ReplyTarget.participantAddress` 的注释里记着)。
* 而这次是**同一个坑的第二个实例**,我自己写的时候没想起来。
* 区别是这次有判据接着:写错的当天就被抓住了,没有流到设备上。
*/
g.path = '';
const ws: string = all[0].session_workspace;
g.path = typeof ws === 'string' && ws.length > 0 ? ws : '';
}
}

View File

@ -77,6 +77,30 @@ export class MailSummary implements MailLike {
*/
attachments: AttachmentInfo[] = [];
permission_mode: string = '';
/**
* **会话的**工作目录(`sessions.workspace`)—— 拼 `name@path.session` 里的 `path`。
*
* ★★ 2026-09-23 补(跨端对齐的一个真缺口)。
*
* ── 为什么现在才补 ──
* 服务端**一直在返回它**:`ListInboxScoped` 的 SELECT 里有 `s.workspace`,
* Scan 也读进了 `m.SessionWorkspace`(`repo.go:592/621`),
* 模型里带 `json:"session_workspace,omitempty"`(`models.go:181`)。
* 而鸿蒙的 `MailSummary` **从来没声明过这个字段** ⇒ 反序列化时静默丢掉,
* 与 WebUI 的 `Mail.session_workspace?`(`types/index.ts:149`)分叉。
*
* 后果:WebUI 能在 **两处**用它拼出完整地址(`MailList.tsx:167` 会话组头、
* `:273` 行),鸿蒙拼不出来 —— 而同一个应用里 `MailDetail`(`Models.ets:243`)
* **有这个字段**,即同一份数据两个模型一个有一个没有。
* 这正是"跨端分叉"最典型的形状:**不是没实现,是两套模型只一套带它。**
*
* ── `omitempty` 的防御 ──
* 空值时 Go 根本不输出这个 key ⇒ ArkTS 拿到 `undefined`,
* 而类里的 `= ''` 默认值**不适用于这种情况**(它只管"整个对象缺失")。
* ⇒ 必须在 `normalize()` 里 `str(...)` 归一,与 `mail_type`/`permission_result` 同列。
* (本仓为漏这一步真的白屏过,见 `ReplyTarget.participantAddress` 的注释。)
*/
session_workspace: string = '';
/**
* 邮件类型:`permission_request` = 待人点头的**待办**,其余是要读的内容。
* 收件箱与授权两栏按它分家(`MailGrouping.ts` 的 `splitByPermission`)。
@ -153,6 +177,8 @@ export class MailSummary implements MailLike {
/* 两个 `omitempty` 字段(缺失时是 undefined)—— 判据命中的就是它们 */
m.mail_type = str(m.mail_type);
m.permission_result = str(m.permission_result);
/* `session_workspace` 也是 `omitempty`:空值时 key 不出现 ⇒ 必须归一 */
m.session_workspace = str(m.session_workspace);
m.attachments = m.attachments === undefined || m.attachments === null ? [] : m.attachments;
m.cc_list = m.cc_list === undefined || m.cc_list === null ? [] : m.cc_list;
return m;
@ -243,6 +269,23 @@ export class MailDetail {
session_workspace: string = '';
cc_list: Address[] = [];
mail_type: string = '';
/*
* ★★ 2026-09-23 补:详情端点**刚修好**会回这四个字段(`server/internal/repo/repo.go`
* 的 `GetMailByID`:`permission_options` 从 INSERT 起就写进 mails 表,却从来没被
* 任何读路径选过 ⇒ 决策面板永远拿不到预设选项)。
*
* 对应 WebUI `MailView.tsx` 的 `PermissionPanel` 读的:
* · `permission_kind` —— `question` = 主动提问
* · `permission_multi_select` —— 多选
* · `permission_options` —— 预设选项
* · `permission_result` —— 已有决策(服务端一直在回,客户端原来没收)
* 另有 `permission_expires_at`(等待窗口)详情端点**还没补** ——
* 面板会走「没截止=判不出越窗」,不报错,后续补这个字段即可。
*/
permission_kind: string = '';
permission_multi_select: boolean = false;
permission_options: string[] = [];
permission_result: string = '';
/**
* 把服务端 `omitempty` 造成的**缺失字段**补回声明的初值。
@ -264,6 +307,12 @@ export class MailDetail {
m.session_workspace = str(m.session_workspace);
m.permission_mode = str(m.permission_mode);
m.permission_enforcement = str(m.permission_enforcement);
m.permission_kind = str(m.permission_kind);
m.permission_multi_select = !!m.permission_multi_select;
if (!Array.isArray(m.permission_options)) {
m.permission_options = [];
}
m.permission_result = str(m.permission_result);
m.mail_type = str(m.mail_type);
m.from_human = boolOr(m.from_human, false);
m.to_human = boolOr(m.to_human, false);
@ -387,11 +436,35 @@ export class PermissionRequest {
*/
/** 发起请求的 Agent(权限请求一定由 Agent 发出) */
agent_name: string = '';
/**
* ★★ 2026-09-23 补:这条待办来自哪个账号的网关。
*
* 服务端**不返回**这个字段 —— 它是客户端侧记的(拉 pending 时按账号
* 逐台拉,顺手给每条标上来源),用来在「点卡片 → 详情页」时定位到
* 正确的那台网关。与 `MailSummary.source_account_id` 同一个用意。
*/
source_account_id: string = '';
/** Agent 的问题原文:「是否允许我删除 X」 */
question: string = '';
/** 可选项(WebUI 用它与 allow/deny 两个按钮对应) */
options: string[] = [];
context: string = '';
/**
* 等待窗口的截止时刻(服务端 `expires_at`)。
*
* ★★ 2026-09-23 补。服务端**一直返回它**(`models.go:254` 的
* `ExpiresAt time.Time \`json:"expires_at"\``,在 `ListPendingPermissionsFor`
* 里由 `models.PermissionDeadline(pr.CreatedAt)` 算出),
* 只是两端客户端的类型都没声明它 ⇒ 反序列化时静默丢掉。
*
* ★ 这是**跨端共同缺口**(鸿蒙与 WebUI 都缺),不是鸿蒙单独落后 ——
* 所以它不是一个"抄过去"就能对上的差异,要两边一起补。
* 已记入 `docs/DEBTS.json`。
*
* ★ 它**不带** `omitempty`(服务端那是 `time.Time` 而非指针)⇒
* 正常情况下一定存在;仍做空串兜底以防零值。
*/
expires_at: string = '';
/** 已有决策结果(pending 列表里应恒为空串) */
result: string = '';
decided_at: string = '';

View File

@ -522,6 +522,47 @@ export struct ComposeView {
return account !== null ? account.displayName : '选择账号';
}
/**
* 账号选择菜单(`bindMenu` 的内容)—— 原来内联在 `build()` 里的那块列表。
*
* ★★ 2026-09-21 新增:从内联 `if/ForEach` 改为官方菜单内容。
*
* ★ 逐项不再挂 `Theme.menuIn()`:那个过渡原本是为了掩盖"列表推进布局流
* 导致下方内容被顶下去"。现在菜单是系统浮层(不占布局),
* 出入场由系统菜单自己负责 —— 再挂一层会与系统动画叠在一起。
*
* ★ 保留逐项的高亮与勾选(选中态的语言不变);
* 只是把"怎么弹出来"交给了系统。
*/
@Builder
AccountMenu() {
Column() {
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Text(acct.displayName)
.fontSize(13)
.fontColor(this.selectedAccountId === acct.id ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
if (this.selectedAccountId === acct.id) {
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
}
}
.width(220).height(40).padding({ left: 12, right: 12 })
.backgroundColor(this.selectedAccountId === acct.id
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.selectedAccountId = acct.id;
this.showAccountPicker = false;
})
}, (acct: AccountInfo) => acct.id)
}
.width(220)
.padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Start)
}
build() {
Column() {
// 顶栏
@ -591,46 +632,25 @@ export struct ComposeView {
.rotate({ angle: this.showAccountPicker ? 90 : 0 })
/* 展开/收起箭头平滑旋转(WebUI `transition-transform` 的对应物) */
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
.width('100%').height(40).padding({ left: 12, right: 12 })
.backgroundColor(Theme.surface)
// 账号选择下拉
if (this.showAccountPicker) {
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Text(acct.displayName)
.fontSize(13)
.fontColor(this.selectedAccountId === acct.id ? Theme.accentFor() : Theme.textPrimary)
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
if (this.selectedAccountId === acct.id) {
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
}
}
.width('100%').height(40).padding({ left: 40, right: 12 })
.backgroundColor(this.selectedAccountId === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
this.selectedAccountId = acct.id;
this.showAccountPicker = false;
})
/*
* 弹层入场(对齐 WebUI `@keyframes menu-in`:下移 4vp + 缩到 0.985 + 淡入)。
*
* ★ 只给**弹层**挂 —— WebUI 那条 `.animate-menu-in` 的注释把适用范围
* 钉得很窄("只给真正是弹层的东西"),因为它曾经挂着 `glass-control`,
* 于是每次切视图页面上**所有**按钮与输入框一起淡入位移(几十个元素同时动),
* 2026-09-15 被摘掉。别把这个放到常驻控件上。
*
* 挂在 `Row` 上(即 `ForEach` 的每一项):整张候选列表逐项轻落,
* 与 WebUI 里"整块列表一起动"略有差别 —— 但 ArkUI 的 `ForEach` 每项
* 是独立节点,逐项入场反而更接近"列表展开"的观感,且不会让整层不可交互。
*/
.transition(Theme.menuIn())
}, (acct: AccountInfo) => acct.id)
}
/*
* ★★ 2026-09-21 改用 `bindMenu(isShow, content)`(**API 11** 重载)。
*
* 第一版写的是 `bindMenu(... ? this.AccountMenu() : undefined, {onDisappear})`
* —— 那是 API 7 那个靠"内容为 undefined 就不弹"的重载。
* 改成 `isShow` 版后:
* · 显隐是**显式参数**(不再靠内容是不是 undefined 隐式表达);
* · `$$` 两向绑定让"用户点外部关闭"自动回写状态(不再靠 onDisappear 补)。
*
* ★ 与 `bindSheet($$this.showAddDialog, …)` 同一套写法 ——
* 两处弹层用一种形状,而不是两种。
*
* ★ 仍然与 `MainPage` 的账号筛选器一致(同一交互,不该两种形状)。
*/
.bindMenu($$this.showAccountPicker, this.AccountMenu())
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
Divider().color(Theme.border)
}
@ -757,6 +777,31 @@ export struct ComposeView {
.width('100%')
.backgroundColor(Theme.surface)
.borderRadius(Theme.glassRadius)
/*
* ★★ 2026-09-21 补:**候选列表的入场过渡**(原来一个都没有 ⇒ 硬弹)。
*
* 这是 `Theme.menuIn()` 的注释里点名要覆盖的那一类("账号选择器、**下拉候选**"),
* 但它从来没有真正挂到候选列表上 —— 只有账号选择器用了。
*
* ── 为什么现在才暴露 ──
* 三个原本挂 `menuIn()` 的地方里,两个账号选择器今天换成了官方
* `bindMenu`(系统自带转场),`menuIn()` 于是只剩定义、没有调用点。
* 我去查"要不要删掉这个死方法"时,顺手拿它的**注释**对了一遍实际用法:
* 它写的是给"账号选择器、下拉候选",**候选那一半从来没兑现**。
*
* ── 为什么必须补(不是纯观感)──
* WebUI 的同一处在 `AddressInput.tsx:261` 挂着 `animate-menu-in`
* (`.animate-menu-in` 就是 `@keyframes menu-in`,与 `menuIn()` 逐值对齐)。
* 也就是说:**WebUI 的候选是动的、鸿蒙的是硬弹** —— 这是真实的跨端不一致,
* 不是"我们少做了个美化"。
*
* ★ 挂在这一整层 `Column` 上(整块列表一起动),与 WebUI 的
* `<div class="animate-menu-in">` 包整块一致。
* (历史上一度逐项挂 `menuIn()` 的是**账号选择器**,那时是为了掩盖
* "下拉推进布局流把列表顶下去";现在的候选是浮在内容之上的,
* 整块入场才是对的,别再逐项。)
*/
.transition(Theme.menuIn())
}
Divider().color(Theme.border)

View File

@ -0,0 +1,797 @@
/*
* AgentMail 鸿蒙客户端 — 联系人(底部第三项)
*
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4008 行)。
*
* 抽出的动因是**对齐需要可对比的单元**(与 `PermissionTab` 同一轮、同一理由):
*
* WebUI 鸿蒙(抽出前) 鸿蒙(抽出后)
* components/ContactPanel.tsx 276 行 MainPage.ets 里一段 752 行内联 struct
* pages/ContactsTab.ets 753 行
*
* 之前没有边界时,"两端对齐"只能靠人在 4000+ 行里逐段找渲染字段 —— 那正是
* 用户批评的修修补补。有了这个文件,`ContactPanel.tsx` 与它就能**对着读**。
*
* ★ 它比 WebUI 那个大(752 vs 276),差额主要在**卡/列表两种视图**与
* 归档确认的交互 —— 这些在 WebUI 里被拆到了 `ContactPanel` 内部的
* 两个子组件(`ArchiveConfirm` / `ContactRow`)。鸿蒙这边是 `@Builder`
* (ArkTS 的 `@Builder` 就是组件内的渲染片段,与 React 的子组件同层)。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { GlassCardModifier, PaneModifier, PressEffectModifier } from '../common/Surface';
import { AmIcon } from '../common/Icons';
import { MailApi } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailSummary, Contact } from '../model/Models';
import { LIST_FADE_LENGTH, HEADER_BACK_HIT } from '../model/NavItems';
import { LengthMetrics, ComponentContent } from '@kit.ArkUI';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { MailDetailDestination } from './NavDestinations';
import { MAIL_DETAIL_ROUTE, DetailPlaceholder, compactMailTime } from './NavShared';
import {
budgetState,
budgetLabel,
permissionLabel,
permissionChipText,
enforcementLabel,
shortTimeOf,
lastFromIsHuman,
nextContactView,
contactViewTitle,
permissionHint
} from '../model/MailGrouping';
@Component
export struct ContactsTab {
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
@State contacts: Contact[] = [];
@State loading: boolean = false;
@State error: string = '';
/*
* 打开的会话(空串 = 没开)—— 与 WebUI `ContactPanel` 的 `selectSession` 同一行为:
* 点一条“跟谁在聊”就是打开**这条会话的邮件列表**。
*
* ★ 2026-09-17 用户:「联系人页面连点都点不开」。
* 根因:`ContactItem` / `WorkCard` 两个 builder **根本没有 `onClick`** ——
* 卡片画出来了、但没有任何点击路径(判据当时只钉了“字段与 WebUI 一致”,
* 没钉“点了会发生什么”)。现在补上:点卡片 → 在**本 pane 内**打开会话,
* 底部导航保留(与 WebUI 的 pane 模型一致,不跳 @Entry 页)。
*/
@State openSessionId: string = '';
@State openSessionTitle: string = '';
@State sessionMails: MailSummary[] = [];
@State sessionLoading: boolean = false;
/**
* 联系人自己的导航栈 —— 与 `CommPage` 同一模式(每个有「列表→详情」的窗格自带一个
* `Navigation`)。这样点联系人卡片打开邮件详情时,**底部导航一直可见**
* (详情挂在窗格内部),而不是推一个盖住导航的 @Entry 页。
* 这也正是用户说的「我的页面完全没有遵守 nav 的导航规则」那条的同源问题。
*/
private navPathStack: NavPathStack = new NavPathStack();
/**
* 视图:'list'(跟谁在聊)/ 'card'(在聊什么、进展如何)。
*
* 这不是装饰:WebUI 里「会话」从来不是一个入口,它是**两处已有视图** ——
* 列表视图是 `ContactRow`,卡片视图是 `WorkCard`(标题「工作列表」)。
* 鸿蒙侧原来把会话单列成一个 tab,等于把"卡片视图"放错了位置。
* 顺序:先在这里补上卡片视图,再把平级「会话」tab 撤掉(撤早了会丢信息:
* 轮次预算 / status / from_agent 就没地方看了)。
*/
@State contactView: string = 'list';
/**
* 待归档确认的会话 id(空 = 没有确认框)。
*
* ★ 归档是**破坏性**操作(Agent 侧会话归档 + 邮箱界面同时移除),
* 所以先确认再发请求 —— 与 WebUI `contactStore.pendingArchive` 同构。
* 用 `session_id` 当这个"待确认"的键,而不是 `address`:
* address 会随别名变化,拿它当身份迟早对不上(见 `MailApi.archiveContact`)。
*
* ★ 两种视图**共用同一个确认框**(WebUI 的原话:换个视图就换套确认 UI
* 只会让人对「自己点了什么」更没底)。所以这块 UI 只写一次。
*/
@State pendingArchiveId: string = '';
/** 归档请求在飞:防连点(一次点击就可能删掉一条会话,重复提交没有意义) */
@State archiving: boolean = false;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.mailApi = new MailApi(ApiClient.getInstance(ctx));
this.loadData();
}
}
async loadData(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
try {
const resp = await m.contacts();
this.contacts = resp.contacts;
/* 联系人数也发布给导航栏徽标(口径见 model/NavItems.ts 的 navBadgeCount) */
/* 键名与 MainPage 的 KEY_NAV_BADGE_CONTACTS 同值('agentmail.nav.contacts')——
* 两个 struct 不能共享私有常量,所以这里写字面量并在两处注释里互指。 */
AppStorage.setOrCreate('agentmail.nav.contacts', resp.contacts.length);
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
/** 切换视图:切换规则本身在 MailGrouping.nextContactView(判据直接执行那一层) */
switchView(): void {
this.contactView = nextContactView(this.contactView);
}
/**
* 写信给**指定地址**(联系人卡片上的「写信」)。
*
* `openCompose()` 是"写一封全新的",`to` 为空;这个版本把该联系人的三维地址
* 预填进去 —— 与 WebUI `startCompose({ to: c.address })` 同构。
* 复用同一条 `ComposePage` 路由与同一套 `ComposeParams`,不另开页面。
*/
composeTo(c: Contact): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
const params: ComposeParams = {
to: c.address,
reply_to: '',
session_alias: '',
account_id: accountId
};
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
}
/** 请求归档:只打开确认框,不发请求(破坏性操作先确认) */
requestArchive(c: Contact): void {
this.pendingArchiveId = c.session_id;
}
cancelArchive(): void {
this.pendingArchiveId = '';
}
/**
* 确认归档:发 `POST /contacts/archive`,成功后**本地即时移除**不等 SSE
* (与 WebUI 同做法:等 SSE 会让按钮看起来没反应)。
*
* 失败时把服务端的话原样显示 —— 归档半途失败最需要的是"到底成了没有",
* 而不是一句笼统的"操作失败"。
*/
async confirmArchive(c: Contact): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null || this.archiving) {
return;
}
this.archiving = true;
try {
await m.archiveContact(c.session_id);
this.contacts = this.contacts.filter((x: Contact) => x.session_id !== c.session_id);
if (this.openSessionId === c.session_id) {
this.openSessionId = '';
}
this.pendingArchiveId = '';
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '归档失败';
this.pendingArchiveId = '';
} finally {
this.archiving = false;
}
}
/**
* 打开一条会话的邮件列表(点联系人卡片就走这里)。
*
* 与 WebUI `ContactPanel.open()` 同构:`selectSession(c.session_id)` 后
* 内容栏切到会话。这里把“会话的邮件”拉下来就地展示在**本 pane 内** ——
* 不推 @Entry 页,因为 WebUI 的会话是**同一个 pane 的另一种内容**,
* 底部导航一直可见(推页会把导航盖掉,那是另一种信息架构)。
*/
async openSession(c: Contact): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.openSessionId = c.session_id;
this.openSessionTitle = c.subject.length > 0
? c.subject
: (c.session_alias.length > 0 ? c.session_alias : c.agent_name);
this.sessionMails = [];
this.sessionLoading = true;
try {
this.sessionMails = await m.sessionMails(c.session_id);
} catch (e) {
const ae = e as ApiError;
this.getUIContext().getPromptAction().showToast({ message: ae.message.length > 0 ? ae.message : '打开会话失败' });
} finally {
this.sessionLoading = false;
}
}
/** 回到联系人列表 */
closeSession(): void {
this.openSessionId = '';
this.openSessionTitle = '';
this.sessionMails = [];
}
/** 点一封邮件 → 推进本窗格的导航栈(与收件箱同一条详情路径) */
openMail(mailId: string, accountId: string): void {
const params: MailDetailParams = { mail_id: mailId, account_id: accountId };
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params });
}
@Builder
DestinationBuilder(name: string, param: Object) {
if (name === MAIL_DETAIL_ROUTE) {
MailDetailDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
}
}
build() {
/* 本窗格自带 Navigation(与 CommPage 同一模式):列表 → 邮件详情,底部导航始终可见 */
Navigation(this.navPathStack) {
Column() {
Row() {
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
/*
* ★★ 2026-09-21 补(逐页对齐 WebUI 时发现的漏项)。
*
* WebUI `ContactPanel.tsx:68` 在标题右边有一个**计数**:
* <span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
* 我们这里没有 —— 于是"有几个人/几条工作"只能靠往下数。
*
* 取值用 `contacts.length`(与 WebUI 同一个来源),**不是**卡片视图的
* 别的计数:两个视图共用同一份 `contacts`,所以计数不随视图变。
*
* 字号/颜色照 WebUI:`text-xs`(12) + `gray-400` → `fontSmall` + `textSubtleFor`。
* ★ 用 `textSubtleFor()` 而不是写死灰:深色下三级文字要提亮
* (本仓 2026-09-18 实测过「三级文字浅色也一样不够」)。
*/
Text(this.contacts.length.toString())
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor())
.margin({ left: 8 })
Blank()
/*
* 40×40 命中区 + 图标居中:尺寸加在**外层 Stack** 上,不加在 `AmIcon` 上。
* 内层容器固定 `iconSize`(18) 且靠左上 —— 直接链 `.width(40)` 会让
* 18vp 的图标贴在 40×40 命中区左上角(同 `LoginPage` 品牌卡、
* `AdminUsersPage` 刷新键,2026-09-18 一起修)。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({
iconName: this.contactView === 'list' ? 'cardView' : 'listView',
iconSize: 18,
iconColor: Theme.textMuted
})
}
.width(40).height(40)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.switchView(); })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
/*
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
*
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
* 「期望:融进背景」)。
*
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
*
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
* 就是用户说的那条白条。
*
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 会话视图:点联系人卡片后**在本 pane 内**展示那条会话的邮件。
* 不另开 @Entry 页 —— 与 WebUI 的 pane 模型一致(底部导航一直可见)。
*/
if (this.openSessionId.length > 0) {
this.SessionMailsView()
} else if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor(Theme.dangerFor())
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contacts.length === 0) {
Column() {
Text('暂无联系人').fontSize(16).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contactView === 'card') {
// 卡片视图:竖向堆叠(320~400vp 放不下多列),每项一张卡
List({ space: 8 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
/*
* 待归档的那一条**整块换成确认框**(与 WebUI 同做法):
* 归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底 —— 所以卡片视图与列表视图共用这一个分支。
*/
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.WorkCard(c)
}
}
.width('100%')
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
} else {
// 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义
List({ space: 6 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
/* 与卡片视图共用同一个确认框分支(见上) */
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.ContactItem(c, idx)
}
}
/*
* ★★ 2026-09-19 修(用户报「联系人界面存在严重问题」):
*
* 这里原来写 `.height(85)`,而**内容需要 95vp**:
*
* agent 名 14 + 3 + 主题 12 + 3 + 档位行 11 + 6
* + 动作行 26 + padding(10+10) = **95vp**
*
* 加上 `.clip(true)` ⇒ 多出来的 **10vp(该平板 ≈ 21px)**
* 被硬切掉 —— 切掉的正好是**「写信 / 归档」那一行的下半截**。
*
* 真机现场(HUAWEI MatePad Pro,密度 2.125):
* 卡片实测高 181px = 85vp(吻合),
* 截图里两个按钮只剩上半截,看起来像"渲染坏了"。
*
* ★ 讽刺的是:**旁边那段注释早就写明了这个道理**,
* 只是它只管了「确认态」那一支 ——
* 「确认框比 85 高,会被裁掉而看不见按钮,高度交给内容自己定」。
* 而普通态同样装不下,只是差得少(10vp)、不容易一眼看出来。
*
* ⇒ 两个分支的不变式其实是同一条:**高度必须由内容决定**。
* 不要把一个"按当时字号的估算值"钉成常量 ——
* 字号、行高、动作行的存在与否都会变,而常量不会跟着变。
*/
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.clip(true)
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
.width('100%').height('100%')
.attributeModifier(PaneModifier.of(this.bgActive))
}
.navDestination(this.DestinationBuilder)
.mode(NavigationMode.Auto)
/*
* ★★ 2026-09-20 修:联系人栏宽度**随视图模式变**(用户:「联系人页宽度」)。
*
* WebUI `ContactPanel.tsx:59-62` 原文:
* // 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
* view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
*
* 而鸿蒙原来**写死 320** —— 卡片视图与列表视图同宽。
* 卡片视图比列表视图多一行(`mail_count 封 · 时间`)**再加一条预算胶囊**
* (见 `CardItem` 里 `budgetLabel(...)` 那一块),320 装不下,
* WebUI 早就为此单独放宽到 400,我们没跟上。
*
* 范围上限也一起抬到 400(否则 `navBarWidth(400)` 会被 range 夹回 360 ——
* 那正是"改了宽度但没变"的典型症状,值被另一处静默覆盖)。
*/
.navBarWidth(this.contactView === 'card' ? 400 : 320)
.navBarWidthRange([280, 400])
.minContentWidth(360)
.hideTitleBar(true)
.width('100%').height('100%')
/* 右栏占位:栈空时显示引导(否则宽屏两栏并排时右栏是一大片空白)。
用系统入口 `splitPlaceholder`,**不是** NavDestination 的 else —— 后者栈空时不挂载。 */
.splitPlaceholder(new ComponentContent(
this.getUIContext(), wrapBuilder<[]>(DetailPlaceholder)))
/* 同 CommPage 的 Navigation:不设背景时系统默认不透明白,会把里面
已经透明的窗格盖住。联系人页是同一形状,同一修法。 */
.attributeModifier(PaneModifier.of(this.bgActive))
}
/**
* 会话视图:一条会话下的全部邮件(点联系人卡片后显示)。
*
* 与 WebUI 的会话内容区同构:顶部一行返回 + 会话标题,下面是那几封邮件。
* 每封点开推进本窗格的 `Navigation`(与收件箱同一条详情路径)。
*/
@Builder
SessionMailsView() {
Column() {
/* 返回行:与 WebUI 的 `BackButton label="会话"` 同一语义 */
Row() {
/* 返回键用图标,不用 `‹`(本仓禁 Unicode 符号当图标 —— 见 Icons.ets) */
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(HEADER_BACK_HIT).height(HEADER_BACK_HIT)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.closeSession(); })
Text(this.openSessionTitle)
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.sessionMails.length + ' 封')
.fontSize(11).fontColor(Theme.textMuted)
}
.width('100%').height(44).padding({ left: 4, right: 12 })
.backgroundColor(Theme.surface)
if (this.sessionLoading) {
Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.sessionMails.length === 0) {
Column() {
Text('这条会话没有邮件').fontSize(14).fontColor(Theme.textSubtleFor())
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 6 }) {
ForEach(this.sessionMails, (m: MailSummary) => {
ListItem() {
Column() {
Row() {
Text(m.from_name.length > 0 ? m.from_name : '—')
.fontSize(12).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
Text(compactMailTime(m.created_at))
.fontSize(10).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text(m.subject)
.fontSize(14)
.fontWeight(m.status === 'unread' ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%').margin({ top: 4 })
Text(m.body_preview)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%').margin({ top: 2 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
.width('100%')
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.openMail(m.mail_id, m.source_account_id); })
}, (m: MailSummary) => m.mail_id)
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
.width('100%').layoutWeight(1)
}
/**
* 工作卡片 —— 对应 WebUI 的 `WorkCard`(卡片视图)。
*
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:主题是主角,
* 最新一封说了什么、谁说的,以及**往返预算还剩多少**(预算是任务的属性,
* 快跑满的任务需要人介入 —— 这就是为什么卡片视图要先于删 tab 落地)。
*/
@Builder
WorkCard(c: Contact) {
Column() {
Row() {
Text(c.agent_name)
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(c.path)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 6 }).layoutWeight(1)
if (c.unread_count > 0) {
Text(c.unread_count + '')
.fontSize(10).fontColor(Theme.accentFg)
.backgroundColor(Theme.accent)
.borderRadius(9).width(18).height(18)
.textAlign(TextAlign.Center)
}
}
.width('100%')
Row() {
/* WebUI `ContactPanel.tsx:247` 这里是 `<ChevronRightIcon className="w-3 h-3">`
—— 一个 SVG 图标,不是 `›` 这个字符。逐项对齐。 */
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.accentFor() })
Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)')
.fontSize(11).fontColor(Theme.accentFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 4 }).layoutWeight(1)
}
.width('100%').margin({ top: 2 })
// 主题是这张卡片的主角:它回答「这条线索在干什么」
Text(c.subject.length > 0 ? c.subject : '(无主题)')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
if (c.last_preview.length > 0) {
Row() {
AmIcon({
iconName: lastFromIsHuman(c.agent_name, c.last_from) ? 'person' : 'bot',
iconSize: 12,
iconColor: Theme.textMuted
})
.margin({ right: 4 })
Text(c.last_preview)
.fontSize(11).fontColor(Theme.textMuted)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Top)
}
Row() {
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtleFor())
Blank()
/*
* 权限档位徽标:**档位 + 强制力标记**,点它弹出"平台实际做到了什么"。
*
* 只显示档位会让人以为 plan 档真的管住了对方;WebUI 把这句话放在悬停提示里,
* 而手指没有悬停 —— 所以鸿蒙这边拆成两步:标记形状当场可辨(● 平台强制 /
* ◉ 覆盖不完整 / ○ 仅提示),点一下用 toast 说完整那句话(文案两边逐字一致)。
*/
if (permissionChipText(c.permission_mode, c.permission_enforcement).length > 0) {
Text(permissionChipText(c.permission_mode, c.permission_enforcement))
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ right: 6 })
.onClick(() => {
this.getUIContext().getPromptAction().showToast({
message: permissionHint(c.permission_mode, c.permission_enforcement)
+ '(强制力:' + enforcementLabel(c.permission_enforcement) + ')',
duration: 6000
});
})
}
// 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据)
if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) {
Text(budgetLabel(c.max_rounds, c.used_rounds))
.fontSize(10)
.fontColor(Theme.budgetFg(budgetState(c.max_rounds, c.used_rounds)))
.backgroundColor(Theme.budgetBg(budgetState(c.max_rounds, c.used_rounds)))
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
}
.width('100%').margin({ top: 8 })
/* 次要动作:写信 / 归档(常显,理由见 CardAction 的注释) */
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.padding(12)
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.surface)
.border({ width: 1, color: Theme.border })
}
/**
* 卡片上的次要动作按钮(写信 / 归档)—— 两视图共用。
*
* ★ WebUI 用 `.reveal`(默认隐藏、悬停显形)承载这两个动作。**鸿蒙不能照抄**:
* `client/electron/src/index.css` 那段的注释里已经踩过这个坑 ——
* 「触摸设备没有 hover,于是这些按钮永远是透明的,却仍然接收点击……一个看不见
* 却按得动的破坏性按钮比没有按钮更糟」。WebUI 的修法是**只在真的支持悬停的设备上
* 才隐藏**(`@media (hover: hover) and (pointer: fine)`)。
* 鸿蒙的输入就是手指 ⇒ 这两个按钮**必须常显**,没有"显形"这一步。
*
* `tone='danger'` 只用于归档:它改的是服务端状态,红色让人在按之前先看一眼。
*/
@Builder
CardAction(label: string, iconName: string, tone: string, onTap: () => void) {
Row() {
AmIcon({
iconName: iconName,
iconSize: 12,
iconColor: tone === 'danger' ? Theme.dangerFor() : Theme.textMuted
})
Text(label)
.fontSize(11)
.fontColor(tone === 'danger' ? Theme.dangerFor() : Theme.textMuted)
.margin({ left: 4 })
}
.height(26)
.padding({ left: 10, right: 10 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.attributeModifier(PressEffectModifier.of())
.onClick(onTap)
}
/**
* 归档确认框 —— 列表视图与卡片视图**共用这一个**。
*
* WebUI 的原话:归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底。文案两边逐字一致(`ArchiveConfirm`):
* 「归档 <address>?」/「对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除」
*/
@Builder
ArchiveConfirmCard(c: Contact) {
Column() {
Text('归档 ' + c.address + '?')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text('对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除')
.fontSize(11).fontColor(Theme.textSubtleFor())
.margin({ top: 3 })
.width('100%')
Row() {
Row() {
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.accentFg })
Text('确认归档').fontSize(12).fontColor(Theme.accentFg).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.danger)
.opacity(this.archiving ? 0.5 : 1)
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.confirmArchive(c); })
Row() {
AmIcon({ iconName: 'close', iconSize: 12, iconColor: Theme.textMuted })
Text('取消').fontSize(12).fontColor(Theme.textMuted).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.margin({ left: 8 })
.attributeModifier(PressEffectModifier.of())
.onClick(() => { this.cancelArchive(); })
}
.width('100%').margin({ top: 10 })
}
.width('100%')
.padding(12)
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.dangerBgFor())
.border({ width: 1, color: Theme.danger })
}
@Builder
ContactItem(c: Contact, idx: number) {
Row() {
Column() {
Row() {
Text(c.agent_name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
if (c.unread_count > 0) {
Text(c.unread_count + '')
.fontSize(11).fontColor(Theme.accentFg)
.backgroundColor(Theme.danger)
.borderRadius(10).width(20).height(20)
.textAlign(TextAlign.Center)
}
}
.width('100%')
Text(c.subject.length > 0 ? c.subject : c.session_alias)
.fontSize(12).fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
// 列表视图(跟谁在聊):只要档位,不塞强制力标记 —— 详细说明在卡片视图那颗可点的徽标上
Text(permissionLabel(c.permission_mode).length > 0 ? permissionLabel(c.permission_mode) : '—')
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
/*
* 列表视图也要"写了多少 / 什么时候"——WebUI `ContactRow` 的
* `{mail_count} 封 · {time}` 与卡片视图同源。原来这里放的是 last_preview,
* 于是同一个联系人在两种视图里给出的关键信息不一致。
*/
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').margin({ top: 3 })
/*
* 动作行与卡片视图**同语义**(写信 / 归档),
* 只是列表视图行更矮,所以动作也画得紧凑些。
*/
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 6 })
}
/*
* ★★ 2026-09-19:把 `height('100%')` 拿掉。
*
* 它与外层 ListItem 的硬高度是**一对**:
* ListItem `.height(85)` + 这里 `height('100%')` ⇒ 刚好 85。
* 我只把 ListItem 那半去掉之后,这里就变成"填满整个列表"——
* 实测 ListItem 高 **1563px**(一整屏就一张卡)。
*
* ⇒ 两半必须一起改:**让内容决定高度**。
* `layoutWeight(1)` 保留(横向撑满),纵向不再写 height。
*/
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
}
}

View File

@ -10,6 +10,8 @@ import { Markdown, MarkdownController } from '@luvi/lv-markdown-in';
import { MailApi, SessionBudget, ThreadApiResponse, AddressSuggestionResponse } from '../api/MailApi';
import { ThreadNode } from '../model/Models';
import { SessionApi } from '../api/SessionApi';
/* ★ 2026-09-23:决策面板(授权栏走 navigator_only 后,决策入口搬到详情页) */
import { PermissionPanel } from './PermissionPanel';
import { RenameProposal } from '../model/SessionRename';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models';
@ -108,6 +110,14 @@ export struct MailDetailView {
@State body: string = '';
@State createdAt: string = '';
@State permissionMode: string = '';
/* ★ 2026-09-23:决策面板要的字段(对应 WebUI `MailView.tsx` 的 `PermissionPanel`) */
@State permissionKind: string = '';
@State permissionMulti: boolean = false;
@State permissionOptions: string[] = [];
@State permissionResult: string = '';
/* server/token:决策接口要用,存一份 @State 以便传给子组件 `PermissionPanel` */
@State gatewayServer: string = '';
@State gatewayToken: string = '';
@State sessionAlias: string = '';
@State loading: boolean = true;
@State error: string = '';
@ -224,6 +234,9 @@ export struct MailDetailView {
return;
}
this.accountId = account.id;
/* ★ 2026-09-23:存一份 server/token,供 `PermissionPanel` 决策用(同一个账号) */
this.gatewayServer = account.server;
this.gatewayToken = account.token;
/*
* 当前登录用户名:决定「这封是不是我发的」,进而决定回给谁。
* 与 WebUI 的 `useAuthStore(s => s.user?.username)` 同一口径 ——
@ -268,6 +281,17 @@ export struct MailDetailView {
this.body = mail.body;
this.createdAt = mail.created_at;
this.permissionMode = mail.permission_mode;
/*
* ★★ 2026-09-23:决策面板要的四个字段(与 WebUI `PermissionPanel` 同口径)。
* 服务端刚把 `permission_options` 补进详情端点 —— 见 `model/Models.ets`
* 上方注释。`expires_at` 详情端点**还没补**(由 `AttachPermissionDeadline`
* 填,但那个只填 pending 的)⇒ 面板里越窗判断会走「没截止就判不出越窗」,
* 不会报错、不会误判,后续再补这个字段即可。
*/
this.permissionKind = mail.permission_kind;
this.permissionMulti = mail.permission_multi_select;
this.permissionOptions = mail.permission_options;
this.permissionResult = mail.permission_result;
this.sessionAlias = mail.session_alias;
this.sessionId = mail.session_id;
/*
@ -1263,6 +1287,31 @@ export struct MailDetailView {
.padding({ left: 16, right: 16, bottom: 16 })
}
/*
* ★★ 2026-09-23:决策面板(对应 WebUI `MailView.tsx:693` 的 `PermissionPanel`)。
*
* 用户裁定授权栏走 `navigator_only`:点一条 → 进详情页决策。
* 所以决策入口在这里,不在授权栏里(我上一版的内联决策要被移除)。
*
* 只在「待办邮件」上挂 —— 与 WebUI 的 `isPermission` 同口径。
*/
if (this.mailType === 'permission_request') {
PermissionPanel({
mailId: this.mailId,
server: this.gatewayServer,
token: this.gatewayToken,
result: this.permissionResult,
expiresAt: '', /* 详情端点还没回这个字段,面板会走「没截止=判不出越窗」 */
kind: this.permissionKind,
options: this.permissionOptions,
multiSelect: this.permissionMulti,
isDark: this.isDarkNow,
onDecided: (decision: string) => {
this.permissionResult = decision;
}
})
}
/* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */
Blank().height(80)
}
@ -1616,6 +1665,17 @@ export struct MailDetailView {
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.clip(true)
/*
* ★★ 2026-09-21 补:候选列表入场过渡(与写信页候选同一处补齐)。
*
* 理由详见 `ComposePage.ets` 同名位置的注释 —— 一句话:
* WebUI 的同一个候选菜单挂 `animate-menu-in`(`AddressInput.tsx:261`),
* 而鸿蒙这两处**都是硬弹**。`Theme.menuIn()` 的注释本来就写着
* 它该覆盖"下拉候选",只是从来没兑现。
*
* ★ 整块 `Column` 一起动(对齐 WebUI 包整块 div 的写法),不逐项。
*/
.transition(Theme.menuIn())
}
/*
@ -1688,51 +1748,103 @@ export struct MailDetailView {
* 用同一套遮罩 + 底部弹层外壳(与回复/转发框同构),
* 这样三个弹层的交互记忆是一致的。
*/
if (this.showThread) {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay)
.onClick(() => { this.showThread = false; })
Column() {
Row() {
Text('对话树')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
Text('关闭')
.fontSize(13).fontColor(Theme.accentFor())
.onClick(() => { this.showThread = false; })
}
.width('100%')
.margin({ bottom: 10 })
List() {
ForEach(this.threadLines, (line: string, idx: number) => {
ListItem() {
Text(line)
.fontSize(12).fontColor(Theme.textMuted)
.width('100%')
.margin({ bottom: 10 })
}
}, (line: string, idx: number) => idx.toString())
}
.layoutWeight(1).width('100%')
}
.width('100%')
.height('60%')
.padding(16)
.backgroundColor(Theme.surface)
.borderRadius({ topLeft: 12, topRight: 12 })
.transition(Theme.paneRiseIn())
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
/*
* ★★ 2026-09-21 改:对话树弹层 → **官方半模态 `bindSheet`**(理由见 `ThreadSheet`)。
*
* ── 改前的自绘形状(已删)──
* if (this.showThread) {
* Column() {
* Column().layoutWeight(1).backgroundColor(Theme.overlay) // 手写遮罩
* Column() { …标题行 + List… }.height('60%')
* .backgroundColor(Theme.surface)
* .borderRadius({ topLeft: 12, topRight: 12 })
* .transition(Theme.paneRiseIn())
* }.position({ x: 0, y: 0 })
* }
*
* ★ 为什么用 `LARGE`:对话树是**列表**,长度不可预期(长线索几十条)
* ⇒ 大档 + 内部 `List` 自己滚。而改前那个 `height('60%')` 是拍脑袋的
* 百分比:内容短时它空一大块,内容长时它又不够高。
*/
.bindSheet($$this.showThread, this.ThreadSheet(), {
height: SheetSize.LARGE,
showClose: true,
/*
* ★★ `SheetMode.EMBEDDED` —— 实测调出来的关键一项。
*
* 默认(`OVERLAY`)下,半模态是**窗口级**的:宽屏 3184px 时它
* 居中弹在整个窗口上(截图实测),而触发它的「对话树」按钮
* 在**右侧详情栏**里 —— 弹层却出现在左栏上方,位置上就"不是从那儿出来的"。
*
* `EMBEDDED` 把它限制在**宿主节点所在的那块区域**内 ⇒ 弹层从
* 详情栏里升起,与触发点的空间关系对得上。
*
* ★ 这正是"自绘弹层看起来更对"的原因之一:我原来那个
* `.position({x:0,y:0})` 挂在 `MailDetailView` 的 Stack 上,
* 所以它**天然**只盖住详情栏。换成官方容器后,默认作用域变大了,
* 必须显式选 `EMBEDDED` 才能拿回原来那个(更对的)范围。
*
* ★ 官方选项默认值是 `OVERLAY`,所以这一条不是"锦上添花",
* 而是**换容器后必须补的语义**。
*/
mode: SheetMode.EMBEDDED,
onDisappear: () => { this.showThread = false; }
})
}
.width('100%').height('100%')
}
/**
* 「对话树」半模态内容(原来自绘弹层里那块列表,搬进来不改)。
*
* ★★ 2026-09-21 改:从自绘弹层换成官方 `bindSheet`(宿主见 `build()`)。
*
* 用户:「现在最割裂的就是弹出效果」。原来这个"盖在正文上的面板"是
* 自己拼的:手写遮罩 + `height('60%')` + `borderRadius({topLeft:12,topRight:12})`
* + `.transition(Theme.paneRiseIn())`。而系统对"半模态面板"的定义
* 不止外观 —— 还包括拖拽条、下滑关闭、遮罩浓度、键盘避让、出入场曲线。
*
* 那些每一个都是"用户可以预期"的交互契约:系统里别的应用能下拉关掉,
* 我们这里不能,就是割裂。
*
* ★ 原来的标题行里有一个「关闭」文字按钮 —— 保留它(多一个显式出口),
* 现在同时有系统的下滑手势与遮罩点击(多通道,不冲突)。
*/
@Builder
ThreadSheet() {
Column() {
/*
* ★★ 2026-09-21 改:**去掉自己那个「关闭」文字按钮**。
*
* 实测(宽屏截图):系统在 `showClose: true` 时已经在右上角画了一个
* 圆形 ✕,与我的「关闭」文字**重叠**在一起 —— 两个关闭入口挤在同一角,
* 既是视觉噪声,也是两个职责相同的控件。
*
* ⇒ 只留系统的那个(它是所有半模态的统一位置),标题行只放标题。
*/
Text('对话树')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.width('100%')
.margin({ bottom: 10 })
List() {
ForEach(this.threadLines, (line: string, idx: number) => {
ListItem() {
Text(line)
.fontSize(12).fontColor(Theme.textMuted)
.width('100%')
.margin({ bottom: 10 })
}
}, (line: string, idx: number) => idx.toString())
}
.layoutWeight(1).width('100%')
.scrollBar(BarState.Off)
}
.width('100%').height('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.alignItems(HorizontalAlign.Start)
}
/**
* 接受改名建议 —— 走 `PUT /sessions/{id}/alias`。
*

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
/*
* AgentMail 鸿蒙客户端 — Navigation 目标页包装(详情 / 写信)
*
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4170 行)。
*
* 抽出的动因与 `PermissionTab` 那次相同 —— **对齐需要可对比的单元**:
* 这两个包装是多个栏共用的(`InboxTab` / `ContactsTab` 都用
* `MailDetailDestination`),埋在 4170 行里时谁都不敢确认"只此一处"。
*
* ★ 它们是**壳**:官方 `Navigation` 的 `NavDestination` 包装,
* 按 `onReady` 读路径参数,再把真正的页面(`MailDetailView` / `ComposePage`)
* 挂进去。壳与页面分开是有意的 —— 页面本身不知道自己在 `Navigation` 里
* (那样它才能在别处复用、也才好单独对齐)。
*/
import { Theme } from '../common/Theme';
import { PaneModifier } from '../common/Surface';
import { MailDetailView } from './MailDetailPage';
import { ComposeView } from './ComposePage';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
@Component
export struct MailDetailDestination {
@State mailId: string = '';
@State accountId: string = '';
/** 底部悬浮条高度(窄屏非 0)——透传给详情页,让它的回复球抬过条 */
@Prop navReserve: number = 0;
/** 壁纸是否开启。宽屏下本栏要**自己**有圆角(WebUI `app-shell > *` 的对应物) */
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: MailDetailParams = ctx.pathInfo.param as MailDetailParams;
this.mailId = params?.mail_id ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
if (this.mailId.length > 0) {
MailDetailView({
initialMailId: this.mailId,
initialAccountId: this.accountId,
embedded: true,
navReserve: this.navReserve,
onBack: (): void => { this.pathStack.pop(); }
})
} else {
Column() {
LoadingProgress().width(32).height(32)
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
}
}
.hideTitleBar(true)
/*
* ★★ 2026-09-20 修(用户:「邮件展示左侧没有圆角」)。
*
* WebUI 宽屏的真结构是**三个独立圆角面板并排**(`App.tsx:262`):
*
* <div className="app-shell"> ← padding/gap = 10px
* <Sidebar/> {list} {main} ← 每个子元素各自 border-radius:14px
* </div>
*
* 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。
*
* 我们这边是一个 `Navigation`,由系统 Split 成 navBar + content 两半,
* 圆角只加在**外壳**(那个 `Row` 里的内容列)上 ⇒ 内部这两半变成直角,
* 实测详情栏白区左缘在 y=145 与 y=2195 都是 `x=1152`(**完全垂直、无圆角**)。
*
* 修法:给**右栏**(`NavDestination`)自己补上圆角 + 裁切,
* 与 WebUI 的 `app-shell > *` 逐条对应;左栏(navBar 内容)在它的根容器上补。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
/*
* ★ 这里同样**不写** `.clip()` —— 理由见左栏那处(WebUI `index.css:968` 的
* 「不能写 overflow: hidden」那条,我照搬圆角时把裁切也一起搬了,导致列表滚不动)。
*/
/*
* ★★ 2026-09-21 补:**同时去掉 NavDestination 的系统白底**。
*
* 上面那个 `.borderRadius` 一直"看着生效了",其实只裁了内容 ——
* ArkUI 的 `borderRadius` **不裁 `backgroundColor`**。而 `NavDestination`
* 自带一层不透明的 system background,它比外壳**四周各小 2px**
* (实测 `[30,142]` vs 外壳 `[28,140]`)⇒ 圆角内侧露出 2px 直角白边。
*
* 就是用户报的「圆角下方还是有白框(直角框)」。`Color.Transparent`
* 让外壳那层玻璃显出来 —— 与 `ComposeDestination` 同一处修法。
*/
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}
/**
* 写信窗格的内嵌壳(宽屏右栏)。
*
* ★★ 2026-09-20 加(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
*
* 与 `MailDetailDestination` **完全同构**(那是既有先例):
* 同一个 `NavDestination` 机制,宽屏并排在右栏、窄屏 push 覆盖全屏 ——
* 由 `Navigation.mode(Auto)` 按宽度自动决定,这里不自己判断宽窄。
*
* ★ 为什么是路由而不是一个 `@State showCompose`:
* 路由让"返回"这件事自动正确(系统返回键、手势、头部返回都弹同一个栈),
* 而一个布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批
* 「返回直接回到登录页」bug 的来源。
*/
@Component
export struct ComposeDestination {
@State to: string = '';
@State replyTo: string = '';
@State sessionAlias: string = '';
@State accountId: string = '';
@Prop navReserve: number = 0;
@Prop bgActive: boolean = false;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
const params: ComposeParams = ctx.pathInfo.param as ComposeParams;
this.to = params?.to ?? '';
this.replyTo = params?.reply_to ?? '';
this.sessionAlias = params?.session_alias ?? '';
this.accountId = params?.account_id ?? '';
this.pathStack = ctx.pathStack;
}
build() {
NavDestination() {
ComposeView({
embedded: true,
initialTo: this.to,
initialReplyTo: this.replyTo,
initialSessionAlias: this.sessionAlias,
initialAccountId: this.accountId,
onBack: (): void => { this.pathStack.pop(); }
})
}
/*
* ★★ 共享元素转场的 **in 端**(与通信页右下那个加号同一个 id)。
*
* 系统按两端各自的 frame 与圆角插值 ⇒ "球长成整页"这件事不需要我算。
* 起点圆角 28(球的半径)→ 终点 0(整幅面板)由两端各自声明。
*/
.geometryTransition('compose-morph')
.hideTitleBar(true)
/*
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
*
* `MailDetailDestination` 早就补了圆角,这里**漏了** —— 而它的注释还写着
* 「与 `MailDetailDestination` 同一处圆角修法」,说的是"打算照做",
* 实际只搬了圆角、漏了下面那件更要紧的事。
*
* ── 实测(`uitest dumpLayout`,窄屏 1008px,写信页)──
*
* Column(外壳,有圆角) [28,140][980,1957] bg=#C7FFFFFF
* NavDestination [30,142][978,1955] bg=#FFFFFFFF ← 直角、全白
*
* 那个 `#FFFFFFFF` 是 **NavDestination 自己的系统底色**,而它比外壳
* **四周各小 2px**(30 vs 28、142 vs 140 …)。于是外壳那圈 14vp 的圆角
* 内侧露出 2px 的**直角白边** —— 像素实测:y=1946 时圆角已收窄到 x=45,
* 而 x=30..39 仍是纯白;y=1952 时 x=30..48 仍是纯白。
*
* 肉眼就是用户说的「圆角下方一个白框(直角框)」,宽屏在右栏更明显。
*
* ── 修法 ──
*
* 两件事都要做,只做一件都盖不住:
* ① `borderRadius` —— 让**自己**是圆的(原来这里就有);
* ② `backgroundColor(Color.Transparent)` —— 去掉那层系统白底。
* 只给圆角不改底色没用:圆角只裁自己的**内容**,
* 而那块白是**底色**,圆角外照样画得出来。
*
* ★ 为什么 ① 原来没生效:`.borderRadius()` 在 ArkUI 里**不裁背景色**,
* 它裁的是内容与子节点。白底是 `backgroundColor`,不受圆角约束 ⇒
* 必须把底色去掉,让外壳那层(`#C7FFFFFF` 玻璃)显出来。
*/
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
.backgroundColor(Color.Transparent)
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
}
}

View File

@ -0,0 +1,65 @@
/*
* AgentMail 鸿蒙客户端 — NavDestination 路由与共用渲染片段
*
* ★★ 2026-09-23 从 `pages/MainPage.ets` 抽出。
*
* 动因:`ContactsTab` / `InboxTab` / `SentTab` 都要往详情页 push,
* 都要那个"选择一封邮件查看"的右栏占位 —— 而这些原本都定义在
* `MainPage.ets` 里。一旦某个栏被抽成独立文件(本轮在做的事),
* 它就够不到这些共用件了。
*
* ⇒ 把"多个栏共用"的东西放到一处,而不是各自复制一份 ——
* 复制是最容易产生跨端/跨栏漂移的做法(本仓为此吃过多次)。
*/
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
/**
* 详情页的路由名。
*
* ★ 必须与 `MainPage` 里 `NavDestination` 注册的路由名**逐字一致** ——
* `pushPath({ name })` 与 `navDestination({ name })` 拼错不报错,
* 只表现为"点了没反应"(本仓在 `AppStorage` 键名上踩过同一个坑)。
*/
export const MAIL_DETAIL_ROUTE: string = 'mail-detail';
/** WebUI 邮件列表统一使用 MM/DD HH:mm(三个栏都用它格式化时间) */
export function compactMailTime(iso: string): string {
const value: Date = new Date(iso);
if (Number.isNaN(value.getTime())) {
return '';
}
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
const day: string = value.getDate().toString().padStart(2, '0');
const hour: string = value.getHours().toString().padStart(2, '0');
const minute: string = value.getMinutes().toString().padStart(2, '0');
return month + '/' + day + ' ' + hour + ':' + minute;
}
/**
* `Navigation` split 模式下、栈空时右栏显示的占位。
*
* ★ 全局 `@Builder` 而不是 struct 方法:`splitPlaceholder(ComponentContent)`
* 要求传 `wrapBuilder<[]>`,而成员方法会报
* "The wrapBuilder's parameter should be '@Builder' function"。
* 内容本身是静态的(不需要 `this`),全局正好。
*
* 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律:
* 两端对同一件事说同一句话。
*/
@Builder
export function DetailPlaceholder() {
Column() {
AmIcon({ iconName: 'mail', iconSize: 40, iconColor: Theme.textSubtleFor() })
Text('选择一封邮件查看,或点击左侧「新建」写邮件')
.fontSize(14).fontColor(Theme.textMuted)
.margin({ top: 12 })
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
/*
* 宽屏右栏默认页也要透明(`splitPlaceholder` 那块由 `Navigation` 直接渲染,
* 不设背景就用系统默认底 —— 近白不透明,整片把壁纸挡死)。
*/
.backgroundColor(Color.Transparent)
}

View File

@ -0,0 +1,339 @@
/*
* AgentMail 鸿蒙客户端 — 详情页里的**待办决策面板**
*
* ★★ 2026-09-23 新建。用户裁定授权栏走 **`navigator_only`**
* (照 WebUI 的架构):授权栏只做**导航**,点一条 → 进详情页决策。
*
* ── 这次改动推翻了我上一版的做法,理由记在这里 ──
*
* 我 2026-09-23 早些时候把决策做在了**授权栏内联**(`PermissionTab` 里的
* `RequestCard`:卡片内直接「同意 / 拒绝」)。当时我给自己写的理由是
* "鸿蒙的待决卡片能显示 `question`/`options`/`context`,比 WebUI 更全"。
*
* 那个理由**本身没错,但它答的不是"该在哪决策"这个问题** ——
* 我把"信息更全"当成了"可以就地决策"。用户裁定照 WebUI 走,因为:
*
* ① **两端同一种形状**比"某一端更顺手"重要(用户这一路反复强调的东西);
* ② 详情页**本来就要能决策** —— WebUI 的 `MailView.tsx:693` 挂着
* `PermissionPanel`,而鸿蒙详情页对 `permission_request` 只显示了一个
* 「权限请求」小标签(`MailDetailPage.ets:986`),**根本没有决策入口**。
* 也就是说:鸿蒙当时是"栏里能决策、点进详情反而不能" —— 反的。
*
* ⇒ 本文件是 `components/MailView.tsx` 的 `PermissionPanel`(`:712-870`)
* 在鸿蒙侧的对应件,逐块对齐。
*
* ── 两种形态(与 WebUI 同)──
* · **审批型**(`permission`):同意 / 拒绝,单行备注,点胶囊即提交;
* · **主动提问**(`question`):多选/单选胶囊 + 多行回答,要按「提交回答」。
* 还有第三种状态:**已处理** —— 只显示结果 + 失效横幅,不给操作。
*/
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
import { Motion } from '../common/Motion';
import { MailApi } from '../api/MailApi';
import { ApiClient, ApiError } from '../api/ApiClient';
import { DecideResponse } from '../model/Models';
@Component
export struct PermissionPanel {
/** 这条待办的邮件 id(决策接口用它) */
@Prop mailId: string = '';
/** 服务端来自哪个账号的网关(多账号时决策要打到对的那台) */
@Prop server: string = '';
@Prop token: string = '';
/** 已有决策结果(空串 = 还没人处理)—— 与 WebUI 的 `mail.permission_result` 同义 */
@Prop result: string = '';
/**
* 等待窗口的截止时刻(服务端 `expires_at`)。
*
* ★ WebUI 在**详情页**这边读的是 `mail.permission_expires_at`(另一个字段,
* 由 inbox 的 `AttachPermissionDeadline` 算出来)。两边值语义相同,
* 都是"超过它就别指望那次调用还能恢复"。
*/
@Prop expiresAt: string = '';
/**
* 待办种类:`question` = 主动提问(勾选 + 自由文本),其余 = 审批(同意/拒绝)。
* 对齐 WebUI `MailView.tsx:713` 的 `mail.permission_kind === 'question'`。
*/
@Prop kind: string = '';
/** 预设选项(WebUI 的 `mail.permission_options`) */
@Prop options: string[] = [];
/** 多选题(仅 `question` 用;对齐 `mail.permission_multi_select === true`) */
@Prop multiSelect: boolean = false;
/** 决策成功后通知外层(让它刷新详情/列表) */
onDecided: (decision: string) => void = (): void => {};
/**
* 当前是否深色 —— 用来取"深浅档不同"的令牌。
*
* ★ `Theme.chipWarnBgFor(dark)` 这类 `*For()` 入口都要这个参数:
* 它们存在的理由就是"静态常量跟不了主题"(本仓 2026-09-18 实测过
* 「三级文字浅色也一样不够」)。默认 false 走浅色档,与 `MailDetailPage`
* 的 `@StorageProp` 同源 —— 由挂载处把真值传进来。
*/
@Prop isDark: boolean = false;
@State note: string = '';
@State busy: boolean = false;
/** 本地已决策的结果(提交成功后立即回显,不等外层刷新) */
@State decided: string = '';
/** 问题型已勾选的选项 */
@State picked: string[] = [];
/** 服务端在越窗时回的说明(WebUI 的 `staleWarning`) */
@State staleWarning: string = '';
aboutToAppear(): void {
/* 进来时若已有结果,直接进"已处理"态(与 WebUI 的 `useState(mail.permission_result)` 同) */
this.decided = this.result;
}
/**
* 这条待办**是否已过等待窗口**。
*
* 判法与 WebUI 逐字同口径(`MailView.tsx:727-729`):
* `!result && expiresAt 存在 && Date.now() > Date.parse(expiresAt)`。
*
* ★ 与授权栏那条(`PermissionTab.isStale`)是**同一件事的两处**:
* 栏里给"即将点下去"的人看,这里给"已经点进来"的人看。WebUI 也是两处都有
* (`PermissionList.tsx:249` 与 `MailView.tsx:727`)—— 不是重复,是同一提示
* 出现在用户可能驻足的两个位置。
*/
private isStale(): boolean {
if (this.decided.length > 0 || this.expiresAt.length === 0) {
return false;
}
const until: number = Date.parse(this.expiresAt);
return !Number.isNaN(until) && Date.now() > until;
}
private staleText(): string {
return this.staleWarning.length > 0 ? this.staleWarning
: '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' +
'现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。';
}
/** 是不是"同意"类(与 WebUI `MailView.tsx:844` 同一正则) */
private isApprove(s: string): boolean {
const l: string = s.toLowerCase();
return s.indexOf('同意') >= 0 || s.indexOf('允许') >= 0 || s.indexOf('批准') >= 0
|| l.indexOf('approve') >= 0 || l.indexOf('yes') >= 0;
}
/**
* 提交决策。
*
* ★ 越窗提示**决策前后都要显**(WebUI 那段注释写明了理由):
* 只在决策后显示 = 让人先做错一次;只在决策前显示 = 补不上
* 服务端在两次渲染之间越窗的情形。
*/
private async submit(decision: string, noteText: string): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined || this.busy) {
return;
}
this.busy = true;
try {
const c: ApiClient = new ApiClient(ctx);
c.setBase(this.server);
c.setToken(this.token);
const resp: DecideResponse = await new MailApi(c).decidePermission(this.mailId, decision, noteText);
/*
* 服务端在请求已越过等待窗口时回 `expired` + `warning`。
* 这不是错误,是"这次批准不会恢复当时那次调用" —— 必须留痕给用户看,
* 而不是弹个 toast 就没了(toast 会消失,而这件事需要一直看得见)。
*/
if (resp.warning.length > 0) {
this.staleWarning = resp.warning;
} else if (resp.expired) {
this.staleWarning = this.staleText();
}
this.decided = decision.length > 0 ? decision : '(自由文本回答)';
this.onDecided(decision);
} catch (e) {
const ae = e as ApiError;
this.getUIContext().getPromptAction().showToast({
message: ae.message.length > 0 ? ae.message : '决策失败',
duration: 4000
});
} finally {
this.busy = false;
}
}
/** 越窗横幅(WebUI `staleBanner` 的对应物) */
@Builder
StaleBanner() {
if (this.isStale() || this.staleWarning.length > 0) {
Text(this.staleText())
.fontSize(Theme.fontSmall)
.lineHeight(19)
.fontColor(Theme.warnFgFor())
.backgroundColor(Theme.warnBgFor())
.borderRadius(Theme.radiusControl)
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.margin({ top: 8 })
.width('100%')
}
}
/**
* 一颗选项胶囊(对齐 WebUI `ComposerChip`,`Composer.tsx:158`)。
*
* 两种变体:
* · `action`(审批型):本身即动作按钮,按语义**一直**填色
* (同意 = 实心绿 / 拒绝 = 浅红);
* · `toggle`(提问型):选中才填色,未选中是淡的。
*/
@Builder
Chip(label: string, active: boolean, asAction: boolean) {
Text(label)
.fontSize(Theme.fontSmall)
.fontColor(
(asAction || active)
? (asAction ? this.chipFgFor(label) : Theme.accentFg)
: Theme.textPrimary
)
.backgroundColor(
(asAction || active)
? (asAction ? this.chipActionBgFor(label) : Theme.accent)
: Theme.chipBgFor()
)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.margin({ right: 8, top: 6 })
.opacity(this.busy ? 0.5 : 1)
.onClick(() => {
if (this.busy) {
return;
}
if (asAction) {
/* 审批:点胶囊即提交(只有批准/拒绝两个动作,不需要再按一次) */
this.submit(label, this.note);
} else if (this.multiSelect) {
this.picked = this.picked.indexOf(label) >= 0
? this.picked.filter((p: string) => p !== label)
: this.picked.concat([label]);
} else {
/* 单选:再点同一项则取消,否则替换(与 WebUI `toggle` 同) */
this.picked = this.picked.indexOf(label) >= 0 ? [] : [label];
}
})
}
/** 动作型胶囊的字色(实心底 ⇒ 恒用前景白/品牌前景) */
private chipFgFor(label: string): string {
return Theme.accentFg;
}
/** 动作型胶囊的底色:同意 = 实心绿,其余 = 危险红(对齐 WebUI `activeCls`) */
private chipActionBgFor(label: string): string {
return this.isApprove(label) ? Theme.approve : Theme.danger;
}
build() {
Column() {
/* ── 已处理态:只显示结果,不给操作(WebUI `MailView.tsx:762-770`)── */
if (this.decided.length > 0) {
Row() {
Text('已处理:').fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
Text(this.decided)
.fontSize(Theme.fontSmall).fontWeight(FontWeight.Bold)
.fontColor(Theme.textPrimary)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
this.StaleBanner()
} else if (this.kind === 'question') {
/*
* ── 主动提问:勾选 + 自由文本(WebUI `MailView.tsx:772-825`)──
*
* 回答**必须非空**:空提交会让模型拿到一个什么都没说的结果继续跑。
*/
Text(this.options.length === 0
? '这题没有预设选项,请直接填写回答:'
: (this.multiSelect ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'))
.fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
.width('100%')
this.StaleBanner()
if (this.options.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.options, (opt: string) => {
this.Chip(opt, this.picked.indexOf(opt) >= 0, false)
}, (opt: string) => 'opt-' + opt)
}
.width('100%')
}
TextInput({
placeholder: this.options.length === 0 ? '你的回答(必填)' : '补充说明(可选)',
text: this.note
})
.width('100%').height(40).margin({ top: 8 })
.fontSize(Theme.fontSmall)
.onChange((v: string) => { this.note = v; })
Row() {
Button(this.busy ? '提交中…' : '提交回答')
.height(38)
.fontSize(Theme.fontSmall)
/* 空回答时禁用 —— 与 WebUI `submit.disabled = blank` 同 */
.enabled(!this.busy && (this.picked.length > 0 || this.note.trim().length > 0))
.backgroundColor(Theme.accent)
.onClick(() => {
this.submit(this.picked.join('\n'), this.note.trim());
})
if (this.picked.length === 0 && this.note.trim().length === 0) {
Text('请先选择或填写回答')
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor())
.margin({ left: 8 })
}
}
.width('100%')
.margin({ top: 8 })
} else {
/*
* ── 审批型:同意 / 拒绝(WebUI `MailView.tsx:826-870`)──
*
* 没有预设选项时退回 ['同意', '拒绝'] —— 与 WebUI 同一兜底。
*/
this.StaleBanner()
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.actionOptions(), (opt: string) => {
this.Chip(opt, false, true)
}, (opt: string) => 'act-' + opt)
}
.width('100%')
TextInput({ placeholder: '备注(可选)', text: this.note })
.width('100%').height(40).margin({ top: 8 })
.fontSize(Theme.fontSmall)
.onChange((v: string) => { this.note = v; })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 10 })
/*
* 与正文之间那条橙色分隔(WebUI `border-t border-orange-200`)。
*
* ★ 用 `chipWarnBg`(= tailwind `orange-100`,`#FFEDD5`)而不是新造一个
* `warnBorder` 令牌:本仓的纪律是"令牌要么来自系统、要么是跨端逐字一致的
* Tailwind 值"。WebUI 那边 `orange-200` 只出现在这两处边框上,
* 为它单独立一个跨端令牌**反而会把两边绑到一个各自都用不到几次的值上**。
* 用已有的橙色档(差一档、观感同族)比多一个令牌好。
*/
.border({ width: { top: 1 }, color: Theme.chipWarnBgFor(this.isDark) })
.margin({ top: 12 })
/*
* 入场的淡入位移 —— 详情页正文之后出现的一块内容。
* 与 WebUI 那边 `.rise-in` 同观感(本仓的 `Theme.paneRiseIn`)。
*/
.transition(Theme.paneRiseIn())
}
/** 审批型胶囊的取值(与 WebUI `mail.permission_options?.length ? … : ['同意','拒绝']` 同) */
private actionOptions(): string[] {
return this.options.length > 0 ? this.options : ['同意', '拒绝'];
}
}

View File

@ -0,0 +1,666 @@
/*
* AgentMail 鸿蒙客户端 — 授权(通信页第三栏)
*
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4592 行、28 个内联
* `@Builder`)。抽出的动因不是"文件太长不好看",而是**对齐需要**:
*
* 用户要求「两端系统性对齐」。而 WebUI 那边每一块都是一个组件文件
* (`components/PermissionList.tsx` / `PermissionChip.tsx` / `MailList.tsx` …),
* 鸿蒙这边全是一个大 struct 里的内联 Builder ⇒ **没有一个可对比的单元**。
* 于是"对齐"只能退化成逐行比渲染字段 —— 而那正是用户批评的修修补补。
*
* ⇒ 把授权栏抽成独立组件,让它与 `PermissionList.tsx` 形成**一一对应**:
* 以后对齐看这一处,不用在 4592 行里找。
*
* ★ 为什么放在 `pages/` 而不是 `common/`:
* `common/` 里是可被任意页面复用的**通用**件(`Surface` 的玻璃卡/按压、
* `BackgroundPicker`、`Icons`…)。授权栏是**一个功能面**,只服务通信页第三栏,
* 与 `WideSidebar.ets` 同类 —— 那个也是放在 `pages/` 下的独立组件文件。
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AppHeader, GlassCardModifier, PaneModifier } from '../common/Surface';
import { AmIcon } from '../common/Icons';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import {
PermissionRequest,
PendingResponse,
MailSummary
} from '../model/Models';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { emptyTitle, emptyHint } from '../model/CommTabs';
import { LengthMetrics } from '@kit.ArkUI';
import { MailLike, PermissionGroup, groupPermissions, shortTimeOf } from '../model/MailGrouping';
import { INBOX_PAGE_SIZE } from '../common/MailStore';
/** WebUI 邮件列表统一使用 MM/DD HH:mm(与 `MainPage` 的同名函数一致)。 */
function compactMailTime(iso: string): string {
const value: Date = new Date(iso);
if (Number.isNaN(value.getTime())) {
return '';
}
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
const day: string = value.getDate().toString().padStart(2, '0');
const hour: string = value.getHours().toString().padStart(2, '0');
const minute: string = value.getMinutes().toString().padStart(2, '0');
return month + '/' + day + ' ' + hour + ':' + minute;
}
/*
* ─────────────────────── 授权(通信页第三栏) ───────────────────────
*
* 只放**待人点头**的事:`GET /permission/pending`(不从收件箱筛,理由见 `MailApi`)。
* 决策走 `POST /permission/decide`,`note` 会随决策送达模型 ——
* 所以拒绝时**能填备注**,而且填了必须真的发出去。
*
* 两件必须如实说的事(WebUI 侧踩过坑,见 `decidePermission` 的返回类型):
* ① 请求**已过期**时服务端会带 `expired`:这时决策落到了一个没人在等的请求上,
* 得当场告诉人(否则会以为"批了,Agent 继续干活了");
* ② `warning` 同理,服务端让显示什么就显示什么。
*/
@Component
export struct PermissionTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
/*
* ★★ 2026-09-23:授权栏走 `navigator_only`(用户裁定)。
*
* 点一条 → 推详情页(与 InboxTab / ContactsTab 同一条 `openMail`)。
* 决策 UI 在详情页的 `PermissionPanel`,**不在**这里 ——
* 所以这里不再需要「同意/拒绝」按钮、备注框、`decide()` 那一整套。
* 对齐 WebUI `PermissionList.tsx:81` 的 `pick()` = `selectMail + showDetail`。
*/
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
@State requests: PermissionRequest[] = [];
/**
* 已决策的**历史**,按会话分组(对齐 WebUI `PermissionList.tsx:182` 的「历史 {n}」)。
*
* ★ 来源是 **inbox**(不是 `/permission/pending`)—— 后者 SQL 带
* `WHERE pr.result IS NULL`,永远拿不到已决策的。
* 详见 `load()` 里那段取舍说明。
*/
@State permGroups: PermissionGroup[] = [];
@State loading: boolean = false;
@State error: string = '';
/*
* ★★ 2026-09-23:`noteFor` / `noteText` / `busyId` 已删 ——
* `navigator_only` 后决策 UI 搬到详情页,授权栏不再做决策。
*/
/**
* 已展开的会话分组(键 = `PermissionGroup.key`)。
*
* ★★ 2026-09-23 新增 —— 对齐 WebUI `PermissionList.tsx` 的**展开层**。
*
* ── 为什么需要它(之前那版把这一整层漏了)──
* 上一版鸿蒙只给每个会话一行「历史 n 条」,**不可展开**。
* 而 WebUI 那行是个 `<button onClick={onToggle}>`(`PermissionList.tsx:156`),
* 点开之后是逐条的 `PermissionRow`(`:214-222`)—— 每一条带
* `subject` + `created_at` + **决策结果**(绿√ 同意 / 红✗ 拒绝),
* 以及一个鸿蒙**完全没有**的东西:**「可能已失效」告警**(`:249-255`)。
*
* ★ 那个告警不是装饰,它有后果:`permission_expires_at` 过了之后,
* 发起询问的 Agent 很可能**已不再阻塞等待** —— 这时人再去看历史,
* 得知道"这条当时可能没真的生效"。WebUI 的实现里它是个 `title`
* 提示 + 一个灰色胶囊;鸿蒙这边直接写成一行小字(没有 hover,
* 触屏用 title 是无效的)。
*
* ★ 为什么用 `string[]` 而不是 `Set`:ArkTS 的 `@State` 对 `Set` 的
* 变更检测不可靠(`@ohos` 的观察机制按引用/原始值走,`Set.add()` 不触发重绘)。
* 本仓在 `expandedKeys`(收件箱会话折叠)上已经用了同一个形状 —— 保持一致。
*/
@State expandedKeys: string[] = [];
/** 已展开分组里再展开的「历史逐条」段(WebUI 的 `showSettled` 是逐组一个 state) */
@State settledOpenKeys: string[] = [];
aboutToAppear(): void {
this.load();
}
async load(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.loading = true;
this.error = '';
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
const all: PermissionRequest[] = [];
/* 已决策的历史(从 inbox 取,见下面 `settled` 的注释) */
const settled: MailLike[] = [];
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
try {
const c: ApiClient = new ApiClient(ctx);
c.setBase(acct.server);
c.setToken(acct.token);
const resp: PendingResponse = await new MailApi(c).pendingPermissions();
for (let j = 0; j < resp.requests.length; j++) {
/* ★ 2026-09-23:记下来源账号,点卡片跳详情要用 */
resp.requests[j].source_account_id = acct.id;
all.push(resp.requests[j]);
}
/*
* ★★ 2026-09-21 补**已决策的历史**。
*
* 用户可见差异(登记在 `docs/DEBTS.json` 的 `harmony-permission-history`,
* 其到期条件正是「做『授权栏与 WebUI 对齐』时」):
* `GET /permission/pending` 的 SQL 带 `WHERE pr.result IS NULL`
* ⇒ **只拿得到待决的**,于是"这条会话批过哪些事"在鸿蒙上完全看不到,
* 而 WebUI 能看到(`PermissionList.tsx:182` 的「历史 {n}」)。
*
* ── 为什么不改走 WebUI 的 inbox 分组 ──
*
* 核实过:WebUI 从 inbox 分组(`groupPermissions`),但它的
* `PermissionRow` **只渲染** `subject` / `created_at` / `permission_result`
* / `permission_expires_at`(逐字段 grep 过)。
* 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` ——
* 那四个字段在 `permission_requests` **表**里,
* inbox 回包(`models.Mail`)**没有它们**(模型里逐条核过)。
*
* ⇒ 待决继续走专用端点(信息更全、能直接决策),
* 历史走 inbox 补上。两条来源合起来,与 WebUI 的可见信息量一致。
* 代价:多一次请求/账号。这是**有意的取舍**,不是漏了优化。
*/
const inbox: InboxResponse = await new MailApi(c).inbox('all', INBOX_PAGE_SIZE);
for (let j = 0; j < inbox.mails.length; j++) {
const m: MailSummary = inbox.mails[j];
/* 只要**已决策**的权限请求(待决的已由上面那个端点给了) */
if (m.mail_type === 'permission_request' && m.permission_result.length > 0) {
m.source_account_id = acct.id;
settled.push(m);
}
}
} catch (e) {
// 单账号失败不空整栏
}
}
this.requests = all;
this.permGroups = groupPermissions(settled);
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
/** 分组是否已展开(WebUI 的 `expanded` 集合) */
private isGroupOpen(key: string): boolean {
return this.expandedKeys.indexOf(key) >= 0;
}
/** 分组内的「已决策」段是否展开(WebUI 的 `showSettled`) */
private isSettledOpen(key: string): boolean {
return this.settledOpenKeys.indexOf(key) >= 0;
}
/**
* 开关分组。
*
* ★ 用 `concat`/`filter` 造**新数组**而不是 `push`/`splice` 改原数组:
* ArkTS 的 `@State` 对数组是**引用比较**,原地 `push` 不会触发重绘
* (本仓在 `expandedKeys`(收件箱会话折叠)上已经用了同一个形状)。
*/
private toggleGroup(key: string): void {
if (this.isGroupOpen(key)) {
this.expandedKeys = this.expandedKeys.filter((k: string) => k !== key);
} else {
this.expandedKeys = this.expandedKeys.concat([key]);
}
}
private toggleSettled(key: string): void {
if (this.isSettledOpen(key)) {
this.settledOpenKeys = this.settledOpenKeys.filter((k: string) => k !== key);
} else {
this.settledOpenKeys = this.settledOpenKeys.concat([key]);
}
}
/**
* 这条**待决**请求是否已过等待窗口(对齐 WebUI `PermissionList.tsx:248-250`)。
*
* ★★ 2026-09-23 重要纠正:我第一版把它用在了**历史行**上,那是错的。
*
* ── WebUI 的原口径 ──
* const expired = !settled && !!mail.permission_expires_at && Date.now() > …
* 那个 **`!settled`** 是关键:它**只在待决行**上判定。
*
* ── 为什么历史行上永远不会出现它(服务端定的)──
* `permission_expires_at` 不是数据库列,而是读路径**算出来**的:
* func AttachPermissionDeadline(m *Mail) {
* if m.MailType != "permission_request" || m.PermResult != "" { return }
* d := models.PermissionDeadline(m.CreatedAt); m.PermissionExpiresAt = &d
* }
* 第二行那个 `PermResult != "" ⇒ return` 意味着:**已决策的邮件拿不到这个字段**。
* ⇒ 在历史行上判 stale 恒为 false,那段代码是**死分支**(看着有、永远不执行)
* —— 而那正是本仓反复在消的形状("声明比实现宽")。
*
* ⇒ 现在它只服务待决行,与 WebUI 同口径。
*
* ★ 与 WebUI 的一处**有意差异**:WebUI 把它做成 `title` 悬停提示,
* 而触屏没有 hover ⇒ 那句话在平板上永远看不到。
* 这里渲染成可读的小字。
*/
private isStale(expiresAt: string): boolean {
if (expiresAt.length === 0) {
return false;
}
const until: number = Date.parse(expiresAt);
return !Number.isNaN(until) && Date.now() > until;
}
/**
* 一条历史逐条行(对齐 WebUI `PermissionRow` 的 `settled` 分支,
* `PermissionList.tsx:249-312`)。
*
* 渲染:`subject` + 时间 + **决策结果**(绿√ / 红✗ + 原文)。
*
* ★ 这里**没有**「可能已失效」分支 —— 那是**待决**行的。
* 我第一版把它也写在这里,属于**死分支**:
* 服务端的 `AttachPermissionDeadline` 对已决策的邮件**直接 return**
* (`PermResult != ""` 就不填 `expires_at`)⇒ 这里判永远是假。
* 删掉是必要的 —— 留着就是"看着有、永远不执行"的代码,
* 而本仓为这一形状反复吃过亏。
*/
@Builder
PermissionHistRow(m: MailLike) {
Column() {
Row() {
Text(m.subject.length > 0 ? m.subject : '(无主题)')
.fontSize(12)
.fontColor(m.permission_result.length > 0 ? Theme.textMuted : Theme.textPrimary)
.fontWeight(m.permission_result.length > 0 ? FontWeight.Normal : FontWeight.Medium)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(shortTimeOf(m.created_at))
.fontSize(10).fontColor(Theme.textSubtleFor())
.margin({ left: 6 })
}
.width('100%')
Row() {
/*
* 已决策:绿√(同意类)/ 红✗(其余)。
* 判定逐字对齐 WebUI `PermissionList.tsx:243` 的
* `/同意|允许|批准|approve|yes/i` —— 服务端存的是中文决策词,
* 而 Agent 可能传英文,所以两边都要认。
*/
AmIcon({
iconName: this.isApproved(m) ? 'check' : 'close',
iconSize: 10,
iconColor: this.isApproved(m) ? Theme.approveFor() : Theme.dangerFor()
})
Text(m.permission_result)
.fontSize(10)
.fontColor(this.isApproved(m) ? Theme.approveFor() : Theme.dangerFor())
.margin({ left: 2 })
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.margin({ top: 2 })
}
.width('100%')
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.surfaceMuted)
.margin({ top: 3 })
}
/** 决策是不是"同意"类(与 WebUI 同一正则,见 `PermissionHistRow` 的说明) */
private isApproved(m: MailLike): boolean {
const r: string = m.permission_result;
return r.indexOf('同意') >= 0 || r.indexOf('允许') >= 0 || r.indexOf('批准') >= 0
|| r.toLowerCase().indexOf('approve') >= 0 || r.toLowerCase().indexOf('yes') >= 0;
}
/**
* 会话分组头上那行地址:`agent@path.alias`。
*
* ★★ 2026-09-23 新增(对齐 WebUI `PermissionList.tsx:165`)。
*
* WebUI 那行是**内联模板**,不是调 `formatAddress`:
* {g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
* 语义是「**三段各自可缺**,缺哪段就不出哪段」。
*
* ★ 为什么不直接调 `participantAddress()`(它就在 `ReplyTarget.ts` 里):
* 那个函数是**参与方地址**的口径(人只给名字、Agent 才三段,靠 `isHuman` 分流)。
* 而这里显示的是**会话的 Agent 身份**,不是"收件人/发件人"那个角色 ——
* 没有 `isHuman` 可传,也不应把人名硬套进去。
* 两者形状像,语义不同;用错会得到一个看似正常、实际丢段的地址。
*
* ★ 三段的来源:`agentName` ← `from_name`(权限请求一定由 Agent 发出),
* `path` ← **会话的** `session_workspace`(不是 `from_workspace` ——
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`,
* `ReplyTarget.participantAddress` 的注释里记着这个坑)。
*/
private permGroupLabel(g: PermissionGroup): string {
let out: string = g.agentName.length > 0 ? g.agentName : '(未知 Agent)';
if (g.path.length > 0) {
out += '@' + g.path;
}
if (g.alias.length > 0) {
out += '.' + g.alias;
}
return out;
}
/** 顶栏右侧:待决策徽标(橙=有人被卡住,不是"有东西要读") */
@Builder
PendingTrailing() {
if (this.requests.length > 0) {
Text(this.requests.length + ' 待决策')
.fontSize(12).fontColor(Theme.accentFg)
.backgroundColor(Theme.warnFg)
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.margin({ right: 8 })
}
}
@Builder
RequestCard(req: PermissionRequest) {
Column() {
Row() {
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
/*
* ★★ 2026-09-20 修:**这里原来读的是一个不存在的字段**。
*
* `req` 是 `PermissionRequest`,而服务端那个 struct
* (`models.go:239-255`)**没有 `session_alias`** ——
* `repo.ListPendingPermissionsFor` 的 SELECT 也没查它。
* WebUI 的类型里同样没有(`types/index.ts:233` 只有 `session_id`/`agent_name`)。
*
* ⇒ `req.session_alias` 恒为 `undefined`,
* 一旦有待办,`.length` 当场抛 TypeError(整页白屏)。
* 这个 bug **一直没暴露只是因为当前待办数一直是 0** ——
* 实测 `/permission/pending` 返回 `{"requests":[]}`。
*
* 改成服务端**确实有**的 `agent_name`(授权请求一定由 Agent 发出,
* 这是卡片上最有辨识度的一格)。
* 与 WebUI 同口径:那边授权卡标题也只显示 Agent 与问题,不显示会话别名。
*/
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
.fontSize(10).fontColor(Theme.accentFor())
}
.width('100%')
// 问题是这张卡的主角:人要照着它决定点头还是摇头
Text(req.question.length > 0 ? req.question : '(无问题描述)')
.fontSize(13).fontColor(Theme.textPrimary)
.margin({ top: 6 })
if (req.context.length > 0) {
Text(req.context)
.fontSize(11).fontColor(Theme.textSubtleFor())
.maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
/* 同 ①:WebUI `PermissionList.tsx:251` 也格式化,不印 ISO */
Text(compactMailTime(req.created_at)).fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
/*
* ★★ 2026-09-23 补:**「可能已失效」告警**(对齐 WebUI `PermissionList.tsx:248-255`)。
*
* ── 为什么它必须在这里(而不是历史行上)──
* WebUI 的判定带 `!settled` —— 它**只在待决行**上用。
* 而服务端 `AttachPermissionDeadline` 对已决策的邮件直接 return
* (`PermResult != ""` 就不填 expires_at)⇒ 历史行上永远算不出失效来。
* 我第一版把它写在了历史行上,那是**死分支**(已删)。
*
* ── 为什么值得占一行屏幕 ──
* 过了等待窗口之后,发起询问的 Agent **很可能已不再阻塞等待**:
* 现在点"同意"不会恢复当时那次工具调用(决策只会当作一条通知投给它)。
* 不说的话,人会以为"我批了,它接着干了"—— 而实际没有。
*
* ★ 与 WebUI 的一处**有意差异**:WebUI 把它放在 `title` 属性里(悬停提示),
* 而触屏没有 hover ⇒ 那句话在平板上永远看不到。
* 这里直接渲染成可读的横条。
*/
if (this.isStale(req.expires_at)) {
Text('已超过等待窗口 —— 点开查看并决策')
.fontSize(10)
.lineHeight(15)
.fontColor(Theme.warnFgFor())
.backgroundColor(Theme.warnBgFor())
.borderRadius(Theme.radiusControl)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.margin({ top: 6 })
.width('100%')
} else {
/*
* ★★ 2026-09-23:`navigator_only` 后,这里只留一个「点开决策」的提示。
* 原来的「同意/拒绝」按钮 + 备注框全删了 —— 决策 UI 在详情页的
* `PermissionPanel`(与 WebUI `MailView.tsx:693` 同一处)。
*/
Row() {
Text('点开决策 →')
.fontSize(12)
.fontColor(Theme.accent)
.layoutWeight(1)
AmIcon({ iconName: 'chevronRight', iconSize: 18, iconColor: Theme.textSubtleFor() })
}
.width('100%')
.margin({ top: 8 })
}
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(12)
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.margin({ bottom: 8 })
/*
* ★★ 2026-09-23:整卡可点 → 推详情页(与 WebUI `PermissionList.tsx:81` 的
* `pick()` = `selectMail + showDetail` 同义)。决策在详情页做,不在这里。
*/
.onClick(() => {
this.onOpenMail(req.mail_id, req.source_account_id);
})
}
build() {
Column() {
/* 同 SentTab:统一走 AppHeader(圆框 + 与底栏同族的几何与材质) */
AppHeader({
title: '授权',
showBack: false,
active: this.bgActive,
topInsetPx: 0
}) {
this.PendingTrailing()
}
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() { Text(this.error).fontSize(13).fontColor(Theme.dangerFor()) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.requests.length === 0 && this.permGroups.length === 0) {
Column() {
AmIcon({ iconName: 'shield', iconSize: 36, iconColor: Theme.textSubtleFor() }).margin({ bottom: 8 })
Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted)
Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 8 }) {
/* 待决(可决策) */
ForEach(this.requests, (req: PermissionRequest) => {
ListItem() {
this.RequestCard(req)
}
.width('100%')
}, (req: PermissionRequest) => req.request_id)
/*
* ── 已决策的会话分组(对齐 WebUI `PermissionList` 的会话行 + 展开层)──
*
* ★★ 2026-09-21 新增「历史」这一段(此前鸿蒙完全看不到已决策的)。
*
* ★★ 2026-09-23 **按 WebUI 逐行重写,并补上展开层**。
*
* ── 我是怎么发现原来不对的 ──
* 上一版这里只渲染「别名 + 历史 n 条」,而我写了一句话注释声称
* "与 WebUI 同层、不是少做了"。那句话**是我自己说的、没有对照过**。
* 真的去逐行读 `PermissionList.tsx:155-222` 才发现:
* · 会话行是个 `<button onClick={onToggle}>`(**可点、带展开箭头**);
* · 行上有:机器人图标 + `agent@path.alias` + **时间**(`latest`)
* + 「N 待决策」橙胶囊 /「已全部处理」灰胶囊 + 「历史 {n}」;
* · **展开后**才是逐条 `PermissionRow`:`subject` + 时间 +
* **决策结果**(绿√ / 红✗)+ **「可能已失效」告警**。
* 我上一版只做了行上的两项,把整个展开层与告警都漏了。
*
* ⇒ 现在补齐。每一块都注了 WebUI 的出处。
*/
ForEach(this.permGroups, (g: PermissionGroup) => {
ListItem() {
Column() {
/*
* 组头:可点(WebUI 的 `<button onClick={onToggle}>`)。
* 整个组头是一块命中区 —— 不是只点箭头(触屏上小靶难中,
* 本仓的 `AppHeader` 返回键也为此做到 44vp)。
*/
Column() {
/* 第一行:展开箭头 + 机器人图标 + `agent@path.alias` + 时间 */
Row() {
/*
* 展开箭头(WebUI 的 `ChevronRightIcon`,展开时转 90°)。
* 用 `rotate` 而不是换图标:换图标会让那一列宽度跳一下。
*/
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
.rotate({ angle: this.isGroupOpen(g.key) ? 90 : 0 })
AmIcon({ iconName: 'bot', iconSize: 14, iconColor: Theme.textMuted })
.margin({ left: 4 })
/*
* 地址拼接逐字对齐 WebUI `PermissionList.tsx:165`:
* {g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
* —— 三段各自可缺,缺哪段就不出哪段(不是拼出 `@@`、`.undefined`)。
*/
Text(this.permGroupLabel(g))
.fontSize(13)
.fontFamily('monospace')
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
.margin({ left: 5 })
/* 组头时间取组内**最新一封**(`g.latest`)—— 与 WebUI 的 `{time}` 同源 */
if (g.latest !== undefined) {
Text(shortTimeOf(g.latest.created_at))
.fontSize(11).fontColor(Theme.textSubtleFor())
.margin({ left: 6 })
}
}
.width('100%')
/* 第二行:状态胶囊 + 历史条数(同 WebUI 的第二行 flex) */
Row() {
/*
* 状态胶囊:**只有历史**时是「已全部处理」(灰),
* 有待决时是「N 待决策」(橙)。
*
* ★ 注意:待决那一段已经在上面的 `this.requests` 里**单独成卡**了,
* 所以这里出现"N 待决策"不是重复展示,而是**告诉人这个会话里
* 还有东西要他点头**(否则他会以为这整个分组都是历史)。
* 这与 WebUI 一致 —— 它那边也是「同一个组头既报待决数、又报历史数」。
*/
if (g.pending.length > 0) {
Row() {
AmIcon({ iconName: 'shield', iconSize: 10, iconColor: Theme.accentFg })
Text(g.pending.length.toString() + ' 待决策')
.fontSize(10).fontColor(Theme.accentFg)
.margin({ left: 2 })
}
.backgroundColor(Theme.warnFg)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
} else {
Row() {
AmIcon({ iconName: 'check', iconSize: 10, iconColor: Theme.textSubtleFor() })
Text('已全部处理')
.fontSize(10).fontColor(Theme.textSubtleFor())
.margin({ left: 2 })
}
.backgroundColor(Theme.surfaceMuted)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
}
/* 历史条数(WebUI 是 `历史 {n}`,措辞保持逐字一致) */
Text('历史 ' + g.settled.length.toString())
.fontSize(10).fontColor(Theme.textSubtleFor())
.margin({ left: 6 })
}
.width('100%')
.margin({ top: 4 })
}
.width('100%')
.onClick(() => { this.toggleGroup(g.key); })
/*
* ── 展开层 ──
*
* 对齐 WebUI `PermissionList.tsx:194-222`:
* · 待决逐条(`PermissionRow`);
* · 再一个「展开/收起已决策 N」开关,展开后才列历史逐条。
*
* ★ WebUI 把两段分开开关(组头开组、组内再开历史)。
* 照做:历史在鸿蒙这边可能很长(本机实测库里 107 条),
* 一展开全铺出来会把待决那半顶出屏幕。
*/
if (this.isGroupOpen(g.key)) {
Column() {
/* 待决逐条 */
ForEach(g.pending, (m: MailLike) => {
this.PermissionHistRow(m)
}, (m: MailLike) => 'p-' + m.mail_id)
/* 历史段的开关(WebUI: `{showSettled ? '收起' : '展开'}已决策 {n}`) */
if (g.settled.length > 0) {
Text(this.isSettledOpen(g.key)
? '收起已决策 ' + g.settled.length.toString()
: '展开已决策 ' + g.settled.length.toString())
.fontSize(11).fontColor(Theme.textSubtleFor())
.width('100%')
.padding({ left: 6, top: 6, bottom: 4 })
.onClick(() => { this.toggleSettled(g.key); })
if (this.isSettledOpen(g.key)) {
ForEach(g.settled, (m: MailLike) => {
this.PermissionHistRow(m)
}, (m: MailLike) => 's-' + m.mail_id)
}
}
}
.width('100%')
.margin({ top: 6 })
}
}
.width('100%')
.padding(12)
.attributeModifier(GlassCardModifier.of(this.bgActive, false))
.borderRadius(Theme.radiusCard)
}
.width('100%')
}, (g: PermissionGroup) => 'hist-' + g.key)
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
.contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
.width('100%').height('100%')
.attributeModifier(PaneModifier.of(this.bgActive))
}
}

View File

@ -115,7 +115,13 @@ export struct SettingsPane {
@State newKeyToken: string = '';
@State keyLabel: string = '';
@State creatingKey: boolean = false;
/* 修改密码表单 */
/*
* 修改密码表单。
*
* ★★ 2026-09-21(用户:「修改密码也应该做成弹窗吧」):这一组原来常驻在页面上,
* 现在装进官方半模态 —— `showPasswordSheet` 是它的显隐开关(`$$` 两向绑定)。
*/
@State showPasswordSheet: boolean = false;
@State oldPw: string = '';
@State newPw: string = '';
@State confirmPw: string = '';
@ -768,69 +774,133 @@ export struct SettingsPane {
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
/* 末尾让位:最后一段(退出登录)要能滚出悬浮条之下 */
.contentEndOffset(this.navReserve)
// 新增账号弹层(留在最外层,不被滚动容器裁掉)
if (this.showAddDialog) {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor(Theme.overlay)
.onClick(() => { this.showAddDialog = false; })
Column() {
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.margin({ bottom: 16 })
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newDisplayName = value; })
TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newServer = value; })
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newUsername = value; })
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
.width('100%').height(44).margin({ bottom: 16 })
.type(InputType.Password)
.onChange((value: string) => { this.newToken = value; })
Row() {
Button('取消')
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
.onClick(() => { this.showAddDialog = false; })
Blank()
Button(this.adding ? '验证中…' : '添加')
.width(80).height(36).backgroundColor(Theme.accent)
.enabled(!this.adding)
.onClick(() => { this.addNewAccount(); })
}
.width('100%')
}
.width('100%').height('68%')
.padding(16).attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius({ topLeft: 12, topRight: 12 })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 底部弹层挂在 `if (this.showAddDialog)` 上,原来硬弹。
* 与 `MailDetailPage` 的回复框/转发框**同一档、同一挂法**:
* `paneRiseIn()` 挂在**弹层本身**(下半部那块表面),遮罩立即出现。
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
* 那看起来是"页面在动",而不是"弹层弹出来"。
*/
.transition(Theme.paneRiseIn())
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
}
.width('100%').height('100%')
/* 与其它窗格同一约定:背景开着时让出页面底,壁纸才透得过 */
.attributeModifier(PaneModifier.of(this.bgActive))
/*
* ★★ 2026-09-21 改:新增账号弹层 → **官方半模态 `bindSheet`**。
*
* ── 改前的自绘形状(已删)──
* if (this.showAddDialog) {
* Column() {
* Column().layoutWeight(1).backgroundColor(Theme.overlay) // 手写遮罩
* Column() { …表单… }.height('68%').transition(Theme.paneRiseIn())
* }.position({ x: 0, y: 0 })
* }
*
* 四个具体毛病(每个都是"割裂"的一条):
* ① **遮罩颜色手写**(`Theme.overlay`)—— 不同页面各选一次,深浅档也不同步;
* ② **高度硬编** `height('68%')` —— 系统半模态是按内容/档位算的,我拍了个百分比;
* 键盘弹起时它不会自动让位(官方 `SheetOptions.keyboardAvoidMode` 就是干这个的);
* ③ **没有拖拽条**(官方 `dragBar` 默认 true)—— 用户没法下拉关掉,
* 而这是半模态的**通用手势**;
* ④ **没有下滑关闭 / 遮罩点击关闭的官方语义**(我手写了遮罩 onClick)。
*
* ── 换完得到什么 ──
* 与系统其它应用的半模态**同一种观感**(高度档位、圆角、拖拽条、
* 遮罩浓度、出入场曲线全部由系统给),而且自带:
* · `detents` 多档高度(用户可拖拽切换);
* · `keyboardAvoidMode` 键盘避让;
* · 下滑手势关闭(`shouldDismiss` / `onWillDismiss` 可拦截)。
*
* ★ 这就是用户说的"系统预制动效不需要占带宽,为什么不用"的同一个道理:
* 弹层不只是动画,**它的几何、遮罩、手势、无障碍语义都是系统的**。
*
* ★ 保留 `showAddDialog` 作 `isShow` —— 它是"显示与否"的唯一状态,
* 带 `$$` 两向绑定后,用户拖拽/点遮罩关闭时会**自动回写** false
* (否则界面关了而状态还是 true,再点「+」就不出来了)。
*/
.bindSheet($$this.showAddDialog, this.AddAccountSheet(), {
/*
* ★ `FIT_CONTENT` 而不是 `MEDIUM`。
*
* 实测(宽屏 3184):`MEDIUM` 给的是一个**固定档高度**,
* 而这块表单只有 4 个输入框 + 一行按钮 ⇒ 下方空出一大片
* (截图里弹层下半截是空的)。`FIT_CONTENT` 让容器**贴着内容**,
* 这才是"表单型半模态"该有的形状。
*
* ★ 官方 `SheetSize` 只有三档(`FIT_CONTENT` / `MEDIUM` / `LARGE`),
* 都是"按内容或屏幕比例算"的 —— 比改前那个手写的 `height('68%')` 讲道理。
*/
height: SheetSize.FIT_CONTENT,
/*
* ★★ 2026-09-21 改:`false` → `true`(与隔壁「修改密码」刚刚统一)。
*
* 原来这里是 `false`(不要系统那个 ✕),理由是"内容里已经有「取消」按钮,
* 再给一个 ✕ 就重复了"。那个理由本身站得住 —— 但它只看到**本块**,
* 没看到两个同胞的形状:
* · 新增账号:`showClose: false` + 内容里「取消」
* · 修改密码:`showClose: false` + 内容里「取消」
* 两个**同类**(表单型半模态)一个有一个没有,用户看到的是"为什么这个能叉掉、
* 那个不能"。
*
* ⇒ 统一为 `true`。系统 ✕ 与「取消」并存不是重复:
* ✕ 是"关掉面板"(与下滑关闭同类,也是无障碍读屏的关闭入口),
* 「取消」是"不提交这次编辑"(表单语汇)。两者语义不同,都有才有。
* 这也是系统 HIG 的常规形状(关闭钮 + 动作钮)。
*
* ★ 这两个半模态**必须同形** —— 判据里也把这条钉住了
* (见 `harmony-admin.test.mjs` 的「★ 两个表单型半模态必须**同形**」)。
*/
showClose: true,
onDisappear: () => { this.showAddDialog = false; }
})
.bindSheet($$this.showPasswordSheet, this.PasswordSheet(), {
/* 与「新增账号」同一档(都是表单型半模态)—— 同一类交互同一种高度语义 */
height: SheetSize.FIT_CONTENT,
showClose: true,
onDisappear: () => { this.showPasswordSheet = false; }
})
}
/**
* 「新增账号」半模态的内容(原来自绘弹层里那块表单,搬进来不作改动)。
*
* ★ 它现在只是**内容**:外层的圆角/高度/遮罩/拖拽条/动画全部交给 `bindSheet`。
* 所以这里去掉 `.height('68%')`、`.transition(...)`、`GlassCardModifier`
* —— 那些在系统容器里要么多余(高度)、要么会被覆盖(圆角/背景),
* 留着反而会和系统样式打架(比如两份圆角不同)。
*/
@Builder
AddAccountSheet() {
Column() {
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.margin({ bottom: 16 })
.width('100%')
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newDisplayName = value; })
TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newServer = value; })
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newUsername = value; })
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
.width('100%').height(44).margin({ bottom: 16 })
.type(InputType.Password)
.onChange((value: string) => { this.newToken = value; })
Row() {
Button('取消')
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
.onClick(() => { this.showAddDialog = false; })
Blank()
Button(this.adding ? '验证中…' : '添加')
.width(80).height(36).backgroundColor(Theme.accent)
.enabled(!this.adding)
.onClick(() => { this.addNewAccount(); })
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
/** 分区标题(与 WebUI `AccountPage` 的 `<h3>` 同形:小字、灰、上间距) */
@ -863,7 +933,14 @@ export struct SettingsPane {
* 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)——
* 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。
*/
/** 顶栏右侧的「新增账号」 */
/**
* 顶栏右侧的「新增账号」。
*
* ★★ 2026-09-21 改:弹层从**自绘**换成官方 `bindSheet`(见 `AddAccountSheet`)。
*
* 用户:「现在最割裂的就是弹出效果」—— 指的是同一类交互(弹一个面板)
* 在不同页面里长得不一样、动得不一样,而系统本来就给了现成的半模态容器。
*/
@Builder
HeaderTrailing() {
Stack({ alignContent: Alignment.Center }) {
@ -1185,25 +1262,78 @@ export struct SettingsPane {
* 但**内容与顺序**与 WebUI 一致:先密码、后退出。
*/
/**
* 修改密码(对齐 WebUI `AccountPage` 的「修改密码」section)。
* 修改密码 —— **入口卡片**(真正的表单在官方半模态里)。
*
* ★ 2026-09-21 从 `SecuritySection` 拆出来 —— WebUI 那边「修改密码」与
* 「登录状态」是**两个独立的 <section>**,中间还夹着多账号/密钥/外观。
* 我们原来把两者焊成一个,于是顺序上就没法对齐了(这是顺序错位的根因)。
* ★★ 2026-09-21 改(用户:「修改密码也应该做成弹窗吧」):
*
* ── 改前的形状 ──
* 三个 `TextInput` + 错误/成功提示 + 「保存」按钮**全部常驻在页面上**,
* 占掉一整张卡(实测约 300vp 高)。
*
* 三个具体的坏处:
* ① **它是个一次性操作,却常驻占屏** —— 「我的」页本就很长
* (基本资料/权限/密码/多账号/密钥/外观/通知/登录/管理九段),
* 而修改密码一年用几次。它把「多账号」「密钥」这些
* **看一眼就要用**的内容挤到需要滚动才能看到。
* ② **密码框在浏览器/系统里会被自动填充或误触**:三个 `InputType.Password`
* 常驻,密码管理器可能往里填、长按也可能选中。展开后才出现能避免这个。
* ③ 与「新增账号」**形状不一致** —— 两者都是"填几个字段提交"的表单,
* 一个已改官方半模态、一个是内联卡,同一类交互两种形状(用户说的"割裂")。
*
* ★ 入口本身用「锁图标 + 标题 + 右箭头」,与其它可点行的语汇一致
* (不写"修改"按钮 —— 整行可点,与「管理」入口同形)。
*/
@Builder
PasswordSection() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('修改密码')
.fontSize(14).fontColor(Theme.textPrimary)
.margin({ left: 6 })
.layoutWeight(1)
AmIcon({ iconName: 'chevronRight', iconSize: 14, iconColor: Theme.textSubtleFor() })
}
.width('100%').height(44)
.alignItems(VerticalAlign.Center)
.padding(16).margin({ top: SECTION_GAP })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.attributeModifier(PressEffectModifier.of())
.onClick(() => {
/*
* ★ 每次打开都**清空上次的输入与提示**。
* 不清理的话:上次改了密码成功、关闭,下次再点开还挂着
* 「密码已修改,请重新登录」,而三个框是空的 —— 状态与画面不一致。
*/
this.oldPw = '';
this.newPw = '';
this.confirmPw = '';
this.pwError = '';
this.pwMsg = '';
this.showPasswordSheet = true;
})
}
/**
* 「修改密码」半模态内容(原来常驻的那块表单,搬进来不改逻辑)。
*
* ★ 只做内容:圆角/高度/遮罩/键盘避让全交给 `bindSheet`。
* 与 `AddAccountSheet` 同一形状 —— 两个表单型半模态长得一样。
*/
@Builder
PasswordSheet() {
Column() {
Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('修改密码')
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.margin({ left: 6 })
}
.width('100%')
.margin({ bottom: 16 })
TextInput({ placeholder: '当前密码', text: this.oldPw })
.width('100%').height(44).margin({ top: 10 })
.width('100%').height(44)
.type(InputType.Password)
.onChange((v: string) => { this.oldPw = v; })
@ -1231,17 +1361,23 @@ export struct SettingsPane {
.fontSize(12).fontColor(Theme.approveFg).margin({ top: 8 })
}
Button(this.pwBusy ? '保存中' : '保存')
.height(40).margin({ top: 12 })
.backgroundColor(Theme.accent).fontSize(14)
.enabled(!this.pwBusy)
.onClick(() => { this.changePassword(); })
Row() {
Button('取消')
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
.onClick(() => { this.showPasswordSheet = false; })
Blank()
Button(this.pwBusy ? '保存中' : '保存')
.width(80).height(36)
.backgroundColor(Theme.accent).fontSize(14)
.enabled(!this.pwBusy)
.onClick(() => { this.changePassword(); })
}
.width('100%')
.margin({ top: 12 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: SECTION_GAP })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
.alignItems(HorizontalAlign.Start)
}
/**