diff --git a/client/electron/test/animation-audit.test.mjs b/client/electron/test/animation-audit.test.mjs index be3bcfa..4056f29 100644 --- a/client/electron/test/animation-audit.test.mjs +++ b/client/electron/test/animation-audit.test.mjs @@ -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('动画盘点'); diff --git a/client/electron/test/background.test.mjs b/client/electron/test/background.test.mjs index 73516cb..dd55c09 100644 --- a/client/electron/test/background.test.mjs +++ b/client/electron/test/background.test.mjs @@ -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) && diff --git a/client/electron/test/cross-client-logic.test.mjs b/client/electron/test/cross-client-logic.test.mjs index 14df944..944f7c6 100644 --- a/client/electron/test/cross-client-logic.test.mjs +++ b/client/electron/test/cross-client-logic.test.mjs @@ -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', [[]]] ] }, { diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index d43c54a..36e8eaa 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -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.\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) })); /* diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index bc6da93..3c37dc9 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -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\)/, '候选要在布局里内联挂载'); }); diff --git a/client/electron/test/harmony-admin.test.mjs b/client/electron/test/harmony-admin.test.mjs index 7560b2e..7c93834 100644 --- a/client/electron/test/harmony-admin.test.mjs +++ b/client/electron/test/harmony-admin.test.mjs @@ -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:') + .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)` 抢空间。 * diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index de32281..53967f5 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -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('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑'); diff --git a/client/electron/test/harmony-arkts.test.mjs b/client/electron/test/harmony-arkts.test.mjs index 50a6e5c..a283180 100644 --- a/client/electron/test/harmony-arkts.test.mjs +++ b/client/electron/test/harmony-arkts.test.mjs @@ -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))); diff --git a/client/electron/test/harmony-contacts.test.mjs b/client/electron/test/harmony-contacts.test.mjs index c4766d3..8021a14 100644 --- a/client/electron/test/harmony-contacts.test.mjs +++ b/client/electron/test/harmony-contacts.test.mjs @@ -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 格式化'); diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index f66453a..7f8b93b 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -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('★ 判据自检:把状态机的默认页签改错必须判红', () => { diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index bb0d16e..21e1858 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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 ')}`); +}); diff --git a/client/electron/test/mutants/baseline.sha b/client/electron/test/mutants/baseline.sha index 9233c1a..a33fce6 100644 --- a/client/electron/test/mutants/baseline.sha +++ b/client/electron/test/mutants/baseline.sha @@ -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 diff --git a/client/electron/test/mutants/jobs/jobs-nav-contract.json b/client/electron/test/mutants/jobs/jobs-nav-contract.json index c896b79..e0e1e3b 100644 --- a/client/electron/test/mutants/jobs/jobs-nav-contract.json +++ b/client/electron/test/mutants/jobs/jobs-nav-contract.json @@ -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 不一致,判据该红)" }, diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 4aeb17a..860bf66 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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], diff --git a/client/harmony/entry/src/main/ets/common/Motion.ets b/client/harmony/entry/src/main/ets/common/Motion.ets index e61447f..acd819e 100644 --- a/client/harmony/entry/src/main/ets/common/Motion.ets +++ b/client/harmony/entry/src/main/ets/common/Motion.ets @@ -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); } } diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 4272a34..734bce3 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -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)); } } diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index f9cfdbd..60b0461 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -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 : ''; } } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index bd3fed1..bdf27ec 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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 = ''; diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 4a12625..56eda99 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -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 的 + * `
` 包整块一致。 + * (历史上一度逐项挂 `menuIn()` 的是**账号选择器**,那时是为了掩盖 + * "下拉推进布局流把列表顶下去";现在的候选是浮在内容之上的, + * 整块入场才是对的,别再逐项。) + */ + .transition(Theme.menuIn()) } Divider().color(Theme.border) diff --git a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets new file mode 100644 index 0000000..ed8ceae --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets @@ -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 { + 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 { + 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 { + 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` 在标题右边有一个**计数**: + * {contacts.length} + * 我们这里没有 —— 于是"有几个人/几条工作"只能靠往下数。 + * + * 取值用 `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` 这里是 `` + —— 一个 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`): + * 「归档
?」/「对应 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) + } +} \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index f330645..d2f3fb1 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -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`。 * diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 80d6b3c..ce10076 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -13,6 +13,28 @@ import { AppHeader, CompositeModifier, GlassCardModifier, PaneModifier, PressEff import { Motion } from '../common/Motion'; import { AmIcon } from '../common/Icons'; import { WideSidebar } from './WideSidebar'; +/* + * 授权(通信页第三栏)—— ★★ 2026-09-23 从本文件抽出为独立组件。 + * + * 理由见 `pages/PermissionTab.ets` 抬头:不是为了"文件太长", + * 而是为了让它与 WebUI 的 `components/PermissionList.tsx` **一一对应**, + * 这样"两端对齐"才有一个可对比的单元(原来它是 4592 行文件里的一段内联 Builder, + * 对齐只能退化成逐行找渲染字段)。 + * + * 本文件里同类的先例:`WideSidebar`(也是 `pages/` 下的独立组件)。 + */ +import { PermissionTab } from './PermissionTab'; +/* + * Navigation 目标页包装(详情 / 写信)—— ★★ 2026-09-23 从本文件抽出。 + * + * 它们是**壳**:按 `onReady` 读路径参数,再把真正的页面挂进去。 + * 抽出的理由与 `PermissionTab` 相同 —— 这两个壳被多个栏共用 + * (`InboxTab` 与 `ContactsTab` 都用 `MailDetailDestination`), + * 埋在 4000+ 行里时没人能确认"只此一处"。 + */ +import { MailDetailDestination, ComposeDestination } from './NavDestinations'; +import { ContactsTab } from './ContactsTab'; +import { MAIL_DETAIL_ROUTE, DetailPlaceholder, compactMailTime } from './NavShared'; import { MailDetailView } from './MailDetailPage'; import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; @@ -100,7 +122,6 @@ import { normalizeNavIndex, TAB_BAR_HEIGHT } from '../model/NavItems'; /** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ -const MAIL_DETAIL_ROUTE: string = 'mail-detail'; /** * 写信页签的路由名(宽屏在**右栏**内嵌打开,见 `ComposeDestination`)。 * @@ -117,240 +138,6 @@ const COMPOSE_ROUTE: string = 'compose'; */ const THEME_FADE_ROOT_ID: string = 'theme-fade-root'; -/** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */ -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 目标页:按官方示例在 NavDestination.onReady 读取路径参数。 - * 系统 Navigation 自动决定 Stack / Split;这里不读窗口宽度、不手搓分栏。 - */ -/** - * 右栏占位(`Navigation` **split 模式**下、栈空时显示的那一块)。 - * - * ★★ 2026-09-20 补(用户:「你自己看看跟 webui 相比,观感真的差很多」)。 - * - * 宽屏下详情是**常驻右栏**;没选邮件时 WebUI 有引导(`MailView.tsx:121-129`): - * 信封图标 + 「选择一封邮件查看,或点击左侧「新建」写邮件」 - * 而我们什么都不渲染 ⇒ 两栏并排时右栏是**一大片空白** - * (实测同 1107vp 视口并排:WebUI 那栏中央有图标+文案,我们那栏全空)。 - * - * ★ 为什么用 `splitPlaceholder` 而不是"在 `NavDestination` 里加 `else` 分支": - * `NavDestination` **只在 push 之后才挂载**,栈空时它根本不存在 —— - * 我第一版就是写在它的 `else` 里,**一次都不会显示**(已撤销)。 - * `splitPlaceholder(ComponentContent)` 是系统给"右栏默认页"的专用入口 - * (`navigation.d.ts`,API 20+,我们是 23),由 `Navigation` 在栈空时自己渲染。 - * - * ★ 必须是**全局** `@Builder`:`splitPlaceholder` 要的是 - * `wrapBuilder<[]>(fn)`,而它只接受全局 @Builder 函数 - * (成员方法会报 "The wrapBuilder's parameter should be '@Builder' function")。 - * 内容本身是静态的,不需要 `this`,所以全局正好。 - * - * 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律: - * 两端对同一件事说同一句话(见 `harmony-logic` 里「空态主句逐字一致」那条)。 - */ -@Builder -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) - /* - * ★★ 2026-09-21 补:**宽屏右栏默认页也要透明**(用户:「邮件看着没有玻璃效果」)。 - * - * `splitPlaceholder` 那块内容由 `Navigation` 直接渲染 - * (布局树里的 `SplitPlaceholderContentNode`,实测 x=1152..3154)。 - * 不设背景时它用**系统默认底**(近白不透明),于是整片右栏把壁纸挡死 —— - * 实测宽屏 3184px 下:右栏中心 `(242,244,244)`,而屏幕边缘的壁纸是 - * `(192,194,192)`。差这么远,说明中间隔着的不透明层就是它。 - * - * 这与 2026-09-19 修的 `CommPage` 那个 `Navigation` 是**同一个形状** - * (见那段注释里"那条缝是决定性的证据")—— 系统容器不设背景就不会透。 - * - * ★ 全局 `@Builder` 里**拿不到 `this`**(`splitPlaceholder` 只接受全局 Builder), - * 所以不能用 `bgActive ? Transparent : surface`。 - * 这里改成**恒透明**:右栏默认页本来就没有"实体面"的语义 - * (它是一块空态提示),壁纸关着时底下是 `WallpaperLayer` 之外的 - * `pageBg`,观感仍然是浅色底 —— 不会因为透明变成异色。 - */ - .backgroundColor(Color.Transparent) -} - -@Component -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`): - * - *
← padding/gap = 10px - * {list} {main} ← 每个子元素各自 border-radius:14px - *
- * - * 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。 - * - * 我们这边是一个 `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 -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); }) - } -} @Component struct InboxTab { @@ -597,6 +384,73 @@ struct InboxTab { this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params }); } + /** + * 账号筛选菜单(`bindMenu` 的内容)—— 原来自绘在列表头下方的那块。 + * + * ★★ 2026-09-21 新增(与 `ComposePage.AccountMenu` 同一改法)。 + * + * 逐项不再挂 `Theme.menuIn()`:那是为了掩盖"下拉推进布局流把列表顶下去", + * 而官方菜单不占布局 —— 再挂一层会与系统菜单动画叠在一起。 + * + * ★ 宽度从原来的 `'100%'`(横铺整栏)收成固定 240:菜单应该贴着锚点, + * 而不是占满一行。这正是"看起来不像系统菜单"的主因之一。 + */ + @Builder + AccountFilterMenu() { + Column() { + Row() { + Text('全部邮箱') + .fontSize(14).fontColor(this.accountFilter === 'all' ? Theme.accentFor() : Theme.textPrimary) + .fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal) + .layoutWeight(1) + if (this.accountFilter === 'all') { + AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() }) + } + } + .width(240).height(44).padding({ left: 12, right: 12 }) + .backgroundColor(this.accountFilter === 'all' + ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + .attributeModifier(PressEffectModifier.of()) + .onClick(() => { + this.accountFilter = 'all'; + this.accountName = '全部邮箱'; + this.showAccountPicker = false; + this.loadData(); + }) + + ForEach(this.accountList, (acct: AccountInfo) => { + Row() { + Column() { + Text(acct.displayName) + .fontSize(14) + .fontColor(this.accountFilter === acct.id ? Theme.accentFor() : Theme.textPrimary) + .fontWeight(this.accountFilter === acct.id ? FontWeight.Bold : FontWeight.Normal) + Text(acct.username) + .fontSize(11).fontColor(Theme.textSubtleFor()) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + if (this.accountFilter === acct.id) { + AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() }) + } + } + .width(240).height(48).padding({ left: 12, right: 12 }) + .backgroundColor(this.accountFilter === acct.id + ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + .attributeModifier(PressEffectModifier.of()) + .onClick(() => { + this.accountFilter = acct.id; + this.accountName = acct.displayName; + this.showAccountPicker = false; + this.loadData(); + }) + }, (acct: AccountInfo) => acct.id) + } + .width(240) + .padding({ top: 4, bottom: 4 }) + .alignItems(HorizontalAlign.Start) + } + build() { Column() { /* @@ -619,12 +473,30 @@ struct InboxTab { Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName) .fontSize(11).fontColor(Theme.textMuted) AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() }) - .rotate({ angle: 90 }) + .rotate({ angle: this.showAccountPicker ? 90 : 0 }) .animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft }) .margin({ left: 3 }) } .height(32) .attributeModifier(PressEffectModifier.of()) + /* + * ★★ 2026-09-21 改用 `bindMenu(isShow, content, options)`(**API 11** 的重载)。 + * + * 我第一版写的是 `bindMenu(this.showAccountPicker ? this.X() : undefined)` —— + * 那是 `bindMenu(content, options)`(API 7)那个重载,靠"内容为 undefined + * 就不弹"来模拟开关。它能跑,但不是这个场景的官方形状: + * · 菜单的显隐**由内容是否为 undefined 隐式决定**(读代码时会疑惑); + * · 系统无法把"关闭"回写给状态(要靠 `onDisappear` 手动补)。 + * + * `isShow` 版直接把"显示与否"当第一个参数,与 `bindSheet($$isShow)` + * 同一套语义 —— 两处弹层用同一种写法,不是两种 + * ("同一交互不该有两种形状")。 + * + * ★ 它**支持 `$$` 两向绑定**(@since 11 的注释里写了 `$` 两向), + * 所以用户点遮罩/按 Esc 关掉时状态会自动回写 —— 不再依赖 `onDisappear` + * 去补(那是我第一版的绕法)。 + */ + .bindMenu($$this.showAccountPicker, this.AccountFilterMenu()) .onClick(() => { this.showAccountPicker = !this.showAccountPicker; }) } if (this.unread > 0) { @@ -670,64 +542,6 @@ struct InboxTab { .margin({ top: 6 }) } - // 账号选择器下拉 - if (this.showAccountPicker && this.accountList.length > 1) { - Column() { - Text('全部邮箱') - .fontSize(14).fontColor(this.accountFilter === 'all' ? Theme.accentFor() : Theme.textPrimary) - .fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal) - .width('100%').height(40).padding({ left: 16 }) - .backgroundColor(this.accountFilter === 'all' ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface) - .onClick(() => { - this.accountFilter = 'all'; - this.accountName = '全部邮箱'; - this.showAccountPicker = false; - this.loadData(); - }) - ForEach(this.accountList, (acct: AccountInfo) => { - Row() { - Column() { - Text(acct.displayName) - .fontSize(14) - .fontColor(this.accountFilter === acct.id ? Theme.accentFor() : Theme.textPrimary) - .fontWeight(this.accountFilter === acct.id ? FontWeight.Bold : FontWeight.Normal) - Text(acct.username) - .fontSize(11).fontColor(Theme.textSubtleFor()) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - } - .width('100%').height(48).padding({ left: 16 }) - .backgroundColor(this.accountFilter === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface) - .attributeModifier(PressEffectModifier.of()) - .onClick(() => { - this.accountFilter = acct.id; - this.accountName = acct.displayName; - this.showAccountPicker = false; - this.loadData(); - }) - }, (acct: AccountInfo) => acct.id) - } - .width('100%') - .attributeModifier(GlassCardModifier.of(this.bgActive)) - .border({ width: { bottom: 1 }, color: Theme.border }) - /* - * ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。 - * - * 这个下拉原来是**硬弹**出来的:它挂在 `if (this.showAccountPicker)` 上, - * 条件一变就整块出现/消失,中间一帧过渡都没有 —— 而它正是 - * "出现/消失"最典型的一处(用户点一下按钮,东西凭空冒出来)。 - * - * 用 `menuIn()`:下移 4vp + 缩到 0.985 + 淡入,与 WebUI - * `@keyframes menu-in` 逐值对齐(`Theme.menuIn` 的注释里有出处)。 - * 方向是**从上往下**(`translateY(-4px)` → 0)—— 下拉从触发点下方展开, - * 从上方"落"下来;与 `paneRiseIn` 的**上浮**方向相反,别混。 - * - * `.transition()` 在这里**会播**(与日历那个常驻窗格不同): - * 它是 `if` 包着的,条件为真时才挂载 —— 正是 transition 的触发条件。 - */ - .transition(Theme.menuIn()) - } if (this.loading) { Column() { @@ -1467,346 +1281,6 @@ struct SentTab { } } -/* - * ─────────────────────── 授权(通信页第三栏) ─────────────────────── - * - * 只放**待人点头**的事:`GET /permission/pending`(不从收件箱筛,理由见 `MailApi`)。 - * 决策走 `POST /permission/decide`,`note` 会随决策送达模型 —— - * 所以拒绝时**能填备注**,而且填了必须真的发出去。 - * - * 两件必须如实说的事(WebUI 侧踩过坑,见 `decidePermission` 的返回类型): - * ① 请求**已过期**时服务端会带 `expired`:这时决策落到了一个没人在等的请求上, - * 得当场告诉人(否则会以为"批了,Agent 继续干活了"); - * ② `warning` 同理,服务端让显示什么就显示什么。 - */ - -@Component -struct PermissionTab { - /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ - @Prop bgActive: boolean = false; - /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */ - @Prop navReserve: number = 0; - @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 = ''; - /** 正在填备注的那条(空串 = 没有) */ - @State noteFor: string = ''; - @State noteText: string = ''; - @State busyId: string = ''; - - aboutToAppear(): void { - this.load(); - } - - async load(): Promise { - 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++) { - 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; - } - } - - async decide(req: PermissionRequest, decision: string, note: string): Promise { - const ctx = this.getUIContext().getHostContext(); - if (ctx === undefined) { - return; - } - this.busyId = req.mail_id; - try { - const acctMgr: AccountManager = AccountManager.getInstance(ctx); - await acctMgr.load(); - const accounts: AccountInfo[] = acctMgr.getAccounts(); - let done: boolean = false; - 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: DecideResponse = await new MailApi(c).decidePermission(req.mail_id, decision, note); - done = true; - /* - * 过期/警告要当场说清 —— 不能只说"已同意"。 - * 过期意味着**审批不会让那次调用继续**(请求方已经不等了), - * 人必须知道这一点,否则会以为 Agent 会接着跑。 - */ - if (resp.expired) { - this.getUIContext().getPromptAction().showToast({ - message: '这条请求已经过期 —— 审批不会让那次调用继续,Agent 需要重新请求', - duration: 6000 - }); - } else if (resp.warning.length > 0) { - this.getUIContext().getPromptAction().showToast({ message: resp.warning, duration: 6000 }); - } else { - this.getUIContext().getPromptAction().showToast({ - message: decision === 'deny' ? '已拒绝' + (note.length > 0 ? '(备注已随决策送出)' : '') : '已同意' - }); - } - break; - } catch (e) { - // 换下一个账号试:决策只在一个账号的网关上有效 - } - } - if (!done) { - this.getUIContext().getPromptAction().showToast({ message: '决策失败:没找到这条请求所在的账号' }); - } - this.noteFor = ''; - this.noteText = ''; - await this.load(); - } finally { - this.busyId = ''; - } - } - - /** 顶栏右侧:待决策徽标(橙=有人被卡住,不是"有东西要读") */ - @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 }) - - if (this.noteFor === req.mail_id) { - TextInput({ placeholder: '备注(会随决策一起送给 Agent)', text: this.noteText }) - .fontSize(12).height(40).margin({ top: 8 }) - .onChange((v: string) => { this.noteText = v; }) - } - - Row() { - Button('同意') - .fontSize(13).height(36).layoutWeight(1) - .backgroundColor(Theme.approve).fontColor(Theme.accentFg) - .onClick(() => { this.decide(req, 'allow', ''); }) - Text('拒绝') - .fontSize(13).height(36).layoutWeight(1) - .textAlign(TextAlign.Center) - .backgroundColor(Theme.dangerBgFor()).fontColor(Theme.dangerFor()) - .borderRadius(Theme.radiusControl) - // 拒绝先展开备注框,而不是直接拒:拒绝往往要说明理由,而理由是给模型看的 - .onClick(() => { - if (this.noteFor === req.mail_id) { - this.decide(req, 'deny', this.noteText); - } else { - this.noteFor = req.mail_id; - this.noteText = ''; - } - }) - .margin({ left: 8 }) - } - .width('100%').margin({ top: 10 }) - - if (this.noteFor === req.mail_id) { - Text(this.noteText.length > 0 ? '再点一次「拒绝」即送出(带备注)' : '可填备注,再点一次「拒绝」送出') - .fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 4 }) - } - } - .width('100%').alignItems(HorizontalAlign.Start) - .padding(12) - .attributeModifier(GlassCardModifier.of(this.bgActive)) - .borderRadius(Theme.radiusCard) - .margin({ bottom: 8 }) - } - - 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.tsx:182` 的「历史 {n}」)── - * - * ★★ 2026-09-21 新增。这一段在鸿蒙侧**此前完全看不到** - * (`harmony-permission-history`,其到期条件就是"做授权栏对齐")。 - * - * 只读、不可操作(决策已经发生,重放没有意义)—— 与 WebUI 一致: - * 它把 `settled` 归到折叠的分组里,默认收起。 - * 这里是**平铺一行一行**的历史摘要(会话别名 + 决策 + 时间), - * 不逐条列邮件正文:那是详情页的事。 - */ - ForEach(this.permGroups, (g: PermissionGroup) => { - ListItem() { - Row() { - Column() { - Text(g.alias.length > 0 ? g.alias : '(未命名会话)') - .fontSize(13) - .fontFamily('monospace') - .fontColor(Theme.textMuted) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') - Text('历史 ' + g.settled.length.toString() + ' 条') - .fontSize(11).fontColor(Theme.textSubtleFor()) - .margin({ top: 2 }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .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)) - } -} /* * ─────────────────────── 通信页(底部第一项) ─────────────────────── @@ -2024,7 +1498,10 @@ struct CommPage { * 生效的硬条件(官方文档:「必须配合 `animateTo` 使用才有动画效果… * 不支持 `animation` 动画」)。 * - * 时长取 `Theme.durMorph`(220) —— WebUI FLIP 的原值。 + * 时长/曲线由 `Motion.morph` 里的 `Theme.springResponsive` 决定 + * (官方 `responsiveSpringMotion`,定位就是"跟手/衔接"场景)。 + * ★ 2026-09-21 改:原来这里写「时长取 `Theme.durMorph`(220) —— WebUI FLIP 的原值」, + * 而弹簧曲线下 **duration 不生效**(SDK 原文),那个令牌已删。 */ openComposeWithMorph(): void { const ctx = this.getUIContext().getHostContext(); @@ -2179,7 +1656,11 @@ struct CommPage { onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); } }) } else if (this.commTab === 'permissions') { - PermissionTab({ bgActive: this.bgActive, navReserve: this.navReserve }) + PermissionTab({ + bgActive: this.bgActive, + navReserve: this.navReserve, + onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); } + }) } else { InboxTab({ bgActive: this.bgActive, @@ -2336,759 +1817,6 @@ struct CommPage { } } -@Component -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 { - 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 { - 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 { - 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` 在标题右边有一个**计数**: - * {contacts.length} - * 我们这里没有 —— 于是"有几个人/几条工作"只能靠往下数。 - * - * 取值用 `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` 这里是 `` - —— 一个 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`): - * 「归档
?」/「对应 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) - } -} @Entry @Component diff --git a/client/harmony/entry/src/main/ets/pages/NavDestinations.ets b/client/harmony/entry/src/main/ets/pages/NavDestinations.ets new file mode 100644 index 0000000..efe4acf --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/NavDestinations.ets @@ -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`): + * + *
← padding/gap = 10px + * {list} {main} ← 每个子元素各自 border-radius:14px + *
+ * + * 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。 + * + * 我们这边是一个 `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); }) + } +} \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/NavShared.ets b/client/harmony/entry/src/main/ets/pages/NavShared.ets new file mode 100644 index 0000000..218d2b2 --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/NavShared.ets @@ -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) +} diff --git a/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets b/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets new file mode 100644 index 0000000..affb15b --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets @@ -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 { + 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 : ['同意', '拒绝']; + } +} diff --git a/client/harmony/entry/src/main/ets/pages/PermissionTab.ets b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets new file mode 100644 index 0000000..df9ed9e --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets @@ -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 那行是个 `