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