跨端: 闭合 harmony-permission-history —— 授权栏补上「已决策的历史」

这是 `docs/DEBTS.json` 里登记的一条,它的到期条件原文是
「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。

## 原缺口

鸿蒙的 `PermissionTab` 只调 `GET /permission/pending`(服务端
`ListPendingPermissionsFor`,SQL 带 `WHERE pr.result IS NULL`)
⇒ **只拿得到待决的**,于是"这条会话批过哪些事"完全看不到;
而 WebUI 有(`PermissionList.tsx:182` 的「历史 {n}」)。

## 关键判断:**不照抄 WebUI 的 inbox 分组**

我先把 `PermissionTab.load` 整个改成读 inbox + `groupPermissions`,
**改到一半发现行不通**(编译报 `question`/`context`/`agent_name` 找不到):

· WebUI 从 inbox 分组,但它的 `PermissionRow` **只渲染**
  `subject`/`created_at`/`permission_result`/`permission_expires_at`
  (逐字段 grep 过,全文件没有 `question`/`options`/`context`);
· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind`
  —— 那四个字段在 `permission_requests` **表**里,
  inbox 回包(`models.Mail`)**没有它们**(模型逐条核过,只有 `permission_result`
  与 `permission_options`)。

⇒ 两条来源各有各的信息量,不是二选一:
  · **待决**继续走专用端点(信息更全、能直接决策);
  · **历史**走 inbox 补上。
  代价是每账号多一次请求 —— 这是**有意的取舍**,写在代码注释里。

(半成品已 `git checkout` 撤掉,没有把它留在提交里。
 撤掉的原因如实记在注释里,免得下一个人以为"照着 WebUI 改"就行。)

## 落地

· `model/MailGrouping.ts`:加 `groupPermissions` + `PermissionGroup` +
  `isPendingPermission`,逐条对齐 WebUI 的 `groupPermissions`,
  含它那**三步排序**(有待决的先来 → 待决多的更靠前 → 最新一封倒序)。
· `PermissionTab`:从 inbox 取 `mail_type=permission_request &&
  permission_result != ''` 的,分组后渲染「历史 n 条」(只读、不可操作)。
· 空态判据从 `requests.length === 0` 改成**两者都空**才显示 ——
  否则"有待决的历史"会被误报成"没有待决策的请求"。

## 判据自己抓到了我

`cross-client-logic.test.mjs` 的「缺口只减不增」在我补上 `groupPermissions`
之后立刻变红,并给出准确指引:

    减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉

⇒ 已清空 `gaps`。**这条判据在这轮里三次发挥作用**:
  第一次报出这个缺口(09-20),第二次在我半成品时红了,
  第三次确认闭合。`pass=7 fail=0`。

`docs/DEBTS.json` 的 `count` 已改 0、`due` 记完成、`note` 写明修法与取舍。

## 设备验证(如实)

✓ 授权页正常渲染,进程存活(23343),无新 jscrash
✓ 空态文案正确(本机确实没有权限邮件)
✗ **"历史 n 条"真的显示出来**这条路径没能实测:
  本机没有已决策的权限请求(服务端实测 `permission_request` 0 封)。
  逻辑逐条对齐 WebUI、编译通过,但我不声称已看到它渲染。
This commit is contained in:
2026-09-21 16:45:23 +08:00
parent 1869924507
commit d857352e29
4 changed files with 201 additions and 6 deletions

View File

@ -135,7 +135,15 @@ const PAIRS = [
* 登记在这里的意义:这条判据会让**下次再少一个导出**时红,
* 而"这个缺口是已知的、有据可查的"与"悄悄少了一项比较"是两件事。
*/
gaps: ['groupPermissions'],
/*
* ★★ 2026-09-21 **缺口已闭合**:`groupPermissions` 原本登记在这里
* (`harmony-permission-history`:鸿蒙只调 `/permission/pending`,
* 拿不到已决策的历史)。本轮补上了:
* · 纯逻辑层加 `groupPermissions`(与 WebUI 逐条对齐,含三步排序);
* · `PermissionTab` 从 inbox 取**已决策**的权限请求并分组渲染「历史 n 条」。
* ⇒ 按这条判据自己的提示「减少 → 请把 gaps 里对应的名字删掉」,删掉它。
*/
gaps: [],
project: {
groupMailsBySession: (v) => (v ?? []).map(g => ({
sessionId: g.sessionId ?? g.session_id,

View File

@ -170,6 +170,112 @@ export function sessionKey(m: MailLike): string {
return m.source_account_id + '/' + sid;
}
/** 一个会话下的权限请求分组(对齐 WebUI `PermissionGroup`) */
export class PermissionGroup {
/** 分组键:账号 + session_id(鸿蒙是多账号合并,见 `sessionKey`) */
key: string = '';
session_id: string = '';
alias: string = '';
/** 发起请求的 Agent 名(取最新一条) */
agentName: string = '';
/** 工作区路径(取最新一条) */
path: string = '';
/** 还没决策的 */
pending: MailLike[] = [];
/**
* **已决策的历史**。
*
* ★★ 2026-09-21 新增 —— 这一段在鸿蒙侧**此前完全看不到**:
* 旧实现只调 `GET /permission/pending`(SQL 里 `WHERE pr.result IS NULL`),
* 于是"这个会话批过哪些事"无从查证。
* 登记在 `docs/DEBTS.json` 的 `harmony-permission-history`,
* 它的到期条件是「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。
*/
settled: MailLike[] = [];
latest: MailLike | undefined = undefined;
}
/**
* 按会话把权限请求分组,每组**同时**给出待决与已决策两段。
*
* 逐条对齐 WebUI `mailGroups.ts:88-122` 的 `groupPermissions`:
* · 只挑 `mail_type === 'permission_request'` 的邮件;
* · 按会话桶化,组内按时间倒序,组头取最新一封;
* · **排序**:有待决的先来 → 待决多的更靠前 → 其余按最新一封倒序。
* 那三步的意图 WebUI 注释写明了:「那条会话卡得更久」优先。
*
* ★ 为什么放进纯逻辑层而不是写在 `PermissionTab` 里:
* 它和 `groupMailsBySession` 是同一类东西(数据的组织方式),
* 而 `cross-client-logic.test.mjs` 会把 `.ts` 模型与 electron 的同名函数**逐例比对**。
* 写在组件闭包里就比对不到 —— 那种写法本仓已经栽过(`AddressSuggest` 那一轮)。
*/
export function groupPermissions(mails: MailLike[]): PermissionGroup[] {
const groups: PermissionGroup[] = [];
const index: Map<string, number> = new Map<string, number>();
for (let i = 0; i < mails.length; i++) {
const m: MailLike = mails[i];
if (m.mail_type !== 'permission_request') {
continue;
}
const key: string = sessionKey(m);
let g: PermissionGroup | undefined = undefined;
const at: number | undefined = index.get(key);
if (at !== undefined) {
g = groups[at];
}
if (g === undefined) {
g = new PermissionGroup();
g.key = key;
g.session_id = m.session_id;
index.set(key, groups.length);
groups.push(g);
}
if (isPendingPermission(m)) {
g.pending.push(m);
} else {
g.settled.push(m);
}
}
for (let i = 0; i < groups.length; i++) {
const g: PermissionGroup = groups[i];
g.pending.sort(byNewest);
g.settled.sort(byNewest);
/* 组头字段取"最新一封"(两段合起来看,与 WebUI 的 `sorted[0]` 同义) */
const all: MailLike[] = g.pending.concat(g.settled);
all.sort(byNewest);
if (all.length > 0) {
g.latest = all[0];
g.alias = all[0].session_alias;
g.agentName = all[0].from_name;
/*
* 路径取组内最新一封的。★ `MailLike` **没有** `session_workspace`(本仓
* 为它踩过白屏:缺失时客户端拿到 `undefined`)⇒ 这里不做猜测,
* 保持空串;授权页要显示路径的话得先给 `MailLike` 补字段并守 `omitempty`。
* 本轮只做"待决/历史两段"这一件事,不顺手扩字段(顺手加是本仓反复出现的错法)。
*/
g.path = '';
}
}
groups.sort((a: PermissionGroup, b: PermissionGroup): number => {
const aHas: boolean = a.pending.length > 0;
const bHas: boolean = b.pending.length > 0;
if (aHas !== bHas) {
return aHas ? -1 : 1;
}
if (a.pending.length !== b.pending.length) {
return b.pending.length - a.pending.length;
}
const al: MailLike | undefined = a.latest;
const bl: MailLike | undefined = b.latest;
return (al === undefined || bl === undefined) ? 0 : byNewest(al, bl);
});
return groups;
}
/** 按会话折叠;组头取组内**最新一封**,组间按最新一封时间倒序 */
export function groupMailsBySession(mails: MailLike[]): SessionGroup[] {
const groups: SessionGroup[] = [];

View File

@ -29,6 +29,8 @@ import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../
import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models';
import {
MailLike,
PermissionGroup,
groupPermissions,
MailSplit,
SessionGroup,
groupMailsBySession,
@ -1409,6 +1411,14 @@ struct PermissionTab {
/** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
@Prop navReserve: number = 0;
@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 = '';
/** 正在填备注的那条(空串 = 没有) */
@ -1432,6 +1442,8 @@ struct PermissionTab {
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 {
@ -1442,11 +1454,43 @@ struct PermissionTab {
for (let j = 0; j < resp.requests.length; j++) {
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 : '加载失败';
@ -1624,7 +1668,7 @@ struct PermissionTab {
} 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) {
} 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)
@ -1633,12 +1677,49 @@ struct PermissionTab {
.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.tsx:182` 的「历史 {n}」)──
*
* ★★ 2026-09-21 新增。这一段在鸿蒙侧**此前完全看不到**
* (`harmony-permission-history`,其到期条件就是"做授权栏对齐")。
*
* 只读、不可操作(决策已经发生,重放没有意义)—— 与 WebUI 一致:
* 它把 `settled` 归到折叠的分组里,默认收起。
* 这里是**平铺一行一行**的历史摘要(会话别名 + 决策 + 时间),
* 不逐条列邮件正文:那是详情页的事。
*/
ForEach(this.permGroups, (g: PermissionGroup) => {
ListItem() {
Row() {
Column() {
Text(g.alias.length > 0 ? g.alias : '(未命名会话)')
.fontSize(13)
.fontFamily('monospace')
.fontColor(Theme.textMuted)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text('历史 ' + g.settled.length.toString() + ' 条')
.fontSize(11).fontColor(Theme.textSubtleFor())
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.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) })

View File

@ -146,11 +146,11 @@
},
{
"id": "harmony-permission-history",
"count": 1,
"due": "做「授权栏与 WebUI 对齐」时",
"count": 0,
"due": "**已完成 2026-09-21**(本轮「授权栏与 WebUI 对齐」)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets 的 PermissionTab(现在只读 /permission/pending)",
"kind": "scope",
"note": "2026-09-20 由 `cross-client-logic.test.mjs` 的**缺口判据**报出来的(它要求两侧导出缺口只减不增,`groupPermissions` 是新多出来的一个)。\n\n**两端拿授权栏数据的方式根本不同:**\n· WebUI:`PermissionList.tsx:27` 拿 inbox 自己分组 —— `const groups = groupPermissions(inbox)`,每组同时渲染两段:\n · `g.pending`(显示 `{n} 待决策`)\n · `g.settled`(显示 `历史 {n}`,见 `:182`)\n 即**待决 + 已决策历史**都在,且按会话分组。\n· 鸿蒙:`MainPage.ets` 的 `PermissionTab` 调专用端点 `GET /permission/pending`(服务端 `ListPendingPermissionsFor`,SQL 里带 `WHERE pr.result IS NULL`)—— **只拿得到待决的**,而且是**平铺一列**,不分组。\n\n⇒ 用户可见差异:**已决策的授权记录在鸿蒙上完全看不到**(WebUI 能看到每个会话的历史条数)。\n\n★ 为什么没当场改:这不是照抄一个函数能解决的 —— 要么改成读 inbox 并实现分组渲染(结构改动),要么另调一个含已决策的端点(服务端可能没有)。两条路都要想清楚哪边是权威(本仓方向:**electron 是唯一真实源泉**⇒ 倾向于改成读 inbox + 分组)。先登记,别假装对齐了。"
"note": "**已修(2026-09-21)**。原文:鸿蒙只调 `/permission/pending`(SQL `WHERE pr.result IS NULL`)⇒ 已决策的历史完全看不到。\n\n修法与**为什么不照抄 WebUI 的 inbox 分组**:\n· WebUI 从 inbox 分组(`groupPermissions`),但它的 `PermissionRow` 只渲染\n `subject`/`created_at`/`permission_result`/`permission_expires_at`(逐字段 grep 过);\n· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind` —— 那四个字段在\n `permission_requests` **表**里,inbox 回包(`models.Mail`)**没有**(模型逐条核过)。\n⇒ 待决继续走专用端点(信息更全、能直接决策),**历史**走 inbox 补上。\n 代价:每账号多一次请求。这是有意的取舍。\n\n落地:`model/MailGrouping.ts` 加 `groupPermissions` + `PermissionGroup` + `isPendingPermission`;\n`PermissionTab.load` 取 inbox 里 `mail_type=permission_request && permission_result!=空` 的,\n分组后渲染「历史 n 条」。`cross-client-logic.test.mjs` 的 `gaps` 已按提示清空。"
}
]
}
}