跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 bug

用户:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
把两个客户端**同一个宽度**并排看之后,缺的很具体:WebUI 联系人卡片有
「写信 / 归档」,鸿蒙一个都没有。补的过程撞出三个缺陷,都不是"代码不合法"——
编译器与既有判据全绿:

## ① 归档打的是**服务端不存在**的路由(死函数)

`MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}`:

- `grep 'me/contacts' server/cmd/server/main.go` **零命中** ⇒ 按钮接上去就是 404;
- 全仓**没有任何调用方** ⇒ 它是个从没跑过的死函数,所以"没有归档按钮"这件事
  一直没暴露这个错。

服务端真实形状是 `POST /api/v1/contacts/archive`(`handler.ArchiveContact`),
body 二选一 `{session_id}` / `{address}`。**跟 WebUI 同口径用 session_id**:
address 会随别名变化,只有 session_id 是会话的身份。

## ② 用已存 token 恢复登录**永远进不去主界面**(最恶劣的一个)

`LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
(界面出现「登录成功:jianf」)却**从无跳转**。本文件另外两条成功路径
(`aboutToAppear` 快速路径、`doLogin` 末尾)都有跳转,唯独这条没有,
而它**恰恰是老用户最常走的那条**(重启时 token 还在,`doLogin` 根本不会被调用)。

症状最坏的地方是它**看起来是成功的**。实测(模拟器 + jianf 的永久 key):
日志只有 `→ GET /auth/me` 然后什么都没有。补齐 ① 跳转 ② 账号登记
③ SSE 连接(后两条是 `doLogin` 有而这里缺的,少了它们进主界面是个瘸的状态,
而且因为 `aboutToAppear` 的快速路径靠账号命中,下次启动还会重走这里 ⇒ 永远进不去)。

修后实测:启动即进主界面(可见文本变成「发件箱/授权/收件箱 · 2 组 · 50 封」)。

## ③ 时间戳原样印出来

卡片直接印 `last_activity` ⇒ 屏上是 `2026-09-18T02:50:35.49065Z`。
WebUI 是 `09/18 10:50`(`toLocaleString('zh-CN', {month,day,hour,minute})`,**本地**时区)。
加 `MailGrouping.shortTimeOf`(走 `Date` 取本地字段;**不许 `.slice()`** ——
那是拿 UTC 的月/日当本地时刻,UTC+8 的 09-15 00:30 会显示成 09-14 16:30)。
实测:`112 封 · 09/18 10:50`,与 WebUI 逐字一致。

## 补的界面(三折叠模拟器 3184px 展开态实测)

- 两种视图**各一处**「写信 / 归档」(WebUI 两视图同语义),WebUI 用 `.reveal`
  悬停显形,**鸿蒙不能照抄**:`index.css` 那段注释已经踩过这个坑
  (触摸设备没 hover ⇒ 按钮透明却仍可点,一个看不见却按得动的破坏性按钮更糟),
  WebUI 的修法是只在真支持悬停的设备上隐藏 ⇒ 鸿蒙这两个按钮**常显**。
- 归档先确认,**两视图共用同一个确认框**(WebUI 原话:换个视图就换套确认 UI
  只会让人对「自己点了什么」更没底);文案逐字一致。
- 列表视图的 meta 行原来放 `last_preview`,于是同一联系人在两视图里的关键信息
  不一致 ⇒ 改成与卡片视图同源(`N 封 · 时间`)。
- ★ 一处只有跑起来才会发现的坑:列表视图的 ListItem 钉死 `.height(85)` +
  `clip(true)`,而确认框比 85 高 ⇒ **按钮被裁掉、点不了也退不出**。
  确认态下高度交给内容自己定。

## 判据(新增 harmony-contacts,5 条;4 个变异方向都跑过)

判的都是"按下去会发生什么",不是"按钮在不在":
① 归档打的是服务端真有的路由(同时钉住**没有**再用那条不存在的 DELETE —— 只钉前者的话,
   加个平行实现也能全绿);② 两视图各一处动作且去向正确;③ 确认框只有 1 个定义、
   两视图都调它、文案逐字一致、确认态下点卡片不开会话;④ 时间戳走了格式化且
   函数本身不走 slice;⑤ **切出 `tryRestore` 的函数体**判它自己含跳转
   (只判全文件出现次数的话,另外两条路径里那两句就够让它变绿)。
This commit is contained in:
2026-09-18 12:17:41 +08:00
parent 0e5eec61bd
commit 4ff6b10260
6 changed files with 483 additions and 12 deletions

View File

@ -0,0 +1,173 @@
/**
* 联系人页(工作列表 / 联系人)的判据 —— 与 WebUI `ContactPanel` 对齐。
*
* ── 这一整个文件的来历 ──
*
* 用户 2026-09-18:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
* 把两个客户端**同一个宽度**并排看之后,缺的东西很具体:联系人卡片上 WebUI 有
* 「写信 / 归档」,鸿蒙一个都没有。
*
* 补的时候撞出三个**只有跑起来才会现形**的问题,所以这个文件判的正是那三样
* (不是"按钮在不在",而是"按下去会发生什么"):
*
* ① `MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}` ——
* **服务端根本没有这条路由**,而且全仓没有调用方 ⇒ 它是个从没跑过的死函数。
* 按钮接上去的那一刻就是 404。服务端真实形状是 `POST /contacts/archive`
* (body `{session_id}` 或 `{address}`)。
*
* ② `LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
* (界面出现「登录成功:jianf」)却**从不跳转**。而这条恰恰是老用户最常走的
* 那条(重启时 token 还在,`doLogin` 根本不会被调用)。
*
* ③ 卡片把 `last_activity` **原样印出来** ⇒ 屏上是
* `2026-09-18T02:50:35.49065Z`,而 WebUI 是 `09/18 10:50`。
*
* ⚠️ 这个文件读的是**文本**。它不能替代设备验证:按钮真的画出来、点下去真的
* 发出那个请求、确认框真的换掉那一张卡片 —— 那三条我是在三折叠模拟器上
* 用 `dumpLayout` + `uitest uiInput click` 实测过的(见提交信息)。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
/** 去注释:判据要判**代码**,不是判我们自己在注释里写的那些话 */
function code(p) {
return readFileSync(p, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
const MAIN = join(ETS, 'pages', 'MainPage.ets');
const API = join(ETS, 'api', 'MailApi.ets');
const LOGIN = join(ETS, 'pages', 'LoginPage.ets');
const GROUP = join(ETS, 'model', 'MailGrouping.ts');
test('★ 归档打的是服务端**真有的**那条路由(POST /contacts/archive,不是不存在的 DELETE /me/contacts/…)', () => {
/*
* 这条判的是 ① 。原来那个实现不只是"过时"——它指向的路径
* 在 `server/cmd/server/main.go` 里**一条都没有**,所以按下去必然 404,
* 而按钮一旦出现在界面上,用户看到的就是"点了没反应/报错"。
*
* 判据形状:既钉住**用了** `POST /contacts/archive`,也钉住
* **没有**再用那条不存在的 DELETE 路径。只钉前者的话,加回一个
* 平行实现(两个都发)也能全绿 —— 那不是"修好了"。
*/
const src = code(API);
assert.match(src, /this\.client\.post<[^>]*>\(\s*'\/contacts\/archive'/,
'archiveContact 必须 POST /contacts/archive(服务端 handler.ArchiveContact 的真实路由)');
assert.doesNotMatch(src, /\/me\/contacts\//,
'不要再打 /me/contacts/… —— 服务端没有这条路由(这次修的就是它)');
/*
* body 必须带 session_id:服务端 archiveRequest 接受 session_id 或 address,
* 但 address 会随别名变化,只有 session_id 是会话的身份。
* WebUI 也用 session_id(contactStore.archive → api.archiveContact({session_id}))。
*/
assert.match(src, /session_id/, '归档请求体要带 session_id(address 会随别名变化,不是身份)');
});
test('★ 「写信 / 归档」两个动作在**两种视图**里都有(WebUI 两视图同语义)', () => {
/*
* WebUI `ContactPanel` 的卡片视图(`WorkCard`)与列表视图(`ContactRow`)
* 都带这两个动作,且**共用同一个确认框**(原话:归档是破坏性操作,
* 换个视图就换套确认 UI 只会让人对「自己点了点什么」更没底)。
* 鸿蒙两视图都要有 —— 只加一边的话,切到另一边就像"这个功能没了"。
*/
const src = code(MAIN);
const writes = [...src.matchAll(/this\.CardAction\('写信'[\s\S]{0,80}?composeTo\(c\)/g)].length;
const archives = [...src.matchAll(/this\.CardAction\('归档'[\s\S]{0,80}?requestArchive\(c\)/g)].length;
assert.equal(writes, 2, `「写信」要在卡片视图与列表视图各一处,实际 ${writes} 处`);
assert.equal(archives, 2, `「归档」要在卡片视图与列表视图各一处,实际 ${archives} 处`);
// 两个动作各自的去向也要对:写信 → composeTo(预填该地址),归档 → requestArchive(先确认)
assert.match(src, /composeTo\(c: Contact\)[\s\S]{0,400}?to: c\.address/,
'composeTo 要把该联系人的三维地址预填进 ComposeParams(WebUI: startCompose({to: c.address}))');
assert.match(src, /requestArchive\(c: Contact\)[\s\S]{0,200}?pendingArchiveId = c\.session_id/,
'requestArchive 只打开确认框、不发请求 —— 破坏性操作先确认');
});
test('★ 归档要**先确认**,且两种视图共用同一个确认框(不是各画一个)', () => {
/*
* 「共用」这条必须是机器可判的:如果两个视图各画一个确认框,
* 文案就会各自漂移,而漂移之后没人会同时看两边。
* 判据:确认框的 builder 只有一个定义,两个视图都调它。
*/
const src = code(MAIN);
const defs = [...src.matchAll(/@Builder\s+ArchiveConfirmCard\(/g)].length;
assert.equal(defs, 1, `ArchiveConfirmCard 只应有 1 个定义,实际 ${defs} 个`);
const calls = [...src.matchAll(/this\.ArchiveConfirmCard\(c\)/g)].length;
assert.equal(calls, 2, `两个视图都要调同一个确认框,实际 ${calls} 处调用`);
// 文案与 WebUI `ArchiveConfirm` 逐字一致(用户是在两个客户端之间来回看的)
assert.match(src, /'归档 ' \+ c\.address \+ '?'/, '确认框主句要与 WebUI 逐字一致:归档 <address>?');
assert.match(src, /'对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除'/,
'确认框副句要与 WebUI 逐字一致');
// 确认态下点卡片不许打开会话(那块区域已经是确认框了)
assert.match(src, /if \(this\.pendingArchiveId !== c\.session_id\) \{[\s\S]{0,80}?this\.openSession\(c\);/,
'确认态下卡片的点击不能再打开会话');
});
test('★ 时间戳要按 WebUI 的口径格式化(不是把 ISO 串原样印出来)', () => {
/*
* 这条判的是 ③ 。原样印 `last_activity` 得到的是
* `2026-09-18T02:50:35.49065Z` —— 秒级微秒的 UTC 串。
* WebUI 用 `toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})`
* ⇒ `09/18 10:50`(**本地**时区)。
*
* 两件事都要判:卡片/列表**用了**格式化函数,且那个函数走 `Date` 取本地字段
* —— 不许 `.slice()` 切字符串(那是拿 UTC 的月/日当本地时刻用,
* UTC+8 的 09-15 00:30 会显示成 09-14 16:30,而格子/表头全都正常)。
*/
const main = code(MAIN);
assert.doesNotMatch(main, /\+ c\.last_activity\b/,
'last_activity 不许原样拼接显示(会印出 ISO 串),要走 shortTimeOf');
assert.match(main, /shortTimeOf\(c\.last_activity\)/, '要用 shortTimeOf 格式化');
const g = code(GROUP);
assert.match(g, /export function shortTimeOf\(/, 'MailGrouping 要导出 shortTimeOf');
const fn = g.slice(g.indexOf('export function shortTimeOf('));
assert.match(fn, /new Date\(ms\)/, '必须走 Date 解析');
assert.match(fn, /getMonth\(\)/, '月份要取**本地**字段(getMonth),不是 UTC');
assert.match(fn, /getDate\(\)/, '日期要取本地字段(getDate)');
assert.doesNotMatch(fn.slice(0, 900), /slice\(/, '不许用 slice 切 ISO 字符串当日期');
// 自检:探测器要真能分辨"格式化过"与"原样印"
assert.match('shortTimeOf(c.last_activity)', /shortTimeOf\(c\.last_activity\)/);
assert.doesNotMatch("' 封 · ' + c.last_activity", /shortTimeOf\(c\.last_activity\)/);
});
test('★ 用已存 token 恢复登录也必须**真的进主界面**(不能只显示「登录成功」)', () => {
/*
* 这条判的是 ② ,也是这一轮里最恶劣的一个 —— 因为它**看起来是成功的**:
* 界面上出现「登录成功:jianf」,然后永远停在登录页。
* 实测证据:模拟器日志只有 `→ GET /auth/me` 然后什么都没有。
*
* 判据形状(不是"文件里有 pushUrl"这种能靠别处蒙混过去的写法):
* 把 `tryRestore` 的**函数体**切出来,要求它自己含跳转主界面那句。
* 只判全文件出现次数的话,另外两条路径里那两句就够让它变绿。
*/
const src = code(LOGIN);
const start = src.indexOf('async tryRestore(');
assert.ok(start > 0, 'LoginPage 要有 tryRestore');
// 从函数头到下一个同缩进的成员定义为止
const rest = src.slice(start);
const end = rest.indexOf('\n }\n');
const body = rest.slice(0, end > 0 ? end : 2000);
assert.match(body, /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
'tryRestore 成功后必须跳转主界面 —— 它原来只设 loggedIn=true 就结束了');
/*
* 还要登记账号 + 建 SSE:少了它们,"恢复登录"进主界面是个瘸的状态
* (设置页看不到账号;而 aboutToAppear 的快速路径正是靠账号命中的,
* 所以下次启动又会重走 tryRestore ⇒ 永远进不去)。
*/
assert.match(body, /addAccount\(/, 'tryRestore 要把账号写进多账号管理器(否则下次启动还会重走这里)');
assert.match(body, /connectForAccount\(/, 'tryRestore 要建 SSE(否则主界面收不到实时事件)');
// 自检:这条判据必须能抓到"只设 loggedIn 不跳转"那个原形状
const broken = "async tryRestore(t) {\n this.loggedIn = true;\n }\n";
const bStart = broken.indexOf('async tryRestore(');
const bRest = broken.slice(bStart);
const bEnd = bRest.indexOf('\n }\n');
assert.doesNotMatch(bRest.slice(0, bEnd > 0 ? bEnd : 2000), /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
'自检:原形状(只有 loggedIn=true)必须判红');
});

View File

@ -116,6 +116,7 @@ const SUITE = [
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
['test/harmony-arkts.test.mjs', [], 5],
['test/harmony-contacts.test.mjs', [], 5],
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
//
// 它们**早就存在**,却从没进过 SUITE ⇒ 自检 2("每个 *.test.mjs 都要在清单里")

View File

@ -72,6 +72,17 @@ export class BudgetPayload {
max_rounds: number = 0;
}
/**
* 归档请求体(`POST /contacts/archive`)。
*
* 服务端 `archiveRequest` 有 `address` 与 `session_id` 两个字段,二选一。
* 这里只发 session_id —— 见 `archiveContact` 的注释:address 会随别名变化,
* session_id 才是会话的身份。
*/
export class ArchiveContactRequest {
session_id: string = '';
}
/** 决策请求体(`POST /permission/decide`) */
export class DecidePayload {
mail_id: string = '';
@ -202,9 +213,25 @@ export class MailApi {
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
}
/** 归档联系人 */
async archiveContact(name: string, path: string): Promise<void> {
await this.client.del<Object>('/me/contacts/' + name + '/' + path);
/**
* 归档一条会话(Agent 侧会话归档 + 邮箱界面移除)。
*
* ★★ 2026-09-18 修:原来的实现是 `DELETE /me/contacts/{name}/{path}` ——
* **服务端根本没有这条路由**(`grep 'me/contacts' server/cmd/server/main.go` 零命中),
* 而且全仓没有任何调用方 ⇒ 它是个**从没跑过的死函数**,
* 所以"联系人页没有归档按钮"这件事一直没有暴露:按钮来了它就会 404。
*
* 服务端的真实形状是 `POST /api/v1/contacts/archive`(`handler.ArchiveContact`),
* body 二选一:`{session_id}` 或 `{address}`(`archiveRequest` 两个字段)。
* WebUI 走的是 **session_id**(`contactStore.archive` → `api.archiveContact({session_id})`),
* session_id 才是会话的**身份**;address 只是它当前的寻址串
* (起别名/改会话名都会变),拿它当主键迟早对不上。
* ⇒ 这里跟 WebUI 同口径,用 session_id。
*/
async archiveContact(sessionId: string): Promise<void> {
const payload: ArchiveContactRequest = new ArchiveContactRequest();
payload.session_id = sessionId;
await this.client.post<Object>('/contacts/archive', payload);
}
/** 上传附件(multipart/form-data,字段名 file)→ attachment_id */

View File

@ -67,6 +67,36 @@ export function timeOf(iso: string): number {
return Number.isNaN(t) ? 0 : t;
}
/**
* ISO 时间戳 → `MM/DD HH:MM`(**设备本地时区**)。
*
* ★ 加它的原因:联系人卡片与列表原来直接把 `last_activity` 原样印出来,
* 于是屏上是 `2026-09-18T02:50:35.49065Z` —— 一个秒级微秒的 UTC 串,
* 既读不出"多久以前",也和 WebUI 的 `09/18 10:50` 不是一回事
* (WebUI 用 `toLocaleString('zh-CN', {month,day,hour,minute})`)。
*
* ★ 必须走 `Date` 解析再取**本地**字段,不许 `.slice()` 切字符串:
* 那是拿 UTC 的月/日当时刻用,UTC+8 的 09-15 00:30 会被显示成 09-14 16:30。
* 这与 `Calendar.localIsoOf` 那条禁令同源(那里错的是格子,这里错的是"什么时候")。
*
* 解析失败返回空串 —— 调用侧对空串当"没有时间"处理,不显示半个日期。
* 月/日补零(`09/18` 而不是 `9/18`),与 WebUI 的 `2-digit` 一致。
*/
export function shortTimeOf(iso: string): string {
const ms: number = Date.parse(iso);
if (Number.isNaN(ms)) {
return '';
}
const d: Date = new Date(ms);
return pad2(d.getMonth() + 1) + '/' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
/** 两位补零(`shortTimeOf` 与月/日/时/分显示共用) */
function pad2(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
/** 时间倒序;同一时刻用 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);

View File

@ -72,6 +72,27 @@ struct LoginPage {
}
}
/**
* 用**已存下来的 token** 恢复登录(启动时 `init()` 读到 token 就走这里)。
*
* ★★ 2026-09-18 修:原来这个方法**验完 token 就结束了** —— 它设了 `loggedIn = true`
* (于是界面上出现「登录成功:jianf」)却**从不跳转主界面**。
* 症状最恶劣的地方在于它**看起来是成功的**:用户看到"登录成功"四个字,
* 然后永远停在登录页,再点登录又会重新走一遍。实测(模拟器、jianf 的永久 key):
* 日志只有 `→ GET /auth/me` 然后什么都没有。
*
* 本文件里另外两条成功路径都有跳转(`aboutToAppear` 的快速路径、
* `doLogin` 末尾),唯独这条没有 —— 而这条**恰恰是老用户最常走的那条**
* (重启 App 时 token 还在,`doLogin` 根本不会被调用)。
*
* 顺带补齐 `doLogin` 有而这里缺的三件事,否则"恢复登录"进主界面之后
* 是个瘸的状态:
* ① 写进多账号管理器(少了它,设置页/账号切换看不到这个账号,
* 而 `aboutToAppear` 的快速路径正是靠它命中的 ⇒ 下次启动又会重走这里);
* ② 为账号建 SSE(少了它,主界面收不到实时事件);
* ③ 跳转主界面。
* 推送 token 的补报本来就有(下一轮起 `reportPushToken` 会命中已报过的缓存)。
*/
async tryRestore(token: string): Promise<void> {
this.loading = true;
try {
@ -82,7 +103,19 @@ struct LoginPage {
const user = await new AuthApi(c).loginWithKey(token);
this.me = user;
this.loggedIn = true;
// ① 登记账号 + ② 建 SSE(与 doLogin 同一套)
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const server: string = c.getBase();
const acct = await acctMgr.addAccount(server, user.username, token,
user.display_name.length > 0 ? user.display_name : user.username);
SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token);
}
this.reportPushToken(c);
// ③ ★ 跳转 —— 原来缺的就是这一句
await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' });
} catch (e) {
const c2: ApiClient | null = this.client;
if (c2 !== null) {

View File

@ -37,7 +37,8 @@ import {
permissionLabel,
permissionChipText,
permissionHint,
enforcementLabel
enforcementLabel,
shortTimeOf
} from '../model/MailGrouping';
import {
COMM_TABS,
@ -1428,6 +1429,20 @@ struct ContactsTab {
* 轮次预算 / status / from_agent 就没地方看了)。
*/
@State contactView: string = 'list';
/**
* 待归档确认的会话 id(空 = 没有确认框)。
*
* ★ 归档是**破坏性**操作(Agent 侧会话归档 + 邮箱界面同时移除),
* 所以先确认再发请求 —— 与 WebUI `contactStore.pendingArchive` 同构。
* 用 `session_id` 当这个"待确认"的键,而不是 `address`:
* address 会随别名变化,拿它当身份迟早对不上(见 `MailApi.archiveContact`)。
*
* ★ 两种视图**共用同一个确认框**(WebUI 的原话:换个视图就换套确认 UI
* 只会让人对「自己点了什么」更没底)。所以这块 UI 只写一次。
*/
@State pendingArchiveId: string = '';
/** 归档请求在飞:防连点(一次点击就可能删掉一条会话,重复提交没有意义) */
@State archiving: boolean = false;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
@ -1460,6 +1475,66 @@ struct ContactsTab {
this.contactView = nextContactView(this.contactView);
}
/**
* 写信给**指定地址**(联系人卡片上的「写信」)。
*
* `openCompose()` 是"写一封全新的",`to` 为空;这个版本把该联系人的三维地址
* 预填进去 —— 与 WebUI `startCompose({ to: c.address })` 同构。
* 复用同一条 `ComposePage` 路由与同一套 `ComposeParams`,不另开页面。
*/
composeTo(c: Contact): void {
const ctx = this.getUIContext().getHostContext();
let accountId: string = '';
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
const params: ComposeParams = {
to: c.address,
reply_to: '',
session_alias: '',
account_id: accountId
};
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
}
/** 请求归档:只打开确认框,不发请求(破坏性操作先确认) */
requestArchive(c: Contact): void {
this.pendingArchiveId = c.session_id;
}
cancelArchive(): void {
this.pendingArchiveId = '';
}
/**
* 确认归档:发 `POST /contacts/archive`,成功后**本地即时移除**不等 SSE
* (与 WebUI 同做法:等 SSE 会让按钮看起来没反应)。
*
* 失败时把服务端的话原样显示 —— 归档半途失败最需要的是"到底成了没有",
* 而不是一句笼统的"操作失败"。
*/
async confirmArchive(c: Contact): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null || this.archiving) {
return;
}
this.archiving = true;
try {
await m.archiveContact(c.session_id);
this.contacts = this.contacts.filter((x: Contact) => x.session_id !== c.session_id);
if (this.openSessionId === c.session_id) {
this.openSessionId = '';
}
this.pendingArchiveId = '';
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '归档失败';
this.pendingArchiveId = '';
} finally {
this.archiving = false;
}
}
/**
* 打开一条会话的邮件列表(点联系人卡片就走这里)。
*
@ -1562,11 +1637,25 @@ struct ContactsTab {
List({ space: 8 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
this.WorkCard(c)
/*
* 待归档的那一条**整块换成确认框**(与 WebUI 同做法):
* 归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底 —— 所以卡片视图与列表视图共用这一个分支。
*/
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.WorkCard(c)
}
}
.width('100%')
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.onClick(() => { this.openSession(c); })
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
@ -1578,14 +1667,29 @@ struct ContactsTab {
List({ space: 6 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
this.ContactItem(c, idx)
/* 与卡片视图共用同一个确认框分支(见上) */
if (this.pendingArchiveId === c.session_id) {
this.ArchiveConfirmCard(c)
} else {
this.ContactItem(c, idx)
}
}
.height(85)
/*
* ★ 确认态下不能再钉死 85 高 + `clip(true)`:
* 确认框(两行文案 + 两个按钮)比 85 高,会被裁掉而看不见按钮 ——
* 那正是"破坏性操作点不了也退不出"。高度交给内容自己定。
*/
.height(this.pendingArchiveId === c.session_id ? undefined : 85)
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
.clip(true)
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
.onClick(() => { this.openSession(c); })
.onClick(() => {
// 确认态下卡片的点击不打开会话:那块区域已经是"确认框"了
if (this.pendingArchiveId !== c.session_id) {
this.openSession(c);
}
})
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
@ -1743,7 +1847,7 @@ struct ContactsTab {
}
Row() {
Text(c.mail_count + ' 封 · ' + c.last_activity)
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtle)
Blank()
/*
@ -1778,6 +1882,13 @@ struct ContactsTab {
}
}
.width('100%').margin({ top: 8 })
/* 次要动作:写信 / 归档(常显,理由见 CardAction 的注释) */
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.padding(12)
@ -1786,6 +1897,88 @@ struct ContactsTab {
.border({ width: 1, color: Theme.border })
}
/**
* 卡片上的次要动作按钮(写信 / 归档)—— 两视图共用。
*
* ★ WebUI 用 `.reveal`(默认隐藏、悬停显形)承载这两个动作。**鸿蒙不能照抄**:
* `client/electron/src/index.css` 那段的注释里已经踩过这个坑 ——
* 「触摸设备没有 hover,于是这些按钮永远是透明的,却仍然接收点击……一个看不见
* 却按得动的破坏性按钮比没有按钮更糟」。WebUI 的修法是**只在真的支持悬停的设备上
* 才隐藏**(`@media (hover: hover) and (pointer: fine)`)。
* 鸿蒙的输入就是手指 ⇒ 这两个按钮**必须常显**,没有"显形"这一步。
*
* `tone='danger'` 只用于归档:它改的是服务端状态,红色让人在按之前先看一眼。
*/
@Builder
CardAction(label: string, iconName: string, tone: string, onTap: () => void) {
Row() {
AmIcon({
iconName: iconName,
iconSize: 12,
iconColor: tone === 'danger' ? Theme.danger : Theme.textMuted
})
Text(label)
.fontSize(11)
.fontColor(tone === 'danger' ? Theme.danger : Theme.textMuted)
.margin({ left: 4 })
}
.height(26)
.padding({ left: 10, right: 10 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.onClick(onTap)
}
/**
* 归档确认框 —— 列表视图与卡片视图**共用这一个**。
*
* WebUI 的原话:归档是破坏性操作,换个视图就换套确认 UI 只会让人对
* 「自己点了什么」更没底。文案两边逐字一致(`ArchiveConfirm`):
* 「归档 <address>?」/「对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除」
*/
@Builder
ArchiveConfirmCard(c: Contact) {
Column() {
Text('归档 ' + c.address + '?')
.fontSize(13).fontColor(Theme.textPrimary)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text('对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除')
.fontSize(11).fontColor(Theme.textSubtle)
.margin({ top: 3 })
.width('100%')
Row() {
Row() {
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.surface })
Text('确认归档').fontSize(12).fontColor(Theme.surface).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.danger)
.opacity(this.archiving ? 0.5 : 1)
.onClick(() => { this.confirmArchive(c); })
Row() {
AmIcon({ iconName: 'close', iconSize: 12, iconColor: Theme.textMuted })
Text('取消').fontSize(12).fontColor(Theme.textMuted).margin({ left: 4 })
}
.height(30).padding({ left: 12, right: 12 })
.borderRadius(Theme.radiusControl)
.border({ width: 1, color: Theme.border })
.backgroundColor(Theme.surface)
.margin({ left: 8 })
.onClick(() => { this.cancelArchive(); })
}
.width('100%').margin({ top: 10 })
}
.width('100%')
.padding(12)
.borderRadius(Theme.radiusControl)
.backgroundColor(Theme.dangerBg)
.border({ width: 1, color: Theme.danger })
}
@Builder
ContactItem(c: Contact, idx: number) {
Row() {
@ -1815,12 +2008,26 @@ struct ContactsTab {
.backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
Text(c.last_preview.length > 0 ? c.last_preview : '—')
/*
* 列表视图也要"写了多少 / 什么时候"——WebUI `ContactRow` 的
* `{mail_count} 封 · {time}` 与卡片视图同源。原来这里放的是 last_preview,
* 于是同一个联系人在两种视图里给出的关键信息不一致。
*/
Text(c.mail_count + ' 封 · ' + shortTimeOf(c.last_activity))
.fontSize(11).fontColor(Theme.textSubtle)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%').margin({ top: 3 })
/*
* 动作行与卡片视图**同语义**(写信 / 归档),
* 只是列表视图行更矮,所以动作也画得紧凑些。
*/
Row({ space: 8 }) {
this.CardAction('写信', 'compose', 'normal', () => { this.composeTo(c); })
this.CardAction('归档', 'archive', 'danger', () => { this.requestArchive(c); })
}
.width('100%').margin({ top: 6 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)