feat(harmony): P2a —— 收件箱按会话折叠 + 联系人页卡片视图(判据直接跑同一份逻辑)
按 pi 的结论落地 P2a 的前一半:**先补视图与折叠,再删平级「会话」tab**(tab 本轮保留)。 ## 判据怎么"点用户真正会点的那一层" 鸿蒙侧没有设备(`hdc list targets` 为空、模拟器在本机沙箱下起不来),"点一下"暂时 无法自动验。应对不是编个能过的新判据,而是把会点的那一层的内核抽成纯逻辑: `entry/src/main/ets/model/MailGrouping.ts`(无 UI 依赖),判据用 node 的 `--experimental-strip-types` **执行同一份代码**(`test/harmony-logic.test.mjs`,14 条), 断言的是行为而不是"源码里出现过某个字符串": - 折叠后组头是不是**最新一封**、组内是否时间倒序、组间排序、同一时刻用 `mail_id` 倒序兜底; - 时间解析失败**不能让顺序依赖入参**(WebUI 侧踩过的 NaN 比较坑); - 多账号合并下同名 `session_id` 不能被错并成一组;`session_id` 缺失时各自成组; - 预算档位与 WebUI `BudgetChip` 完全一致(剩 0 用尽 / ≤1 将尽 / 上限 0 不显示); - 视图切换与卡片上"最新一封是人还是 Agent"的判据。 页面那一层另用源码判据钉"确实调了这些函数",两层合起来覆盖「逻辑对」+「页面接上了」。 **变异验证 4 处全部判红**:去掉组内排序(2 条红)、预算阈值 `<=1` 改 `<1`、 分组键去掉账号前缀、页面不再区分单封组。 ## 收件箱折叠 - 组头取组内最新一封的别名与主题,带未读数徽标与「N 封」,点它展开/收起; - **单封不成组、平铺**(与 WebUI `isFlatGroup` 同结论:给孤立一封信套组头只是多一次点击); - 多账号是鸿蒙特有:分组键带账号前缀;`session_id` 缺失按 `mail:<id>` 各自成组。 ## 顺带修掉一个"看起来是总数、其实是未读数"的显示 `/me/mail/inbox` 的 `total` 是 **`CountUnread`(未读总数)**,不是总封数 (`server/internal/handler/me.go`)。鸿蒙底部原写「共 N 封」⇒ 同一屏出现 「共 7 封」和「未读 7」两行自相矛盾的字。改成:未读数用服务端 `total`(权威, 原来数这一页会少报);「共 N 封」→「已加载 N 封」;**这一页取满时如实提示 「已加载 50 封(本页上限 50,可能还有更多)」** —— 客户端没有可信总封数, 就不能把 50 封说成全部(pi 提醒的"别让只取 50 封伪装成只有这么多会话")。 WebUI 侧不读这个字段,故只影响鸿蒙。 ## 联系人页补卡片视图(撤 tab 的前置) - 右上角切列表/卡片,标题「联系人」/「工作列表」(与 WebUI 同词),切换规则在 `nextContactView()`; - 卡片对应 WebUI 的 `WorkCard`:Agent 名 + 工作目录 + 未读徽标、会话别名、 **主题当主角**、最新摘要 + 人/Agent 标记、`N 封 · 时间`、权限档位徽标、 **往返预算条**(同一档位判据)。 - 平级「会话」tab 暂留:撤 tab 按 pi 的顺序排在后面单独一步(撤早了预算/status/from_agent 没处看)。 ## 验证 - `hvigorw assembleHap` **BUILD SUCCESSFUL**(`.ts` 纯逻辑模块被 `.ets` 引用,实测可行)。 - `npm test` **退出码 0**:窄屏布局全通过、主题 30、背景 34、cross-client 8、 harmony-logic 14、packaging 3、vitest 258/258;新判据已接进 `npm test`。 - **视觉与点击仍未验**(无设备):展开手感、卡片间距、组头命中区没有任何自动判据 能代替人眼 —— 交付按"结构/逻辑已验证、观感未验"写,未写成已完成。
This commit is contained in:
@ -104,6 +104,44 @@ export class Theme {
|
||||
return Theme.approveFg;
|
||||
}
|
||||
|
||||
/** 中性 chip(对应 WebUI 的 --c-gray-100 / --c-gray-500)—— 预算条"还宽裕"档 */
|
||||
static readonly chipNeutralBg: string = '#F3F4F6';
|
||||
static readonly chipNeutralFg: string = '#5A6270';
|
||||
/** 预算用尽(对应 WebUI 的 --c-red-100 / --c-red-700) */
|
||||
static readonly chipSpentBg: string = '#FEE2E2';
|
||||
static readonly chipSpentFg: string = '#B91C1C';
|
||||
/** 预算将尽(对应 WebUI 的 --c-orange-100 / --c-orange-700) */
|
||||
static readonly chipWarnBg: string = '#FFEDD5';
|
||||
static readonly chipWarnFg: string = '#C2410C';
|
||||
|
||||
/**
|
||||
* 往返预算档位 → chip 底色 / 字色。
|
||||
*
|
||||
* 与 WebUI 的 `BudgetChip` **同一映射**(`WorkCard.tsx`):
|
||||
* 剩 0 = 红(用尽)、剩 ≤1 = 橙(将尽,任务需要人介入)、其余 = 中性灰。
|
||||
* 档位本身由 `MailGrouping.ts` 的 `budgetState()` 算(那是可被判据执行的一层),
|
||||
* 这里只管"哪个档用什么颜色"。
|
||||
*/
|
||||
static budgetBg(state: string): string {
|
||||
if (state === 'spent') {
|
||||
return Theme.chipSpentBg;
|
||||
}
|
||||
if (state === 'warn') {
|
||||
return Theme.chipWarnBg;
|
||||
}
|
||||
return Theme.chipNeutralBg;
|
||||
}
|
||||
|
||||
static budgetFg(state: string): string {
|
||||
if (state === 'spent') {
|
||||
return Theme.chipSpentFg;
|
||||
}
|
||||
if (state === 'warn') {
|
||||
return Theme.chipWarnFg;
|
||||
}
|
||||
return Theme.chipNeutralFg;
|
||||
}
|
||||
|
||||
/** 圆角:卡片 14、控件 8(与 WebUI 的 --radius-card / --radius-control 一致) */
|
||||
static readonly radiusCard: number = 14;
|
||||
static readonly radiusControl: number = 8;
|
||||
|
||||
228
client/harmony/entry/src/main/ets/model/MailGrouping.ts
Normal file
228
client/harmony/entry/src/main/ets/model/MailGrouping.ts
Normal file
@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 收件箱的会话折叠 / 联系人页的卡片视图 / 往返预算 —— **纯逻辑,无 UI 依赖**。
|
||||
*
|
||||
* 为什么单独成文件、而不是直接写在页面里:
|
||||
*
|
||||
* 这几条规则是**判据的对象**。写在 `build()` 里的话,判据只能断言"源码里出现了
|
||||
* 某个字符串"(看起来绿,实际什么都没验);放在这里,判据可以跑**同一份代码**
|
||||
* —— `client/electron/test/harmony-logic.test.mjs` 用 node 的
|
||||
* `--experimental-strip-types` 直接执行本文件,断言的是**行为**:
|
||||
* 折叠后组头取的是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档。
|
||||
*
|
||||
* 这正是移交信里交代的纪律:「判据必须点用户真正会点的那一层」——
|
||||
* 页面里那一层要点设备才能验,这一层是它的**可执行内核**,
|
||||
* 页面再用源码判据钉住"确实调了这里"。
|
||||
*
|
||||
* 与 WebUI 的 `src/lib/mailGroups.ts`(折叠 / 单封不成组)、
|
||||
* `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、
|
||||
* `ContactPanel.tsx`(列表 / 卡片切换)一一对应。
|
||||
*
|
||||
* ⚠️ 本文件必须保持**类型可擦除**:不用 `enum`、`namespace`、构造器参数属性,
|
||||
* 否则 node 的 strip-types 跑不起来,判据就断了(用 `class` + 联合类型代替 enum)。
|
||||
*/
|
||||
|
||||
/** 折叠只用到这几个字段(与 `Models.ets` 的 `MailSummary` 对齐,由它 implements) */
|
||||
export interface MailLike {
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
from_name: string;
|
||||
subject: string;
|
||||
body_preview: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
permission_mode: string;
|
||||
source_account_id: string;
|
||||
source_account_name: string;
|
||||
}
|
||||
|
||||
/** 一个会话折叠成的一组(对应 WebUI 的 `MailGroup`) */
|
||||
export class SessionGroup {
|
||||
/** 分组键:账号 + session_id */
|
||||
key: string = '';
|
||||
session_id: string = '';
|
||||
/** 组头别名(取组内最新一封的会话别名,空串表示未命名) */
|
||||
alias: string = '';
|
||||
/** 组头主题(取最新一封) */
|
||||
subject: string = '';
|
||||
/** 组内最新一封 */
|
||||
latest: MailLike | undefined = undefined;
|
||||
/** 组内全部邮件,时间倒序 */
|
||||
mails: MailLike[] = [];
|
||||
unreadCount: number = 0;
|
||||
}
|
||||
|
||||
/** ISO 时间 → 毫秒;解析失败给 0 而不是 NaN(NaN 参与比较恒为 false,会让排序依赖入参顺序) */
|
||||
export function timeOf(iso: string): number {
|
||||
const t: number = Date.parse(iso);
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
/** 时间倒序;同一时刻用 mail_id 倒序兜底(与后端 `ORDER BY created_at DESC, mail_id DESC` 一致) */
|
||||
export function byNewest(a: MailLike, b: MailLike): number {
|
||||
const d: number = timeOf(b.created_at) - timeOf(a.created_at);
|
||||
if (d !== 0) {
|
||||
return d;
|
||||
}
|
||||
if (b.mail_id > a.mail_id) {
|
||||
return 1;
|
||||
}
|
||||
if (b.mail_id < a.mail_id) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组键。
|
||||
*
|
||||
* 鸿蒙的收件箱是**多账号合并**的(一个页面里混着多个 Gateway 的信),
|
||||
* 所以键要带账号前缀:同一个 `session_id` 出现在两个账号里是两件事。
|
||||
* WebUI 是单账号,只按 `session_id` 桶化(`bucketBySession`)。
|
||||
* `session_id` 缺失时用 `mail:<id>` 兜底单独成组 —— 一条脏数据不该让整栏空白。
|
||||
*/
|
||||
export function sessionKey(m: MailLike): string {
|
||||
const sid: string = m.session_id.length > 0 ? m.session_id : 'mail:' + m.mail_id;
|
||||
return m.source_account_id + '/' + sid;
|
||||
}
|
||||
|
||||
/** 按会话折叠;组头取组内**最新一封**,组间按最新一封时间倒序 */
|
||||
export function groupMailsBySession(mails: MailLike[]): SessionGroup[] {
|
||||
const groups: SessionGroup[] = [];
|
||||
const index: Map<string, number> = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < mails.length; i++) {
|
||||
const m: MailLike = mails[i];
|
||||
const key: string = sessionKey(m);
|
||||
let g: SessionGroup | undefined = undefined;
|
||||
const at: number | undefined = index.get(key);
|
||||
if (at !== undefined) {
|
||||
g = groups[at];
|
||||
}
|
||||
if (g === undefined) {
|
||||
g = new SessionGroup();
|
||||
g.key = key;
|
||||
g.session_id = m.session_id;
|
||||
index.set(key, groups.length);
|
||||
groups.push(g);
|
||||
}
|
||||
g.mails.push(m);
|
||||
}
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const g: SessionGroup = groups[i];
|
||||
g.mails.sort(byNewest);
|
||||
const latest: MailLike = g.mails[0];
|
||||
g.latest = latest;
|
||||
g.alias = latest.session_alias;
|
||||
g.subject = latest.subject;
|
||||
let unread: number = 0;
|
||||
for (let j = 0; j < g.mails.length; j++) {
|
||||
if (g.mails[j].status === 'unread') {
|
||||
unread++;
|
||||
}
|
||||
}
|
||||
g.unreadCount = unread;
|
||||
}
|
||||
|
||||
groups.sort((a: SessionGroup, b: SessionGroup): number => {
|
||||
const la: MailLike | undefined = a.latest;
|
||||
const lb: MailLike | undefined = b.latest;
|
||||
if (la === undefined || lb === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return byNewest(la, lb);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单封邮件的组不算「组」,平铺显示即可。
|
||||
*
|
||||
* 给一封孤立的邮件套上可折叠的组头 = 多一次点击才能读到内容,
|
||||
* 而收件箱里大多数人类来信就是孤立的一封(WebUI 侧同一结论,见 `isFlatGroup`)。
|
||||
*/
|
||||
export function isFlatGroup(g: SessionGroup): boolean {
|
||||
return g.mails.length === 1;
|
||||
}
|
||||
|
||||
/** 未读数:把服务端返回的 `total` 相加(它是 `CountUnread`,权威;不要数这一页) */
|
||||
export function sumUnreadTotals(totals: number[]): number {
|
||||
let n: number = 0;
|
||||
for (let i = 0; i < totals.length; i++) {
|
||||
const t: number = totals[i];
|
||||
if (t > 0) {
|
||||
n += t;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一页**可能不全**时的提示。
|
||||
*
|
||||
* `/me/mail/inbox` 有 `limit`(客户端取 50),而它返回的 `total` 是**未读数**
|
||||
* (服务端 `CountUnread`),**不是总封数** —— 所以客户端手上根本没有一个可信的
|
||||
* "一共有多少封"。那就既不能把 50 封说成全部,也不能拿未读数冒充总数
|
||||
* (鸿蒙界面原先写的「共 N 封」就是这么来的,显示的其实是未读数)。
|
||||
* 只有"取满了这一页"时才有话可说,此时如实说"可能还有更多"。
|
||||
*/
|
||||
export function partialLoadNotice(fetched: number, limit: number): string {
|
||||
if (limit <= 0 || fetched < limit) {
|
||||
return '';
|
||||
}
|
||||
return '已加载 ' + fetched + ' 封(本页上限 ' + limit + ',可能还有更多)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 往返预算的档位 —— 与 WebUI 的 `BudgetChip` 同一判据:
|
||||
* `max <= 0` = 不限(不显示徽标,一个"0/0"对每张卡片都成立,等于噪声);
|
||||
* 剩 0 个来回 = 用尽;剩 ≤1 = 将尽(快跑满的任务需要人介入)。
|
||||
* 返回值用字符串而不是 enum:本文件要保持类型可擦除(见文件头)。
|
||||
*/
|
||||
export function budgetState(max: number, used: number): string {
|
||||
if (max <= 0) {
|
||||
return 'none';
|
||||
}
|
||||
if (budgetRemaining(max, used) === 0) {
|
||||
return 'spent';
|
||||
}
|
||||
if (budgetRemaining(max, used) <= 1) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** 剩余往返次数(不小于 0) */
|
||||
export function budgetRemaining(max: number, used: number): number {
|
||||
const left: number = max - used;
|
||||
return left > 0 ? left : 0;
|
||||
}
|
||||
|
||||
/** 预算徽标文字:`剩余/上限`;不限时是空串(页面据此不渲染) */
|
||||
export function budgetLabel(max: number, used: number): string {
|
||||
if (max <= 0) {
|
||||
return '';
|
||||
}
|
||||
return budgetRemaining(max, used) + '/' + max;
|
||||
}
|
||||
|
||||
/** 联系人页的视图切换(WebUI:`setView(view === 'list' ? 'card' : 'list')`) */
|
||||
export function nextContactView(current: string): string {
|
||||
return current === 'list' ? 'card' : 'list';
|
||||
}
|
||||
|
||||
/** 视图标题:卡片视图叫「工作列表」,列表视图叫「联系人」(与 WebUI 同词) */
|
||||
export function contactViewTitle(view: string): string {
|
||||
return view === 'card' ? '工作列表' : '联系人';
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上那条最新摘要**是谁发的**:人还是 Agent。
|
||||
*
|
||||
* WebUI 的判据是 `c.last_from !== c.agent_name`(人发的用头像图标、Agent 发的用机器人图标)。
|
||||
* 这个信息决定人要不要接手,所以它得是逻辑而不是"看图标"。
|
||||
*/
|
||||
export function lastFromIsHuman(agentName: string, lastFrom: string): boolean {
|
||||
return lastFrom !== agentName;
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
* AgentMail 鸿蒙客户端 — 领域模型
|
||||
* 与 docs/API.md 字段一一对应(单一事实源)
|
||||
*/
|
||||
import { MailLike } from './MailGrouping';
|
||||
|
||||
/** 当前登录用户 */
|
||||
export class Me {
|
||||
@ -28,10 +29,18 @@ export class Session {
|
||||
unread_count: number = 0;
|
||||
}
|
||||
|
||||
/** 邮件摘要(收件箱/会话列表用) */
|
||||
export class MailSummary {
|
||||
/**
|
||||
* 邮件摘要(收件箱/会话列表用)。
|
||||
*
|
||||
* `implements MailLike`:折叠逻辑在 `MailGrouping.ts`(纯逻辑、可被判据直接执行),
|
||||
* 它只认这个接口。ArkTS 不做结构类型匹配,所以这里必须显式 implements
|
||||
* —— 少写一个字段编译期就会红,这是好事。
|
||||
*/
|
||||
export class MailSummary implements MailLike {
|
||||
mail_id: string = '';
|
||||
session_id: string = '';
|
||||
/** 会话别名(服务端 `omitempty`,未命名会话时是空串)—— 折叠后的组头用它 */
|
||||
session_alias: string = '';
|
||||
from_name: string = '';
|
||||
to_name: string = '';
|
||||
subject: string = '';
|
||||
|
||||
@ -8,15 +8,39 @@ import { MailApi, InboxResponse } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { SseService, SseEvent } from '../api/SseService';
|
||||
import { MailSummary, Session, Contact } from '../model/Models';
|
||||
import {
|
||||
MailLike,
|
||||
SessionGroup,
|
||||
groupMailsBySession,
|
||||
isFlatGroup,
|
||||
partialLoadNotice,
|
||||
sumUnreadTotals,
|
||||
budgetState,
|
||||
budgetLabel,
|
||||
nextContactView,
|
||||
contactViewTitle,
|
||||
lastFromIsHuman
|
||||
} from '../model/MailGrouping';
|
||||
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
|
||||
/** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */
|
||||
const INBOX_PAGE_SIZE: number = 50;
|
||||
|
||||
@Component
|
||||
struct InboxTab {
|
||||
@State mails: MailSummary[] = [];
|
||||
/** 按会话折叠后的列表(单封的组平铺渲染) */
|
||||
@State groups: SessionGroup[] = [];
|
||||
/** 已展开的组(键 = SessionGroup.key) */
|
||||
@State expandedKeys: string[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State total: number = 0;
|
||||
/** 这一页**取回来**的封数 */
|
||||
@State loaded: number = 0;
|
||||
/** 未读总数:服务端 total(CountUnread),权威 */
|
||||
@State unread: number = 0;
|
||||
/** 本页取满时的"可能还有更多"提示,空串表示不用提示 */
|
||||
@State notice: string = '';
|
||||
@State error: string = '';
|
||||
@State accountName: string = '';
|
||||
@State accountFilter: string = 'all'; // 'all' 或 accountId
|
||||
@ -93,7 +117,8 @@ struct InboxTab {
|
||||
}
|
||||
|
||||
const mergedMails: MailSummary[] = [];
|
||||
let mergedTotal: number = 0;
|
||||
const unreadTotals: number[] = [];
|
||||
let maxFetched: number = 0;
|
||||
|
||||
for (let i = 0; i < allAccounts.length; i++) {
|
||||
const acct: AccountInfo = allAccounts[i];
|
||||
@ -104,14 +129,17 @@ struct InboxTab {
|
||||
const accountClient: ApiClient = new ApiClient(ctx);
|
||||
accountClient.setBase(acct.server);
|
||||
accountClient.setToken(acct.token);
|
||||
const response: InboxResponse = await new MailApi(accountClient).inbox('all', 50);
|
||||
const response: InboxResponse = await new MailApi(accountClient).inbox('all', INBOX_PAGE_SIZE);
|
||||
for (let j = 0; j < response.mails.length; j++) {
|
||||
const mail: MailSummary = response.mails[j];
|
||||
mail.source_account_id = acct.id;
|
||||
mail.source_account_name = acct.displayName;
|
||||
mergedMails.push(mail);
|
||||
}
|
||||
mergedTotal += response.total;
|
||||
unreadTotals.push(response.total);
|
||||
if (response.mails.length > maxFetched) {
|
||||
maxFetched = response.mails.length;
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.accountFilter !== 'all') {
|
||||
throw e as ApiError;
|
||||
@ -119,25 +147,15 @@ struct InboxTab {
|
||||
}
|
||||
}
|
||||
|
||||
mergedMails.sort((a: MailSummary, b: MailSummary) => {
|
||||
if (a.created_at > b.created_at) {
|
||||
return -1;
|
||||
}
|
||||
if (a.created_at < b.created_at) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.mails = mergedMails;
|
||||
this.total = mergedTotal;
|
||||
|
||||
let unreadCount: number = 0;
|
||||
for (let i = 0; i < this.mails.length; i++) {
|
||||
if (this.mails[i].status === 'unread') {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
this.unread = unreadCount;
|
||||
// 按会话折叠:组头取组内最新一封(含别名),组间按最新一封倒序;
|
||||
// 单封的组不算组,平铺(见 MailGrouping.isFlatGroup)。
|
||||
this.groups = groupMailsBySession(mergedMails);
|
||||
this.loaded = mergedMails.length;
|
||||
// 未读数用服务端返回的 total —— 它是 CountUnread,权威;数这一页会少报。
|
||||
this.unread = sumUnreadTotals(unreadTotals);
|
||||
// 这一页取满了就如实说"可能还有更多":不能把 50 封说成全部(见 partialLoadNotice)。
|
||||
this.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
this.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
@ -146,6 +164,43 @@ struct InboxTab {
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开状态放在数组里(ArkTS 的 @State 对 Map/Set 的变更不总是能观察到) */
|
||||
isExpanded(key: string): boolean {
|
||||
for (let i = 0; i < this.expandedKeys.length; i++) {
|
||||
if (this.expandedKeys[i] === key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
openCompose(): void {
|
||||
let accountId: string = this.accountFilter === 'all' ? '' : this.accountFilter;
|
||||
if (accountId.length === 0) {
|
||||
@ -256,24 +311,45 @@ struct InboxTab {
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
/*
|
||||
* 本页取满了就当场说清楚"可能还有更多"。
|
||||
*
|
||||
* 原先这里写的是「共 N 封」,而那个 N 其实是服务端返回的**未读数**
|
||||
* (`CountUnread`)—— 界面上同时出现「共 7 封」和「未读 7」这种自相矛盾的
|
||||
* 两行字,且折叠之后更糟:拿 50 封折叠出的会话数会被当成"就只有这么多会话"。
|
||||
*/
|
||||
if (this.notice.length > 0) {
|
||||
Row() {
|
||||
Text(this.notice)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(Theme.warnFg)
|
||||
.maxLines(2)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
|
||||
.backgroundColor(Theme.warnBg)
|
||||
}
|
||||
|
||||
List({ space: 1 }) {
|
||||
ForEach(this.mails, (mail: MailSummary) => {
|
||||
ForEach(this.groups, (g: SessionGroup) => {
|
||||
ListItem() {
|
||||
this.MailItem(mail)
|
||||
if (isFlatGroup(g)) {
|
||||
// 单封不成组:套一个可折叠的组头只是多一次点击(与 WebUI isFlatGroup 同结论)
|
||||
this.MailRow(g.mails[0])
|
||||
} else {
|
||||
Column() {
|
||||
this.GroupHeader(g)
|
||||
if (this.isExpanded(g.key)) {
|
||||
ForEach(g.mails, (m: MailLike) => {
|
||||
this.MailRow(m)
|
||||
}, (m: MailLike) => m.source_account_id + ':' + m.mail_id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.height(80)
|
||||
.backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface)
|
||||
.onClick(() => {
|
||||
const params: MailDetailParams = {
|
||||
mail_id: mail.mail_id,
|
||||
account_id: mail.source_account_id
|
||||
};
|
||||
this.getUIContext().getRouter().pushUrl({
|
||||
url: 'pages/MailDetailPage',
|
||||
params: params
|
||||
});
|
||||
})
|
||||
}, (mail: MailSummary) => mail.source_account_id + ':' + mail.mail_id)
|
||||
.width('100%')
|
||||
}, (g: SessionGroup) => g.key)
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
|
||||
@ -282,7 +358,7 @@ struct InboxTab {
|
||||
// 底部信息栏 + 悬浮写邮件按钮
|
||||
Stack({ alignContent: Alignment.BottomEnd }) {
|
||||
Row() {
|
||||
Text('共 ' + this.total + ' 封').fontSize(12).fontColor(Theme.textSubtle)
|
||||
Text('已加载 ' + this.loaded + ' 封').fontSize(12).fontColor(Theme.textSubtle)
|
||||
Blank()
|
||||
Text('未读 ' + this.unread).fontSize(12).fontColor(Theme.textSubtle)
|
||||
}
|
||||
@ -328,8 +404,74 @@ struct InboxTab {
|
||||
.width('100%').height(180).padding(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表里的一封(列表项本体):底色按已读/未读,点它进详情。
|
||||
*
|
||||
* 参数类型是 `MailLike`(`MailGrouping.ts` 的接口)而不是 `MailSummary`:
|
||||
* 折叠后的组里装的是接口类型,ArkTS 不做结构类型匹配,写死具体类就传不进来。
|
||||
*/
|
||||
@Builder
|
||||
MailItem(mail: MailSummary) {
|
||||
MailRow(mail: MailLike) {
|
||||
Row() {
|
||||
this.MailItem(mail)
|
||||
}
|
||||
.width('100%').height(80)
|
||||
.backgroundColor(mail.status === 'unread' ? Theme.accentSoft : Theme.surface)
|
||||
.onClick(() => {
|
||||
this.openMail(mail);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话组头:一行说清"这是哪条线索、几封、几封没读",点它展开/收起。
|
||||
*
|
||||
* 组头取组内**最新一封**的别名与主题(与 WebUI `mailGroups.ts` 同口径):
|
||||
* 会话主题会随任务推进被改写,最新的那个最贴切。
|
||||
*/
|
||||
@Builder
|
||||
GroupHeader(g: SessionGroup) {
|
||||
Row() {
|
||||
Text(this.isExpanded(g.key) ? '▾' : '▸')
|
||||
.fontSize(12).fontColor(Theme.textMuted).width(18)
|
||||
Column() {
|
||||
Row() {
|
||||
Text(g.alias.length > 0 ? g.alias : '(未命名会话)')
|
||||
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
if (g.unreadCount > 0) {
|
||||
Text(g.unreadCount + '')
|
||||
.fontSize(10).fontColor(Theme.surface)
|
||||
.backgroundColor(Theme.danger)
|
||||
.borderRadius(9).width(18).height(18)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
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)
|
||||
.onClick(() => {
|
||||
this.toggleExpanded(g.key);
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
MailItem(mail: MailLike) {
|
||||
Row() {
|
||||
Column() {
|
||||
if (mail.status === 'unread') {
|
||||
@ -495,6 +637,16 @@ struct ContactsTab {
|
||||
@State contacts: Contact[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State error: string = '';
|
||||
/**
|
||||
* 视图:'list'(跟谁在聊)/ 'card'(在聊什么、进展如何)。
|
||||
*
|
||||
* 这不是装饰:WebUI 里「会话」从来不是一个入口,它是**两处已有视图** ——
|
||||
* 列表视图是 `ContactRow`,卡片视图是 `WorkCard`(标题「工作列表」)。
|
||||
* 鸿蒙侧原来把会话单列成一个 tab,等于把"卡片视图"放错了位置。
|
||||
* 顺序:先在这里补上卡片视图,再把平级「会话」tab 撤掉(撤早了会丢信息:
|
||||
* 轮次预算 / status / from_agent 就没地方看了)。
|
||||
*/
|
||||
@State contactView: string = 'list';
|
||||
private mailApi: MailApi | null = null;
|
||||
|
||||
aboutToAppear(): void {
|
||||
@ -522,12 +674,22 @@ struct ContactsTab {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换视图:切换规则本身在 MailGrouping.nextContactView(判据直接执行那一层) */
|
||||
switchView(): void {
|
||||
this.contactView = nextContactView(this.contactView);
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text('联系人').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Blank()
|
||||
Text(this.contactView === 'list' ? '▦' : '☰')
|
||||
.fontSize(18).fontColor(Theme.textMuted)
|
||||
.width(40).height(40).textAlign(TextAlign.Center)
|
||||
.onClick(() => { this.switchView(); })
|
||||
}
|
||||
.width('100%').height(56).padding({ left: 16 })
|
||||
.width('100%').height(56).padding({ left: 16, right: 8 })
|
||||
.backgroundColor(Theme.surface)
|
||||
|
||||
if (this.loading) {
|
||||
@ -546,6 +708,18 @@ struct ContactsTab {
|
||||
Text('📭 暂无联系人').fontSize(16).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.contactView === 'card') {
|
||||
// 卡片视图:竖向堆叠(320~400vp 放不下多列),每项一张卡
|
||||
List({ space: 8 }) {
|
||||
ForEach(this.contacts, (c: Contact, idx: number) => {
|
||||
ListItem() {
|
||||
this.WorkCard(c)
|
||||
}
|
||||
.width('100%')
|
||||
}, (_c: Contact, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
} else {
|
||||
List({ space: 1 }) {
|
||||
ForEach(this.contacts, (c: Contact, idx: number) => {
|
||||
@ -563,6 +737,91 @@ struct ContactsTab {
|
||||
.backgroundColor(Theme.pageBg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作卡片 —— 对应 WebUI 的 `WorkCard`(卡片视图)。
|
||||
*
|
||||
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:主题是主角,
|
||||
* 最新一封说了什么、谁说的,以及**往返预算还剩多少**(预算是任务的属性,
|
||||
* 快跑满的任务需要人介入 —— 这就是为什么卡片视图要先于删 tab 落地)。
|
||||
*/
|
||||
@Builder
|
||||
WorkCard(c: Contact) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(c.agent_name)
|
||||
.fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(c.path)
|
||||
.fontSize(11).fontColor(Theme.textSubtle)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 6 }).layoutWeight(1)
|
||||
if (c.unread_count > 0) {
|
||||
Text(c.unread_count + '')
|
||||
.fontSize(10).fontColor(Theme.surface)
|
||||
.backgroundColor(Theme.accent)
|
||||
.borderRadius(9).width(18).height(18)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
Text('›').fontSize(12).fontColor(Theme.accent)
|
||||
Text(c.session_alias.length > 0 ? c.session_alias : '(未命名会话)')
|
||||
.fontSize(11).fontColor(Theme.accent)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 4 }).layoutWeight(1)
|
||||
}
|
||||
.width('100%').margin({ top: 2 })
|
||||
|
||||
// 主题是这张卡片的主角:它回答「这条线索在干什么」
|
||||
Text(c.subject.length > 0 ? c.subject : '(无主题)')
|
||||
.fontSize(13).fontColor(Theme.textPrimary)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 6 })
|
||||
|
||||
if (c.last_preview.length > 0) {
|
||||
Row() {
|
||||
Text(lastFromIsHuman(c.agent_name, c.last_from) ? '👤' : '🤖')
|
||||
.fontSize(11).margin({ right: 4 })
|
||||
Text(c.last_preview)
|
||||
.fontSize(11).fontColor(Theme.textMuted)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Top)
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text(c.mail_count + ' 封 · ' + c.last_activity)
|
||||
.fontSize(11).fontColor(Theme.textSubtle)
|
||||
Blank()
|
||||
if (c.permission_mode.length > 0) {
|
||||
Text(c.permission_mode)
|
||||
.fontSize(10).fontColor(Theme.permFg(c.permission_mode))
|
||||
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
.margin({ right: 6 })
|
||||
}
|
||||
// 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据)
|
||||
if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) {
|
||||
Text(budgetLabel(c.max_rounds, c.used_rounds))
|
||||
.fontSize(10)
|
||||
.fontColor(Theme.budgetFg(budgetState(c.max_rounds, c.used_rounds)))
|
||||
.backgroundColor(Theme.budgetBg(budgetState(c.max_rounds, c.used_rounds)))
|
||||
.borderRadius(9)
|
||||
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||
}
|
||||
}
|
||||
.width('100%').margin({ top: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(Theme.surface)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContactItem(c: Contact, idx: number) {
|
||||
Row() {
|
||||
|
||||
Reference in New Issue
Block a user