Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/PermissionTab.ets
dsh 477479a370 fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0 ⇒ 顶栏压进系统状态栏(真机实测)
设备:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS NEXT,API 26,
      `hdc tconn 192.168.2.87:43679`(此前一直无真机,本条挂了 5 天)。

## 症状(用户报:「左上角全屏状态下不应该显示全屏,会与顶部系统顶栏冲突」)

截图像看是状态栏压住顶栏。**实测证伪了这个读法**:像素扫描 + `uitest dumpLayout`
显示顶栏文字在 y=134..166、系统状态栏止于 y=82,**两者不重叠**。
真正被切掉的是**列表第一封邮件的标题**(y=294..319,只剩一条细线)。

⇒ 两处独立问题,第二个(窗格头 y=222..320 与列表首行 y=294..319 重叠)
   **不是顶栏避让**造成的,本次未修,见下。

## 已修:AppHeader 那一半的避让确实是坏的

`EntryAbility` 早就把避让读到了(真机日志 `insets: statusBar=38.588235
navIndicator=27.764706 windowDecor=37`),`CommPage`/`MainPage` 内容层也用了
(`top: max(statusBar, windowDecor) + paneGap`)。**但 `AppHeader` 是另一个消费者,
三处都写死了 `topInsetPx: 0`**:

  · `SentTab`(发件箱)      MainPage.ets:1658
  · `SettingsPage`(我的)   SettingsPage.ets:687
  · `PermissionTab`(授权)  PermissionTab.ets:487

只有 `AdminUsersPage` 是对的(`topInset(this.windowInsets)`)—— 抄它。

## 为什么已有的判据没抓到(这才是关键)

`harmony-window.test.mjs` 接线⑤「每一个 @Entry 页都要消费避让」是**绿的**,
因为它的 `consumes()` 认两种形状,其中一种只要**文件里出现过**
`this.windowInsets.statusBar` 就算过 —— 而 `MainPage` 的**内容层**正好读了它。
⇒ 页面上有**两个**避让消费者,判据只问"页里有没有出现过那个字段",
  于是 `AppHeader` 里那个 0 被完全放过。

新判据(判据 10)改成**逐个消费者问**:任何传给 `AppHeader` 的 `topInsetPx`
不得是字面量 0。自检要求扫到 ≥4 处,防"遍历写错 ⇒ 永远绿"。

★ 这条判据自己先犯过一次同类错并当场被抓:第一版用 `prose()`(含注释),
  把**我自己写进注释里的**「原先 topInsetPx: 0」抓成了红 ——
  判据在判自己的注释。改用本文件已有的 `stripped()`(其注释原话:
  「判据的锚不能落在被守对象的自述上」)。红绿已验:把 SettingsPage 退回
  缺陷版 ⇒ 红;恢复 ⇒ 绿。

## 编译期抓到的一个坑

`MainPage.ets:1658` 的 AppHeader 在 **`SentTab`** 里,而 `windowInsets` 原本
只声明在 `CommPage` 上。ArkTS 报 `Property 'windowInsets' does not exist on
type 'SentTab'`。⇒ `@StorageLink` 是**每个组件各自**订阅 `AppStorage` 的,
父组件的不会自动传给子组件;直接各自订阅同一把键(比"父传子"少一层)。

## 真机验证

装机后重跑 dumpLayout:发件箱顶栏 `y=134..166`(状态栏底 y=82,间隙 52px),
截图确认「发件箱」完整显示、不再被压。

## 未修(诚实登记)

列表第一行标题被窗格头盖住(`List` 首项 y=294..319 落在窗格头 y=222..320 内),
与顶栏避让**无关**,本次未动。根因待查:`MailRow` 是 `.height(64)` 定高,
而 List 容器从 y=320 起算,首项被画到容器上方。
2026-10-01 18:49:06 +08:00

674 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
/** 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 => {};
/*
* 状态栏避让(窗口级事实,见 `model/WindowInsets.ts`)。
* ★ 原先 `AppHeader({ topInsetPx: 0 })` 硬编码 0 ⇒ 授权页顶栏压进系统状态栏
* (真机 MatePad Pro 实测 statusBar=38.58 读到了却被丢掉)。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@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: topInset(this.windowInsets)
}) {
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.plain(this.bgActive))
}
}