跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options

用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
This commit is contained in:
2026-09-24 10:10:32 +08:00
parent 487c1c222b
commit 65de1c3884
31 changed files with 4147 additions and 1684 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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