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

@ -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))
}
}