跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):
① 授权栏口径 = navigator_only(照 WebUI 架构)
· 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
· 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
栏里能决策、点进详情反而不能)
· PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
· PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)
② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
· mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
· GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)
③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
MainPage.ets 4592 → 3192 行
· pages/PermissionTab.ets 720 行 ↔ PermissionList.tsx
· pages/ContactsTab.ets 796 行 ↔ ContactPanel.tsx
· pages/NavDestinations.ets 181 行 ↔ Navigation 壳
· pages/NavShared.ets 65 行 ↔ 跨栏共用件
④ 判据跟着组件搬家(否则静默失效,不是红)
harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。
animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。
判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
This commit is contained in:
@ -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('动画盘点');
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -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', [[]]]
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -936,8 +936,31 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
|
||||
* 由那个方法自己的使用点担保)。
|
||||
*/
|
||||
const themeSrc = code(themePath);
|
||||
const declared = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
|
||||
assert.ok(declared.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${declared.length} 个)`);
|
||||
/*
|
||||
* ★★ 2026-09-21 补:清单从"只有 `static readonly` 字段"扩到 **`static` 方法**。
|
||||
*
|
||||
* ── 怎么发现的 ──
|
||||
* 我把两个账号选择器换成官方 `bindMenu`(系统自带转场)之后,
|
||||
* `Theme.menuIn()` 就**没有任何调用点了** —— 它是个 `static` 方法,
|
||||
* 而这份清单当时只匹配 `static readonly NAME`,于是它**从来不在这份清单里**,
|
||||
* 这条判据一路绿着看它变成死代码。
|
||||
*
|
||||
* 讽刺的是本条判据的出身正是同一个形状:pi 2026-09-15 那一刀是为了
|
||||
* "`navMaterial` 没人用了却没被抓"(一个**字段**)。今天同一个洞
|
||||
* 长在**方法**上 —— 覆盖面按"声明种类"划,就必然漏掉另一种声明。
|
||||
* ⇒ 清单改成"字段 + 方法",两者用同一套可达性规则。
|
||||
*
|
||||
* ★ 方法名不带 `()`:比对用的是 `Theme.<name>\b`,
|
||||
* 而调用点写 `Theme.menuIn()` —— `\b` 在 `n` 与 `(` 之间成立,匹配得上。
|
||||
*/
|
||||
const fieldTokens = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
|
||||
const methodTokens = [...themeSrc.matchAll(/^\s*static (\w+)\(/gm)].map(m => m[1]);
|
||||
const declared = [...new Set([...fieldTokens, ...methodTokens])];
|
||||
assert.ok(fieldTokens.length > 20,
|
||||
`要从 Theme.ets 里读到令牌清单(读到 ${fieldTokens.length} 个)`);
|
||||
assert.ok(methodTokens.length > 10,
|
||||
`也要读到 static 方法清单(读到 ${methodTokens.length} 个)—— ` +
|
||||
'少了它,`menuIn` 那种"方法型的死代码"就永远不在射程内');
|
||||
const others = collectEts(etsRoot).filter(f => f !== themePath);
|
||||
const othersSrc = others.map(f => ({ f: f.slice(etsRoot.length + 1), src: code(f) }));
|
||||
/*
|
||||
|
||||
@ -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\)/, '候选要在布局里内联挂载');
|
||||
});
|
||||
|
||||
|
||||
@ -621,8 +621,26 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
|
||||
if (a.clickable !== 'true') return false;
|
||||
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
|
||||
if (!m) return false;
|
||||
const [, , y1, x2, y2] = m.map(Number);
|
||||
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
|
||||
const [, x1, y1, x2, y2] = m.map(Number);
|
||||
/*
|
||||
* ★★ 2026-09-21 修:加宽高比约束(与 `harmony-appearance` 两处同一个隐患)。
|
||||
*
|
||||
* 侧栏下半部有**三个**可点方块,实测(3184×2232,density 2.875):
|
||||
* 头像 40×40vp(115×115px)ratio 1.000
|
||||
* 主题切换 36×28vp(104× 81px)ratio 1.284
|
||||
* 退出登录 36×28vp(104× 81px)ratio 1.284
|
||||
* `(y2-y1) > 80` **三者全中**,`.find()` 靠遍历顺序才取到头像。
|
||||
*
|
||||
* 一旦顺序变了就会点到**退出登录** ⇒ 设备被登出、账号被清空
|
||||
* (`performLogout` → `clearAll()`,实测 `accounts_json` 变 `[]`)。
|
||||
* 我这次调试期间设备真的被登出过一次。
|
||||
*
|
||||
* ⇒ 用宽高比把"正方形头像"与"扁矩形按钮"分开。阈值 1.15 落在
|
||||
* 1.000 与 1.284 之间,两边都有余量。
|
||||
*/
|
||||
const w = x2 - x1;
|
||||
const h = y2 - y1;
|
||||
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15;
|
||||
});
|
||||
if (!avatar) return t.skip('宽屏侧栏头像找不到 —— 无法进「我的」');
|
||||
const c = D.boundsCenter(avatar.attributes.bounds);
|
||||
@ -747,6 +765,87 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
|
||||
assert.ok(userRows.length > 0,
|
||||
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
|
||||
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-21 补:**把应用放回主界面再退出**。
|
||||
*
|
||||
* ── 真 bug(连着两轮套件都因此红)──
|
||||
*
|
||||
* 本条爬到「管理」子页(`AdminUsersPage`,push 在 `MainPage` 之上的路由)
|
||||
* 之后**就地结束**,应用就停在那里。下一个跑的设备判据
|
||||
* (`harmony-nav` 的「底栏真渲染了可点的导航项」)直接 `dumpLayout` 量现场
|
||||
* ⇒ 量到的是**管理页**,那一页上根本没有底栏/侧栏 ⇒ 报
|
||||
* 「宽屏左侧栏要渲染出 ≥1 个可点的导航项(实际 0)」
|
||||
* 侧栏明明好好的。**那不是功能缺失,是我的判据留下的现场。**
|
||||
*
|
||||
* `backToMain` 的注释里早写着同一个根因("宽屏侧栏找不到 ⇒ 双双 skip"),
|
||||
* 只是本条的属主当时没顺手收尾。
|
||||
*
|
||||
* ★ 判据有义务**清理自己造成的状态**,不能指望"下一条会自己归位" ——
|
||||
* 那等于把顺序耦合藏进设备状态里(谁先跑谁后跑决定了绿红)。
|
||||
* `harmony-nav` 那边我也补了自保(它现在会先 `backToMain`),
|
||||
* 但两边都做才是对的:一条产生脏状态、一条消化脏状态,
|
||||
* 任何一边缺失都只是"碰巧还能过"。
|
||||
*/
|
||||
await D.launchOurApp(hdc, { settle: 400, tries: 20 }).catch(() => {});
|
||||
await D.backToMain(hdc).catch(() => {});
|
||||
});
|
||||
|
||||
test('★ 两个表单型半模态必须**同形**(同类交互不能一个能叉、一个不能)', () => {
|
||||
/*
|
||||
* ★★ 2026-09-21 加:用户「修改密码也应该做成弹窗吧」之后,
|
||||
* `SettingsPage` 里出现了**两个同类**的表单型半模态:
|
||||
* · 新增账号(`AddAccountSheet`)
|
||||
* · 修改密码(`PasswordSheet`)
|
||||
*
|
||||
* 第一版我给新增账号写了 `showClose: false`(理由是"内容里已经有「取消」按钮"),
|
||||
* 给修改密码写了 `true` —— 于是两个**同类**一个能叉掉、一个不能。
|
||||
* 用户看到的是"为什么这个能关、那个不能"。
|
||||
*
|
||||
* ── 为什么这条值得单独钉 ──
|
||||
*
|
||||
* 这是用户这一路反复指出的那个形状:**同类交互必须是同一种形状**。
|
||||
* (「修改密码也应该做成弹窗吧」的 `也应该` 本身就是这个意思 ——
|
||||
* 不是"这个要弹窗",而是"两个表单别一个内联一个弹窗"。)
|
||||
*
|
||||
* 靠人记得"改一个要顺手改另一个"是不成立的 —— 这条判据把它变成**结构约束**:
|
||||
* 两份 sheet 的选项必须逐字相同。以后谁只改其中一个,这里立刻红。
|
||||
*
|
||||
* ★ 为什么用「逐字对比两份选项块」而不是逐项 assert:
|
||||
* 逐项 assert 只能钉我**今天想到的那几项**(height / showClose),
|
||||
* 而"同形"的含义是**整块相同** —— 明天加个 `dragBar: false`
|
||||
* 只加在一个上,逐项 assert 照样绿。对比整块才真的守住"同形"。
|
||||
*/
|
||||
const src = code(SETTINGS_PAGE);
|
||||
const grabOpts = (marker) => {
|
||||
const at = src.indexOf(marker);
|
||||
if (at < 0) return null;
|
||||
/* 取 `.bindSheet(...)` 的**最后一个参数**(选项对象):从 marker 起配对 `{}` */
|
||||
const open = src.indexOf('{', src.indexOf(',', at + marker.length));
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open, i + 1); }
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const add = grabOpts('.bindSheet($$this.showAddDialog');
|
||||
const pw = grabOpts('.bindSheet($$this.showPasswordSheet');
|
||||
assert.ok(add, '要能找到「新增账号」半模态(`.bindSheet($$this.showAddDialog`)');
|
||||
assert.ok(pw, '要能找到「修改密码」半模态(`.bindSheet($$this.showPasswordSheet`)');
|
||||
|
||||
/* 选项里的值各自不同(回调名),所以只对比**键**与**非回调字面量** */
|
||||
const normalize = (o) => o
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // 去注释(两份注释必然不同)
|
||||
.replace(/onDisappear\s*:\s*\(\)\s*=>\s*\{[^}]*\}/g, 'onDisappear:<cb>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
assert.equal(normalize(pw), normalize(add),
|
||||
'★ 两个表单型半模态的选项必须**整块相同** —— 同类交互同一种形状。\n' +
|
||||
` 新增账号:${normalize(add)}\n` +
|
||||
` 修改密码:${normalize(pw)}\n` +
|
||||
' 只改其中一个会让两个同类面板长得不一样(用户看到的"割裂"就是这么来的)。');
|
||||
});
|
||||
|
||||
test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => {
|
||||
@ -882,17 +981,22 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
|
||||
'★ 不要显式设成 `OFFSET`(那就是"整页上移",正是要避免的那个模式)');
|
||||
|
||||
/*
|
||||
* 逐个**仍然存在的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
|
||||
* 逐个**仍然自绘的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
|
||||
*
|
||||
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层),这道检查现在只剩
|
||||
* **对话树弹层**一个对象。它仍然该有明确高度 —— 理由没变:
|
||||
* 尺寸由内容决定的覆盖层,键盘弹起时内容会被挤出可视区。
|
||||
* (内联底栏那半改用上面的 `KeyboardAvoidMode.RESIZE` 承担。)
|
||||
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层)。
|
||||
* ★★ 2026-09-21:**对话树弹层已换成官方 `bindSheet`**(用户:「现在最割裂的
|
||||
* 就是弹出效果」)⇒ 自绘的覆盖式弹层现在是 **0 个**。
|
||||
*
|
||||
* 「明确高度」这条要求**依然成立、且被满足得更好**:
|
||||
* · 旧自绘版:`height('60%')` —— 拍脑袋的百分比,内容多时会被挤出可视区;
|
||||
* · 官方 `bindSheet`:高度档位由系统定义(`SheetSize.LARGE`),
|
||||
* 并有 `keyboardAvoidMode` 专门做键盘避让。
|
||||
*
|
||||
* ⇒ 所以这里不再要求"至少 1 个自绘弹层"(那会把回退到自绘当成优点),
|
||||
* 改为:**若还有自绘弹层,它必须有明确高度**;同时断言换过去的那一个
|
||||
* 确实还在官方容器里(否则就是"既没自绘也没官方"= 硬弹且无避让)。
|
||||
*/
|
||||
const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)];
|
||||
assert.ok(sheets.length >= 1,
|
||||
`详情页应还有覆盖式弹层(对话树)—— 实际找到 ${sheets.length} 个。\n` +
|
||||
' 若回复/转发已全变内联底栏,至少对话树那一个还在;一个都没有说明结构被改坏了。');
|
||||
const noHeight = [];
|
||||
for (const m of sheets) {
|
||||
const before = src.slice(Math.max(0, m.index - 1200), m.index);
|
||||
@ -906,6 +1010,13 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
|
||||
'内容一多就会被挤出可视区(旧版回复/转发弹层实测过:按钮点不到)。\n' +
|
||||
`缺高度的弹层:\n ${noHeight.join('\n ')}`);
|
||||
|
||||
/* 换到官方容器的那一个必须真的在(不能"两边都不在") */
|
||||
assert.match(src, /\.bindSheet\(\$\$this\.showThread/,
|
||||
'★ 详情页的**覆盖式弹层**(对话树)必须二选一:\n' +
|
||||
' · 官方 `bindSheet`(推荐:自带拖拽条/下滑关闭/遮罩/键盘避让),或\n' +
|
||||
' · 自绘 `borderRadius({topLeft})` + 明确高度 + `transition`。\n' +
|
||||
' 两者都没有 = 旧弹层被删了却什么都没换上去。');
|
||||
|
||||
/*
|
||||
* ② 底栏内部各元素用**内容尺寸/百分比**,不得再用 `layoutWeight(1)` 抢空间。
|
||||
*
|
||||
|
||||
@ -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('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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 格式化');
|
||||
|
||||
@ -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('★ 判据自检:把状态机的默认页签改错必须判红', () => {
|
||||
|
||||
@ -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 ')}`);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 不一致,判据该红)"
|
||||
},
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -522,6 +522,47 @@ export struct ComposeView {
|
||||
return account !== null ? account.displayName : '选择账号';
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号选择菜单(`bindMenu` 的内容)—— 原来内联在 `build()` 里的那块列表。
|
||||
*
|
||||
* ★★ 2026-09-21 新增:从内联 `if/ForEach` 改为官方菜单内容。
|
||||
*
|
||||
* ★ 逐项不再挂 `Theme.menuIn()`:那个过渡原本是为了掩盖"列表推进布局流
|
||||
* 导致下方内容被顶下去"。现在菜单是系统浮层(不占布局),
|
||||
* 出入场由系统菜单自己负责 —— 再挂一层会与系统动画叠在一起。
|
||||
*
|
||||
* ★ 保留逐项的高亮与勾选(选中态的语言不变);
|
||||
* 只是把"怎么弹出来"交给了系统。
|
||||
*/
|
||||
@Builder
|
||||
AccountMenu() {
|
||||
Column() {
|
||||
ForEach(this.accountList, (acct: AccountInfo) => {
|
||||
Row() {
|
||||
Text(acct.displayName)
|
||||
.fontSize(13)
|
||||
.fontColor(this.selectedAccountId === acct.id ? Theme.accentFor() : Theme.textPrimary)
|
||||
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
|
||||
.layoutWeight(1)
|
||||
if (this.selectedAccountId === acct.id) {
|
||||
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
|
||||
}
|
||||
}
|
||||
.width(220).height(40).padding({ left: 12, right: 12 })
|
||||
.backgroundColor(this.selectedAccountId === acct.id
|
||||
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => {
|
||||
this.selectedAccountId = acct.id;
|
||||
this.showAccountPicker = false;
|
||||
})
|
||||
}, (acct: AccountInfo) => acct.id)
|
||||
}
|
||||
.width(220)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 顶栏
|
||||
@ -591,46 +632,25 @@ export struct ComposeView {
|
||||
.rotate({ angle: this.showAccountPicker ? 90 : 0 })
|
||||
/* 展开/收起箭头平滑旋转(WebUI `transition-transform` 的对应物) */
|
||||
.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })
|
||||
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
|
||||
}
|
||||
.width('100%').height(40).padding({ left: 12, right: 12 })
|
||||
.backgroundColor(Theme.surface)
|
||||
|
||||
// 账号选择下拉
|
||||
if (this.showAccountPicker) {
|
||||
ForEach(this.accountList, (acct: AccountInfo) => {
|
||||
Row() {
|
||||
Text(acct.displayName)
|
||||
.fontSize(13)
|
||||
.fontColor(this.selectedAccountId === acct.id ? Theme.accentFor() : Theme.textPrimary)
|
||||
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
|
||||
.layoutWeight(1)
|
||||
if (this.selectedAccountId === acct.id) {
|
||||
AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accentFor() })
|
||||
}
|
||||
}
|
||||
.width('100%').height(40).padding({ left: 40, right: 12 })
|
||||
.backgroundColor(this.selectedAccountId === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => {
|
||||
this.selectedAccountId = acct.id;
|
||||
this.showAccountPicker = false;
|
||||
})
|
||||
/*
|
||||
* 弹层入场(对齐 WebUI `@keyframes menu-in`:下移 4vp + 缩到 0.985 + 淡入)。
|
||||
*
|
||||
* ★ 只给**弹层**挂 —— WebUI 那条 `.animate-menu-in` 的注释把适用范围
|
||||
* 钉得很窄("只给真正是弹层的东西"),因为它曾经挂着 `glass-control`,
|
||||
* 于是每次切视图页面上**所有**按钮与输入框一起淡入位移(几十个元素同时动),
|
||||
* 2026-09-15 被摘掉。别把这个放到常驻控件上。
|
||||
*
|
||||
* 挂在 `Row` 上(即 `ForEach` 的每一项):整张候选列表逐项轻落,
|
||||
* 与 WebUI 里"整块列表一起动"略有差别 —— 但 ArkUI 的 `ForEach` 每项
|
||||
* 是独立节点,逐项入场反而更接近"列表展开"的观感,且不会让整层不可交互。
|
||||
*/
|
||||
.transition(Theme.menuIn())
|
||||
}, (acct: AccountInfo) => acct.id)
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-21 改用 `bindMenu(isShow, content)`(**API 11** 重载)。
|
||||
*
|
||||
* 第一版写的是 `bindMenu(... ? this.AccountMenu() : undefined, {onDisappear})`
|
||||
* —— 那是 API 7 那个靠"内容为 undefined 就不弹"的重载。
|
||||
* 改成 `isShow` 版后:
|
||||
* · 显隐是**显式参数**(不再靠内容是不是 undefined 隐式表达);
|
||||
* · `$$` 两向绑定让"用户点外部关闭"自动回写状态(不再靠 onDisappear 补)。
|
||||
*
|
||||
* ★ 与 `bindSheet($$this.showAddDialog, …)` 同一套写法 ——
|
||||
* 两处弹层用一种形状,而不是两种。
|
||||
*
|
||||
* ★ 仍然与 `MainPage` 的账号筛选器一致(同一交互,不该两种形状)。
|
||||
*/
|
||||
.bindMenu($$this.showAccountPicker, this.AccountMenu())
|
||||
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
|
||||
|
||||
Divider().color(Theme.border)
|
||||
}
|
||||
@ -757,6 +777,31 @@ export struct ComposeView {
|
||||
.width('100%')
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.glassRadius)
|
||||
/*
|
||||
* ★★ 2026-09-21 补:**候选列表的入场过渡**(原来一个都没有 ⇒ 硬弹)。
|
||||
*
|
||||
* 这是 `Theme.menuIn()` 的注释里点名要覆盖的那一类("账号选择器、**下拉候选**"),
|
||||
* 但它从来没有真正挂到候选列表上 —— 只有账号选择器用了。
|
||||
*
|
||||
* ── 为什么现在才暴露 ──
|
||||
* 三个原本挂 `menuIn()` 的地方里,两个账号选择器今天换成了官方
|
||||
* `bindMenu`(系统自带转场),`menuIn()` 于是只剩定义、没有调用点。
|
||||
* 我去查"要不要删掉这个死方法"时,顺手拿它的**注释**对了一遍实际用法:
|
||||
* 它写的是给"账号选择器、下拉候选",**候选那一半从来没兑现**。
|
||||
*
|
||||
* ── 为什么必须补(不是纯观感)──
|
||||
* WebUI 的同一处在 `AddressInput.tsx:261` 挂着 `animate-menu-in`
|
||||
* (`.animate-menu-in` 就是 `@keyframes menu-in`,与 `menuIn()` 逐值对齐)。
|
||||
* 也就是说:**WebUI 的候选是动的、鸿蒙的是硬弹** —— 这是真实的跨端不一致,
|
||||
* 不是"我们少做了个美化"。
|
||||
*
|
||||
* ★ 挂在这一整层 `Column` 上(整块列表一起动),与 WebUI 的
|
||||
* `<div class="animate-menu-in">` 包整块一致。
|
||||
* (历史上一度逐项挂 `menuIn()` 的是**账号选择器**,那时是为了掩盖
|
||||
* "下拉推进布局流把列表顶下去";现在的候选是浮在内容之上的,
|
||||
* 整块入场才是对的,别再逐项。)
|
||||
*/
|
||||
.transition(Theme.menuIn())
|
||||
}
|
||||
|
||||
Divider().color(Theme.border)
|
||||
|
||||
797
client/harmony/entry/src/main/ets/pages/ContactsTab.ets
Normal file
797
client/harmony/entry/src/main/ets/pages/ContactsTab.ets
Normal file
@ -0,0 +1,797 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 联系人(底部第三项)
|
||||
*
|
||||
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4008 行)。
|
||||
*
|
||||
* 抽出的动因是**对齐需要可对比的单元**(与 `PermissionTab` 同一轮、同一理由):
|
||||
*
|
||||
* WebUI 鸿蒙(抽出前) 鸿蒙(抽出后)
|
||||
* components/ContactPanel.tsx 276 行 MainPage.ets 里一段 752 行内联 struct
|
||||
* pages/ContactsTab.ets 753 行
|
||||
*
|
||||
* 之前没有边界时,"两端对齐"只能靠人在 4000+ 行里逐段找渲染字段 —— 那正是
|
||||
* 用户批评的修修补补。有了这个文件,`ContactPanel.tsx` 与它就能**对着读**。
|
||||
*
|
||||
* ★ 它比 WebUI 那个大(752 vs 276),差额主要在**卡/列表两种视图**与
|
||||
* 归档确认的交互 —— 这些在 WebUI 里被拆到了 `ContactPanel` 内部的
|
||||
* 两个子组件(`ArchiveConfirm` / `ContactRow`)。鸿蒙这边是 `@Builder`
|
||||
* (ArkTS 的 `@Builder` 就是组件内的渲染片段,与 React 的子组件同层)。
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { GlassCardModifier, PaneModifier, PressEffectModifier } from '../common/Surface';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
import { MailApi } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { MailSummary, Contact } from '../model/Models';
|
||||
import { LIST_FADE_LENGTH, HEADER_BACK_HIT } from '../model/NavItems';
|
||||
import { LengthMetrics, ComponentContent } from '@kit.ArkUI';
|
||||
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
|
||||
import { MailDetailDestination } from './NavDestinations';
|
||||
import { MAIL_DETAIL_ROUTE, DetailPlaceholder, compactMailTime } from './NavShared';
|
||||
import {
|
||||
budgetState,
|
||||
budgetLabel,
|
||||
permissionLabel,
|
||||
permissionChipText,
|
||||
enforcementLabel,
|
||||
shortTimeOf,
|
||||
lastFromIsHuman,
|
||||
nextContactView,
|
||||
contactViewTitle,
|
||||
permissionHint
|
||||
} from '../model/MailGrouping';
|
||||
|
||||
@Component
|
||||
export struct ContactsTab {
|
||||
@Prop bgActive: boolean = false;
|
||||
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
|
||||
@Prop navReserve: number = 0;
|
||||
@State contacts: Contact[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State error: string = '';
|
||||
/*
|
||||
* 打开的会话(空串 = 没开)—— 与 WebUI `ContactPanel` 的 `selectSession` 同一行为:
|
||||
* 点一条“跟谁在聊”就是打开**这条会话的邮件列表**。
|
||||
*
|
||||
* ★ 2026-09-17 用户:「联系人页面连点都点不开」。
|
||||
* 根因:`ContactItem` / `WorkCard` 两个 builder **根本没有 `onClick`** ——
|
||||
* 卡片画出来了、但没有任何点击路径(判据当时只钉了“字段与 WebUI 一致”,
|
||||
* 没钉“点了会发生什么”)。现在补上:点卡片 → 在**本 pane 内**打开会话,
|
||||
* 底部导航保留(与 WebUI 的 pane 模型一致,不跳 @Entry 页)。
|
||||
*/
|
||||
@State openSessionId: string = '';
|
||||
@State openSessionTitle: string = '';
|
||||
@State sessionMails: MailSummary[] = [];
|
||||
@State sessionLoading: boolean = false;
|
||||
/**
|
||||
* 联系人自己的导航栈 —— 与 `CommPage` 同一模式(每个有「列表→详情」的窗格自带一个
|
||||
* `Navigation`)。这样点联系人卡片打开邮件详情时,**底部导航一直可见**
|
||||
* (详情挂在窗格内部),而不是推一个盖住导航的 @Entry 页。
|
||||
* 这也正是用户说的「我的页面完全没有遵守 nav 的导航规则」那条的同源问题。
|
||||
*/
|
||||
private navPathStack: NavPathStack = new NavPathStack();
|
||||
/**
|
||||
* 视图:'list'(跟谁在聊)/ 'card'(在聊什么、进展如何)。
|
||||
*
|
||||
* 这不是装饰:WebUI 里「会话」从来不是一个入口,它是**两处已有视图** ——
|
||||
* 列表视图是 `ContactRow`,卡片视图是 `WorkCard`(标题「工作列表」)。
|
||||
* 鸿蒙侧原来把会话单列成一个 tab,等于把"卡片视图"放错了位置。
|
||||
* 顺序:先在这里补上卡片视图,再把平级「会话」tab 撤掉(撤早了会丢信息:
|
||||
* 轮次预算 / status / from_agent 就没地方看了)。
|
||||
*/
|
||||
@State contactView: string = 'list';
|
||||
/**
|
||||
* 待归档确认的会话 id(空 = 没有确认框)。
|
||||
*
|
||||
* ★ 归档是**破坏性**操作(Agent 侧会话归档 + 邮箱界面同时移除),
|
||||
* 所以先确认再发请求 —— 与 WebUI `contactStore.pendingArchive` 同构。
|
||||
* 用 `session_id` 当这个"待确认"的键,而不是 `address`:
|
||||
* address 会随别名变化,拿它当身份迟早对不上(见 `MailApi.archiveContact`)。
|
||||
*
|
||||
* ★ 两种视图**共用同一个确认框**(WebUI 的原话:换个视图就换套确认 UI
|
||||
* 只会让人对「自己点了什么」更没底)。所以这块 UI 只写一次。
|
||||
*/
|
||||
@State pendingArchiveId: string = '';
|
||||
/** 归档请求在飞:防连点(一次点击就可能删掉一条会话,重复提交没有意义) */
|
||||
@State archiving: boolean = false;
|
||||
private mailApi: MailApi | null = null;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx !== undefined) {
|
||||
this.mailApi = new MailApi(ApiClient.getInstance(ctx));
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
const m: MailApi | null = this.mailApi;
|
||||
if (m === null) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const resp = await m.contacts();
|
||||
this.contacts = resp.contacts;
|
||||
/* 联系人数也发布给导航栏徽标(口径见 model/NavItems.ts 的 navBadgeCount) */
|
||||
/* 键名与 MainPage 的 KEY_NAV_BADGE_CONTACTS 同值('agentmail.nav.contacts')——
|
||||
* 两个 struct 不能共享私有常量,所以这里写字面量并在两处注释里互指。 */
|
||||
AppStorage.setOrCreate('agentmail.nav.contacts', resp.contacts.length);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换视图:切换规则本身在 MailGrouping.nextContactView(判据直接执行那一层) */
|
||||
switchView(): void {
|
||||
this.contactView = nextContactView(this.contactView);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写信给**指定地址**(联系人卡片上的「写信」)。
|
||||
*
|
||||
* `openCompose()` 是"写一封全新的",`to` 为空;这个版本把该联系人的三维地址
|
||||
* 预填进去 —— 与 WebUI `startCompose({ to: c.address })` 同构。
|
||||
* 复用同一条 `ComposePage` 路由与同一套 `ComposeParams`,不另开页面。
|
||||
*/
|
||||
composeTo(c: Contact): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
let accountId: string = '';
|
||||
if (ctx !== undefined) {
|
||||
accountId = AccountManager.getInstance(ctx).getActiveId();
|
||||
}
|
||||
const params: ComposeParams = {
|
||||
to: c.address,
|
||||
reply_to: '',
|
||||
session_alias: '',
|
||||
account_id: accountId
|
||||
};
|
||||
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
|
||||
}
|
||||
|
||||
/** 请求归档:只打开确认框,不发请求(破坏性操作先确认) */
|
||||
requestArchive(c: Contact): void {
|
||||
this.pendingArchiveId = c.session_id;
|
||||
}
|
||||
|
||||
cancelArchive(): void {
|
||||
this.pendingArchiveId = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认归档:发 `POST /contacts/archive`,成功后**本地即时移除**不等 SSE
|
||||
* (与 WebUI 同做法:等 SSE 会让按钮看起来没反应)。
|
||||
*
|
||||
* 失败时把服务端的话原样显示 —— 归档半途失败最需要的是"到底成了没有",
|
||||
* 而不是一句笼统的"操作失败"。
|
||||
*/
|
||||
async confirmArchive(c: Contact): Promise<void> {
|
||||
const m: MailApi | null = this.mailApi;
|
||||
if (m === null || this.archiving) {
|
||||
return;
|
||||
}
|
||||
this.archiving = true;
|
||||
try {
|
||||
await m.archiveContact(c.session_id);
|
||||
this.contacts = this.contacts.filter((x: Contact) => x.session_id !== c.session_id);
|
||||
if (this.openSessionId === c.session_id) {
|
||||
this.openSessionId = '';
|
||||
}
|
||||
this.pendingArchiveId = '';
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.error = ae.message.length > 0 ? ae.message : '归档失败';
|
||||
this.pendingArchiveId = '';
|
||||
} finally {
|
||||
this.archiving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开一条会话的邮件列表(点联系人卡片就走这里)。
|
||||
*
|
||||
* 与 WebUI `ContactPanel.open()` 同构:`selectSession(c.session_id)` 后
|
||||
* 内容栏切到会话。这里把“会话的邮件”拉下来就地展示在**本 pane 内** ——
|
||||
* 不推 @Entry 页,因为 WebUI 的会话是**同一个 pane 的另一种内容**,
|
||||
* 底部导航一直可见(推页会把导航盖掉,那是另一种信息架构)。
|
||||
*/
|
||||
async openSession(c: Contact): Promise<void> {
|
||||
const m: MailApi | null = this.mailApi;
|
||||
if (m === null) {
|
||||
return;
|
||||
}
|
||||
this.openSessionId = c.session_id;
|
||||
this.openSessionTitle = c.subject.length > 0
|
||||
? c.subject
|
||||
: (c.session_alias.length > 0 ? c.session_alias : c.agent_name);
|
||||
this.sessionMails = [];
|
||||
this.sessionLoading = true;
|
||||
try {
|
||||
this.sessionMails = await m.sessionMails(c.session_id);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.getUIContext().getPromptAction().showToast({ message: ae.message.length > 0 ? ae.message : '打开会话失败' });
|
||||
} finally {
|
||||
this.sessionLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 回到联系人列表 */
|
||||
closeSession(): void {
|
||||
this.openSessionId = '';
|
||||
this.openSessionTitle = '';
|
||||
this.sessionMails = [];
|
||||
}
|
||||
|
||||
/** 点一封邮件 → 推进本窗格的导航栈(与收件箱同一条详情路径) */
|
||||
openMail(mailId: string, accountId: string): void {
|
||||
const params: MailDetailParams = { mail_id: mailId, account_id: accountId };
|
||||
this.navPathStack.pushPath({ name: MAIL_DETAIL_ROUTE, param: params });
|
||||
}
|
||||
|
||||
@Builder
|
||||
DestinationBuilder(name: string, param: Object) {
|
||||
if (name === MAIL_DETAIL_ROUTE) {
|
||||
MailDetailDestination({ navReserve: this.navReserve, bgActive: this.bgActive })
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
/* 本窗格自带 Navigation(与 CommPage 同一模式):列表 → 邮件详情,底部导航始终可见 */
|
||||
Navigation(this.navPathStack) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
/*
|
||||
* ★★ 2026-09-21 补(逐页对齐 WebUI 时发现的漏项)。
|
||||
*
|
||||
* WebUI `ContactPanel.tsx:68` 在标题右边有一个**计数**:
|
||||
* <span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
* 我们这里没有 —— 于是"有几个人/几条工作"只能靠往下数。
|
||||
*
|
||||
* 取值用 `contacts.length`(与 WebUI 同一个来源),**不是**卡片视图的
|
||||
* 别的计数:两个视图共用同一份 `contacts`,所以计数不随视图变。
|
||||
*
|
||||
* 字号/颜色照 WebUI:`text-xs`(12) + `gray-400` → `fontSmall` + `textSubtleFor`。
|
||||
* ★ 用 `textSubtleFor()` 而不是写死灰:深色下三级文字要提亮
|
||||
* (本仓 2026-09-18 实测过「三级文字浅色也一样不够」)。
|
||||
*/
|
||||
Text(this.contacts.length.toString())
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 8 })
|
||||
Blank()
|
||||
/*
|
||||
* 40×40 命中区 + 图标居中:尺寸加在**外层 Stack** 上,不加在 `AmIcon` 上。
|
||||
* 内层容器固定 `iconSize`(18) 且靠左上 —— 直接链 `.width(40)` 会让
|
||||
* 18vp 的图标贴在 40×40 命中区左上角(同 `LoginPage` 品牌卡、
|
||||
* `AdminUsersPage` 刷新键,2026-09-18 一起修)。
|
||||
*/
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
AmIcon({
|
||||
iconName: this.contactView === 'list' ? 'cardView' : 'listView',
|
||||
iconSize: 18,
|
||||
iconColor: Theme.textMuted
|
||||
})
|
||||
}
|
||||
.width(40).height(40)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => { this.switchView(); })
|
||||
}
|
||||
.width('100%').height(56).padding({ left: 16, right: 8 })
|
||||
/*
|
||||
* 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
|
||||
*
|
||||
* ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
|
||||
* 「期望:融进背景」)。
|
||||
*
|
||||
* WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
|
||||
* (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
|
||||
* 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
|
||||
* (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
|
||||
*
|
||||
* 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
|
||||
* 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
|
||||
* 就是用户说的那条白条。
|
||||
*
|
||||
* 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
|
||||
* · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
|
||||
* · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
|
||||
*/
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
/* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
|
||||
.border({ width: { bottom: 1 }, color: Theme.border })
|
||||
|
||||
/*
|
||||
* ★ 会话视图:点联系人卡片后**在本 pane 内**展示那条会话的邮件。
|
||||
* 不另开 @Entry 页 —— 与 WebUI 的 pane 模型一致(底部导航一直可见)。
|
||||
*/
|
||||
if (this.openSessionId.length > 0) {
|
||||
this.SessionMailsView()
|
||||
} else if (this.loading) {
|
||||
Column() {
|
||||
LoadingProgress().width(40).height(40)
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.error.length > 0) {
|
||||
Column() {
|
||||
Text(this.error).fontSize(14).fontColor(Theme.dangerFor())
|
||||
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.contacts.length === 0) {
|
||||
Column() {
|
||||
Text('暂无联系人').fontSize(16).fontColor(Theme.textSubtleFor())
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.contactView === 'card') {
|
||||
// 卡片视图:竖向堆叠(320~400vp 放不下多列),每项一张卡
|
||||
List({ space: 8 }) {
|
||||
ForEach(this.contacts, (c: Contact, idx: number) => {
|
||||
ListItem() {
|
||||
/*
|
||||
* 待归档的那一条**整块换成确认框**(与 WebUI 同做法):
|
||||
* 归档是破坏性操作,换个视图就换套确认 UI 只会让人对
|
||||
* 「自己点了什么」更没底 —— 所以卡片视图与列表视图共用这一个分支。
|
||||
*/
|
||||
if (this.pendingArchiveId === c.session_id) {
|
||||
this.ArchiveConfirmCard(c)
|
||||
} else {
|
||||
this.WorkCard(c)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => {
|
||||
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
|
||||
if (this.pendingArchiveId !== c.session_id) {
|
||||
this.openSession(c);
|
||||
}
|
||||
})
|
||||
}, (_c: Contact, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
.contentEndOffset(this.navReserve)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
} else {
|
||||
// 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义
|
||||
List({ space: 6 }) {
|
||||
ForEach(this.contacts, (c: Contact, idx: number) => {
|
||||
ListItem() {
|
||||
/* 与卡片视图共用同一个确认框分支(见上) */
|
||||
if (this.pendingArchiveId === c.session_id) {
|
||||
this.ArchiveConfirmCard(c)
|
||||
} else {
|
||||
this.ContactItem(c, idx)
|
||||
}
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-19 修(用户报「联系人界面存在严重问题」):
|
||||
*
|
||||
* 这里原来写 `.height(85)`,而**内容需要 95vp**:
|
||||
*
|
||||
* agent 名 14 + 3 + 主题 12 + 3 + 档位行 11 + 6
|
||||
* + 动作行 26 + padding(10+10) = **95vp**
|
||||
*
|
||||
* 加上 `.clip(true)` ⇒ 多出来的 **10vp(该平板 ≈ 21px)**
|
||||
* 被硬切掉 —— 切掉的正好是**「写信 / 归档」那一行的下半截**。
|
||||
*
|
||||
* 真机现场(HUAWEI MatePad Pro,密度 2.125):
|
||||
* 卡片实测高 181px = 85vp(吻合),
|
||||
* 截图里两个按钮只剩上半截,看起来像"渲染坏了"。
|
||||
*
|
||||
* ★ 讽刺的是:**旁边那段注释早就写明了这个道理**,
|
||||
* 只是它只管了「确认态」那一支 ——
|
||||
* 「确认框比 85 高,会被裁掉而看不见按钮,高度交给内容自己定」。
|
||||
* 而普通态同样装不下,只是差得少(10vp)、不容易一眼看出来。
|
||||
*
|
||||
* ⇒ 两个分支的不变式其实是同一条:**高度必须由内容决定**。
|
||||
* 不要把一个"按当时字号的估算值"钉成常量 ——
|
||||
* 字号、行高、动作行的存在与否都会变,而常量不会跟着变。
|
||||
*/
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.clip(true)
|
||||
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => {
|
||||
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
|
||||
if (this.pendingArchiveId !== c.session_id) {
|
||||
this.openSession(c);
|
||||
}
|
||||
})
|
||||
}, (_c: Contact, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
.contentEndOffset(this.navReserve)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.attributeModifier(PaneModifier.of(this.bgActive))
|
||||
}
|
||||
.navDestination(this.DestinationBuilder)
|
||||
.mode(NavigationMode.Auto)
|
||||
/*
|
||||
* ★★ 2026-09-20 修:联系人栏宽度**随视图模式变**(用户:「联系人页宽度」)。
|
||||
*
|
||||
* WebUI `ContactPanel.tsx:59-62` 原文:
|
||||
* // 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
||||
* view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
|
||||
*
|
||||
* 而鸿蒙原来**写死 320** —— 卡片视图与列表视图同宽。
|
||||
* 卡片视图比列表视图多一行(`mail_count 封 · 时间`)**再加一条预算胶囊**
|
||||
* (见 `CardItem` 里 `budgetLabel(...)` 那一块),320 装不下,
|
||||
* WebUI 早就为此单独放宽到 400,我们没跟上。
|
||||
*
|
||||
* 范围上限也一起抬到 400(否则 `navBarWidth(400)` 会被 range 夹回 360 ——
|
||||
* 那正是"改了宽度但没变"的典型症状,值被另一处静默覆盖)。
|
||||
*/
|
||||
.navBarWidth(this.contactView === 'card' ? 400 : 320)
|
||||
.navBarWidthRange([280, 400])
|
||||
.minContentWidth(360)
|
||||
.hideTitleBar(true)
|
||||
.width('100%').height('100%')
|
||||
/* 右栏占位:栈空时显示引导(否则宽屏两栏并排时右栏是一大片空白)。
|
||||
用系统入口 `splitPlaceholder`,**不是** NavDestination 的 else —— 后者栈空时不挂载。 */
|
||||
.splitPlaceholder(new ComponentContent(
|
||||
this.getUIContext(), wrapBuilder<[]>(DetailPlaceholder)))
|
||||
/* 同 CommPage 的 Navigation:不设背景时系统默认不透明白,会把里面
|
||||
已经透明的窗格盖住。联系人页是同一形状,同一修法。 */
|
||||
.attributeModifier(PaneModifier.of(this.bgActive))
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话视图:一条会话下的全部邮件(点联系人卡片后显示)。
|
||||
*
|
||||
* 与 WebUI 的会话内容区同构:顶部一行返回 + 会话标题,下面是那几封邮件。
|
||||
* 每封点开推进本窗格的 `Navigation`(与收件箱同一条详情路径)。
|
||||
*/
|
||||
@Builder
|
||||
SessionMailsView() {
|
||||
Column() {
|
||||
/* 返回行:与 WebUI 的 `BackButton label="会话"` 同一语义 */
|
||||
Row() {
|
||||
/* 返回键用图标,不用 `‹`(本仓禁 Unicode 符号当图标 —— 见 Icons.ets) */
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
|
||||
}
|
||||
.width(HEADER_BACK_HIT).height(HEADER_BACK_HIT)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => { this.closeSession(); })
|
||||
Text(this.openSessionTitle)
|
||||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text(this.sessionMails.length + ' 封')
|
||||
.fontSize(11).fontColor(Theme.textMuted)
|
||||
}
|
||||
.width('100%').height(44).padding({ left: 4, right: 12 })
|
||||
.backgroundColor(Theme.surface)
|
||||
|
||||
if (this.sessionLoading) {
|
||||
Column() { LoadingProgress().width(32).height(32) }
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.sessionMails.length === 0) {
|
||||
Column() {
|
||||
Text('这条会话没有邮件').fontSize(14).fontColor(Theme.textSubtleFor())
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
List({ space: 6 }) {
|
||||
ForEach(this.sessionMails, (m: MailSummary) => {
|
||||
ListItem() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(m.from_name.length > 0 ? m.from_name : '—')
|
||||
.fontSize(12).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Blank()
|
||||
Text(compactMailTime(m.created_at))
|
||||
.fontSize(10).fontColor(Theme.textSubtleFor())
|
||||
}
|
||||
.width('100%')
|
||||
Text(m.subject)
|
||||
.fontSize(14)
|
||||
.fontWeight(m.status === 'unread' ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%').margin({ top: 4 })
|
||||
Text(m.body_preview)
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%').margin({ top: 2 })
|
||||
}
|
||||
.width('100%').alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
}
|
||||
.width('100%')
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => { this.openMail(m.mail_id, m.source_account_id); })
|
||||
}, (m: MailSummary) => m.mail_id)
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
.contentEndOffset(this.navReserve)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作卡片 —— 对应 WebUI 的 `WorkCard`(卡片视图)。
|
||||
*
|
||||
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:主题是主角,
|
||||
* 最新一封说了什么、谁说的,以及**往返预算还剩多少**(预算是任务的属性,
|
||||
* 快跑满的任务需要人介入 —— 这就是为什么卡片视图要先于删 tab 落地)。
|
||||
*/
|
||||
@Builder
|
||||
WorkCard(c: Contact) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(c.agent_name)
|
||||
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(c.path)
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 6 }).layoutWeight(1)
|
||||
if (c.unread_count > 0) {
|
||||
Text(c.unread_count + '')
|
||||
.fontSize(10).fontColor(Theme.accentFg)
|
||||
.backgroundColor(Theme.accent)
|
||||
.borderRadius(9).width(18).height(18)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
/* WebUI `ContactPanel.tsx:247` 这里是 `<ChevronRightIcon className="w-3 h-3">`
|
||||
—— 一个 SVG 图标,不是 `›` 这个字符。逐项对齐。 */
|
||||
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.accentFor() })
|
||||
Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)')
|
||||
.fontSize(11).fontColor(Theme.accentFor())
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 4 }).layoutWeight(1)
|
||||
}
|
||||
.width('100%').margin({ top: 2 })
|
||||
|
||||
// 主题是这张卡片的主角:它回答「这条线索在干什么」
|
||||
Text(c.subject.length > 0 ? c.subject : '(无主题)')
|
||||
.fontSize(13).fontColor(Theme.textPrimary)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 6 })
|
||||
|
||||
if (c.last_preview.length > 0) {
|
||||
Row() {
|
||||
AmIcon({
|
||||
iconName: lastFromIsHuman(c.agent_name, c.last_from) ? 'person' : 'bot',
|
||||
iconSize: 12,
|
||||
iconColor: Theme.textMuted
|
||||
})
|
||||
.margin({ right: 4 })
|
||||
Text(c.last_preview)
|
||||
.fontSize(11).fontColor(Theme.textMuted)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Top)
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
Blank()
|
||||
/*
|
||||
* 权限档位徽标:**档位 + 强制力标记**,点它弹出"平台实际做到了什么"。
|
||||
*
|
||||
* 只显示档位会让人以为 plan 档真的管住了对方;WebUI 把这句话放在悬停提示里,
|
||||
* 而手指没有悬停 —— 所以鸿蒙这边拆成两步:标记形状当场可辨(● 平台强制 /
|
||||
* ◉ 覆盖不完整 / ○ 仅提示),点一下用 toast 说完整那句话(文案两边逐字一致)。
|
||||
*/
|
||||
if (permissionChipText(c.permission_mode, c.permission_enforcement).length > 0) {
|
||||
Text(permissionChipText(c.permission_mode, c.permission_enforcement))
|
||||
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
|
||||
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
.margin({ right: 6 })
|
||||
.onClick(() => {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: permissionHint(c.permission_mode, c.permission_enforcement)
|
||||
+ '(强制力:' + enforcementLabel(c.permission_enforcement) + ')',
|
||||
duration: 6000
|
||||
});
|
||||
})
|
||||
}
|
||||
// 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据)
|
||||
if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) {
|
||||
Text(budgetLabel(c.max_rounds, c.used_rounds))
|
||||
.fontSize(10)
|
||||
.fontColor(Theme.budgetFg(budgetState(c.max_rounds, c.used_rounds)))
|
||||
.backgroundColor(Theme.budgetBg(budgetState(c.max_rounds, c.used_rounds)))
|
||||
.borderRadius(9)
|
||||
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||
}
|
||||
}
|
||||
.width('100%').margin({ top: 8 })
|
||||
|
||||
/* 次要动作:写信 / 归档(常显,理由见 CardAction 的注释) */
|
||||
Row({ space: 8 }) {
|
||||
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
|
||||
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
|
||||
}
|
||||
.width('100%').margin({ top: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(Theme.surface)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上的次要动作按钮(写信 / 归档)—— 两视图共用。
|
||||
*
|
||||
* ★ WebUI 用 `.reveal`(默认隐藏、悬停显形)承载这两个动作。**鸿蒙不能照抄**:
|
||||
* `client/electron/src/index.css` 那段的注释里已经踩过这个坑 ——
|
||||
* 「触摸设备没有 hover,于是这些按钮永远是透明的,却仍然接收点击……一个看不见
|
||||
* 却按得动的破坏性按钮比没有按钮更糟」。WebUI 的修法是**只在真的支持悬停的设备上
|
||||
* 才隐藏**(`@media (hover: hover) and (pointer: fine)`)。
|
||||
* 鸿蒙的输入就是手指 ⇒ 这两个按钮**必须常显**,没有"显形"这一步。
|
||||
*
|
||||
* `tone='danger'` 只用于归档:它改的是服务端状态,红色让人在按之前先看一眼。
|
||||
*/
|
||||
@Builder
|
||||
CardAction(label: string, iconName: string, tone: string, onTap: () => void) {
|
||||
Row() {
|
||||
AmIcon({
|
||||
iconName: iconName,
|
||||
iconSize: 12,
|
||||
iconColor: tone === 'danger' ? Theme.dangerFor() : Theme.textMuted
|
||||
})
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(tone === 'danger' ? Theme.dangerFor() : Theme.textMuted)
|
||||
.margin({ left: 4 })
|
||||
}
|
||||
.height(26)
|
||||
.padding({ left: 10, right: 10 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
.backgroundColor(Theme.surface)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(onTap)
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档确认框 —— 列表视图与卡片视图**共用这一个**。
|
||||
*
|
||||
* WebUI 的原话:归档是破坏性操作,换个视图就换套确认 UI 只会让人对
|
||||
* 「自己点了什么」更没底。文案两边逐字一致(`ArchiveConfirm`):
|
||||
* 「归档 <address>?」/「对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除」
|
||||
*/
|
||||
@Builder
|
||||
ArchiveConfirmCard(c: Contact) {
|
||||
Column() {
|
||||
Text('归档 ' + c.address + '?')
|
||||
.fontSize(13).fontColor(Theme.textPrimary)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
Text('对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除')
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.margin({ top: 3 })
|
||||
.width('100%')
|
||||
Row() {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.accentFg })
|
||||
Text('确认归档').fontSize(12).fontColor(Theme.accentFg).margin({ left: 4 })
|
||||
}
|
||||
.height(30).padding({ left: 12, right: 12 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(Theme.danger)
|
||||
.opacity(this.archiving ? 0.5 : 1)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => { this.confirmArchive(c); })
|
||||
|
||||
Row() {
|
||||
AmIcon({ iconName: 'close', iconSize: 12, iconColor: Theme.textMuted })
|
||||
Text('取消').fontSize(12).fontColor(Theme.textMuted).margin({ left: 4 })
|
||||
}
|
||||
.height(30).padding({ left: 12, right: 12 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
.backgroundColor(Theme.surface)
|
||||
.margin({ left: 8 })
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => { this.cancelArchive(); })
|
||||
}
|
||||
.width('100%').margin({ top: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(Theme.dangerBgFor())
|
||||
.border({ width: 1, color: Theme.danger })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContactItem(c: Contact, idx: number) {
|
||||
Row() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(c.agent_name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Blank()
|
||||
if (c.unread_count > 0) {
|
||||
Text(c.unread_count + '')
|
||||
.fontSize(11).fontColor(Theme.accentFg)
|
||||
.backgroundColor(Theme.danger)
|
||||
.borderRadius(10).width(20).height(20)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text(c.subject.length > 0 ? c.subject : c.session_alias)
|
||||
.fontSize(12).fontColor(Theme.textMuted)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 3 })
|
||||
|
||||
Row() {
|
||||
// 列表视图(跟谁在聊):只要档位,不塞强制力标记 —— 详细说明在卡片视图那颗可点的徽标上
|
||||
Text(permissionLabel(c.permission_mode).length > 0 ? permissionLabel(c.permission_mode) : '—')
|
||||
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
|
||||
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
|
||||
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
|
||||
Blank()
|
||||
/*
|
||||
* 列表视图也要"写了多少 / 什么时候"——WebUI `ContactRow` 的
|
||||
* `{mail_count} 封 · {time}` 与卡片视图同源。原来这里放的是 last_preview,
|
||||
* 于是同一个联系人在两种视图里给出的关键信息不一致。
|
||||
*/
|
||||
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%').margin({ top: 3 })
|
||||
|
||||
/*
|
||||
* 动作行与卡片视图**同语义**(写信 / 归档),
|
||||
* 只是列表视图行更矮,所以动作也画得紧凑些。
|
||||
*/
|
||||
Row({ space: 8 }) {
|
||||
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
|
||||
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
|
||||
}
|
||||
.width('100%').margin({ top: 6 })
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-19:把 `height('100%')` 拿掉。
|
||||
*
|
||||
* 它与外层 ListItem 的硬高度是**一对**:
|
||||
* ListItem `.height(85)` + 这里 `height('100%')` ⇒ 刚好 85。
|
||||
* 我只把 ListItem 那半去掉之后,这里就变成"填满整个列表"——
|
||||
* 实测 ListItem 高 **1563px**(一整屏就一张卡)。
|
||||
*
|
||||
* ⇒ 两半必须一起改:**让内容决定高度**。
|
||||
* `layoutWeight(1)` 保留(横向撑满),纵向不再写 height。
|
||||
*/
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.justifyContent(FlexAlign.SpaceBetween)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ import { Markdown, MarkdownController } from '@luvi/lv-markdown-in';
|
||||
import { MailApi, SessionBudget, ThreadApiResponse, AddressSuggestionResponse } from '../api/MailApi';
|
||||
import { ThreadNode } from '../model/Models';
|
||||
import { SessionApi } from '../api/SessionApi';
|
||||
/* ★ 2026-09-23:决策面板(授权栏走 navigator_only 后,决策入口搬到详情页) */
|
||||
import { PermissionPanel } from './PermissionPanel';
|
||||
import { RenameProposal } from '../model/SessionRename';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models';
|
||||
@ -108,6 +110,14 @@ export struct MailDetailView {
|
||||
@State body: string = '';
|
||||
@State createdAt: string = '';
|
||||
@State permissionMode: string = '';
|
||||
/* ★ 2026-09-23:决策面板要的字段(对应 WebUI `MailView.tsx` 的 `PermissionPanel`) */
|
||||
@State permissionKind: string = '';
|
||||
@State permissionMulti: boolean = false;
|
||||
@State permissionOptions: string[] = [];
|
||||
@State permissionResult: string = '';
|
||||
/* server/token:决策接口要用,存一份 @State 以便传给子组件 `PermissionPanel` */
|
||||
@State gatewayServer: string = '';
|
||||
@State gatewayToken: string = '';
|
||||
@State sessionAlias: string = '';
|
||||
@State loading: boolean = true;
|
||||
@State error: string = '';
|
||||
@ -224,6 +234,9 @@ export struct MailDetailView {
|
||||
return;
|
||||
}
|
||||
this.accountId = account.id;
|
||||
/* ★ 2026-09-23:存一份 server/token,供 `PermissionPanel` 决策用(同一个账号) */
|
||||
this.gatewayServer = account.server;
|
||||
this.gatewayToken = account.token;
|
||||
/*
|
||||
* 当前登录用户名:决定「这封是不是我发的」,进而决定回给谁。
|
||||
* 与 WebUI 的 `useAuthStore(s => s.user?.username)` 同一口径 ——
|
||||
@ -268,6 +281,17 @@ export struct MailDetailView {
|
||||
this.body = mail.body;
|
||||
this.createdAt = mail.created_at;
|
||||
this.permissionMode = mail.permission_mode;
|
||||
/*
|
||||
* ★★ 2026-09-23:决策面板要的四个字段(与 WebUI `PermissionPanel` 同口径)。
|
||||
* 服务端刚把 `permission_options` 补进详情端点 —— 见 `model/Models.ets`
|
||||
* 上方注释。`expires_at` 详情端点**还没补**(由 `AttachPermissionDeadline`
|
||||
* 填,但那个只填 pending 的)⇒ 面板里越窗判断会走「没截止就判不出越窗」,
|
||||
* 不会报错、不会误判,后续再补这个字段即可。
|
||||
*/
|
||||
this.permissionKind = mail.permission_kind;
|
||||
this.permissionMulti = mail.permission_multi_select;
|
||||
this.permissionOptions = mail.permission_options;
|
||||
this.permissionResult = mail.permission_result;
|
||||
this.sessionAlias = mail.session_alias;
|
||||
this.sessionId = mail.session_id;
|
||||
/*
|
||||
@ -1263,6 +1287,31 @@ export struct MailDetailView {
|
||||
.padding({ left: 16, right: 16, bottom: 16 })
|
||||
}
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-23:决策面板(对应 WebUI `MailView.tsx:693` 的 `PermissionPanel`)。
|
||||
*
|
||||
* 用户裁定授权栏走 `navigator_only`:点一条 → 进详情页决策。
|
||||
* 所以决策入口在这里,不在授权栏里(我上一版的内联决策要被移除)。
|
||||
*
|
||||
* 只在「待办邮件」上挂 —— 与 WebUI 的 `isPermission` 同口径。
|
||||
*/
|
||||
if (this.mailType === 'permission_request') {
|
||||
PermissionPanel({
|
||||
mailId: this.mailId,
|
||||
server: this.gatewayServer,
|
||||
token: this.gatewayToken,
|
||||
result: this.permissionResult,
|
||||
expiresAt: '', /* 详情端点还没回这个字段,面板会走「没截止=判不出越窗」 */
|
||||
kind: this.permissionKind,
|
||||
options: this.permissionOptions,
|
||||
multiSelect: this.permissionMulti,
|
||||
isDark: this.isDarkNow,
|
||||
onDecided: (decision: string) => {
|
||||
this.permissionResult = decision;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 让出回复球的高度:球是浮在正文之上的,不让出最后一段会压在球底下 */
|
||||
Blank().height(80)
|
||||
}
|
||||
@ -1616,6 +1665,17 @@ export struct MailDetailView {
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
.clip(true)
|
||||
/*
|
||||
* ★★ 2026-09-21 补:候选列表入场过渡(与写信页候选同一处补齐)。
|
||||
*
|
||||
* 理由详见 `ComposePage.ets` 同名位置的注释 —— 一句话:
|
||||
* WebUI 的同一个候选菜单挂 `animate-menu-in`(`AddressInput.tsx:261`),
|
||||
* 而鸿蒙这两处**都是硬弹**。`Theme.menuIn()` 的注释本来就写着
|
||||
* 它该覆盖"下拉候选",只是从来没兑现。
|
||||
*
|
||||
* ★ 整块 `Column` 一起动(对齐 WebUI 包整块 div 的写法),不逐项。
|
||||
*/
|
||||
.transition(Theme.menuIn())
|
||||
}
|
||||
|
||||
/*
|
||||
@ -1688,51 +1748,103 @@ export struct MailDetailView {
|
||||
* 用同一套遮罩 + 底部弹层外壳(与回复/转发框同构),
|
||||
* 这样三个弹层的交互记忆是一致的。
|
||||
*/
|
||||
if (this.showThread) {
|
||||
Column() {
|
||||
Column()
|
||||
.width('100%').layoutWeight(1)
|
||||
.backgroundColor(Theme.overlay)
|
||||
.onClick(() => { this.showThread = false; })
|
||||
|
||||
Column() {
|
||||
Row() {
|
||||
Text('对话树')
|
||||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Blank()
|
||||
Text('关闭')
|
||||
.fontSize(13).fontColor(Theme.accentFor())
|
||||
.onClick(() => { this.showThread = false; })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
List() {
|
||||
ForEach(this.threadLines, (line: string, idx: number) => {
|
||||
ListItem() {
|
||||
Text(line)
|
||||
.fontSize(12).fontColor(Theme.textMuted)
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
}
|
||||
}, (line: string, idx: number) => idx.toString())
|
||||
}
|
||||
.layoutWeight(1).width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('60%')
|
||||
.padding(16)
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius({ topLeft: 12, topRight: 12 })
|
||||
.transition(Theme.paneRiseIn())
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.position({ x: 0, y: 0 })
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-21 改:对话树弹层 → **官方半模态 `bindSheet`**(理由见 `ThreadSheet`)。
|
||||
*
|
||||
* ── 改前的自绘形状(已删)──
|
||||
* if (this.showThread) {
|
||||
* Column() {
|
||||
* Column().layoutWeight(1).backgroundColor(Theme.overlay) // 手写遮罩
|
||||
* Column() { …标题行 + List… }.height('60%')
|
||||
* .backgroundColor(Theme.surface)
|
||||
* .borderRadius({ topLeft: 12, topRight: 12 })
|
||||
* .transition(Theme.paneRiseIn())
|
||||
* }.position({ x: 0, y: 0 })
|
||||
* }
|
||||
*
|
||||
* ★ 为什么用 `LARGE`:对话树是**列表**,长度不可预期(长线索几十条)
|
||||
* ⇒ 大档 + 内部 `List` 自己滚。而改前那个 `height('60%')` 是拍脑袋的
|
||||
* 百分比:内容短时它空一大块,内容长时它又不够高。
|
||||
*/
|
||||
.bindSheet($$this.showThread, this.ThreadSheet(), {
|
||||
height: SheetSize.LARGE,
|
||||
showClose: true,
|
||||
/*
|
||||
* ★★ `SheetMode.EMBEDDED` —— 实测调出来的关键一项。
|
||||
*
|
||||
* 默认(`OVERLAY`)下,半模态是**窗口级**的:宽屏 3184px 时它
|
||||
* 居中弹在整个窗口上(截图实测),而触发它的「对话树」按钮
|
||||
* 在**右侧详情栏**里 —— 弹层却出现在左栏上方,位置上就"不是从那儿出来的"。
|
||||
*
|
||||
* `EMBEDDED` 把它限制在**宿主节点所在的那块区域**内 ⇒ 弹层从
|
||||
* 详情栏里升起,与触发点的空间关系对得上。
|
||||
*
|
||||
* ★ 这正是"自绘弹层看起来更对"的原因之一:我原来那个
|
||||
* `.position({x:0,y:0})` 挂在 `MailDetailView` 的 Stack 上,
|
||||
* 所以它**天然**只盖住详情栏。换成官方容器后,默认作用域变大了,
|
||||
* 必须显式选 `EMBEDDED` 才能拿回原来那个(更对的)范围。
|
||||
*
|
||||
* ★ 官方选项默认值是 `OVERLAY`,所以这一条不是"锦上添花",
|
||||
* 而是**换容器后必须补的语义**。
|
||||
*/
|
||||
mode: SheetMode.EMBEDDED,
|
||||
onDisappear: () => { this.showThread = false; }
|
||||
})
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
}
|
||||
|
||||
/**
|
||||
* 「对话树」半模态内容(原来自绘弹层里那块列表,搬进来不改)。
|
||||
*
|
||||
* ★★ 2026-09-21 改:从自绘弹层换成官方 `bindSheet`(宿主见 `build()`)。
|
||||
*
|
||||
* 用户:「现在最割裂的就是弹出效果」。原来这个"盖在正文上的面板"是
|
||||
* 自己拼的:手写遮罩 + `height('60%')` + `borderRadius({topLeft:12,topRight:12})`
|
||||
* + `.transition(Theme.paneRiseIn())`。而系统对"半模态面板"的定义
|
||||
* 不止外观 —— 还包括拖拽条、下滑关闭、遮罩浓度、键盘避让、出入场曲线。
|
||||
*
|
||||
* 那些每一个都是"用户可以预期"的交互契约:系统里别的应用能下拉关掉,
|
||||
* 我们这里不能,就是割裂。
|
||||
*
|
||||
* ★ 原来的标题行里有一个「关闭」文字按钮 —— 保留它(多一个显式出口),
|
||||
* 现在同时有系统的下滑手势与遮罩点击(多通道,不冲突)。
|
||||
*/
|
||||
@Builder
|
||||
ThreadSheet() {
|
||||
Column() {
|
||||
/*
|
||||
* ★★ 2026-09-21 改:**去掉自己那个「关闭」文字按钮**。
|
||||
*
|
||||
* 实测(宽屏截图):系统在 `showClose: true` 时已经在右上角画了一个
|
||||
* 圆形 ✕,与我的「关闭」文字**重叠**在一起 —— 两个关闭入口挤在同一角,
|
||||
* 既是视觉噪声,也是两个职责相同的控件。
|
||||
*
|
||||
* ⇒ 只留系统的那个(它是所有半模态的统一位置),标题行只放标题。
|
||||
*/
|
||||
Text('对话树')
|
||||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
List() {
|
||||
ForEach(this.threadLines, (line: string, idx: number) => {
|
||||
ListItem() {
|
||||
Text(line)
|
||||
.fontSize(12).fontColor(Theme.textMuted)
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
}
|
||||
}, (line: string, idx: number) => idx.toString())
|
||||
}
|
||||
.layoutWeight(1).width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受改名建议 —— 走 `PUT /sessions/{id}/alias`。
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
182
client/harmony/entry/src/main/ets/pages/NavDestinations.ets
Normal file
182
client/harmony/entry/src/main/ets/pages/NavDestinations.ets
Normal file
@ -0,0 +1,182 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — Navigation 目标页包装(详情 / 写信)
|
||||
*
|
||||
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4170 行)。
|
||||
*
|
||||
* 抽出的动因与 `PermissionTab` 那次相同 —— **对齐需要可对比的单元**:
|
||||
* 这两个包装是多个栏共用的(`InboxTab` / `ContactsTab` 都用
|
||||
* `MailDetailDestination`),埋在 4170 行里时谁都不敢确认"只此一处"。
|
||||
*
|
||||
* ★ 它们是**壳**:官方 `Navigation` 的 `NavDestination` 包装,
|
||||
* 按 `onReady` 读路径参数,再把真正的页面(`MailDetailView` / `ComposePage`)
|
||||
* 挂进去。壳与页面分开是有意的 —— 页面本身不知道自己在 `Navigation` 里
|
||||
* (那样它才能在别处复用、也才好单独对齐)。
|
||||
*/
|
||||
import { Theme } from '../common/Theme';
|
||||
import { PaneModifier } from '../common/Surface';
|
||||
import { MailDetailView } from './MailDetailPage';
|
||||
import { ComposeView } from './ComposePage';
|
||||
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
|
||||
|
||||
@Component
|
||||
export struct MailDetailDestination {
|
||||
@State mailId: string = '';
|
||||
@State accountId: string = '';
|
||||
/** 底部悬浮条高度(窄屏非 0)——透传给详情页,让它的回复球抬过条 */
|
||||
@Prop navReserve: number = 0;
|
||||
/** 壁纸是否开启。宽屏下本栏要**自己**有圆角(WebUI `app-shell > *` 的对应物) */
|
||||
@Prop bgActive: boolean = false;
|
||||
private pathStack: NavPathStack = new NavPathStack();
|
||||
|
||||
handleReady(ctx: NavDestinationContext): void {
|
||||
const params: MailDetailParams = ctx.pathInfo.param as MailDetailParams;
|
||||
this.mailId = params?.mail_id ?? '';
|
||||
this.accountId = params?.account_id ?? '';
|
||||
this.pathStack = ctx.pathStack;
|
||||
}
|
||||
|
||||
build() {
|
||||
NavDestination() {
|
||||
if (this.mailId.length > 0) {
|
||||
MailDetailView({
|
||||
initialMailId: this.mailId,
|
||||
initialAccountId: this.accountId,
|
||||
embedded: true,
|
||||
navReserve: this.navReserve,
|
||||
onBack: (): void => { this.pathStack.pop(); }
|
||||
})
|
||||
} else {
|
||||
Column() {
|
||||
LoadingProgress().width(32).height(32)
|
||||
}
|
||||
.width('100%').height('100%').justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
/*
|
||||
* ★★ 2026-09-20 修(用户:「邮件展示左侧没有圆角」)。
|
||||
*
|
||||
* WebUI 宽屏的真结构是**三个独立圆角面板并排**(`App.tsx:262`):
|
||||
*
|
||||
* <div className="app-shell"> ← padding/gap = 10px
|
||||
* <Sidebar/> {list} {main} ← 每个子元素各自 border-radius:14px
|
||||
* </div>
|
||||
*
|
||||
* 所以列表栏和详情栏**各有自己的四个圆角**,中间的缝里透出壁纸。
|
||||
*
|
||||
* 我们这边是一个 `Navigation`,由系统 Split 成 navBar + content 两半,
|
||||
* 圆角只加在**外壳**(那个 `Row` 里的内容列)上 ⇒ 内部这两半变成直角,
|
||||
* 实测详情栏白区左缘在 y=145 与 y=2195 都是 `x=1152`(**完全垂直、无圆角**)。
|
||||
*
|
||||
* 修法:给**右栏**(`NavDestination`)自己补上圆角 + 裁切,
|
||||
* 与 WebUI 的 `app-shell > *` 逐条对应;左栏(navBar 内容)在它的根容器上补。
|
||||
*/
|
||||
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
|
||||
/*
|
||||
* ★ 这里同样**不写** `.clip()` —— 理由见左栏那处(WebUI `index.css:968` 的
|
||||
* 「不能写 overflow: hidden」那条,我照搬圆角时把裁切也一起搬了,导致列表滚不动)。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-09-21 补:**同时去掉 NavDestination 的系统白底**。
|
||||
*
|
||||
* 上面那个 `.borderRadius` 一直"看着生效了",其实只裁了内容 ——
|
||||
* ArkUI 的 `borderRadius` **不裁 `backgroundColor`**。而 `NavDestination`
|
||||
* 自带一层不透明的 system background,它比外壳**四周各小 2px**
|
||||
* (实测 `[30,142]` vs 外壳 `[28,140]`)⇒ 圆角内侧露出 2px 直角白边。
|
||||
*
|
||||
* 就是用户报的「圆角下方还是有白框(直角框)」。`Color.Transparent`
|
||||
* 让外壳那层玻璃显出来 —— 与 `ComposeDestination` 同一处修法。
|
||||
*/
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写信窗格的内嵌壳(宽屏右栏)。
|
||||
*
|
||||
* ★★ 2026-09-20 加(用户:「写邮件 webui 的宽屏样式不是在右侧打开吗」)。
|
||||
*
|
||||
* 与 `MailDetailDestination` **完全同构**(那是既有先例):
|
||||
* 同一个 `NavDestination` 机制,宽屏并排在右栏、窄屏 push 覆盖全屏 ——
|
||||
* 由 `Navigation.mode(Auto)` 按宽度自动决定,这里不自己判断宽窄。
|
||||
*
|
||||
* ★ 为什么是路由而不是一个 `@State showCompose`:
|
||||
* 路由让"返回"这件事自动正确(系统返回键、手势、头部返回都弹同一个栈),
|
||||
* 而一个布尔状态要自己接三条返回路径 —— 那正是 2026-09-17 那批
|
||||
* 「返回直接回到登录页」bug 的来源。
|
||||
*/
|
||||
@Component
|
||||
export struct ComposeDestination {
|
||||
@State to: string = '';
|
||||
@State replyTo: string = '';
|
||||
@State sessionAlias: string = '';
|
||||
@State accountId: string = '';
|
||||
@Prop navReserve: number = 0;
|
||||
@Prop bgActive: boolean = false;
|
||||
private pathStack: NavPathStack = new NavPathStack();
|
||||
|
||||
handleReady(ctx: NavDestinationContext): void {
|
||||
const params: ComposeParams = ctx.pathInfo.param as ComposeParams;
|
||||
this.to = params?.to ?? '';
|
||||
this.replyTo = params?.reply_to ?? '';
|
||||
this.sessionAlias = params?.session_alias ?? '';
|
||||
this.accountId = params?.account_id ?? '';
|
||||
this.pathStack = ctx.pathStack;
|
||||
}
|
||||
|
||||
build() {
|
||||
NavDestination() {
|
||||
ComposeView({
|
||||
embedded: true,
|
||||
initialTo: this.to,
|
||||
initialReplyTo: this.replyTo,
|
||||
initialSessionAlias: this.sessionAlias,
|
||||
initialAccountId: this.accountId,
|
||||
onBack: (): void => { this.pathStack.pop(); }
|
||||
})
|
||||
}
|
||||
/*
|
||||
* ★★ 共享元素转场的 **in 端**(与通信页右下那个加号同一个 id)。
|
||||
*
|
||||
* 系统按两端各自的 frame 与圆角插值 ⇒ "球长成整页"这件事不需要我算。
|
||||
* 起点圆角 28(球的半径)→ 终点 0(整幅面板)由两端各自声明。
|
||||
*/
|
||||
.geometryTransition('compose-morph')
|
||||
.hideTitleBar(true)
|
||||
/*
|
||||
* ★★ 2026-09-21 修(用户:「写邮件页面和其他多个页面圆角下方还是有白框(直角框)」)。
|
||||
*
|
||||
* `MailDetailDestination` 早就补了圆角,这里**漏了** —— 而它的注释还写着
|
||||
* 「与 `MailDetailDestination` 同一处圆角修法」,说的是"打算照做",
|
||||
* 实际只搬了圆角、漏了下面那件更要紧的事。
|
||||
*
|
||||
* ── 实测(`uitest dumpLayout`,窄屏 1008px,写信页)──
|
||||
*
|
||||
* Column(外壳,有圆角) [28,140][980,1957] bg=#C7FFFFFF
|
||||
* NavDestination [30,142][978,1955] bg=#FFFFFFFF ← 直角、全白
|
||||
*
|
||||
* 那个 `#FFFFFFFF` 是 **NavDestination 自己的系统底色**,而它比外壳
|
||||
* **四周各小 2px**(30 vs 28、142 vs 140 …)。于是外壳那圈 14vp 的圆角
|
||||
* 内侧露出 2px 的**直角白边** —— 像素实测:y=1946 时圆角已收窄到 x=45,
|
||||
* 而 x=30..39 仍是纯白;y=1952 时 x=30..48 仍是纯白。
|
||||
*
|
||||
* 肉眼就是用户说的「圆角下方一个白框(直角框)」,宽屏在右栏更明显。
|
||||
*
|
||||
* ── 修法 ──
|
||||
*
|
||||
* 两件事都要做,只做一件都盖不住:
|
||||
* ① `borderRadius` —— 让**自己**是圆的(原来这里就有);
|
||||
* ② `backgroundColor(Color.Transparent)` —— 去掉那层系统白底。
|
||||
* 只给圆角不改底色没用:圆角只裁自己的**内容**,
|
||||
* 而那块白是**底色**,圆角外照样画得出来。
|
||||
*
|
||||
* ★ 为什么 ① 原来没生效:`.borderRadius()` 在 ArkUI 里**不裁背景色**,
|
||||
* 它裁的是内容与子节点。白底是 `backgroundColor`,不受圆角约束 ⇒
|
||||
* 必须把底色去掉,让外壳那层(`#C7FFFFFF` 玻璃)显出来。
|
||||
*/
|
||||
.borderRadius(this.bgActive ? Theme.glassRadius : 0)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onReady((ctx: NavDestinationContext) => { this.handleReady(ctx); })
|
||||
}
|
||||
}
|
||||
65
client/harmony/entry/src/main/ets/pages/NavShared.ets
Normal file
65
client/harmony/entry/src/main/ets/pages/NavShared.ets
Normal file
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — NavDestination 路由与共用渲染片段
|
||||
*
|
||||
* ★★ 2026-09-23 从 `pages/MainPage.ets` 抽出。
|
||||
*
|
||||
* 动因:`ContactsTab` / `InboxTab` / `SentTab` 都要往详情页 push,
|
||||
* 都要那个"选择一封邮件查看"的右栏占位 —— 而这些原本都定义在
|
||||
* `MainPage.ets` 里。一旦某个栏被抽成独立文件(本轮在做的事),
|
||||
* 它就够不到这些共用件了。
|
||||
*
|
||||
* ⇒ 把"多个栏共用"的东西放到一处,而不是各自复制一份 ——
|
||||
* 复制是最容易产生跨端/跨栏漂移的做法(本仓为此吃过多次)。
|
||||
*/
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
|
||||
/**
|
||||
* 详情页的路由名。
|
||||
*
|
||||
* ★ 必须与 `MainPage` 里 `NavDestination` 注册的路由名**逐字一致** ——
|
||||
* `pushPath({ name })` 与 `navDestination({ name })` 拼错不报错,
|
||||
* 只表现为"点了没反应"(本仓在 `AppStorage` 键名上踩过同一个坑)。
|
||||
*/
|
||||
export const MAIL_DETAIL_ROUTE: string = 'mail-detail';
|
||||
|
||||
/** WebUI 邮件列表统一使用 MM/DD HH:mm(三个栏都用它格式化时间) */
|
||||
export function compactMailTime(iso: string): string {
|
||||
const value: Date = new Date(iso);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day: string = value.getDate().toString().padStart(2, '0');
|
||||
const hour: string = value.getHours().toString().padStart(2, '0');
|
||||
const minute: string = value.getMinutes().toString().padStart(2, '0');
|
||||
return month + '/' + day + ' ' + hour + ':' + minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Navigation` split 模式下、栈空时右栏显示的占位。
|
||||
*
|
||||
* ★ 全局 `@Builder` 而不是 struct 方法:`splitPlaceholder(ComponentContent)`
|
||||
* 要求传 `wrapBuilder<[]>`,而成员方法会报
|
||||
* "The wrapBuilder's parameter should be '@Builder' function"。
|
||||
* 内容本身是静态的(不需要 `this`),全局正好。
|
||||
*
|
||||
* 文案**逐字对齐 WebUI**(同一句话,不自己改写)—— 本仓纪律:
|
||||
* 两端对同一件事说同一句话。
|
||||
*/
|
||||
@Builder
|
||||
export function DetailPlaceholder() {
|
||||
Column() {
|
||||
AmIcon({ iconName: 'mail', iconSize: 40, iconColor: Theme.textSubtleFor() })
|
||||
Text('选择一封邮件查看,或点击左侧「新建」写邮件')
|
||||
.fontSize(14).fontColor(Theme.textMuted)
|
||||
.margin({ top: 12 })
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
/*
|
||||
* 宽屏右栏默认页也要透明(`splitPlaceholder` 那块由 `Navigation` 直接渲染,
|
||||
* 不设背景就用系统默认底 —— 近白不透明,整片把壁纸挡死)。
|
||||
*/
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
339
client/harmony/entry/src/main/ets/pages/PermissionPanel.ets
Normal file
339
client/harmony/entry/src/main/ets/pages/PermissionPanel.ets
Normal file
@ -0,0 +1,339 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 详情页里的**待办决策面板**
|
||||
*
|
||||
* ★★ 2026-09-23 新建。用户裁定授权栏走 **`navigator_only`**
|
||||
* (照 WebUI 的架构):授权栏只做**导航**,点一条 → 进详情页决策。
|
||||
*
|
||||
* ── 这次改动推翻了我上一版的做法,理由记在这里 ──
|
||||
*
|
||||
* 我 2026-09-23 早些时候把决策做在了**授权栏内联**(`PermissionTab` 里的
|
||||
* `RequestCard`:卡片内直接「同意 / 拒绝」)。当时我给自己写的理由是
|
||||
* "鸿蒙的待决卡片能显示 `question`/`options`/`context`,比 WebUI 更全"。
|
||||
*
|
||||
* 那个理由**本身没错,但它答的不是"该在哪决策"这个问题** ——
|
||||
* 我把"信息更全"当成了"可以就地决策"。用户裁定照 WebUI 走,因为:
|
||||
*
|
||||
* ① **两端同一种形状**比"某一端更顺手"重要(用户这一路反复强调的东西);
|
||||
* ② 详情页**本来就要能决策** —— WebUI 的 `MailView.tsx:693` 挂着
|
||||
* `PermissionPanel`,而鸿蒙详情页对 `permission_request` 只显示了一个
|
||||
* 「权限请求」小标签(`MailDetailPage.ets:986`),**根本没有决策入口**。
|
||||
* 也就是说:鸿蒙当时是"栏里能决策、点进详情反而不能" —— 反的。
|
||||
*
|
||||
* ⇒ 本文件是 `components/MailView.tsx` 的 `PermissionPanel`(`:712-870`)
|
||||
* 在鸿蒙侧的对应件,逐块对齐。
|
||||
*
|
||||
* ── 两种形态(与 WebUI 同)──
|
||||
* · **审批型**(`permission`):同意 / 拒绝,单行备注,点胶囊即提交;
|
||||
* · **主动提问**(`question`):多选/单选胶囊 + 多行回答,要按「提交回答」。
|
||||
* 还有第三种状态:**已处理** —— 只显示结果 + 失效横幅,不给操作。
|
||||
*/
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
import { Motion } from '../common/Motion';
|
||||
import { MailApi } from '../api/MailApi';
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { DecideResponse } from '../model/Models';
|
||||
|
||||
@Component
|
||||
export struct PermissionPanel {
|
||||
/** 这条待办的邮件 id(决策接口用它) */
|
||||
@Prop mailId: string = '';
|
||||
/** 服务端来自哪个账号的网关(多账号时决策要打到对的那台) */
|
||||
@Prop server: string = '';
|
||||
@Prop token: string = '';
|
||||
/** 已有决策结果(空串 = 还没人处理)—— 与 WebUI 的 `mail.permission_result` 同义 */
|
||||
@Prop result: string = '';
|
||||
/**
|
||||
* 等待窗口的截止时刻(服务端 `expires_at`)。
|
||||
*
|
||||
* ★ WebUI 在**详情页**这边读的是 `mail.permission_expires_at`(另一个字段,
|
||||
* 由 inbox 的 `AttachPermissionDeadline` 算出来)。两边值语义相同,
|
||||
* 都是"超过它就别指望那次调用还能恢复"。
|
||||
*/
|
||||
@Prop expiresAt: string = '';
|
||||
/**
|
||||
* 待办种类:`question` = 主动提问(勾选 + 自由文本),其余 = 审批(同意/拒绝)。
|
||||
* 对齐 WebUI `MailView.tsx:713` 的 `mail.permission_kind === 'question'`。
|
||||
*/
|
||||
@Prop kind: string = '';
|
||||
/** 预设选项(WebUI 的 `mail.permission_options`) */
|
||||
@Prop options: string[] = [];
|
||||
/** 多选题(仅 `question` 用;对齐 `mail.permission_multi_select === true`) */
|
||||
@Prop multiSelect: boolean = false;
|
||||
/** 决策成功后通知外层(让它刷新详情/列表) */
|
||||
onDecided: (decision: string) => void = (): void => {};
|
||||
/**
|
||||
* 当前是否深色 —— 用来取"深浅档不同"的令牌。
|
||||
*
|
||||
* ★ `Theme.chipWarnBgFor(dark)` 这类 `*For()` 入口都要这个参数:
|
||||
* 它们存在的理由就是"静态常量跟不了主题"(本仓 2026-09-18 实测过
|
||||
* 「三级文字浅色也一样不够」)。默认 false 走浅色档,与 `MailDetailPage`
|
||||
* 的 `@StorageProp` 同源 —— 由挂载处把真值传进来。
|
||||
*/
|
||||
@Prop isDark: boolean = false;
|
||||
|
||||
@State note: string = '';
|
||||
@State busy: boolean = false;
|
||||
/** 本地已决策的结果(提交成功后立即回显,不等外层刷新) */
|
||||
@State decided: string = '';
|
||||
/** 问题型已勾选的选项 */
|
||||
@State picked: string[] = [];
|
||||
/** 服务端在越窗时回的说明(WebUI 的 `staleWarning`) */
|
||||
@State staleWarning: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
/* 进来时若已有结果,直接进"已处理"态(与 WebUI 的 `useState(mail.permission_result)` 同) */
|
||||
this.decided = this.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条待办**是否已过等待窗口**。
|
||||
*
|
||||
* 判法与 WebUI 逐字同口径(`MailView.tsx:727-729`):
|
||||
* `!result && expiresAt 存在 && Date.now() > Date.parse(expiresAt)`。
|
||||
*
|
||||
* ★ 与授权栏那条(`PermissionTab.isStale`)是**同一件事的两处**:
|
||||
* 栏里给"即将点下去"的人看,这里给"已经点进来"的人看。WebUI 也是两处都有
|
||||
* (`PermissionList.tsx:249` 与 `MailView.tsx:727`)—— 不是重复,是同一提示
|
||||
* 出现在用户可能驻足的两个位置。
|
||||
*/
|
||||
private isStale(): boolean {
|
||||
if (this.decided.length > 0 || this.expiresAt.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const until: number = Date.parse(this.expiresAt);
|
||||
return !Number.isNaN(until) && Date.now() > until;
|
||||
}
|
||||
|
||||
private staleText(): string {
|
||||
return this.staleWarning.length > 0 ? this.staleWarning
|
||||
: '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' +
|
||||
'现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。';
|
||||
}
|
||||
|
||||
/** 是不是"同意"类(与 WebUI `MailView.tsx:844` 同一正则) */
|
||||
private isApprove(s: string): boolean {
|
||||
const l: string = s.toLowerCase();
|
||||
return s.indexOf('同意') >= 0 || s.indexOf('允许') >= 0 || s.indexOf('批准') >= 0
|
||||
|| l.indexOf('approve') >= 0 || l.indexOf('yes') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交决策。
|
||||
*
|
||||
* ★ 越窗提示**决策前后都要显**(WebUI 那段注释写明了理由):
|
||||
* 只在决策后显示 = 让人先做错一次;只在决策前显示 = 补不上
|
||||
* 服务端在两次渲染之间越窗的情形。
|
||||
*/
|
||||
private async submit(decision: string, noteText: string): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const c: ApiClient = new ApiClient(ctx);
|
||||
c.setBase(this.server);
|
||||
c.setToken(this.token);
|
||||
const resp: DecideResponse = await new MailApi(c).decidePermission(this.mailId, decision, noteText);
|
||||
/*
|
||||
* 服务端在请求已越过等待窗口时回 `expired` + `warning`。
|
||||
* 这不是错误,是"这次批准不会恢复当时那次调用" —— 必须留痕给用户看,
|
||||
* 而不是弹个 toast 就没了(toast 会消失,而这件事需要一直看得见)。
|
||||
*/
|
||||
if (resp.warning.length > 0) {
|
||||
this.staleWarning = resp.warning;
|
||||
} else if (resp.expired) {
|
||||
this.staleWarning = this.staleText();
|
||||
}
|
||||
this.decided = decision.length > 0 ? decision : '(自由文本回答)';
|
||||
this.onDecided(decision);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: ae.message.length > 0 ? ae.message : '决策失败',
|
||||
duration: 4000
|
||||
});
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 越窗横幅(WebUI `staleBanner` 的对应物) */
|
||||
@Builder
|
||||
StaleBanner() {
|
||||
if (this.isStale() || this.staleWarning.length > 0) {
|
||||
Text(this.staleText())
|
||||
.fontSize(Theme.fontSmall)
|
||||
.lineHeight(19)
|
||||
.fontColor(Theme.warnFgFor())
|
||||
.backgroundColor(Theme.warnBgFor())
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.margin({ top: 8 })
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一颗选项胶囊(对齐 WebUI `ComposerChip`,`Composer.tsx:158`)。
|
||||
*
|
||||
* 两种变体:
|
||||
* · `action`(审批型):本身即动作按钮,按语义**一直**填色
|
||||
* (同意 = 实心绿 / 拒绝 = 浅红);
|
||||
* · `toggle`(提问型):选中才填色,未选中是淡的。
|
||||
*/
|
||||
@Builder
|
||||
Chip(label: string, active: boolean, asAction: boolean) {
|
||||
Text(label)
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(
|
||||
(asAction || active)
|
||||
? (asAction ? this.chipFgFor(label) : Theme.accentFg)
|
||||
: Theme.textPrimary
|
||||
)
|
||||
.backgroundColor(
|
||||
(asAction || active)
|
||||
? (asAction ? this.chipActionBgFor(label) : Theme.accent)
|
||||
: Theme.chipBgFor()
|
||||
)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
|
||||
.margin({ right: 8, top: 6 })
|
||||
.opacity(this.busy ? 0.5 : 1)
|
||||
.onClick(() => {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
if (asAction) {
|
||||
/* 审批:点胶囊即提交(只有批准/拒绝两个动作,不需要再按一次) */
|
||||
this.submit(label, this.note);
|
||||
} else if (this.multiSelect) {
|
||||
this.picked = this.picked.indexOf(label) >= 0
|
||||
? this.picked.filter((p: string) => p !== label)
|
||||
: this.picked.concat([label]);
|
||||
} else {
|
||||
/* 单选:再点同一项则取消,否则替换(与 WebUI `toggle` 同) */
|
||||
this.picked = this.picked.indexOf(label) >= 0 ? [] : [label];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 动作型胶囊的字色(实心底 ⇒ 恒用前景白/品牌前景) */
|
||||
private chipFgFor(label: string): string {
|
||||
return Theme.accentFg;
|
||||
}
|
||||
|
||||
/** 动作型胶囊的底色:同意 = 实心绿,其余 = 危险红(对齐 WebUI `activeCls`) */
|
||||
private chipActionBgFor(label: string): string {
|
||||
return this.isApprove(label) ? Theme.approve : Theme.danger;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
/* ── 已处理态:只显示结果,不给操作(WebUI `MailView.tsx:762-770`)── */
|
||||
if (this.decided.length > 0) {
|
||||
Row() {
|
||||
Text('已处理:').fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
|
||||
Text(this.decided)
|
||||
.fontSize(Theme.fontSmall).fontWeight(FontWeight.Bold)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
this.StaleBanner()
|
||||
} else if (this.kind === 'question') {
|
||||
/*
|
||||
* ── 主动提问:勾选 + 自由文本(WebUI `MailView.tsx:772-825`)──
|
||||
*
|
||||
* 回答**必须非空**:空提交会让模型拿到一个什么都没说的结果继续跑。
|
||||
*/
|
||||
Text(this.options.length === 0
|
||||
? '这题没有预设选项,请直接填写回答:'
|
||||
: (this.multiSelect ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'))
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
|
||||
.width('100%')
|
||||
this.StaleBanner()
|
||||
|
||||
if (this.options.length > 0) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.options, (opt: string) => {
|
||||
this.Chip(opt, this.picked.indexOf(opt) >= 0, false)
|
||||
}, (opt: string) => 'opt-' + opt)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
TextInput({
|
||||
placeholder: this.options.length === 0 ? '你的回答(必填)' : '补充说明(可选)',
|
||||
text: this.note
|
||||
})
|
||||
.width('100%').height(40).margin({ top: 8 })
|
||||
.fontSize(Theme.fontSmall)
|
||||
.onChange((v: string) => { this.note = v; })
|
||||
|
||||
Row() {
|
||||
Button(this.busy ? '提交中…' : '提交回答')
|
||||
.height(38)
|
||||
.fontSize(Theme.fontSmall)
|
||||
/* 空回答时禁用 —— 与 WebUI `submit.disabled = blank` 同 */
|
||||
.enabled(!this.busy && (this.picked.length > 0 || this.note.trim().length > 0))
|
||||
.backgroundColor(Theme.accent)
|
||||
.onClick(() => {
|
||||
this.submit(this.picked.join('\n'), this.note.trim());
|
||||
})
|
||||
if (this.picked.length === 0 && this.note.trim().length === 0) {
|
||||
Text('请先选择或填写回答')
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
} else {
|
||||
/*
|
||||
* ── 审批型:同意 / 拒绝(WebUI `MailView.tsx:826-870`)──
|
||||
*
|
||||
* 没有预设选项时退回 ['同意', '拒绝'] —— 与 WebUI 同一兜底。
|
||||
*/
|
||||
this.StaleBanner()
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.actionOptions(), (opt: string) => {
|
||||
this.Chip(opt, false, true)
|
||||
}, (opt: string) => 'act-' + opt)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: '备注(可选)', text: this.note })
|
||||
.width('100%').height(40).margin({ top: 8 })
|
||||
.fontSize(Theme.fontSmall)
|
||||
.onChange((v: string) => { this.note = v; })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ top: 10 })
|
||||
/*
|
||||
* 与正文之间那条橙色分隔(WebUI `border-t border-orange-200`)。
|
||||
*
|
||||
* ★ 用 `chipWarnBg`(= tailwind `orange-100`,`#FFEDD5`)而不是新造一个
|
||||
* `warnBorder` 令牌:本仓的纪律是"令牌要么来自系统、要么是跨端逐字一致的
|
||||
* Tailwind 值"。WebUI 那边 `orange-200` 只出现在这两处边框上,
|
||||
* 为它单独立一个跨端令牌**反而会把两边绑到一个各自都用不到几次的值上**。
|
||||
* 用已有的橙色档(差一档、观感同族)比多一个令牌好。
|
||||
*/
|
||||
.border({ width: { top: 1 }, color: Theme.chipWarnBgFor(this.isDark) })
|
||||
.margin({ top: 12 })
|
||||
/*
|
||||
* 入场的淡入位移 —— 详情页正文之后出现的一块内容。
|
||||
* 与 WebUI 那边 `.rise-in` 同观感(本仓的 `Theme.paneRiseIn`)。
|
||||
*/
|
||||
.transition(Theme.paneRiseIn())
|
||||
}
|
||||
|
||||
/** 审批型胶囊的取值(与 WebUI `mail.permission_options?.length ? … : ['同意','拒绝']` 同) */
|
||||
private actionOptions(): string[] {
|
||||
return this.options.length > 0 ? this.options : ['同意', '拒绝'];
|
||||
}
|
||||
}
|
||||
666
client/harmony/entry/src/main/ets/pages/PermissionTab.ets
Normal file
666
client/harmony/entry/src/main/ets/pages/PermissionTab.ets
Normal file
@ -0,0 +1,666 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 授权(通信页第三栏)
|
||||
*
|
||||
* ★★ 2026-09-23 **从 `pages/MainPage.ets` 抽出来**(那个文件已 4592 行、28 个内联
|
||||
* `@Builder`)。抽出的动因不是"文件太长不好看",而是**对齐需要**:
|
||||
*
|
||||
* 用户要求「两端系统性对齐」。而 WebUI 那边每一块都是一个组件文件
|
||||
* (`components/PermissionList.tsx` / `PermissionChip.tsx` / `MailList.tsx` …),
|
||||
* 鸿蒙这边全是一个大 struct 里的内联 Builder ⇒ **没有一个可对比的单元**。
|
||||
* 于是"对齐"只能退化成逐行比渲染字段 —— 而那正是用户批评的修修补补。
|
||||
*
|
||||
* ⇒ 把授权栏抽成独立组件,让它与 `PermissionList.tsx` 形成**一一对应**:
|
||||
* 以后对齐看这一处,不用在 4592 行里找。
|
||||
*
|
||||
* ★ 为什么放在 `pages/` 而不是 `common/`:
|
||||
* `common/` 里是可被任意页面复用的**通用**件(`Surface` 的玻璃卡/按压、
|
||||
* `BackgroundPicker`、`Icons`…)。授权栏是**一个功能面**,只服务通信页第三栏,
|
||||
* 与 `WideSidebar.ets` 同类 —— 那个也是放在 `pages/` 下的独立组件文件。
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AppHeader, GlassCardModifier, PaneModifier } from '../common/Surface';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
import { MailApi, InboxResponse } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import {
|
||||
PermissionRequest,
|
||||
PendingResponse,
|
||||
MailSummary
|
||||
} from '../model/Models';
|
||||
import { LIST_FADE_LENGTH } from '../model/NavItems';
|
||||
import { emptyTitle, emptyHint } from '../model/CommTabs';
|
||||
import { LengthMetrics } from '@kit.ArkUI';
|
||||
import { MailLike, PermissionGroup, groupPermissions, shortTimeOf } from '../model/MailGrouping';
|
||||
import { INBOX_PAGE_SIZE } from '../common/MailStore';
|
||||
|
||||
/** WebUI 邮件列表统一使用 MM/DD HH:mm(与 `MainPage` 的同名函数一致)。 */
|
||||
function compactMailTime(iso: string): string {
|
||||
const value: Date = new Date(iso);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const month: string = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day: string = value.getDate().toString().padStart(2, '0');
|
||||
const hour: string = value.getHours().toString().padStart(2, '0');
|
||||
const minute: string = value.getMinutes().toString().padStart(2, '0');
|
||||
return month + '/' + day + ' ' + hour + ':' + minute;
|
||||
}
|
||||
|
||||
/*
|
||||
* ─────────────────────── 授权(通信页第三栏) ───────────────────────
|
||||
*
|
||||
* 只放**待人点头**的事:`GET /permission/pending`(不从收件箱筛,理由见 `MailApi`)。
|
||||
* 决策走 `POST /permission/decide`,`note` 会随决策送达模型 ——
|
||||
* 所以拒绝时**能填备注**,而且填了必须真的发出去。
|
||||
*
|
||||
* 两件必须如实说的事(WebUI 侧踩过坑,见 `decidePermission` 的返回类型):
|
||||
* ① 请求**已过期**时服务端会带 `expired`:这时决策落到了一个没人在等的请求上,
|
||||
* 得当场告诉人(否则会以为"批了,Agent 继续干活了");
|
||||
* ② `warning` 同理,服务端让显示什么就显示什么。
|
||||
*/
|
||||
|
||||
@Component
|
||||
export struct PermissionTab {
|
||||
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
|
||||
@Prop bgActive: boolean = false;
|
||||
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
|
||||
@Prop navReserve: number = 0;
|
||||
/*
|
||||
* ★★ 2026-09-23:授权栏走 `navigator_only`(用户裁定)。
|
||||
*
|
||||
* 点一条 → 推详情页(与 InboxTab / ContactsTab 同一条 `openMail`)。
|
||||
* 决策 UI 在详情页的 `PermissionPanel`,**不在**这里 ——
|
||||
* 所以这里不再需要「同意/拒绝」按钮、备注框、`decide()` 那一整套。
|
||||
* 对齐 WebUI `PermissionList.tsx:81` 的 `pick()` = `selectMail + showDetail`。
|
||||
*/
|
||||
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
|
||||
@State requests: PermissionRequest[] = [];
|
||||
/**
|
||||
* 已决策的**历史**,按会话分组(对齐 WebUI `PermissionList.tsx:182` 的「历史 {n}」)。
|
||||
*
|
||||
* ★ 来源是 **inbox**(不是 `/permission/pending`)—— 后者 SQL 带
|
||||
* `WHERE pr.result IS NULL`,永远拿不到已决策的。
|
||||
* 详见 `load()` 里那段取舍说明。
|
||||
*/
|
||||
@State permGroups: PermissionGroup[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State error: string = '';
|
||||
/*
|
||||
* ★★ 2026-09-23:`noteFor` / `noteText` / `busyId` 已删 ——
|
||||
* `navigator_only` 后决策 UI 搬到详情页,授权栏不再做决策。
|
||||
*/
|
||||
/**
|
||||
* 已展开的会话分组(键 = `PermissionGroup.key`)。
|
||||
*
|
||||
* ★★ 2026-09-23 新增 —— 对齐 WebUI `PermissionList.tsx` 的**展开层**。
|
||||
*
|
||||
* ── 为什么需要它(之前那版把这一整层漏了)──
|
||||
* 上一版鸿蒙只给每个会话一行「历史 n 条」,**不可展开**。
|
||||
* 而 WebUI 那行是个 `<button onClick={onToggle}>`(`PermissionList.tsx:156`),
|
||||
* 点开之后是逐条的 `PermissionRow`(`:214-222`)—— 每一条带
|
||||
* `subject` + `created_at` + **决策结果**(绿√ 同意 / 红✗ 拒绝),
|
||||
* 以及一个鸿蒙**完全没有**的东西:**「可能已失效」告警**(`:249-255`)。
|
||||
*
|
||||
* ★ 那个告警不是装饰,它有后果:`permission_expires_at` 过了之后,
|
||||
* 发起询问的 Agent 很可能**已不再阻塞等待** —— 这时人再去看历史,
|
||||
* 得知道"这条当时可能没真的生效"。WebUI 的实现里它是个 `title`
|
||||
* 提示 + 一个灰色胶囊;鸿蒙这边直接写成一行小字(没有 hover,
|
||||
* 触屏用 title 是无效的)。
|
||||
*
|
||||
* ★ 为什么用 `string[]` 而不是 `Set`:ArkTS 的 `@State` 对 `Set` 的
|
||||
* 变更检测不可靠(`@ohos` 的观察机制按引用/原始值走,`Set.add()` 不触发重绘)。
|
||||
* 本仓在 `expandedKeys`(收件箱会话折叠)上已经用了同一个形状 —— 保持一致。
|
||||
*/
|
||||
@State expandedKeys: string[] = [];
|
||||
/** 已展开分组里再展开的「历史逐条」段(WebUI 的 `showSettled` 是逐组一个 state) */
|
||||
@State settledOpenKeys: string[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
|
||||
await acctMgr.load();
|
||||
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
||||
const all: PermissionRequest[] = [];
|
||||
/* 已决策的历史(从 inbox 取,见下面 `settled` 的注释) */
|
||||
const settled: MailLike[] = [];
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const acct: AccountInfo = accounts[i];
|
||||
try {
|
||||
const c: ApiClient = new ApiClient(ctx);
|
||||
c.setBase(acct.server);
|
||||
c.setToken(acct.token);
|
||||
const resp: PendingResponse = await new MailApi(c).pendingPermissions();
|
||||
for (let j = 0; j < resp.requests.length; j++) {
|
||||
/* ★ 2026-09-23:记下来源账号,点卡片跳详情要用 */
|
||||
resp.requests[j].source_account_id = acct.id;
|
||||
all.push(resp.requests[j]);
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-21 补**已决策的历史**。
|
||||
*
|
||||
* 用户可见差异(登记在 `docs/DEBTS.json` 的 `harmony-permission-history`,
|
||||
* 其到期条件正是「做『授权栏与 WebUI 对齐』时」):
|
||||
* `GET /permission/pending` 的 SQL 带 `WHERE pr.result IS NULL`
|
||||
* ⇒ **只拿得到待决的**,于是"这条会话批过哪些事"在鸿蒙上完全看不到,
|
||||
* 而 WebUI 能看到(`PermissionList.tsx:182` 的「历史 {n}」)。
|
||||
*
|
||||
* ── 为什么不改走 WebUI 的 inbox 分组 ──
|
||||
*
|
||||
* 核实过:WebUI 从 inbox 分组(`groupPermissions`),但它的
|
||||
* `PermissionRow` **只渲染** `subject` / `created_at` / `permission_result`
|
||||
* / `permission_expires_at`(逐字段 grep 过)。
|
||||
* 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` ——
|
||||
* 那四个字段在 `permission_requests` **表**里,
|
||||
* inbox 回包(`models.Mail`)**没有它们**(模型里逐条核过)。
|
||||
*
|
||||
* ⇒ 待决继续走专用端点(信息更全、能直接决策),
|
||||
* 历史走 inbox 补上。两条来源合起来,与 WebUI 的可见信息量一致。
|
||||
* 代价:多一次请求/账号。这是**有意的取舍**,不是漏了优化。
|
||||
*/
|
||||
const inbox: InboxResponse = await new MailApi(c).inbox('all', INBOX_PAGE_SIZE);
|
||||
for (let j = 0; j < inbox.mails.length; j++) {
|
||||
const m: MailSummary = inbox.mails[j];
|
||||
/* 只要**已决策**的权限请求(待决的已由上面那个端点给了) */
|
||||
if (m.mail_type === 'permission_request' && m.permission_result.length > 0) {
|
||||
m.source_account_id = acct.id;
|
||||
settled.push(m);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 单账号失败不空整栏
|
||||
}
|
||||
}
|
||||
this.requests = all;
|
||||
this.permGroups = groupPermissions(settled);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 分组是否已展开(WebUI 的 `expanded` 集合) */
|
||||
private isGroupOpen(key: string): boolean {
|
||||
return this.expandedKeys.indexOf(key) >= 0;
|
||||
}
|
||||
|
||||
/** 分组内的「已决策」段是否展开(WebUI 的 `showSettled`) */
|
||||
private isSettledOpen(key: string): boolean {
|
||||
return this.settledOpenKeys.indexOf(key) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关分组。
|
||||
*
|
||||
* ★ 用 `concat`/`filter` 造**新数组**而不是 `push`/`splice` 改原数组:
|
||||
* ArkTS 的 `@State` 对数组是**引用比较**,原地 `push` 不会触发重绘
|
||||
* (本仓在 `expandedKeys`(收件箱会话折叠)上已经用了同一个形状)。
|
||||
*/
|
||||
private toggleGroup(key: string): void {
|
||||
if (this.isGroupOpen(key)) {
|
||||
this.expandedKeys = this.expandedKeys.filter((k: string) => k !== key);
|
||||
} else {
|
||||
this.expandedKeys = this.expandedKeys.concat([key]);
|
||||
}
|
||||
}
|
||||
|
||||
private toggleSettled(key: string): void {
|
||||
if (this.isSettledOpen(key)) {
|
||||
this.settledOpenKeys = this.settledOpenKeys.filter((k: string) => k !== key);
|
||||
} else {
|
||||
this.settledOpenKeys = this.settledOpenKeys.concat([key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条**待决**请求是否已过等待窗口(对齐 WebUI `PermissionList.tsx:248-250`)。
|
||||
*
|
||||
* ★★ 2026-09-23 重要纠正:我第一版把它用在了**历史行**上,那是错的。
|
||||
*
|
||||
* ── WebUI 的原口径 ──
|
||||
* const expired = !settled && !!mail.permission_expires_at && Date.now() > …
|
||||
* 那个 **`!settled`** 是关键:它**只在待决行**上判定。
|
||||
*
|
||||
* ── 为什么历史行上永远不会出现它(服务端定的)──
|
||||
* `permission_expires_at` 不是数据库列,而是读路径**算出来**的:
|
||||
* func AttachPermissionDeadline(m *Mail) {
|
||||
* if m.MailType != "permission_request" || m.PermResult != "" { return }
|
||||
* d := models.PermissionDeadline(m.CreatedAt); m.PermissionExpiresAt = &d
|
||||
* }
|
||||
* 第二行那个 `PermResult != "" ⇒ return` 意味着:**已决策的邮件拿不到这个字段**。
|
||||
* ⇒ 在历史行上判 stale 恒为 false,那段代码是**死分支**(看着有、永远不执行)
|
||||
* —— 而那正是本仓反复在消的形状("声明比实现宽")。
|
||||
*
|
||||
* ⇒ 现在它只服务待决行,与 WebUI 同口径。
|
||||
*
|
||||
* ★ 与 WebUI 的一处**有意差异**:WebUI 把它做成 `title` 悬停提示,
|
||||
* 而触屏没有 hover ⇒ 那句话在平板上永远看不到。
|
||||
* 这里渲染成可读的小字。
|
||||
*/
|
||||
private isStale(expiresAt: string): boolean {
|
||||
if (expiresAt.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const until: number = Date.parse(expiresAt);
|
||||
return !Number.isNaN(until) && Date.now() > until;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条历史逐条行(对齐 WebUI `PermissionRow` 的 `settled` 分支,
|
||||
* `PermissionList.tsx:249-312`)。
|
||||
*
|
||||
* 渲染:`subject` + 时间 + **决策结果**(绿√ / 红✗ + 原文)。
|
||||
*
|
||||
* ★ 这里**没有**「可能已失效」分支 —— 那是**待决**行的。
|
||||
* 我第一版把它也写在这里,属于**死分支**:
|
||||
* 服务端的 `AttachPermissionDeadline` 对已决策的邮件**直接 return**
|
||||
* (`PermResult != ""` 就不填 `expires_at`)⇒ 这里判永远是假。
|
||||
* 删掉是必要的 —— 留着就是"看着有、永远不执行"的代码,
|
||||
* 而本仓为这一形状反复吃过亏。
|
||||
*/
|
||||
@Builder
|
||||
PermissionHistRow(m: MailLike) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(m.subject.length > 0 ? m.subject : '(无主题)')
|
||||
.fontSize(12)
|
||||
.fontColor(m.permission_result.length > 0 ? Theme.textMuted : Theme.textPrimary)
|
||||
.fontWeight(m.permission_result.length > 0 ? FontWeight.Normal : FontWeight.Medium)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
Text(shortTimeOf(m.created_at))
|
||||
.fontSize(10).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 6 })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
/*
|
||||
* 已决策:绿√(同意类)/ 红✗(其余)。
|
||||
* 判定逐字对齐 WebUI `PermissionList.tsx:243` 的
|
||||
* `/同意|允许|批准|approve|yes/i` —— 服务端存的是中文决策词,
|
||||
* 而 Agent 可能传英文,所以两边都要认。
|
||||
*/
|
||||
AmIcon({
|
||||
iconName: this.isApproved(m) ? 'check' : 'close',
|
||||
iconSize: 10,
|
||||
iconColor: this.isApproved(m) ? Theme.approveFor() : Theme.dangerFor()
|
||||
})
|
||||
Text(m.permission_result)
|
||||
.fontSize(10)
|
||||
.fontColor(this.isApproved(m) ? Theme.approveFor() : Theme.dangerFor())
|
||||
.margin({ left: 2 })
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(Theme.surfaceMuted)
|
||||
.margin({ top: 3 })
|
||||
}
|
||||
|
||||
/** 决策是不是"同意"类(与 WebUI 同一正则,见 `PermissionHistRow` 的说明) */
|
||||
private isApproved(m: MailLike): boolean {
|
||||
const r: string = m.permission_result;
|
||||
return r.indexOf('同意') >= 0 || r.indexOf('允许') >= 0 || r.indexOf('批准') >= 0
|
||||
|| r.toLowerCase().indexOf('approve') >= 0 || r.toLowerCase().indexOf('yes') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话分组头上那行地址:`agent@path.alias`。
|
||||
*
|
||||
* ★★ 2026-09-23 新增(对齐 WebUI `PermissionList.tsx:165`)。
|
||||
*
|
||||
* WebUI 那行是**内联模板**,不是调 `formatAddress`:
|
||||
* {g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
|
||||
* 语义是「**三段各自可缺**,缺哪段就不出哪段」。
|
||||
*
|
||||
* ★ 为什么不直接调 `participantAddress()`(它就在 `ReplyTarget.ts` 里):
|
||||
* 那个函数是**参与方地址**的口径(人只给名字、Agent 才三段,靠 `isHuman` 分流)。
|
||||
* 而这里显示的是**会话的 Agent 身份**,不是"收件人/发件人"那个角色 ——
|
||||
* 没有 `isHuman` 可传,也不应把人名硬套进去。
|
||||
* 两者形状像,语义不同;用错会得到一个看似正常、实际丢段的地址。
|
||||
*
|
||||
* ★ 三段的来源:`agentName` ← `from_name`(权限请求一定由 Agent 发出),
|
||||
* `path` ← **会话的** `session_workspace`(不是 `from_workspace` ——
|
||||
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`,
|
||||
* `ReplyTarget.participantAddress` 的注释里记着这个坑)。
|
||||
*/
|
||||
private permGroupLabel(g: PermissionGroup): string {
|
||||
let out: string = g.agentName.length > 0 ? g.agentName : '(未知 Agent)';
|
||||
if (g.path.length > 0) {
|
||||
out += '@' + g.path;
|
||||
}
|
||||
if (g.alias.length > 0) {
|
||||
out += '.' + g.alias;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 顶栏右侧:待决策徽标(橙=有人被卡住,不是"有东西要读") */
|
||||
@Builder
|
||||
PendingTrailing() {
|
||||
if (this.requests.length > 0) {
|
||||
Text(this.requests.length + ' 待决策')
|
||||
.fontSize(12).fontColor(Theme.accentFg)
|
||||
.backgroundColor(Theme.warnFg)
|
||||
.borderRadius(10)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
.margin({ right: 8 })
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
RequestCard(req: PermissionRequest) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
|
||||
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
/*
|
||||
* ★★ 2026-09-20 修:**这里原来读的是一个不存在的字段**。
|
||||
*
|
||||
* `req` 是 `PermissionRequest`,而服务端那个 struct
|
||||
* (`models.go:239-255`)**没有 `session_alias`** ——
|
||||
* `repo.ListPendingPermissionsFor` 的 SELECT 也没查它。
|
||||
* WebUI 的类型里同样没有(`types/index.ts:233` 只有 `session_id`/`agent_name`)。
|
||||
*
|
||||
* ⇒ `req.session_alias` 恒为 `undefined`,
|
||||
* 一旦有待办,`.length` 当场抛 TypeError(整页白屏)。
|
||||
* 这个 bug **一直没暴露只是因为当前待办数一直是 0** ——
|
||||
* 实测 `/permission/pending` 返回 `{"requests":[]}`。
|
||||
*
|
||||
* 改成服务端**确实有**的 `agent_name`(授权请求一定由 Agent 发出,
|
||||
* 这是卡片上最有辨识度的一格)。
|
||||
* 与 WebUI 同口径:那边授权卡标题也只显示 Agent 与问题,不显示会话别名。
|
||||
*/
|
||||
Text(req.agent_name.length > 0 ? req.agent_name : '(未知 Agent)')
|
||||
.fontSize(10).fontColor(Theme.accentFor())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 问题是这张卡的主角:人要照着它决定点头还是摇头
|
||||
Text(req.question.length > 0 ? req.question : '(无问题描述)')
|
||||
.fontSize(13).fontColor(Theme.textPrimary)
|
||||
.margin({ top: 6 })
|
||||
|
||||
if (req.context.length > 0) {
|
||||
Text(req.context)
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
|
||||
/* 同 ①:WebUI `PermissionList.tsx:251` 也格式化,不印 ISO */
|
||||
Text(compactMailTime(req.created_at)).fontSize(10).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-23 补:**「可能已失效」告警**(对齐 WebUI `PermissionList.tsx:248-255`)。
|
||||
*
|
||||
* ── 为什么它必须在这里(而不是历史行上)──
|
||||
* WebUI 的判定带 `!settled` —— 它**只在待决行**上用。
|
||||
* 而服务端 `AttachPermissionDeadline` 对已决策的邮件直接 return
|
||||
* (`PermResult != ""` 就不填 expires_at)⇒ 历史行上永远算不出失效来。
|
||||
* 我第一版把它写在了历史行上,那是**死分支**(已删)。
|
||||
*
|
||||
* ── 为什么值得占一行屏幕 ──
|
||||
* 过了等待窗口之后,发起询问的 Agent **很可能已不再阻塞等待**:
|
||||
* 现在点"同意"不会恢复当时那次工具调用(决策只会当作一条通知投给它)。
|
||||
* 不说的话,人会以为"我批了,它接着干了"—— 而实际没有。
|
||||
*
|
||||
* ★ 与 WebUI 的一处**有意差异**:WebUI 把它放在 `title` 属性里(悬停提示),
|
||||
* 而触屏没有 hover ⇒ 那句话在平板上永远看不到。
|
||||
* 这里直接渲染成可读的横条。
|
||||
*/
|
||||
if (this.isStale(req.expires_at)) {
|
||||
Text('已超过等待窗口 —— 点开查看并决策')
|
||||
.fontSize(10)
|
||||
.lineHeight(15)
|
||||
.fontColor(Theme.warnFgFor())
|
||||
.backgroundColor(Theme.warnBgFor())
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
|
||||
.margin({ top: 6 })
|
||||
.width('100%')
|
||||
} else {
|
||||
/*
|
||||
* ★★ 2026-09-23:`navigator_only` 后,这里只留一个「点开决策」的提示。
|
||||
* 原来的「同意/拒绝」按钮 + 备注框全删了 —— 决策 UI 在详情页的
|
||||
* `PermissionPanel`(与 WebUI `MailView.tsx:693` 同一处)。
|
||||
*/
|
||||
Row() {
|
||||
Text('点开决策 →')
|
||||
.fontSize(12)
|
||||
.fontColor(Theme.accent)
|
||||
.layoutWeight(1)
|
||||
AmIcon({ iconName: 'chevronRight', iconSize: 18, iconColor: Theme.textSubtleFor() })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%').alignItems(HorizontalAlign.Start)
|
||||
.padding(12)
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.margin({ bottom: 8 })
|
||||
/*
|
||||
* ★★ 2026-09-23:整卡可点 → 推详情页(与 WebUI `PermissionList.tsx:81` 的
|
||||
* `pick()` = `selectMail + showDetail` 同义)。决策在详情页做,不在这里。
|
||||
*/
|
||||
.onClick(() => {
|
||||
this.onOpenMail(req.mail_id, req.source_account_id);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
/* 同 SentTab:统一走 AppHeader(圆框 + 与底栏同族的几何与材质) */
|
||||
AppHeader({
|
||||
title: '授权',
|
||||
showBack: false,
|
||||
active: this.bgActive,
|
||||
topInsetPx: 0
|
||||
}) {
|
||||
this.PendingTrailing()
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
Column() { LoadingProgress().width(32).height(32) }
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.error.length > 0) {
|
||||
Column() { Text(this.error).fontSize(13).fontColor(Theme.dangerFor()) }
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.requests.length === 0 && this.permGroups.length === 0) {
|
||||
Column() {
|
||||
AmIcon({ iconName: 'shield', iconSize: 36, iconColor: Theme.textSubtleFor() }).margin({ bottom: 8 })
|
||||
Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted)
|
||||
Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtleFor()).margin({ top: 6 })
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
List({ space: 8 }) {
|
||||
/* 待决(可决策) */
|
||||
ForEach(this.requests, (req: PermissionRequest) => {
|
||||
ListItem() {
|
||||
this.RequestCard(req)
|
||||
}
|
||||
.width('100%')
|
||||
}, (req: PermissionRequest) => req.request_id)
|
||||
|
||||
/*
|
||||
* ── 已决策的会话分组(对齐 WebUI `PermissionList` 的会话行 + 展开层)──
|
||||
*
|
||||
* ★★ 2026-09-21 新增「历史」这一段(此前鸿蒙完全看不到已决策的)。
|
||||
*
|
||||
* ★★ 2026-09-23 **按 WebUI 逐行重写,并补上展开层**。
|
||||
*
|
||||
* ── 我是怎么发现原来不对的 ──
|
||||
* 上一版这里只渲染「别名 + 历史 n 条」,而我写了一句话注释声称
|
||||
* "与 WebUI 同层、不是少做了"。那句话**是我自己说的、没有对照过**。
|
||||
* 真的去逐行读 `PermissionList.tsx:155-222` 才发现:
|
||||
* · 会话行是个 `<button onClick={onToggle}>`(**可点、带展开箭头**);
|
||||
* · 行上有:机器人图标 + `agent@path.alias` + **时间**(`latest`)
|
||||
* + 「N 待决策」橙胶囊 /「已全部处理」灰胶囊 + 「历史 {n}」;
|
||||
* · **展开后**才是逐条 `PermissionRow`:`subject` + 时间 +
|
||||
* **决策结果**(绿√ / 红✗)+ **「可能已失效」告警**。
|
||||
* 我上一版只做了行上的两项,把整个展开层与告警都漏了。
|
||||
*
|
||||
* ⇒ 现在补齐。每一块都注了 WebUI 的出处。
|
||||
*/
|
||||
ForEach(this.permGroups, (g: PermissionGroup) => {
|
||||
ListItem() {
|
||||
Column() {
|
||||
/*
|
||||
* 组头:可点(WebUI 的 `<button onClick={onToggle}>`)。
|
||||
* 整个组头是一块命中区 —— 不是只点箭头(触屏上小靶难中,
|
||||
* 本仓的 `AppHeader` 返回键也为此做到 44vp)。
|
||||
*/
|
||||
Column() {
|
||||
/* 第一行:展开箭头 + 机器人图标 + `agent@path.alias` + 时间 */
|
||||
Row() {
|
||||
/*
|
||||
* 展开箭头(WebUI 的 `ChevronRightIcon`,展开时转 90°)。
|
||||
* 用 `rotate` 而不是换图标:换图标会让那一列宽度跳一下。
|
||||
*/
|
||||
AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() })
|
||||
.rotate({ angle: this.isGroupOpen(g.key) ? 90 : 0 })
|
||||
AmIcon({ iconName: 'bot', iconSize: 14, iconColor: Theme.textMuted })
|
||||
.margin({ left: 4 })
|
||||
/*
|
||||
* 地址拼接逐字对齐 WebUI `PermissionList.tsx:165`:
|
||||
* {g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
|
||||
* —— 三段各自可缺,缺哪段就不出哪段(不是拼出 `@@`、`.undefined`)。
|
||||
*/
|
||||
Text(this.permGroupLabel(g))
|
||||
.fontSize(13)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 5 })
|
||||
/* 组头时间取组内**最新一封**(`g.latest`)—— 与 WebUI 的 `{time}` 同源 */
|
||||
if (g.latest !== undefined) {
|
||||
Text(shortTimeOf(g.latest.created_at))
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 6 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
/* 第二行:状态胶囊 + 历史条数(同 WebUI 的第二行 flex) */
|
||||
Row() {
|
||||
/*
|
||||
* 状态胶囊:**只有历史**时是「已全部处理」(灰),
|
||||
* 有待决时是「N 待决策」(橙)。
|
||||
*
|
||||
* ★ 注意:待决那一段已经在上面的 `this.requests` 里**单独成卡**了,
|
||||
* 所以这里出现"N 待决策"不是重复展示,而是**告诉人这个会话里
|
||||
* 还有东西要他点头**(否则他会以为这整个分组都是历史)。
|
||||
* 这与 WebUI 一致 —— 它那边也是「同一个组头既报待决数、又报历史数」。
|
||||
*/
|
||||
if (g.pending.length > 0) {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'shield', iconSize: 10, iconColor: Theme.accentFg })
|
||||
Text(g.pending.length.toString() + ' 待决策')
|
||||
.fontSize(10).fontColor(Theme.accentFg)
|
||||
.margin({ left: 2 })
|
||||
}
|
||||
.backgroundColor(Theme.warnFg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
} else {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'check', iconSize: 10, iconColor: Theme.textSubtleFor() })
|
||||
Text('已全部处理')
|
||||
.fontSize(10).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 2 })
|
||||
}
|
||||
.backgroundColor(Theme.surfaceMuted)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
}
|
||||
/* 历史条数(WebUI 是 `历史 {n}`,措辞保持逐字一致) */
|
||||
Text('历史 ' + g.settled.length.toString())
|
||||
.fontSize(10).fontColor(Theme.textSubtleFor())
|
||||
.margin({ left: 6 })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => { this.toggleGroup(g.key); })
|
||||
|
||||
/*
|
||||
* ── 展开层 ──
|
||||
*
|
||||
* 对齐 WebUI `PermissionList.tsx:194-222`:
|
||||
* · 待决逐条(`PermissionRow`);
|
||||
* · 再一个「展开/收起已决策 N」开关,展开后才列历史逐条。
|
||||
*
|
||||
* ★ WebUI 把两段分开开关(组头开组、组内再开历史)。
|
||||
* 照做:历史在鸿蒙这边可能很长(本机实测库里 107 条),
|
||||
* 一展开全铺出来会把待决那半顶出屏幕。
|
||||
*/
|
||||
if (this.isGroupOpen(g.key)) {
|
||||
Column() {
|
||||
/* 待决逐条 */
|
||||
ForEach(g.pending, (m: MailLike) => {
|
||||
this.PermissionHistRow(m)
|
||||
}, (m: MailLike) => 'p-' + m.mail_id)
|
||||
|
||||
/* 历史段的开关(WebUI: `{showSettled ? '收起' : '展开'}已决策 {n}`) */
|
||||
if (g.settled.length > 0) {
|
||||
Text(this.isSettledOpen(g.key)
|
||||
? '收起已决策 ' + g.settled.length.toString()
|
||||
: '展开已决策 ' + g.settled.length.toString())
|
||||
.fontSize(11).fontColor(Theme.textSubtleFor())
|
||||
.width('100%')
|
||||
.padding({ left: 6, top: 6, bottom: 4 })
|
||||
.onClick(() => { this.toggleSettled(g.key); })
|
||||
|
||||
if (this.isSettledOpen(g.key)) {
|
||||
ForEach(g.settled, (m: MailLike) => {
|
||||
this.PermissionHistRow(m)
|
||||
}, (m: MailLike) => 's-' + m.mail_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive, false))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
}
|
||||
.width('100%')
|
||||
}, (g: PermissionGroup) => 'hist-' + g.key)
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
.contentEndOffset(this.navReserve)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.attributeModifier(PaneModifier.of(this.bgActive))
|
||||
}
|
||||
}
|
||||
@ -115,7 +115,13 @@ export struct SettingsPane {
|
||||
@State newKeyToken: string = '';
|
||||
@State keyLabel: string = '';
|
||||
@State creatingKey: boolean = false;
|
||||
/* 修改密码表单 */
|
||||
/*
|
||||
* 修改密码表单。
|
||||
*
|
||||
* ★★ 2026-09-21(用户:「修改密码也应该做成弹窗吧」):这一组原来常驻在页面上,
|
||||
* 现在装进官方半模态 —— `showPasswordSheet` 是它的显隐开关(`$$` 两向绑定)。
|
||||
*/
|
||||
@State showPasswordSheet: boolean = false;
|
||||
@State oldPw: string = '';
|
||||
@State newPw: string = '';
|
||||
@State confirmPw: string = '';
|
||||
@ -768,69 +774,133 @@ export struct SettingsPane {
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
/* 末尾让位:最后一段(退出登录)要能滚出悬浮条之下 */
|
||||
.contentEndOffset(this.navReserve)
|
||||
|
||||
// 新增账号弹层(留在最外层,不被滚动容器裁掉)
|
||||
if (this.showAddDialog) {
|
||||
Column() {
|
||||
Column()
|
||||
.width('100%').layoutWeight(1)
|
||||
.backgroundColor(Theme.overlay)
|
||||
.onClick(() => { this.showAddDialog = false; })
|
||||
|
||||
Column() {
|
||||
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.margin({ bottom: 16 })
|
||||
|
||||
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newDisplayName = value; })
|
||||
|
||||
TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newServer = value; })
|
||||
|
||||
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newUsername = value; })
|
||||
|
||||
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
|
||||
.width('100%').height(44).margin({ bottom: 16 })
|
||||
.type(InputType.Password)
|
||||
.onChange((value: string) => { this.newToken = value; })
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
|
||||
.onClick(() => { this.showAddDialog = false; })
|
||||
Blank()
|
||||
Button(this.adding ? '验证中…' : '添加')
|
||||
.width(80).height(36).backgroundColor(Theme.accent)
|
||||
.enabled(!this.adding)
|
||||
.onClick(() => { this.addNewAccount(); })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%').height('68%')
|
||||
.padding(16).attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius({ topLeft: 12, topRight: 12 })
|
||||
/*
|
||||
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
|
||||
*
|
||||
* 底部弹层挂在 `if (this.showAddDialog)` 上,原来硬弹。
|
||||
* 与 `MailDetailPage` 的回复框/转发框**同一档、同一挂法**:
|
||||
* `paneRiseIn()` 挂在**弹层本身**(下半部那块表面),遮罩立即出现。
|
||||
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
|
||||
* 那看起来是"页面在动",而不是"弹层弹出来"。
|
||||
*/
|
||||
.transition(Theme.paneRiseIn())
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.position({ x: 0, y: 0 })
|
||||
}
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
/* 与其它窗格同一约定:背景开着时让出页面底,壁纸才透得过 */
|
||||
.attributeModifier(PaneModifier.of(this.bgActive))
|
||||
/*
|
||||
* ★★ 2026-09-21 改:新增账号弹层 → **官方半模态 `bindSheet`**。
|
||||
*
|
||||
* ── 改前的自绘形状(已删)──
|
||||
* if (this.showAddDialog) {
|
||||
* Column() {
|
||||
* Column().layoutWeight(1).backgroundColor(Theme.overlay) // 手写遮罩
|
||||
* Column() { …表单… }.height('68%').transition(Theme.paneRiseIn())
|
||||
* }.position({ x: 0, y: 0 })
|
||||
* }
|
||||
*
|
||||
* 四个具体毛病(每个都是"割裂"的一条):
|
||||
* ① **遮罩颜色手写**(`Theme.overlay`)—— 不同页面各选一次,深浅档也不同步;
|
||||
* ② **高度硬编** `height('68%')` —— 系统半模态是按内容/档位算的,我拍了个百分比;
|
||||
* 键盘弹起时它不会自动让位(官方 `SheetOptions.keyboardAvoidMode` 就是干这个的);
|
||||
* ③ **没有拖拽条**(官方 `dragBar` 默认 true)—— 用户没法下拉关掉,
|
||||
* 而这是半模态的**通用手势**;
|
||||
* ④ **没有下滑关闭 / 遮罩点击关闭的官方语义**(我手写了遮罩 onClick)。
|
||||
*
|
||||
* ── 换完得到什么 ──
|
||||
* 与系统其它应用的半模态**同一种观感**(高度档位、圆角、拖拽条、
|
||||
* 遮罩浓度、出入场曲线全部由系统给),而且自带:
|
||||
* · `detents` 多档高度(用户可拖拽切换);
|
||||
* · `keyboardAvoidMode` 键盘避让;
|
||||
* · 下滑手势关闭(`shouldDismiss` / `onWillDismiss` 可拦截)。
|
||||
*
|
||||
* ★ 这就是用户说的"系统预制动效不需要占带宽,为什么不用"的同一个道理:
|
||||
* 弹层不只是动画,**它的几何、遮罩、手势、无障碍语义都是系统的**。
|
||||
*
|
||||
* ★ 保留 `showAddDialog` 作 `isShow` —— 它是"显示与否"的唯一状态,
|
||||
* 带 `$$` 两向绑定后,用户拖拽/点遮罩关闭时会**自动回写** false
|
||||
* (否则界面关了而状态还是 true,再点「+」就不出来了)。
|
||||
*/
|
||||
.bindSheet($$this.showAddDialog, this.AddAccountSheet(), {
|
||||
/*
|
||||
* ★ `FIT_CONTENT` 而不是 `MEDIUM`。
|
||||
*
|
||||
* 实测(宽屏 3184):`MEDIUM` 给的是一个**固定档高度**,
|
||||
* 而这块表单只有 4 个输入框 + 一行按钮 ⇒ 下方空出一大片
|
||||
* (截图里弹层下半截是空的)。`FIT_CONTENT` 让容器**贴着内容**,
|
||||
* 这才是"表单型半模态"该有的形状。
|
||||
*
|
||||
* ★ 官方 `SheetSize` 只有三档(`FIT_CONTENT` / `MEDIUM` / `LARGE`),
|
||||
* 都是"按内容或屏幕比例算"的 —— 比改前那个手写的 `height('68%')` 讲道理。
|
||||
*/
|
||||
height: SheetSize.FIT_CONTENT,
|
||||
/*
|
||||
* ★★ 2026-09-21 改:`false` → `true`(与隔壁「修改密码」刚刚统一)。
|
||||
*
|
||||
* 原来这里是 `false`(不要系统那个 ✕),理由是"内容里已经有「取消」按钮,
|
||||
* 再给一个 ✕ 就重复了"。那个理由本身站得住 —— 但它只看到**本块**,
|
||||
* 没看到两个同胞的形状:
|
||||
* · 新增账号:`showClose: false` + 内容里「取消」
|
||||
* · 修改密码:`showClose: false` + 内容里「取消」
|
||||
* 两个**同类**(表单型半模态)一个有一个没有,用户看到的是"为什么这个能叉掉、
|
||||
* 那个不能"。
|
||||
*
|
||||
* ⇒ 统一为 `true`。系统 ✕ 与「取消」并存不是重复:
|
||||
* ✕ 是"关掉面板"(与下滑关闭同类,也是无障碍读屏的关闭入口),
|
||||
* 「取消」是"不提交这次编辑"(表单语汇)。两者语义不同,都有才有。
|
||||
* 这也是系统 HIG 的常规形状(关闭钮 + 动作钮)。
|
||||
*
|
||||
* ★ 这两个半模态**必须同形** —— 判据里也把这条钉住了
|
||||
* (见 `harmony-admin.test.mjs` 的「★ 两个表单型半模态必须**同形**」)。
|
||||
*/
|
||||
showClose: true,
|
||||
onDisappear: () => { this.showAddDialog = false; }
|
||||
})
|
||||
.bindSheet($$this.showPasswordSheet, this.PasswordSheet(), {
|
||||
/* 与「新增账号」同一档(都是表单型半模态)—— 同一类交互同一种高度语义 */
|
||||
height: SheetSize.FIT_CONTENT,
|
||||
showClose: true,
|
||||
onDisappear: () => { this.showPasswordSheet = false; }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 「新增账号」半模态的内容(原来自绘弹层里那块表单,搬进来不作改动)。
|
||||
*
|
||||
* ★ 它现在只是**内容**:外层的圆角/高度/遮罩/拖拽条/动画全部交给 `bindSheet`。
|
||||
* 所以这里去掉 `.height('68%')`、`.transition(...)`、`GlassCardModifier`
|
||||
* —— 那些在系统容器里要么多余(高度)、要么会被覆盖(圆角/背景),
|
||||
* 留着反而会和系统样式打架(比如两份圆角不同)。
|
||||
*/
|
||||
@Builder
|
||||
AddAccountSheet() {
|
||||
Column() {
|
||||
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.margin({ bottom: 16 })
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newDisplayName = value; })
|
||||
|
||||
TextInput({ placeholder: 'Gateway 地址(必填,含 /api/v1)', text: this.newServer })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newServer = value; })
|
||||
|
||||
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
|
||||
.width('100%').height(44).margin({ bottom: 10 })
|
||||
.onChange((value: string) => { this.newUsername = value; })
|
||||
|
||||
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
|
||||
.width('100%').height(44).margin({ bottom: 16 })
|
||||
.type(InputType.Password)
|
||||
.onChange((value: string) => { this.newToken = value; })
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
|
||||
.onClick(() => { this.showAddDialog = false; })
|
||||
Blank()
|
||||
Button(this.adding ? '验证中…' : '添加')
|
||||
.width(80).height(36).backgroundColor(Theme.accent)
|
||||
.enabled(!this.adding)
|
||||
.onClick(() => { this.addNewAccount(); })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/** 分区标题(与 WebUI `AccountPage` 的 `<h3>` 同形:小字、灰、上间距) */
|
||||
@ -863,7 +933,14 @@ export struct SettingsPane {
|
||||
* 数据来自 `/me`(`loadRole()` 里一起存进 `profile`)——
|
||||
* 服务端**一直**在返回这些字段,客户端原来只取了 `role`,其余全丢。
|
||||
*/
|
||||
/** 顶栏右侧的「新增账号」 */
|
||||
/**
|
||||
* 顶栏右侧的「新增账号」。
|
||||
*
|
||||
* ★★ 2026-09-21 改:弹层从**自绘**换成官方 `bindSheet`(见 `AddAccountSheet`)。
|
||||
*
|
||||
* 用户:「现在最割裂的就是弹出效果」—— 指的是同一类交互(弹一个面板)
|
||||
* 在不同页面里长得不一样、动得不一样,而系统本来就给了现成的半模态容器。
|
||||
*/
|
||||
@Builder
|
||||
HeaderTrailing() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
@ -1185,25 +1262,78 @@ export struct SettingsPane {
|
||||
* 但**内容与顺序**与 WebUI 一致:先密码、后退出。
|
||||
*/
|
||||
/**
|
||||
* 修改密码(对齐 WebUI `AccountPage` 的「修改密码」section)。
|
||||
* 修改密码 —— **入口卡片**(真正的表单在官方半模态里)。
|
||||
*
|
||||
* ★ 2026-09-21 从 `SecuritySection` 拆出来 —— WebUI 那边「修改密码」与
|
||||
* 「登录状态」是**两个独立的 <section>**,中间还夹着多账号/密钥/外观。
|
||||
* 我们原来把两者焊成一个,于是顺序上就没法对齐了(这是顺序错位的根因)。
|
||||
* ★★ 2026-09-21 改(用户:「修改密码也应该做成弹窗吧」):
|
||||
*
|
||||
* ── 改前的形状 ──
|
||||
* 三个 `TextInput` + 错误/成功提示 + 「保存」按钮**全部常驻在页面上**,
|
||||
* 占掉一整张卡(实测约 300vp 高)。
|
||||
*
|
||||
* 三个具体的坏处:
|
||||
* ① **它是个一次性操作,却常驻占屏** —— 「我的」页本就很长
|
||||
* (基本资料/权限/密码/多账号/密钥/外观/通知/登录/管理九段),
|
||||
* 而修改密码一年用几次。它把「多账号」「密钥」这些
|
||||
* **看一眼就要用**的内容挤到需要滚动才能看到。
|
||||
* ② **密码框在浏览器/系统里会被自动填充或误触**:三个 `InputType.Password`
|
||||
* 常驻,密码管理器可能往里填、长按也可能选中。展开后才出现能避免这个。
|
||||
* ③ 与「新增账号」**形状不一致** —— 两者都是"填几个字段提交"的表单,
|
||||
* 一个已改官方半模态、一个是内联卡,同一类交互两种形状(用户说的"割裂")。
|
||||
*
|
||||
* ★ 入口本身用「锁图标 + 标题 + 右箭头」,与其它可点行的语汇一致
|
||||
* (不写"修改"按钮 —— 整行可点,与「管理」入口同形)。
|
||||
*/
|
||||
@Builder
|
||||
PasswordSection() {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
|
||||
Text('修改密码')
|
||||
.fontSize(14).fontColor(Theme.textPrimary)
|
||||
.margin({ left: 6 })
|
||||
.layoutWeight(1)
|
||||
AmIcon({ iconName: 'chevronRight', iconSize: 14, iconColor: Theme.textSubtleFor() })
|
||||
}
|
||||
.width('100%').height(44)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.padding(16).margin({ top: SECTION_GAP })
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.attributeModifier(PressEffectModifier.of())
|
||||
.onClick(() => {
|
||||
/*
|
||||
* ★ 每次打开都**清空上次的输入与提示**。
|
||||
* 不清理的话:上次改了密码成功、关闭,下次再点开还挂着
|
||||
* 「密码已修改,请重新登录」,而三个框是空的 —— 状态与画面不一致。
|
||||
*/
|
||||
this.oldPw = '';
|
||||
this.newPw = '';
|
||||
this.confirmPw = '';
|
||||
this.pwError = '';
|
||||
this.pwMsg = '';
|
||||
this.showPasswordSheet = true;
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 「修改密码」半模态内容(原来常驻的那块表单,搬进来不改逻辑)。
|
||||
*
|
||||
* ★ 只做内容:圆角/高度/遮罩/键盘避让全交给 `bindSheet`。
|
||||
* 与 `AddAccountSheet` 同一形状 —— 两个表单型半模态长得一样。
|
||||
*/
|
||||
@Builder
|
||||
PasswordSheet() {
|
||||
Column() {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
|
||||
Text('修改密码')
|
||||
.fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.margin({ left: 6 })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 16 })
|
||||
|
||||
TextInput({ placeholder: '当前密码', text: this.oldPw })
|
||||
.width('100%').height(44).margin({ top: 10 })
|
||||
.width('100%').height(44)
|
||||
.type(InputType.Password)
|
||||
.onChange((v: string) => { this.oldPw = v; })
|
||||
|
||||
@ -1231,17 +1361,23 @@ export struct SettingsPane {
|
||||
.fontSize(12).fontColor(Theme.approveFg).margin({ top: 8 })
|
||||
}
|
||||
|
||||
Button(this.pwBusy ? '保存中' : '保存')
|
||||
.height(40).margin({ top: 12 })
|
||||
.backgroundColor(Theme.accent).fontSize(14)
|
||||
.enabled(!this.pwBusy)
|
||||
.onClick(() => { this.changePassword(); })
|
||||
|
||||
Row() {
|
||||
Button('取消')
|
||||
.width(80).height(36).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted)
|
||||
.onClick(() => { this.showPasswordSheet = false; })
|
||||
Blank()
|
||||
Button(this.pwBusy ? '保存中' : '保存')
|
||||
.width(80).height(36)
|
||||
.backgroundColor(Theme.accent).fontSize(14)
|
||||
.enabled(!this.pwBusy)
|
||||
.onClick(() => { this.changePassword(); })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
}
|
||||
.width('100%').alignItems(HorizontalAlign.Start)
|
||||
.padding(16).margin({ top: SECTION_GAP })
|
||||
.attributeModifier(GlassCardModifier.of(this.bgActive))
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -50,17 +50,19 @@
|
||||
},
|
||||
{
|
||||
"id": "nav-dark-route-b-unguarded",
|
||||
"count": 1,
|
||||
"due": "**引入深色主题(或第一次给导航组件加 `dark:` 变体)时**必须一并堵;堵法按**文件窄豁免**写,不许写成「导航目录不许出现 dark:」(`bg-chrome-600` plain 档徽标那个先例我踩过一次)",
|
||||
"where": "client/electron/test/background.test.mjs 两条反向断言 —— 这是**已知未覆盖的回滚路径**(不是未验的运行时性质):`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉",
|
||||
"kind": "scope"
|
||||
"count": 0,
|
||||
"due": "已完成 2026-09-23(见 note)",
|
||||
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:`.dark .nav-rail{}` 选择器作用域与组件 `dark:` 变体,变异确认过都会逃掉)",
|
||||
"kind": "scope",
|
||||
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
|
||||
},
|
||||
{
|
||||
"id": "nav-blur-route-c-unguarded",
|
||||
"count": 1,
|
||||
"due": "**第一次给导航元素加工具类模糊(Tailwind `backdrop-blur-*`)时**堵",
|
||||
"where": "同上文件 —— **已知未覆盖的回滚路径**:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉",
|
||||
"kind": "scope"
|
||||
"count": 0,
|
||||
"due": "已完成 2026-09-23(见 note)",
|
||||
"where": "client/electron/test/background.test.mjs —— **已堵**:见 note。(原文:元素级 `backdrop-blur-lg` 不在那条选择器下,变异确认过逃得掉)",
|
||||
"kind": "scope",
|
||||
"note": "**已堵(2026-09-23)**。这三条逃逸路(A 选择器作用域 / B Tailwind `dark:bg-*` 变体 / C 元素级 `backdrop-blur-*`)此前各自无判据,变异确认过全逃得掉。到期条件(深色主题落地)在 2026-09-17 就成立了 —— 但那次只修了令牌这条路,欠债逾期至此。\n\n堵法(欠债原文点名的**文件窄豁免**,不是一刀切):`background.test.mjs` 新增三条 check,**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些类串** —— 问的不是「这个文件有没有 dark: 背景」,而是「挂在导航元素上的那个类串里有没有」(与逃逸路的形状同构;同文件别的元素写什么都不影响,避免 `bg-chrome-600` 那个先例的误红)。\nA 条另起一条:CSS 里不许出现 `.dark .nav-rail`/`.dark .nav-item` 这类选择器。\n三条都做了变异验证(逐条注入 ⇒ 各红一条;恢复 ⇒ 47/47 全绿)。"
|
||||
},
|
||||
{
|
||||
"id": "boundary-vocabulary-incomplete",
|
||||
@ -171,10 +173,18 @@
|
||||
{
|
||||
"id": "harmony-permission-history-render-unverified",
|
||||
"count": 1,
|
||||
"due": "库里存在已决策的权限请求时(UPDATE mails SET permission_result 之后即可复现)",
|
||||
"due": "**有一个「未归档会话」里的已决策权限请求时**(光 `UPDATE mails SET permission_result` 不够 —— 见 note 里的实测)",
|
||||
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 permGroups 渲染段",
|
||||
"kind": "env",
|
||||
"note": "2026-09-21 闭合 harmony-permission-history 时新增的「历史 n 条」渲染。\n\n分组逻辑(groupPermissions)是纯函数、有跨端逐例判据;但**渲染那一层没验** —— 本机服务端实测 permission_request 0 封,没有已决策的权限请求可显示。\n\n复现路径:让某个 agent 发一条权限请求、人类决策一次,permission_requests.result 与 mails.permission_result 就都有值了。"
|
||||
"note": "2026-09-21 闭合 harmony-permission-history 时新增的「历史 n 条」渲染。\n\n分组逻辑(`groupPermissions`)是纯函数、有跨端逐例判据;渲染那一层没验。\n\n★★ 2026-09-23 **实测订正复现路径**(原文写的是「`UPDATE mails SET permission_result` 之后即可复现」—— **那样做复现不出来**)。\n\n本机实测:库里**确实有 137 条已决策**的权限请求(`to_name=jianf` 107 条),但 `GET /me/mail/inbox?status=all` 返回的权限请求是 **0 条**。根因不在权限,在**会话归档**:\n · `repo.ListInboxScoped` 硬编码 `AND s.status <> 'archived'`(该过滤在整个 repo 出现 8 处,是「归档会话不进任何列表」的**全局约定**);\n · 而那 107 条所在会话**全部是 archived** ⇒ 被整条过滤掉;\n · 实测「未归档会话 + 已决策权限请求」的组合数 = **0**。\n\n ⇒ 鸿蒙的「历史 n 条」在**当前这台库上恒为空**,不是渲染坏了,是数据够不到。\n 两端一致(WebUI `PermissionList.tsx:66` 也是 `fetchInbox('all')`,同一端点同一过滤)⇒ 这不是鸿蒙的遗漏,**是两端共同的行为**,服务端的过滤是有意的。\n\n要真验,需构造:**未归档**会话 + 该会话里一条已决策的权限请求(例如 `UPDATE sessions SET status='active' WHERE session_id=<那条>`,或让 agent 在活跃会话里发一条再决策)。\n\n★ 另一处(同一次实测发现,已修):渲染注释曾承诺「会话别名 + **决策** + **时间**」,而代码只渲染「别名 + 历史 n 条」—— 属本仓反复在消的「声明比实现宽」。已把注释改成与 WebUI 折叠态一致的**真实形态**(WebUI 也是每组一行 `历史 {n}`,逐条的决策/时间要展开才看得到,而鸿蒙没有展开层)。"
|
||||
},
|
||||
{
|
||||
"id": "permission-expires-at-unused",
|
||||
"count": 1,
|
||||
"due": "决定是否要把「可能已失效」做进**两端**(需要两端都补类型 + 渲染);或确认这个告警对产品不重要、把服务端那个字段也去掉",
|
||||
"where": "服务端 `models.go:254`(`PermissionRequest.ExpiresAt`)/ `repo.go:1617`(`pr.ExpiresAt = models.PermissionDeadline(...)`);客户端类型两处都缺:`client/electron/src/types/index.ts` 的 `PermissionRequest`、`client/harmony/entry/src/main/ets/model/Models.ets` 的 `PermissionRequest`",
|
||||
"kind": "scope",
|
||||
"note": "★★ 2026-09-23 发现自己:**服务端返回一个两端客户端都不读的字段**。\n\n`GET /permission/pending` 的回包里 `expires_at` 一定存在(`ExpiresAt time.Time` 且**不带** `omitempty`),服务端在 `ListPendingPermissionsFor` 里用 `models.PermissionDeadline(pr.CreatedAt)` 算出它。而**两端的 `PermissionRequest` 类型都没有声明这个字段** ⇒ 反序列化静默丢掉。\n\n── 这次的教训与 `mail-list-attachment-count` 那次**方向相反、形状相同** ──\n那一次我把「`omitempty` 字段在一封没附件的邮件里缺失」当成了「服务端不返回这个字段」;这一次是**真的两端都没读**,而我一开始又差点写成「鸿蒙落后于 WebUI」——实际是**共同缺口**(WebUI 也没读)。两次都说明:**「两端不一致」与「两端都没做」必须先分清**,否则会去\"对齐\"一个根本不存在的东西。\n\n── 已经做掉的那一半 ──\n鸿蒙侧 2026-09-23 补上了 `expires_at` 并把它接进**待决卡片**的失效告警(`PermissionTab.ets` 的 `isStale`)。所以这条债现在**只剩 WebUI 那一半**:WebUI 的 `PermissionList.tsx:248` 读的是 `mail.permission_expires_at`(**别的字段**,那是 `Mail` 模型上由 `AttachPermissionDeadline` 算出来的,只在 inbox 路径上有),而它自己那份 `PermissionRequest` 同样没有 `expires_at`。\n\n★ 要闭合需先决定:**这个告警归哪条路径**?· 走 inbox(`permission_expires_at`)—— 但 inbox **只给未决策的**(`AttachPermissionDeadline` 的 return), 且 inbox 的 SQL **根本没选** `permission_expires_at`(实测 0 处), 它是在 repo 层算出来贴上去的 ⇒ 要确认它真的出现在回包里;\n· 走 `/permission/pending`(`expires_at`)—— 字段现成、语义清楚,但 WebUI 那边要改类型 + 读它。\n 我倾向后者(数据来源本来就对着\"待决\"这件事)。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -13,20 +13,47 @@
|
||||
|
||||
## 一、现在的差距(有据可查)
|
||||
|
||||
| 能力 | WebUI | 鸿蒙 | 差距性质 |
|
||||
> ★★ **2026-09-23 更正:这张表曾严重过期,已重写。**
|
||||
>
|
||||
> 原来的表把发件箱/授权/日历/管理/主题壁纸全部标成 **「❌ 缺页面」** ——
|
||||
> 而它们**在那之后全都做完了**(日历 2101 行、我的页 1496 行、写信 968 行、
|
||||
> 管理 682 行;导航也早在 P5 换成了自绘浮动玻璃条)。
|
||||
>
|
||||
> **为什么这是缺陷而不只是"文档旧了"**:这是本文件的**第一节** ——
|
||||
> 任何接手的人(或下一次的我)读到的第一件事就是"还缺一大半",
|
||||
> 而真实进度在四百行之后的 §5.4 分期表里(那里大多是 ✅)。
|
||||
> 照这张表干活会**重做已经做完的东西**。
|
||||
>
|
||||
> 判据:下面每一行都对着**现存代码**重新核过(文件/行数/组件名),不是照印象改的。
|
||||
|
||||
| 能力 | WebUI | 鸿蒙 | 现状 |
|
||||
|---|---|---|---|
|
||||
| 收件箱 | ✅ | ✅ | — |
|
||||
| 会话 | ✅(通信页签) | ✅(独立 tab) | 交互不同 |
|
||||
| 会话 | ✅(收件箱按会话折叠) | ✅(同:收件箱按会话折叠) | **已统一**(平级「会话」tab 已撤,见 §7.15) |
|
||||
| 联系人 | ✅ | ✅ | — |
|
||||
| **发件箱** | ✅(通信页签) | ❌ | 缺页面(数据现成) |
|
||||
| **授权(权限决策)** | ✅(通信页签 + 详情内决策) | ❌ | 缺页面(API 现成) |
|
||||
| **日历** | ✅ | ❌ | 缺页面(最大一块) |
|
||||
| **管理(用户管理)** | ✅(我的页底部,管理员可见) | ❌ | 缺页面 + 权限判定 |
|
||||
| **写信** | ✅ 共用 Composer | ✅ 独立实现 | 组件未统一 |
|
||||
| 底部/侧边导航 | ✅ 悬浮玻璃条 | ⚠️ 系统 TabBar | 观感不同 |
|
||||
| 主题 / 壁纸(账号级) | ✅ 服务端同步 | ❌ | 未接 |
|
||||
| 发件箱 | ✅(通信页签) | ✅(通信页签 `SentTab`) | **已完成**(P2b) |
|
||||
| 授权(权限决策) | ✅(通信页签 + 详情内决策) | ✅(通信页签 `PermissionTab`,**栏内直接决策**) | **已完成**(P3);★ 会话行形状仍在对齐中(见 §一·补) |
|
||||
| 日历 | ✅ | ✅(`CalendarPage` 2101 行,月/周/日 + 农历 + ics 导入导出 + 左右滑翻页) | **已完成**(P6) |
|
||||
| 管理(用户管理) | ✅(我的页底部,管理员可见) | ✅(`AdminUsersPage` 682 行) | **已完成** |
|
||||
| 写信 | ✅ 共用 Composer | ✅ 独立实现(`ComposePage` 968 行) | 组件未统一(**有意**:ArkTS 无法直接复用 React 组件) |
|
||||
| 底部/侧边导航 | ✅ 悬浮玻璃条 | ✅ 自绘浮动玻璃条(**无**系统 `Tabs`/`TabContent`,用系统材质 `Theme.navMaterial`) | **已完成**(P5) |
|
||||
| 主题 / 壁纸(账号级) | ✅ 服务端同步 | ✅(`AppearanceStore` + `/me/appearance`,预设 6 档 + 图片壁纸) | **已完成**(P4;P4c 上传入口未做) |
|
||||
| 设计令牌 | ✅ `:root` | ✅ `Theme.ets` | **已对齐**(判据钉住) |
|
||||
|
||||
### 一·补、现在真正剩下的差距(2026-09-23 实核)
|
||||
|
||||
上面那张表只回答"有没有"。**真正的差距在形状层** —— 以下都是对着两边源码
|
||||
**逐行读出来**的,不是印象:
|
||||
|
||||
| 项 | WebUI | 鸿蒙 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 授权栏·会话行 | 机器人图标 + `agent@path.alias` + 组头时间 + 待决策/已处理胶囊 + `历史 {n}` | 同上(2026-09-23 补齐) | ✅ 已对齐 |
|
||||
| **授权栏·展开层** | 点组头展开 → 逐条 `PermissionRow`(含 `permission_result` 绿√/红✗、**「可能已失效」告警**) | 无展开层(待决已由内联卡片给出**更全**信息) | ⏳ **未做**,见 §5.4 的说明与 `docs/DEBTS.json` |
|
||||
| P4c 外观上传入口 | ✅ | ❌ | ⏳ 未做 |
|
||||
| 视觉观感(配色/材质/动画手感) | — | — | ⚠️ **两端都未逐项验**(需真机上手) |
|
||||
|
||||
★ **没有"缺页面"这一类了。** 剩下的全是**形状/交互细节**与**观感验收**。
|
||||
|
||||
## 二、已经做完的(本轮之前)
|
||||
|
||||
- **设计令牌共用**:`common/Theme.ets` 与 WebUI 的 `:root` 一一对应
|
||||
|
||||
177
scripts/audit-v0-id-diffset.mjs
Normal file
177
scripts/audit-v0-id-diffset.mjs
Normal file
@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 审计「手工要补哪些字段」的差集(**按真实形状实测**,不按分支标签枚举)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
*
|
||||
* 判断差集的方法(pi 提出、dsh 机械枚举过)是:
|
||||
*
|
||||
* 手工要补的 = 校验器要求 − 迁移器自愈
|
||||
*
|
||||
* 但「迁移器自愈」有两层,只读**分支标签**会算错:
|
||||
*
|
||||
* 1. `normalizeLegacyMessage()` 里有那个 `case`(分支存在)
|
||||
* 2. 那个 `case` 的**守卫**对**你手上的形状**放行(分支真的会执行)
|
||||
*
|
||||
* 第 2 层才是决定性的。实测:`assistant/message`、`tool/result` 都有 heal 分支,
|
||||
* 但它们的守卫是
|
||||
*
|
||||
* if (Object.hasOwn(data,"message") || …) return event; // 有 message 键 ⇒ 早退
|
||||
*
|
||||
* 而真实 v0 里这两个事件**都是嵌套 `message` 形状**(109 个文件里扁平旧形状出现 **0** 次)
|
||||
* ⇒ 守卫必命中 ⇒ **不会 heal**。所以按分支标签算出来的差集是漏的。
|
||||
*
|
||||
* ## 本脚本的做法
|
||||
*
|
||||
* 不看代码、不看标签,直接对**真实 v0** 做消融实验:
|
||||
* 把某个位置的消息剥成「无 id」形状,跑官方迁移链,看是否被拒绝。
|
||||
* **拒绝 ⇒ 该位置在差集里**(校验要 id、迁移器不给)—— 这正是"要不要手工补"的定义。
|
||||
*
|
||||
* 对照组(脚本自己跑,缺一不可):
|
||||
* - 不动任何东西 ⇒ 必须 OK(证明方法不误报)
|
||||
* - `user/message` 剥空 ⇒ 必须 OK(证明确有自愈,不是"什么都拒绝")
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/audit-v0-id-diffset.mjs # 默认扫 /root/.dsh/sessions
|
||||
* node scripts/audit-v0-id-diffset.mjs --root <dir>
|
||||
*
|
||||
* 退出码:0 = 扫描完成(**不**因发现差集成员而失败,那是正常结论);2 = 环境/参数问题。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readdirSync, statSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const DS = process.env.DSH_INSTALL ?? '/usr/lib/node_modules/@deepseek-ai/dsh';
|
||||
const { sessionFormatCatalog } = await import(
|
||||
join(DS, 'node_modules/@deepseek-ai/dsh-session-format-catalog/lib/index.js')
|
||||
);
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const argOf = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
||||
const ROOT = argOf('--root', '/root/.dsh/sessions');
|
||||
const PROD = { recovery: 'recoverable', validation: 'transformed' };
|
||||
const clone = (o) => structuredClone(o);
|
||||
|
||||
const readLines = (p) =>
|
||||
execFileSync('zstd', ['-dc', p], { maxBuffer: 1 << 30 }).toString('utf8')
|
||||
.split('\n').filter((l) => l.trim().length);
|
||||
|
||||
/** 生产档迁移:能过就是「无需手工补」。 */
|
||||
function readable(header, events) {
|
||||
try {
|
||||
const r = sessionFormatCatalog.createRestore(clone(header), PROD);
|
||||
for (const e of clone(events)) r.decodeRow(e);
|
||||
r.finish();
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个「message 承载位置」:匹配函数 + 消融函数(把该位置剥成无 id/role 形状)。
|
||||
* 位置按**真实 v0 形状**给,不是按代码分支名。
|
||||
*/
|
||||
const SITES = {
|
||||
'agent/inbox/spliced :: inserted[]': {
|
||||
match: (e) => e.type === 'agent/inbox/spliced' && e.data.inserted?.[0] && 'id' in e.data.inserted[0],
|
||||
ablate: (d) => { delete d.inserted[0].id; delete d.inserted[0].role; },
|
||||
},
|
||||
'session/title-llm-request :: messages[]': {
|
||||
match: (e) => e.type === 'session/title-llm-request' && e.data.messages?.[0] && 'id' in e.data.messages[0],
|
||||
ablate: (d) => { delete d.messages[0].id; delete d.messages[0].role; },
|
||||
},
|
||||
'assistant/message :: data.message': {
|
||||
match: (e) => e.type === 'assistant/message' && e.data.message && 'id' in e.data.message,
|
||||
ablate: (d) => { delete d.message.id; delete d.message.role; },
|
||||
},
|
||||
'tool/result :: data.message': {
|
||||
match: (e) => e.type === 'tool/result' && e.data.message && 'id' in e.data.message,
|
||||
ablate: (d) => { delete d.message.id; delete d.message.role; },
|
||||
},
|
||||
'user/message :: data': {
|
||||
match: (e) => e.type === 'user/message',
|
||||
ablate: (d) => { delete d.id; delete d.role; },
|
||||
},
|
||||
};
|
||||
|
||||
/** 递归收集 <store>/<id>/session.jsonl.zstd */
|
||||
function* walk(dir) {
|
||||
for (const n of readdirSync(dir)) {
|
||||
const p = join(dir, n);
|
||||
if (statSync(p).isDirectory()) yield* walk(p);
|
||||
else if (n === 'session.jsonl.zstd') yield p;
|
||||
}
|
||||
}
|
||||
|
||||
const stat = {};
|
||||
for (const k of Object.keys(SITES)) stat[k] = { tested: 0, refused: 0 };
|
||||
|
||||
let files = 0, skipped = 0, ctrlNoAb = 0, ctrlUserHealed = 0;
|
||||
|
||||
for (const path of walk(ROOT)) {
|
||||
let lines, header;
|
||||
try { lines = readLines(path); header = JSON.parse(lines[0]); } catch { skipped++; continue; }
|
||||
if (header.version !== 0) { skipped++; continue; }
|
||||
files++;
|
||||
|
||||
let events = lines.slice(1).map((l) => JSON.parse(l));
|
||||
|
||||
// 基线:先补掉 spliced(否则基线就不可读,测不出别的位置)
|
||||
let n = 0;
|
||||
events = events.map((e) => {
|
||||
if (e.type !== 'agent/inbox/spliced') return e;
|
||||
const inserted = (e.data.inserted ?? []).map((m) => {
|
||||
if ('id' in m && 'role' in m) return m;
|
||||
n++; return { id: `baseline-${e.seq}-${n}`, role: 'user', ...m };
|
||||
});
|
||||
return { ...e, data: { ...e.data, inserted } };
|
||||
});
|
||||
|
||||
if (!readable(header, events)) { skipped++; continue; }
|
||||
ctrlNoAb++; // 对照一:不动任何东西 ⇒ 可读
|
||||
|
||||
// 对照二:user/message 剥空必须仍可读(证明确有自愈)
|
||||
{
|
||||
const i = events.findIndex(SITES['user/message :: data'].match);
|
||||
if (i >= 0) {
|
||||
const e2 = clone(events);
|
||||
SITES['user/message :: data'].ablate(e2[i].data);
|
||||
if (readable(header, e2)) ctrlUserHealed++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, { match, ablate }] of Object.entries(SITES)) {
|
||||
const i = events.findIndex(match);
|
||||
if (i < 0) continue;
|
||||
const e2 = clone(events);
|
||||
ablate(e2[i].data);
|
||||
stat[label].tested++;
|
||||
if (!readable(header, e2)) stat[label].refused++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`root: ${ROOT}`);
|
||||
console.log(`v0 文件: ${files}(跳过 ${skipped}:解压失败/非 v0/基线不可读)\n`);
|
||||
console.log('=== 对照组(方法有效性)===');
|
||||
console.log(` 不动任何东西 ⇒ 可读的基线: ${ctrlNoAb}`);
|
||||
console.log(` user/message 剥 id+role 后仍可读(= 确有自愈): ${ctrlUserHealed}`);
|
||||
if (ctrlNoAb === 0) { console.error('\n[FAIL] 没有可读基线,结论无效'); process.exit(2); }
|
||||
if (ctrlUserHealed === 0) { console.error('\n[FAIL] 自愈对照未成立,方法可能有问题'); process.exit(2); }
|
||||
|
||||
console.log('\n=== 逐位置消融:剥掉 id(+role) 后是否被拒绝 ===');
|
||||
console.log(' 位置'.padEnd(42), '样本 拒绝 判定');
|
||||
const members = [];
|
||||
for (const [label, v] of Object.entries(stat)) {
|
||||
let verdict;
|
||||
if (v.tested === 0) verdict = '(无样本)';
|
||||
else if (v.refused === v.tested) { verdict = '★ 差集成员(要手工补)'; members.push(label); }
|
||||
else if (v.refused === 0) verdict = '自愈侧(无需补)';
|
||||
else verdict = `混合(${v.refused}/${v.tested})—— 需按形状细分`;
|
||||
console.log(' ' + label.padEnd(40), String(v.tested).padStart(4), String(v.refused).padStart(6), ' ' + verdict);
|
||||
}
|
||||
|
||||
console.log(`\n=== 差集(校验要 id、迁移器不给 ⇒ 必须手工补)共 ${members.length} 个位置 ===`);
|
||||
for (const m of members) console.log(' • ' + m);
|
||||
console.log('\n注意:这是**按真实形状实测**的结果,比按代码分支标签枚举更可靠 ——');
|
||||
console.log('分支存在 ≠ 分支会对你的形状执行(守卫可能早退)。');
|
||||
@ -406,6 +406,7 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
var m models.Mail
|
||||
var alias *string
|
||||
var ccJSON []byte
|
||||
var permOptsJSON []byte
|
||||
var renameAlias, renameReason *string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
|
||||
@ -413,6 +414,7 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
|
||||
COALESCE(m.permission_kind,'') AS permission_kind,
|
||||
COALESCE(m.permission_multi_select,0) AS permission_multi_select,
|
||||
COALESCE(m.permission_options,'[]') AS permission_options,
|
||||
m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human
|
||||
@ -422,12 +424,30 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
|
||||
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
|
||||
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
|
||||
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
|
||||
&m.PermissionKind, &m.PermissionMulti,
|
||||
&m.PermissionKind, &m.PermissionMulti, &permOptsJSON,
|
||||
&m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason,
|
||||
&m.FromHuman, &m.ToHuman)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-23 补:`permission_options` 从 INSERT 起就写进 `mails` 表,
|
||||
* 但**从来没有任何读路径选过它** ⇒ 详情端点永远返回空数组。
|
||||
*
|
||||
* WebUI 的 `MailView.tsx` 决策面板读 `mail.permission_options ?? []`,
|
||||
* 于是提问型的预设选项在两端**全部落空**(审批型靠 `['同意','拒绝']`
|
||||
* 兜底蒙混过去,提问型是直接没有)。
|
||||
*
|
||||
* 修法与 `cc_list` 同款:JSON 列 → 字节 → `json.Unmarshal` 进 `[]string`,
|
||||
* 空则保底 `[]`(与前端 `?? []` 同义,但**在服务端**完成,
|
||||
* 免得每个读方各写一遍兜底)。
|
||||
*/
|
||||
if len(permOptsJSON) > 0 && string(permOptsJSON) != "[]" {
|
||||
json.Unmarshal(permOptsJSON, &m.PermOptions)
|
||||
}
|
||||
if m.PermOptions == nil {
|
||||
m.PermOptions = []string{}
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
json.Unmarshal(ccJSON, &m.CCList)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user