diff --git a/client/electron/test/harmony-contacts.test.mjs b/client/electron/test/harmony-contacts.test.mjs
new file mode 100644
index 0000000..b5db0e6
--- /dev/null
+++ b/client/electron/test/harmony-contacts.test.mjs
@@ -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 逐字一致:归档
?');
+ 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)必须判红');
+});
diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs
index 42736c8..f1705e2 100644
--- a/client/electron/test/run-all.mjs
+++ b/client/electron/test/run-all.mjs
@@ -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 都要在清单里")
diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets
index 7fcad08..bf9ba79 100644
--- a/client/harmony/entry/src/main/ets/api/MailApi.ets
+++ b/client/harmony/entry/src/main/ets/api/MailApi.ets
@@ -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