设备: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 起算,首项被画到容器上方。
674 lines
32 KiB
Plaintext
674 lines
32 KiB
Plaintext
/*
|
||
* 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))
|
||
}
|
||
} |