feat(harmony): P2b+P3 —— 「收件箱」改成「通信」(内部三栏 + 徽标 + 悬浮加号),发件箱与授权栏落地
用户:「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为他们内部的一个悬浮的圆形加号」。 所以这一期不是"再加两个页面",而是对齐信息架构。 ## 鸿蒙侧 - 底部第一项 **收件箱 → 通信**(`CommPage`),内部三栏 收件箱 / 发件箱 / 授权; 页签下划线式(不是浮动白胶囊 —— WebUI 侧用户原话「通信页面的二级页面与其他位置极其割裂」)。 - **徽标**:收件箱红(未读)、授权橙(**待决策**)、发件箱无;0 不显示,>99 写 `99+`。 合并成一个导航项后,底部看不到"授权有 3 个在等我",这个信息不能丢 —— 它比未读更急。 - **悬浮圆形加号**挂到通信页这一层(三个栏都要能新建);`⚙` 也搬上来(否则切栏就够不到设置)。 - **收件箱不再混权限邮件**(`splitByPermission`),未读按筛后算 —— WebUI 实测过"一个会话 17 封权限邮件挤掉另外两个会话"。 - **发件箱**:`GET /me/mail/sent`,与收件箱同构(同一套折叠/行),行上主角是**收件人**; 空态有说明(主句与 WebUI 逐字一致「暂无邮件」+ 一句"这里放什么")。 - **授权栏**(P3 主体):`GET /permission/pending`(不从收件箱筛)+ `POST /permission/decide`。 拒绝**可填备注且备注真的送出**;请求**过期**时当场说清「审批不会让那次调用继续」。 - 顺手删掉死代码:收件箱里「写邮件」的 `bindSheet`(`composeVisible` 从未置 true,谁也打不开)。 ## 判据(harmony-logic 19 → 28 条) 页签键/顺序从 WebUI 源码抽取比对(`uiStore.ts` 的 `CommTab` + `CommTabs.tsx` 的 `TABS`); 页签状态机(键↔下标往返、脏键/越界/非整数 → 回收件箱);徽标规则(数字来源、0 不显示、 99+ 上限、红/橙与 WebUI 类名对应);分家语义(决策过的不再算待决策、空串与 null 同义); 三栏空态互不相同且主句与 WebUI 一致;接线(三个 pane 真的渲染、加号是圆形且在通信页、 "先分家再折叠"、接口路径与决策体三字段、拒绝传备注、过期分支)。 判据抓到一个**真 bug**:`commTabFromIndex` 只判范围,`1.5` → `COMM_TABS[1.5]` = `undefined` (表现"点哪都不亮")。已加 `Number.isInteger`。 变异验证(六种):授权徽标看未读 / 权限邮件不分出去 / 类型名拼错 / 决策过仍算待决策 / 收件箱不分家 / 发件箱空态去掉说明 —— 全部判红。 ## 排序说明 **日历暂时没有入口**:P6 的内容(网格 + 事件读写 + 滑动翻页)还没做, 先放一个点进去空着的入口比暂时没有更糟 —— 有意排序,记在 §7.15 以免被当成漏做。 ## 验证 / 未验 `hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0(10 个判据文件全绿 + vitest 258/258)。 **未验**:页签/徽标/悬浮加号在真机上的观感与点击 —— 需设备或模拟器(模拟器要人在命令行启动)。
This commit is contained in:
@ -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<InboxResponse>('/me/mail/inbox', query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发件箱。响应形状与收件箱同构(`{mails}`)—— 但**没有** `total` 那个坑
|
||||
* (收件箱的 `total` 是未读数,见 docs/API.md 的「同名不同义」),这里也不读它。
|
||||
*/
|
||||
async sent(): Promise<SentResponse> {
|
||||
return this.client.get<SentResponse>('/me/mail/sent');
|
||||
}
|
||||
|
||||
/**
|
||||
* 待决策的权限请求。
|
||||
*
|
||||
* **不从收件箱里筛 `permission_*`**:收件箱默认只取 50 封,而一个会话能连着产生
|
||||
* 十几封权限邮件,从收件箱筛会把别的信挤出视野。与 WebUI 同源(`/permission/pending`)。
|
||||
*/
|
||||
async pendingPermissions(): Promise<PendingResponse> {
|
||||
return this.client.get<PendingResponse>('/permission/pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* 决策(同意/拒绝)。
|
||||
*
|
||||
* `note` 必须**真的送达模型** —— 这是 WebUI 侧踩过的坑(`gateway/handler/permission.go`
|
||||
* 的 Note 传递)。所以这里不做任何"为空就不传"的省略:空串也照传,
|
||||
* 由服务端决定怎么处理,避免"界面看起来能填备注、其实备注没发出去"。
|
||||
*/
|
||||
async decidePermission(mailId: string, decision: string, note: string): Promise<DecideResponse> {
|
||||
const payload: DecidePayload = new DecidePayload();
|
||||
payload.mail_id = mailId;
|
||||
payload.decision = decision;
|
||||
payload.note = note;
|
||||
return this.client.post<DecideResponse>('/permission/decide', payload);
|
||||
}
|
||||
|
||||
/** 会话列表 */
|
||||
async sessions(): Promise<SessionListResponse> {
|
||||
return this.client.get<SessionListResponse>('/me/sessions');
|
||||
|
||||
149
client/harmony/entry/src/main/ets/model/CommTabs.ts
Normal file
149
client/harmony/entry/src/main/ets/model/CommTabs.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ─────────── 通信页三栏用到的响应体 ───────────
|
||||
*/
|
||||
|
||||
/** `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 = '';
|
||||
}
|
||||
|
||||
@ -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<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: 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<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[] = [];
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user