diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 108a14f..ea7af22 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -28,9 +28,11 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); const MODULE_TS = join(HARMONY_ETS, 'model/MailGrouping.ts'); +const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts'); /** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */ const H = await import(pathToFileURL(MODULE_TS).href); +const C = await import(pathToFileURL(COMM_TS).href); const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); /** @@ -56,6 +58,9 @@ const mail = (over) => Object.assign( body_preview: '预览', created_at: '2026-09-14T10:00:00Z', status: 'read', + mail_type: 'normal', + permission_result: '', + to_name: 'pi', permission_mode: '', source_account_id: 'acct-1', source_account_name: '工作邮箱' @@ -202,7 +207,9 @@ test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接 test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => { assert.match(pageCode, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑'); - assert.match(pageCode, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠'); + // 加载后要折叠。变量是**筛过权限邮件之后**的那一批(权限邮件归授权栏, + // 收件箱里不该出现它们 —— 见"权限邮件不进收件箱"那条)。 + assert.match(pageCode, /this\.groups = groupMailsBySession\(inboxMails\)/, '加载后要折叠(筛过权限邮件的那批)'); assert.match(pageCode, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)'); assert.match(pageCode, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染'); assert.match(pageCode, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)'); @@ -211,15 +218,18 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用") // ───────────────────────── 撤掉平级「会话」tab(P2a 收尾) ───────────────────────── -test('底部只剩 收件箱 / 联系人 两个平级页签,「会话」不再是入口', () => { +test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是入口', () => { /* * pi 的判断:WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图 * + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab,等于把"卡片视图"放错了位置。 * 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了, * 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。 */ + // 第一项 2026-09-14 从「收件箱」变成「通信」:收件箱/发件箱/授权现在是**通信页内部**的三栏 + // (用户:「收件发件授权改为一个导航项,通过内部导航区分」),标签必须跟着改 —— + // 否则底部写着"收件箱"、点进去却有发件箱和授权,比没有更让人困惑。 const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); - assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`); + assert.deepEqual(tabLabels, ['通信', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`); assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); }); @@ -384,3 +394,164 @@ test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃 assert.ok(/(? { + /* + * 页签键是**状态机的字母表**:两边不一致时,深链/恢复上次页签的行为会悄悄不同 + * (用户上次停在"授权",鸿蒙这边认不出这个键,就退回收件箱了)。 + * 所以从 WebUI 源码里把键抽出来比,而不是在判据里再抄一遍。 + */ + const union = webUiStore.match(/export type CommTab = ([^;]+);/); + assert.ok(union, 'WebUI 要有 CommTab 联合类型'); + const webKeys = [...union[1].matchAll(/'([a-z]+)'/g)].map(m => m[1]); + assert.deepEqual(C.COMM_TABS, webKeys, '页签键与顺序必须与 WebUI 一致'); + + // 标签也要一致(用户看到的就是这两个字) + const tabBlock = webCommTabs.slice(webCommTabs.indexOf('const TABS'), webCommTabs.indexOf('];')); + const webLabels = [...tabBlock.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]); + assert.deepEqual(C.COMM_TABS.map(C.commTabLabel), webLabels, '页签标签与 WebUI 一致'); + assert.deepEqual(webLabels, ['收件箱', '发件箱', '授权']); +}); + +test('★ 页签状态机:认不出的键与越界下标都落回收件箱(不落在空 pane 上)', () => { + assert.equal(C.COMM_TAB_DEFAULT, 'inbox', '默认页签是收件箱(WebUI 的 uiStore 初值也是它)'); + // 三个键各自往返:键 ↔ 下标 + C.COMM_TABS.forEach((key, i) => { + assert.equal(C.commTabIndex(key), i, `${key} 的下标应是 ${i}`); + assert.equal(C.commTabFromIndex(i), key, `下标 ${i} 应是 ${key}`); + assert.equal(C.normalizeCommTab(key), key, `${key} 是合法键,不该被改写`); + }); + // 脏输入:旧数据、拼错的键、名字改过之后的残留 + for (const junk of ['', 'inboxx', 'SENT', '会话', 'unknown']) { + assert.equal(C.normalizeCommTab(junk), 'inbox', `认不出的键 ${JSON.stringify(junk)} 应落回收件箱`); + assert.equal(C.commTabIndex(junk), 0, `${junk} 的页签栏下标应是默认页签`); + } + for (const bad of [-1, 3, 99, 1.5]) { + assert.equal(C.commTabFromIndex(bad), 'inbox', `越界下标 ${bad} 应落回收件箱`); + } +}); + +test('徽标:未读红 / 待决策橙 / 发件箱无,且 99 以上写 99+(与 CommTabs.tsx 同一套规则)', () => { + // 数字来源:收件箱看未读、授权看**待决策**、发件箱没有徽标 + assert.equal(C.badgeCount('inbox', 3, 5), 3); + assert.equal(C.badgeCount('permissions', 3, 5), 5, '授权徽标看**待决策**,不是权限邮件总数'); + assert.equal(C.badgeCount('sent', 3, 5), 0, '发件箱不该有徽标'); + assert.equal(C.badgeCount('不认识', 3, 5), 3, '认不出的键按默认页签(收件箱)算'); + // 脏数字(负数/NaN 来源)不该显示成负数 + assert.equal(C.badgeCount('inbox', -1, 0), 0); + assert.equal(C.badgeCount('permissions', 0, -3), 0); + + // 文字与上限:WebUI 写的是 `badge > 99 ? '99+' : badge` + assert.equal(C.badgeText(0), '', '0 不显示徽标'); + assert.equal(C.badgeText(-1), '', '负数不显示'); + assert.equal(C.badgeText(1), '1'); + assert.equal(C.badgeText(99), '99'); + assert.equal(C.badgeText(100), '99+'); + assert.match(webCommTabs, /badge > 99 \? '99\+' : badge/, 'WebUI 的上限写法变了,这条判据要跟着核'); + + // 色调:红=要读、橙=有人被卡住。色值来自 Theme,页面不自己挑颜色 + assert.equal(C.badgeTone('inbox'), 'danger'); + assert.equal(C.badgeTone('permissions'), 'warn'); + assert.equal(C.badgeTone('sent'), 'none'); + assert.match(webCommTabs, /key === 'permissions' \? 'bg-orange-700 text-white' : 'bg-red-600 text-white'/, + 'WebUI 的徽标配色变了(授权橙、收件箱红),这条判据要跟着核'); +}); + +test('★ 权限邮件不进收件箱:它进授权栏,且**决策过的**不再算待决策', () => { + const asks = mail({ mail_id: 'perm-1', mail_type: 'permission_request', permission_result: '', status: 'unread', subject: '是否允许删除' }); + const settled = mail({ mail_id: 'perm-2', mail_type: 'permission_request', permission_result: 'allow', status: 'unread' }); + const letter = mail({ mail_id: 'plain-1', mail_type: 'normal', status: 'unread' }); + const split = H.splitByPermission([asks, settled, letter]); + + assert.deepEqual(split.normal.map(m => m.mail_id), ['plain-1'], '收件箱只放要读的'); + assert.deepEqual(split.permissions.map(m => m.mail_id), ['perm-1', 'perm-2'], '授权栏放全部权限邮件(含已决策的)'); + assert.equal(H.isPendingPermission(asks), true, '没有 permission_result = 还在等人点头'); + assert.equal(H.isPendingPermission(settled), false, '已决策的不该再喊人'); + // 后端用 COALESCE 归一成空串,空串与 null 同义 —— 判据要按"假值"而不是"等于空串"来判 + assert.equal(H.isPendingPermission(mail({ mail_type: 'permission_request', permission_result: null })), true); + assert.equal(H.countPendingPermissions([asks, settled, letter]), 1); + + // 收件箱未读不能把授权栏的未读算进去(否则头显示"7 未读"、列表里一封都没有) + const heads = [mail({ status: 'unread', mail_type: 'normal' }), mail({ status: 'unread', mail_type: 'permission_request' })]; + const normalUnread = H.splitByPermission(heads).normal.filter(m => m.status === 'unread').length; + assert.equal(normalUnread, 1); +}); + +test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐字一致', () => { + // WebUI 的空态就是这一句(MailList.tsx)—— 两边对同一件事说同一句话 + assert.match(webMailList, />暂无邮件= 6, `${tab} 要有"为什么是空的"那一句(只说暂无邮件说不清)`); + } + // 空态说明要**分得开**:三栏各说各的,否则等于没说明 + assert.equal(new Set(C.COMM_TABS.map(C.emptyHint)).size, 3, '三栏的空态说明不该是同一句话'); + assert.match(C.emptyHint('sent'), /发出/, '发件箱空态要说清这是"你发出的信"的地方'); + assert.match(C.emptyHint('permissions'), /待决策/, '授权空态要说清"没有人被卡住"'); +}); + +test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => { + // 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变) + const tabLabels = [...sendCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); + assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`); + assert.match(sendCode, /CommPage\(\)/, '第一项要渲染通信页'); + + // 内部页签:三栏由 COMM_TABS 驱动,点击切到 normalizeCommTab 的**同一个函数** + assert.match(sendCode, /ForEach\(COMM_TABS, \(key: string\)/, '页签栏要按页签清单渲染'); + assert.match(sendCode, /commTabLabel\(key\)/, '页签文字走同一份标签'); + assert.match(sendCode, /badgeText\(badgeCount\(key, this\.unreadCount, this\.pendingCount\)\)/, '徽标数字走同一份规则'); + assert.match(sendCode, /this\.commTab = normalizeCommTab\(key\)/, '点击要过状态机的归一化,不能直接赋值'); + // 三个 pane 都要真的存在(少一个就是空页签) + for (const pane of ['InboxTab()', 'SentTab()', 'PermissionTab()']) { + assert.ok(sendCode.includes(pane), `通信页要渲染 ${pane}`); + } + + // 悬浮加号在通信页这一层(三个栏都要能新建),形状是圆形 + const commPage = sendCode.slice(sendCode.indexOf('struct CommPage'), sendCode.indexOf('struct ContactsTab')); + assert.match(commPage, /Text\('\+'\)[\s\S]{0,220}?borderRadius\(28\)/, '悬浮加号要是圆形'); + assert.match(commPage, /this\.openCompose\(\)/, '加号要真的能进写信页'); + + // 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的) + // 注意切到那一行**之后**再截断:原来截到 indexOf 处,正好把要断言的那一行切掉, + // 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省) + const gStart = sendCode.indexOf('this.groups = groupMailsBySession'); + const inboxLoad = sendCode.slice(sendCode.indexOf('async loadData'), gStart + 60); + assert.match(inboxLoad, /splitByPermission\(mergedMails\)/, '收件箱要先分家'); + assert.match(inboxLoad, /groupMailsBySession\(inboxMails\)/, '再折叠筛过的那些'); +}); + +test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => { + const api = readFileSync(join(HARMONY_ETS, 'api/MailApi.ets'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + assert.match(api, /get\('\/me\/mail\/sent'\)/, '发件箱接口'); + assert.match(api, /get\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)'); + assert.match(api, /post\('\/permission\/decide'/, '决策接口'); + // 决策体三个字段名必须与后端/WebUI 一致 + const decide = api.slice(api.indexOf('async decidePermission'), api.indexOf('async decidePermission') + 420); + for (const field of ['mail_id', 'decision', 'note']) { + assert.ok(new RegExp(`payload\\.${field} =`).test(decide), `决策体要带 ${field}(后端按这三个字段解析)`); + } + // 备注必须真的送出:拒绝路径要传 noteText(WebUI 踩过"界面能填、其实没发出去"的坑) + const permTab = sendCode.slice(sendCode.indexOf('struct PermissionTab')); + assert.match(permTab, /this\.decide\(req, 'deny', this\.noteText\)/, '拒绝要把备注送出去'); + assert.match(permTab, /this\.noteText = v;/, '备注框要真的收集输入'); + // 过期必须当场说清:审批不会让那次调用继续 + assert.match(permTab, /resp\.expired/, '过期分支要处理'); + assert.match(permTab, /不会让那次调用继续/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)'); +}); + +test('★ 判据自检:把状态机的默认页签改错必须判红', () => { + // 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红") + const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;"); + assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效'); + const defaultMutated = readFileSync(COMM_TS, 'utf8').replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';"); + assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效'); +}); diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index bde5532..a53360d 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -6,7 +6,7 @@ */ import { ApiClient } from './ApiClient'; -import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult } from '../model/Models'; +import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse } from '../model/Models'; /** 收件箱响应 */ export class InboxResponse { @@ -67,6 +67,14 @@ export class BudgetPayload { max_rounds: number = 0; } +/** 决策请求体(`POST /permission/decide`) */ +export class DecidePayload { + mail_id: string = ''; + /** allow | deny */ + decision: string = ''; + note: string = ''; +} + export class MailApi { private client: ApiClient; @@ -80,6 +88,39 @@ export class MailApi { return this.client.get('/me/mail/inbox', query); } + /** + * 发件箱。响应形状与收件箱同构(`{mails}`)—— 但**没有** `total` 那个坑 + * (收件箱的 `total` 是未读数,见 docs/API.md 的「同名不同义」),这里也不读它。 + */ + async sent(): Promise { + return this.client.get('/me/mail/sent'); + } + + /** + * 待决策的权限请求。 + * + * **不从收件箱里筛 `permission_*`**:收件箱默认只取 50 封,而一个会话能连着产生 + * 十几封权限邮件,从收件箱筛会把别的信挤出视野。与 WebUI 同源(`/permission/pending`)。 + */ + async pendingPermissions(): Promise { + return this.client.get('/permission/pending'); + } + + /** + * 决策(同意/拒绝)。 + * + * `note` 必须**真的送达模型** —— 这是 WebUI 侧踩过的坑(`gateway/handler/permission.go` + * 的 Note 传递)。所以这里不做任何"为空就不传"的省略:空串也照传, + * 由服务端决定怎么处理,避免"界面看起来能填备注、其实备注没发出去"。 + */ + async decidePermission(mailId: string, decision: string, note: string): Promise { + const payload: DecidePayload = new DecidePayload(); + payload.mail_id = mailId; + payload.decision = decision; + payload.note = note; + return this.client.post('/permission/decide', payload); + } + /** 会话列表 */ async sessions(): Promise { return this.client.get('/me/sessions'); diff --git a/client/harmony/entry/src/main/ets/model/CommTabs.ts b/client/harmony/entry/src/main/ets/model/CommTabs.ts new file mode 100644 index 0000000..58fcfa5 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/CommTabs.ts @@ -0,0 +1,149 @@ +/* + * 「通信」页的**内部导航**:收件箱 / 发件箱 / 授权 —— 纯逻辑,无 UI 依赖。 + * + * 用户(2026-09-14):「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为 + * 他们内部的一个悬浮的圆形加号」。于是三个页签落在**通信页内部**,而不是底部导航栏里。 + * + * 为什么单独成文件(与 `MailGrouping.ts` 同一个理由): + * 「点页签 → 落在哪个 pane」「徽标显示几、什么颜色」这两件事是**判据的对象**。 + * 写在 `build()` 里,判据只能 grep 源码字符串;放在这里,判据可以跑**同一份代码** + * —— `client/electron/test/harmony-logic.test.mjs` 直接执行本文件,断言的是行为: + * 未知页签落到哪、越界索引落到哪、徽标 0 时是不是真的不显示、99 以上怎么写。 + * + * 对应关系(参考实现都是 WebUI,改那边时这边要跟着核): + * - 页签键与顺序:`src/stores/uiStore.ts` 的 `CommTab` + `components/CommTabs.tsx` 的 `TABS` + * - 徽标颜色:`CommTabs.tsx`(收件箱红 `bg-red-600`、授权橙 `bg-orange-700`、发件箱无徽标) + * - 徽标上限:`badge > 99 ? '99+' : badge` + * + * ⚠️ 本文件必须保持**类型可擦除**:不用 `enum`、`namespace`、构造器参数属性, + * 否则 node 的 strip-types 跑不起来,判据就断了。 + */ + +/** 三个页签的键,顺序即页签顺序(与 WebUI 的 `CommTab` 联合类型逐字一致) */ +export const COMM_TABS: string[] = ['inbox', 'sent', 'permissions']; + +/** 默认页签:进通信页先看收件箱(WebUI 的 `uiStore` 初值也是 'inbox')。 */ +export const COMM_TAB_DEFAULT: string = 'inbox'; + +export function isCommTab(key: string): boolean { + return COMM_TABS.indexOf(key) >= 0; +} + +/** + * 认不出的页签键 → 回到默认页签。 + * + * 保守方向的选择:宁可落在收件箱,也不要落在一个空白 pane 上 + * (旧数据、拼错的键、以后改过名字的键,都会走到这里)。 + */ +export function normalizeCommTab(key: string): string { + return isCommTab(key) ? key : COMM_TAB_DEFAULT; +} + +/** 页签中文名(与 WebUI `CommTabs.tsx` 的 label 逐字一致) */ +export function commTabLabel(key: string): string { + if (key === 'sent') { + return '发件箱'; + } + if (key === 'permissions') { + return '授权'; + } + return '收件箱'; +} + +/** 页签键 → 下标(页签栏用下标渲染;认不出的键给默认页签的下标) */ +export function commTabIndex(key: string): number { + const i: number = COMM_TABS.indexOf(normalizeCommTab(key)); + return i < 0 ? 0 : i; +} + +/** + * 下标 → 页签键(越界、非整数都回到默认页签,不抛错:这是 UI 状态,不是错误)。 + * + * `Number.isInteger` 这一条是**判据逼出来的**:早先只判了范围, + * 于是 `1.5` 会走到 `COMM_TABS[1.5]` → `undefined` —— 页签栏拿到一个不存在的键, + * 表现是"点哪都不亮"。范围检查挡不住非整数,必须显式判。 + */ +export function commTabFromIndex(index: number): string { + if (!Number.isInteger(index) || index < 0 || index >= COMM_TABS.length) { + return COMM_TAB_DEFAULT; + } + return COMM_TABS[index]; +} + +/** + * 页签徽标数字:收件箱看未读,授权看**待决策**(不是权限邮件总数 —— + * 决策过的那些不该继续喊人),发件箱没有徽标。 + * + * 负数当 0:计数来自网络响应,不假设它一定干净。 + */ +export function badgeCount(key: string, unread: number, pending: number): number { + const tab: string = normalizeCommTab(key); + if (tab === 'inbox') { + return unread > 0 ? unread : 0; + } + if (tab === 'permissions') { + return pending > 0 ? pending : 0; + } + return 0; +} + +/** 徽标文字:0 → 空串(页面据此不渲染),>99 → '99+'(与 WebUI 同一写法) */ +export function badgeText(n: number): string { + if (n <= 0) { + return ''; + } + if (n > 99) { + return '99+'; + } + return n + ''; +} + +/** + * 徽标色调:收件箱红(未读=要读)、授权橙(待决策=有人被卡住,更急)、发件箱无。 + * + * 返回的是**语义名**而不是色值:颜色由 `Theme` 决定(页面不再自己挑颜色)。 + * 名字与 WebUI 的类名对应:red → danger、orange → warn。 + */ +export function badgeTone(key: string): string { + const tab: string = normalizeCommTab(key); + if (tab === 'inbox') { + return 'danger'; + } + if (tab === 'permissions') { + return 'warn'; + } + return 'none'; +} + +/** + * 空态说明(每个页签一句)。 + * + * 收件箱/发件箱的**主句**与 WebUI 的 `MailList.tsx` 逐字一致(「暂无邮件」)—— + * 两边对同一件事说同一句话;各页签再补一句"为什么是空的", + * 因为鸿蒙这边没有 WebUI 那种"列表面板 + 右侧详情"的上下文, + * 一句光秃秃的「暂无邮件」说不清是"没有信"还是"这栏本来就不放东西"。 + */ +export function emptyTitle(key: string): string { + return '暂无邮件'; +} + +export function emptyHint(key: string): string { + const tab: string = normalizeCommTab(key); + if (tab === 'sent') { + return '你发出的信会出现在这里'; + } + if (tab === 'permissions') { + return '没有待决策的请求 —— 没有被卡住的 Agent'; + } + return '还没有收到新邮件'; +} + +/** + * 页签栏上"授权"那一栏要不要**催**(橙色徽标)。 + * + * 与未读分开判断:未读是"有东西要读",待决策是"有人被卡住"。后者更急, + * 所以即使收件箱未读为 0,只要有待决策请求,通信这一项在底部导航上也该有提示。 + */ +export function commNeedsAttention(unread: number, pending: number): boolean { + return pending > 0 || unread > 0; +} diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index 362fbc0..33655f3 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -27,10 +27,19 @@ export interface MailLike { session_id: string; session_alias: string; from_name: string; + /** 收件人显示名 —— 发件箱那一栏行上显示的是它(收件箱显示 from_name) */ + to_name: string; subject: string; body_preview: string; created_at: string; status: string; + /** + * 邮件类型:`permission_request` 是**待办**(等人点头),其余是要读的内容。 + * 与 WebUI 的 `Mail.mail_type` 同名同义 —— 收件箱与授权两栏就按它分家。 + */ + mail_type: string; + /** 决策结果(空串 = 还没人处理过)。后端用 COALESCE 归一成空串,所以空串与 null 同义。 */ + permission_result: string; permission_mode: string; source_account_id: string; source_account_name: string; @@ -328,3 +337,48 @@ export function permissionChipText(mode: string, enforcement: string): string { } return label + ' ' + enforcementGlyph(enforcement); } + + +/* + * ───────── 权限请求从普通邮件里分出来(对应 WebUI `mailGroups.ts` 的同名三个函数) ───────── + * + * 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」。 + * 混在收件箱里两者互相伤害:一次 Agent 任务能连着产生十几封权限请求, + * 把真正需要阅读的来信挤到看不见的地方(WebUI 侧实测过:17 封权限邮件挤掉另外两个会话)。 + * 所以收件箱只放要读的,授权栏只放要批的。 + */ + +/** 权限邮件且尚无决策结果。空串与 null 都算未决策。 */ +export function isPendingPermission(m: MailLike): boolean { + return m.mail_type === 'permission_request' && !m.permission_result; +} + +/** 待决策的权限请求数 —— 授权栏徽标的数字,也是「有人被卡住、要人动手」的唯一信号。 */ +export function countPendingPermissions(mails: MailLike[]): number { + let n: number = 0; + for (let i = 0; i < mails.length; i++) { + if (isPendingPermission(mails[i])) { + n += 1; + } + } + return n; +} + +/** 分家结果(用 class 而不是匿名对象字面量:ArkTS 要求对象字面量处处有类型) */ +export class MailSplit { + normal: MailLike[] = []; + permissions: MailLike[] = []; +} + +export function splitByPermission(mails: MailLike[]): MailSplit { + const out: MailSplit = new MailSplit(); + for (let i = 0; i < mails.length; i++) { + const m: MailLike = mails[i]; + if (m.mail_type === 'permission_request') { + out.permissions.push(m); + } else { + out.normal.push(m); + } + } + return out; +} diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index f3fbe35..658be2e 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -50,6 +50,13 @@ export class MailSummary implements MailLike { is_read: boolean = true; has_attachments: boolean = false; permission_mode: string = ''; + /** + * 邮件类型:`permission_request` = 待人点头的**待办**,其余是要读的内容。 + * 收件箱与授权两栏按它分家(`MailGrouping.ts` 的 `splitByPermission`)。 + */ + mail_type: string = ''; + /** 决策结果:空串 = 还没有人处理(后端 COALESCE 归一,空串与 null 同义)。 */ + permission_result: string = ''; /** 客户端聚合字段:服务端不返回,由收件箱按来源账号填充。 */ source_account_id: string = ''; source_account_name: string = ''; @@ -130,14 +137,29 @@ export class Contact { last_preview: string = ''; } -/** 权限请求 */ +/** + * 一条待决策的权限请求(`GET /permission/pending` 的 `requests[]`)。 + * + * 与 WebUI 的 `PermissionRequest`(`types/index.ts`)同字段 —— 原来这份少了 + * `request_id` / `agent_name` / `result` / `decided_at`,还多了一个服务端不返回的 + * `from_name`;授权栏要显示"谁在问",缺 `agent_name` 就只能显示空。 + */ export class PermissionRequest { + request_id: string = ''; + /** 决策时要回传的邮件 id(`POST /permission/decide` 的 body 用它) */ mail_id: string = ''; session_id: string = ''; - from_name: string = ''; + session_alias: string = ''; + /** 发起请求的 Agent(权限请求一定由 Agent 发出) */ + agent_name: string = ''; + /** Agent 的问题原文:「是否允许我删除 X」 */ question: string = ''; - context: string = ''; + /** 可选项(WebUI 用它与 allow/deny 两个按钮对应) */ options: string[] = []; + context: string = ''; + /** 已有决策结果(pending 列表里应恒为空串) */ + result: string = ''; + decided_at: string = ''; created_at: string = ''; } @@ -184,4 +206,32 @@ export class SendMailResult { export class LoginResult { token: string = ''; user: Me = new Me(); -} \ No newline at end of file +} + +/* + * ─────────── 通信页三栏用到的响应体 ─────────── + */ + +/** `GET /me/mail/sent` 的响应(与收件箱同构,但没有 total 那个坑:这里不读它) */ +export class SentResponse { + mails: MailSummary[] = []; +} + +/** `GET /permission/pending` 的响应 */ +export class PendingResponse { + requests: PermissionRequest[] = []; +} + +/** + * `POST /permission/decide` 的响应。 + * + * `expired` 与 `warning` 必须跟着走:请求越过等待窗口时服务端会带上它们, + * 界面**不能**把"决策落到了一个没人在等的请求上"吞掉(WebUI 侧踩过这个坑)。 + */ +export class DecideResponse { + status: string = ''; + decision_mail_id: string = ''; + expired: boolean = false; + expires_at: string = ''; + warning: string = ''; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 1498df2..892b9f8 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -1,20 +1,25 @@ /* * AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航) - * 收件箱 / 联系人 两个 TabContent(平级的「会话」已并入这两处,见文件末尾的说明) + * + * 底部两项:**通信**(内部三栏:收件箱 / 发件箱 / 授权,见 `CommTabs.ts`)与 **联系人**。 + * 平级的「会话」已并入这两处(见文件末尾的说明);「日历」等它的内容(P6)一起上, + * 不留一个点进去空着的入口。 */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; -import { MailSummary, Contact } from '../model/Models'; +import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models'; import { MailLike, + MailSplit, SessionGroup, groupMailsBySession, isFlatGroup, partialLoadNotice, sumUnreadTotals, + splitByPermission, budgetState, budgetLabel, nextContactView, @@ -25,6 +30,18 @@ import { permissionHint, enforcementLabel } from '../model/MailGrouping'; +import { + COMM_TABS, + commTabLabel, + commTabIndex, + commTabFromIndex, + normalizeCommTab, + badgeCount, + badgeText, + badgeTone, + emptyTitle, + emptyHint +} from '../model/CommTabs'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; /** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ @@ -150,13 +167,38 @@ struct InboxTab { } } + /* + * 收件箱只放**要读的**:权限请求是"待办",归授权那一栏(与 WebUI `MailList.tsx` + * 的 `splitByPermission(inbox).normal` 同一口径)。 + * + * 不筛的后果 WebUI 侧实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。 + * 注意未读数也按**筛过之后**算 —— 否则收件箱头显示"7 未读"、列表里却一封未读都没有 + * (那 7 封都在授权栏等着),这正是"两处数字对不上"的经典来源。 + */ + const split: MailSplit = splitByPermission(mergedMails); + const inboxMails: MailLike[] = split.normal; + this.mails = mergedMails; // 按会话折叠:组头取组内最新一封(含别名),组间按最新一封倒序; // 单封的组不算组,平铺(见 MailGrouping.isFlatGroup)。 - this.groups = groupMailsBySession(mergedMails); - this.loaded = mergedMails.length; - // 未读数用服务端返回的 total —— 它是 CountUnread,权威;数这一页会少报。 - this.unread = sumUnreadTotals(unreadTotals); + this.groups = groupMailsBySession(inboxMails); + this.loaded = inboxMails.length; + /* + * 未读数:服务端 `total`(= CountUnread,权威)+ 本地数一遍筛后的未读,取**较大者**。 + * + * 为什么不用其中一个:`total` 是收件箱里**所有**未读(含权限邮件), + * 而列表里只有普通邮件 —— 只信 total 会把授权栏的未读也算进收件箱; + * 只数列表则会少报(这一页只取了 50 封)。取较大者偏保守:宁可多报一个未读, + * 也不要"显示 0 未读但列表里有红点"这种自相矛盾。 + */ + let localUnread: number = 0; + for (let i = 0; i < inboxMails.length; i++) { + if (inboxMails[i].status === 'unread') { + localUnread += 1; + } + } + const serverUnread: number = sumUnreadTotals(unreadTotals); + this.unread = serverUnread > localUnread ? serverUnread : localUnread; // 这一页取满了就如实说"可能还有更多":不能把 50 封说成全部(见 partialLoadNotice)。 this.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE); } catch (e) { @@ -245,12 +287,7 @@ struct InboxTab { .borderRadius(10) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) } - Text('⚙') - .fontSize(20).fontColor(Theme.textMuted) - .width(36).height(36).textAlign(TextAlign.Center) - .onClick(() => { - this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); - }) + // ⚙ 不在这里了:它搬到了通信页的页头 —— 否则切到发件箱/授权就够不到设置 } .width('100%').height(56).padding({ left: 16, right: 8 }) .backgroundColor(Theme.surface) @@ -358,54 +395,24 @@ struct InboxTab { .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) } - // 底部信息栏 + 悬浮写邮件按钮 - Stack({ alignContent: Alignment.BottomEnd }) { - Row() { - Text('已加载 ' + this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle) - Blank() - Text('未读 ' + this.unread).fontSize(12).fontColor(Theme.textSubtle) - } - .width('100%').height(36).padding({ left: 16, right: 16 }) - .backgroundColor(Theme.surface) - - Text('+') - .fontSize(28).fontColor(Theme.surface) - .width(56).height(56) - .borderRadius(28) - .backgroundColor(Theme.accent) - .textAlign(TextAlign.Center) - .margin({ right: 20, bottom: 44 }) - .onClick(() => { - this.openCompose(); - }) + // 底部信息栏(悬浮加号不在这里:它属于**通信页**,三个栏都要能新建 —— 见 CommPage) + Row() { + Text('已加载 ' + this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle) + Blank() + Text('未读 ' + this.unread).fontSize(12).fontColor(Theme.textSubtle) } - .width('100%') + .width('100%').height(36).padding({ left: 16, right: 16 }) + .backgroundColor(Theme.surface) } .width('100%').height('100%') .backgroundColor(Theme.pageBg) - .bindSheet($$this.composeVisible, this.ComposeSheet()) } - @State composeVisible: boolean = false; - - @Builder - ComposeSheet() { - Column() { - Text('写邮件').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - .margin({ top: 16, bottom: 8 }) - Button('新建邮件') - .width('90%').height(44).backgroundColor(Theme.accent) - .onClick(() => { - this.composeVisible = false; - this.openCompose(); - }) - Button('取消') - .width('90%').height(40).backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted) - .margin({ top: 8 }) - .onClick(() => { this.composeVisible = false; }) - } - .width('100%').height(180).padding(16) - } + /* + * 这里原本还有一个「写邮件」的 bindSheet:`composeVisible` 从来没被置为 true, + * 也就是说**谁也打不开它** —— 死代码。既然新建入口按用户要求搬成了通信页的悬浮加号, + * 就顺手删掉,免得以后有人以为它是可用的第二条路径。 + */ /** * 列表里的一封(列表项本体):底色按已读/未读,点它进详情。 @@ -554,6 +561,611 @@ struct InboxTab { * 那条判据会红,提醒鸿蒙跟着补,而不是悄悄地少一块。 */ +/* + * ─────────────────────── 发件箱(通信页第二栏) ─────────────────────── + * + * 与收件箱**同构**(同一套折叠、同一套行),差别只有三处: + * ① 数据来自 `GET /me/mail/sent`;② 行上显示的是**收件人**(`to_name`); + * ③ **不筛权限邮件** —— 人发不出权限请求(那是 Agent 发的),筛也筛不掉什么, + * 而"不假设数据一定干净"是 WebUI 的原话(`MailList.tsx` 里就这么写的)。 + */ + +@Component +struct SentTab { + @State groups: SessionGroup[] = []; + @State expandedKeys: string[] = []; + @State loading: boolean = false; + @State error: string = ''; + @State loaded: number = 0; + + aboutToAppear(): void { + this.load(); + } + + async load(): Promise { + 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: MailSummary[] = []; + 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: SentResponse = await new MailApi(c).sent(); + for (let j = 0; j < resp.mails.length; j++) { + const mail: MailSummary = resp.mails[j]; + mail.source_account_id = acct.id; + mail.source_account_name = acct.displayName; + all.push(mail); + } + } catch (e) { + // 单个账号拉不到不该让整栏空白(与收件箱同口径) + } + } + this.groups = groupMailsBySession(all); + this.loaded = all.length; + } catch (e) { + const ae = e as ApiError; + this.error = ae.message.length > 0 ? ae.message : '加载失败'; + } finally { + this.loading = false; + } + } + + isExpanded(key: string): boolean { + return this.expandedKeys.indexOf(key) >= 0; + } + + toggleExpanded(key: string): void { + const next: string[] = []; + let found: boolean = false; + for (let i = 0; i < this.expandedKeys.length; i++) { + if (this.expandedKeys[i] === key) { + found = true; + } else { + next.push(this.expandedKeys[i]); + } + } + if (!found) { + next.push(key); + } + this.expandedKeys = next; + } + + openMail(mail: MailLike): void { + const params: MailDetailParams = { + mail_id: mail.mail_id, + account_id: mail.source_account_id + }; + this.getUIContext().getRouter().pushUrl({ url: 'pages/MailDetailPage', params: params }); + } + + @Builder + SentRow(mail: MailLike) { + Column() { + Row() { + // 发件箱里想知道的是"发给谁了" —— 收件人是这一栏的主角 + Text('致 ' + (mail.to_name.length > 0 ? mail.to_name : '(未记录收件人)')) + .fontSize(13).fontWeight(FontWeight.Medium).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text(mail.created_at).fontSize(10).fontColor(Theme.textSubtle) + } + .width('100%') + + Text(mail.subject.length > 0 ? mail.subject : '(无主题)') + .fontSize(13).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 4 }) + + if (mail.body_preview.length > 0) { + Text(mail.body_preview) + .fontSize(11).fontColor(Theme.textMuted) + .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 4 }) + } + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + } + + @Builder + SentGroupHeader(g: SessionGroup) { + Row() { + Text(this.isExpanded(g.key) ? '▾' : '▸') + .fontSize(12).fontColor(Theme.textMuted).width(18) + Column() { + Text(g.alias.length > 0 ? g.alias : '(未命名会话)') + .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Row() { + Text(g.subject.length > 0 ? g.subject : '(无主题)') + .fontSize(12).fontColor(Theme.textMuted) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Blank() + Text(g.mails.length + ' 封').fontSize(11).fontColor(Theme.textSubtle) + } + .width('100%').margin({ top: 2 }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Start) + } + .width('100%').height(60) + .padding({ left: 12, right: 12 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(Theme.surfaceMuted) + .borderRadius(Theme.radiusCard) + .margin({ bottom: 6 }) + .clip(true) + .onClick(() => { this.toggleExpanded(g.key); }) + } + + build() { + Column() { + Row() { + Text('发件箱').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + if (this.loaded > 0) { + Text(this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle) + } + } + .width('100%').height(56).padding({ left: 16, right: 16 }) + .backgroundColor(Theme.surface) + + 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.danger) } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + } else if (this.groups.length === 0) { + /* + * 空态要**有说明**(P2 的验收条件):只说「暂无邮件」说不清是"没有信" + * 还是"这一栏本来就不放东西"。主句与 WebUI 的 `MailList.tsx` 逐字一致, + * 副句说清这一栏是干什么的。 + */ + Column() { + Text('📤').fontSize(32).margin({ bottom: 8 }) + Text(emptyTitle('sent')).fontSize(15).fontColor(Theme.textMuted) + Text(emptyHint('sent')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + } else { + List({ space: 6 }) { + ForEach(this.groups, (g: SessionGroup) => { + ListItem() { + Column() { + this.SentGroupHeader(g) + if (isFlatGroup(g)) { + this.SentRow(g.mails[0]) + } + } + .width('100%') + } + .width('100%') + if (!isFlatGroup(g) && this.isExpanded(g.key)) { + ForEach(g.mails, (m: MailLike) => { + ListItem() { + Column() { + this.SentRow(m) + } + .width('100%') + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .margin({ bottom: 6 }) + .onClick(() => { this.openMail(m); }) + } + .width('100%') + }, (m: MailLike) => m.mail_id) + } + }, (g: SessionGroup) => g.key) + } + .width('100%').layoutWeight(1) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + } + } + .width('100%').height('100%') + .backgroundColor(Theme.pageBg) + } +} + +/* + * ─────────────────────── 授权(通信页第三栏) ─────────────────────── + * + * 只放**待人点头**的事:`GET /permission/pending`(不从收件箱筛,理由见 `MailApi`)。 + * 决策走 `POST /permission/decide`,`note` 会随决策送达模型 —— + * 所以拒绝时**能填备注**,而且填了必须真的发出去。 + * + * 两件必须如实说的事(WebUI 侧踩过坑,见 `decidePermission` 的返回类型): + * ① 请求**已过期**时服务端会带 `expired`:这时决策落到了一个没人在等的请求上, + * 得当场告诉人(否则会以为"批了,Agent 继续干活了"); + * ② `warning` 同理,服务端让显示什么就显示什么。 + */ + +@Component +struct PermissionTab { + @State requests: PermissionRequest[] = []; + @State loading: boolean = false; + @State error: string = ''; + /** 正在填备注的那条(空串 = 没有) */ + @State noteFor: string = ''; + @State noteText: string = ''; + @State busyId: string = ''; + + aboutToAppear(): void { + this.load(); + } + + async load(): Promise { + 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[] = []; + 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++) { + all.push(resp.requests[j]); + } + } catch (e) { + // 单账号失败不空整栏 + } + } + this.requests = all; + } catch (e) { + const ae = e as ApiError; + this.error = ae.message.length > 0 ? ae.message : '加载失败'; + } finally { + this.loading = false; + } + } + + async decide(req: PermissionRequest, decision: string, note: string): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + this.busyId = req.mail_id; + try { + const acctMgr: AccountManager = AccountManager.getInstance(ctx); + await acctMgr.load(); + const accounts: AccountInfo[] = acctMgr.getAccounts(); + let done: boolean = false; + 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: DecideResponse = await new MailApi(c).decidePermission(req.mail_id, decision, note); + done = true; + /* + * 过期/警告要当场说清 —— 不能只说"已同意"。 + * 过期意味着**审批不会让那次调用继续**(请求方已经不等了), + * 人必须知道这一点,否则会以为 Agent 会接着跑。 + */ + if (resp.expired) { + this.getUIContext().getPromptAction().showToast({ + message: '这条请求已经过期 —— 审批不会让那次调用继续,Agent 需要重新请求', + duration: 6000 + }); + } else if (resp.warning.length > 0) { + this.getUIContext().getPromptAction().showToast({ message: resp.warning, duration: 6000 }); + } else { + this.getUIContext().getPromptAction().showToast({ + message: decision === 'deny' ? '已拒绝' + (note.length > 0 ? '(备注已随决策送出)' : '') : '已同意' + }); + } + break; + } catch (e) { + // 换下一个账号试:决策只在一个账号的网关上有效 + } + } + if (!done) { + this.getUIContext().getPromptAction().showToast({ message: '决策失败:没找到这条请求所在的账号' }); + } + this.noteFor = ''; + this.noteText = ''; + await this.load(); + } finally { + this.busyId = ''; + } + } + + @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) + Text(req.session_alias.length > 0 ? req.session_alias : '(未命名会话)') + .fontSize(10).fontColor(Theme.accent) + } + .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.textSubtle) + .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 4 }) + } + + Text(req.created_at).fontSize(10).fontColor(Theme.textSubtle).margin({ top: 6 }) + + if (this.noteFor === req.mail_id) { + TextInput({ placeholder: '备注(会随决策一起送给 Agent)', text: this.noteText }) + .fontSize(12).height(40).margin({ top: 8 }) + .onChange((v: string) => { this.noteText = v; }) + } + + Row() { + Button('同意') + .fontSize(13).height(36).layoutWeight(1) + .backgroundColor(Theme.approve).fontColor(Theme.surface) + .onClick(() => { this.decide(req, 'allow', ''); }) + Text('拒绝') + .fontSize(13).height(36).layoutWeight(1) + .textAlign(TextAlign.Center) + .backgroundColor(Theme.dangerBg).fontColor(Theme.danger) + .borderRadius(Theme.radiusControl) + // 拒绝先展开备注框,而不是直接拒:拒绝往往要说明理由,而理由是给模型看的 + .onClick(() => { + if (this.noteFor === req.mail_id) { + this.decide(req, 'deny', this.noteText); + } else { + this.noteFor = req.mail_id; + this.noteText = ''; + } + }) + .margin({ left: 8 }) + } + .width('100%').margin({ top: 10 }) + + if (this.noteFor === req.mail_id) { + Text(this.noteText.length > 0 ? '再点一次「拒绝」即送出(带备注)' : '可填备注,再点一次「拒绝」送出') + .fontSize(10).fontColor(Theme.textSubtle).margin({ top: 4 }) + } + } + .width('100%').alignItems(HorizontalAlign.Start) + .padding(12) + .backgroundColor(Theme.surface) + .borderRadius(Theme.radiusCard) + .margin({ bottom: 8 }) + } + + build() { + Column() { + Row() { + Text('授权').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + if (this.requests.length > 0) { + // 待决策的橙色徽标:这不是"有东西要读",是"有人被卡住" + Text(this.requests.length + ' 待决策') + .fontSize(12).fontColor(Theme.surface) + .backgroundColor(Theme.warnFg) + .borderRadius(10) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + } + } + .width('100%').height(56).padding({ left: 16, right: 16 }) + .backgroundColor(Theme.surface) + + 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.danger) } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + } else if (this.requests.length === 0) { + Column() { + Text('✅').fontSize(32).margin({ bottom: 8 }) + Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted) + Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtle).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) + } + .width('100%').layoutWeight(1) + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + } + } + .width('100%').height('100%') + .backgroundColor(Theme.pageBg) + } +} + +/* + * ─────────────────────── 通信页(底部第一项) ─────────────────────── + * + * 用户(2026-09-14):「收件发件授权改为一个导航项,通过内部导航区分, + * 然后新建作为他们内部的一个悬浮的圆形加号」。 + * + * 三件事都在这一层: + * ① **内部页签**(下划线式,不是浮动的白胶囊 —— WebUI 侧用户原话是 + * 「通信页面的二级页面与其他位置极其割裂」,白胶囊看起来是硬贴上去的另一套控件); + * ② **徽标**:收件箱红(未读)、授权橙(待决策)、发件箱无。合并成一个导航项之后, + * 底部导航上看不到"授权有 3 个在等我"了,这个信息不能丢 —— 它比未读更急; + * ③ **悬浮圆形加号**:三个栏都要能新建,所以它挂在这一层,而不是某个栏里。 + * + * 徽标数字**由本层自己拉**(`inbox` + `pending` 各一次),不依赖某个 pane 的加载: + * 否则切到发件箱时,收件箱的未读徽标就没了 —— 而它恰恰是"别处有东西等你看"的提示。 + * 多一次请求换"徽标始终是对的",这个交换是划算的。 + */ + +@Component +struct CommPage { + @State commTab: string = 'inbox'; + @State unreadCount: number = 0; + @State pendingCount: number = 0; + + aboutToAppear(): void { + this.refreshCounts(); + } + + /** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */ + async refreshCounts(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + try { + const acctMgr: AccountManager = AccountManager.getInstance(ctx); + await acctMgr.load(); + const accounts: AccountInfo[] = acctMgr.getAccounts(); + let unread: number = 0; + let pending: number = 0; + 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 api: MailApi = new MailApi(c); + const inbox: InboxResponse = await api.inbox('all', INBOX_PAGE_SIZE); + // 与收件箱那一栏同口径:权限邮件不算"要读的",它们归授权栏 + const split: MailSplit = splitByPermission(inbox.mails); + for (let j = 0; j < split.normal.length; j++) { + if (split.normal[j].status === 'unread') { + unread += 1; + } + } + const pend: PendingResponse = await api.pendingPermissions(); + pending += pend.requests.length; + } catch (e) { + // 单个账号失败不影响其他账号的徽标 + } + } + this.unreadCount = unread; + this.pendingCount = pending; + } catch (e) { + // 徽标拉不到就不显示(比显示一个错的数字好) + } + } + + openCompose(): void { + const ctx = this.getUIContext().getHostContext(); + let accountId: string = ''; + if (ctx !== undefined) { + accountId = AccountManager.getInstance(ctx).getActiveId(); + } + const params: ComposeParams = { to: '', reply_to: '', session_alias: '', account_id: accountId }; + this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params }); + } + + @Builder + CommTabBar() { + Row() { + ForEach(COMM_TABS, (key: string) => { + Row() { + Text(commTabLabel(key)) + .fontSize(13) + .fontWeight(this.commTab === key ? FontWeight.Bold : FontWeight.Normal) + .fontColor(this.commTab === key ? Theme.accent : Theme.textMuted) + if (badgeText(badgeCount(key, this.unreadCount, this.pendingCount)).length > 0) { + Text(badgeText(badgeCount(key, this.unreadCount, this.pendingCount))) + .fontSize(10).fontColor(Theme.surface) + // 红=有东西要读、橙=有人被卡住(更急);色值来自 Theme,页面不自己挑 + .backgroundColor(badgeTone(key) === 'warn' ? Theme.warnFg : Theme.danger) + .borderRadius(9) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + .margin({ left: 4 }) + } + } + .justifyContent(FlexAlign.Center) + .layoutWeight(1) + .height(44) + // 选中态用**下划线**(与 WebUI 的 `border-b-2` 同一观感),不用浮动白胶囊 + .border({ width: { bottom: this.commTab === key ? 2 : 0 }, color: Theme.accent }) + .onClick(() => { + this.commTab = normalizeCommTab(key); + this.refreshCounts(); + }) + }, (key: string) => key) + } + .width('100%') + .backgroundColor(Theme.surface) + } + + build() { + Stack({ alignContent: Alignment.BottomEnd }) { + Column() { + Row() { + Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) + Blank() + Text('⚙') + .fontSize(20).fontColor(Theme.textMuted) + .width(36).height(36).textAlign(TextAlign.Center) + .onClick(() => { + this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); + }) + } + .width('100%').height(52).padding({ left: 16, right: 8 }) + .backgroundColor(Theme.surface) + + this.CommTabBar() + + if (this.commTab === 'sent') { + SentTab() + } else if (this.commTab === 'permissions') { + PermissionTab() + } else { + InboxTab() + } + } + .width('100%').height('100%') + + /* + * 悬浮的圆形加号(用户点名要的形状):56 圆、品牌色、右下角。 + * 放在通信页这一层,所以三个栏里都在 —— 新建邮件这件事不挑栏。 + */ + Text('+') + .fontSize(28).fontColor(Theme.surface) + .width(56).height(56) + .borderRadius(28) + .backgroundColor(Theme.accent) + .textAlign(TextAlign.Center) + .margin({ right: 20, bottom: 20 }) + .onClick(() => { this.openCompose(); }) + } + .width('100%').height('100%') + .backgroundColor(Theme.pageBg) + } +} + @Component struct ContactsTab { @State contacts: Contact[] = []; @@ -838,7 +1450,14 @@ struct MainPage { build() { /* - * 只剩两个平级页签:收件箱 / 联系人。 + * 底部两项:**通信** / 联系人。 + * + * 通信不再等于收件箱:它内部有三栏(收件箱 / 发件箱 / 授权 + 徽标), + * 见 `CommPage` 与 `model/CommTabs.ts`。这也是 WebUI 现在的信息架构 + * (用户 2026-09-14:「收件发件授权改为一个导航项,通过内部导航区分」)。 + * + * 日历**还没有**入口:它的内容(P6:网格 + 事件读写 + 左右滑动翻页)还没做, + * 先放一个点进去空着的入口不如等它一起上 —— 这条选择记在计划文档 §7.15。 * * 「会话」原先是个平级 tab,现在**撤掉**了 —— 它不是第三个地方, * 而是"同一批数据的另一种看法":收件箱那栏按会话折叠(组头就是会话), @@ -852,9 +1471,9 @@ struct MainPage { */ Tabs({ barPosition: BarPosition.End }) { TabContent() { - InboxTab() + CommPage() } - .tabBar(this.TabBarBuilder('收件箱', '📬', 0)) + .tabBar(this.TabBarBuilder('通信', '✉️', 0)) TabContent() { ContactsTab() diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index e6876c9..1222b60 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -557,3 +557,50 @@ SDK 里写着:`@ohos.promptAction.d.ts` 的全局 `showToast` 标 `@deprecated (同一批整理里还没做的:全局 `animateTo` 也要走 `getUIContext().animateTo(...)`, 放到动效那一期一起改 —— 那一期本来就是"自定义 transition → 系统 `curves`"。) + +### 7.15 P2b + P3 主体:把「收件箱」这一项改成「通信」(内部三栏 + 徽标) + +用户(2026-09-14):「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为他们 +内部的一个悬浮的圆形加号」。所以这一期不是"再加两个页面",而是**对齐信息架构**: + +- 底部第一项从 **收件箱** 变成 **通信**(`CommPage`),内部三栏 **收件箱 / 发件箱 / 授权**; +- 页签带**徽标**:收件箱红(未读)、授权橙(**待决策**)、发件箱无;>99 写 `99+`; +- 选中态用**下划线**(不是浮动白胶囊)—— WebUI 侧用户原话是「通信页面的二级页面与其他位置极其割裂」; +- **悬浮圆形加号**挂到通信页这一层(三个栏都要能新建),`⚙` 也搬到这一层 + (否则切到发件箱/授权就够不到设置); +- **收件箱不再混权限邮件**:按 `splitByPermission` 分家,未读也按筛过之后算 —— + WebUI 侧实测过"一个会话的 17 封权限邮件挤掉另外两个会话"。 + +**P3 主体(授权栏)**:`GET /permission/pending`(不从收件箱筛,理由见 §5.2①)、 +`POST /permission/decide`。两件如实说的事:**拒绝可填备注且备注必须送出**; +**请求过期**时当场说明「审批不会让那次调用继续」—— 否则人会以为 Agent 接着跑了。 + +**顺带清掉一处死代码**:收件箱里那个「写邮件」`bindSheet` 的 `composeVisible` +从来没被置为 true(**谁也打不开**),随新建入口改成悬浮加号一并删除。 + +**日历为什么还没有入口**:它的内容(P6:网格 + 事件读写 + 左右滑动翻页)还没做。 +先放一个点进去空着的入口,比暂时没有入口更糟 —— 所以等内容一起上。 +这是**有意排序**,不是漏做(记在这里以免被当成遗漏)。 + +**判据**(`harmony-logic.test.mjs`,28 条): + +| 判据 | 说明 | +|---|---| +| 页签键与顺序 | 从 WebUI 的 `uiStore.ts` 抽出 `CommTab` 联合类型、从 `CommTabs.tsx` 抽出 `TABS` 标签,比**键集合与顺序**(两边不一致时"恢复上次页签"会悄悄退回) | +| 页签状态机 | 键↔下标往返;认不出的键/越界下标/非整数 → 回收件箱(不落在空白 pane 上) | +| 徽标 | 收件箱看未读、授权看**待决策**(不是权限邮件总数)、发件箱无;0 不显示;>99 写 `99+`;红/橙与 WebUI 的 `bg-red-600`/`bg-orange-700` 对应 | +| 分家 | 权限邮件不进收件箱;**决策过的不再算待决策**(空串与 null 都算未决策);收件箱未读不含授权栏 | +| 空态 | 主句与 WebUI 的「暂无邮件」逐字一致;三栏各有一句"为什么是空的",且三句互不相同 | +| 接线 | 底部第一项是「通信」;三个 pane 都真的渲染;悬浮加号是圆形且在通信页这一层;收件箱"先分家再折叠";接口路径/决策体三个字段;拒绝要传 `noteText`;过期分支必须说清后果 | + +**变异验证**:授权徽标改成看未读 → 红;权限邮件不分出去 → 红;类型名拼错 +(权限邮件全混进收件箱)→ 红;决策过的仍算待决策 → 红;收件箱不再分家 → 红 2 条; +发件箱空态去掉说明 → 红。 + +**判据自己抓到的真 bug**:`commTabFromIndex` 原来只判范围,`1.5` 会走到 +`COMM_TABS[1.5]` → `undefined`(表现是"点哪都不亮")。范围检查挡不住非整数, +已加 `Number.isInteger`。 + +**未验**:底部/内部页签在真机上的观感、悬浮加号的位置、徽标与文字的排版 —— +仍然只有真机(或模拟器,需人在命令行启动)能看。已验证:构建成功、28 条判据全绿、 +六种变异都能判红。