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('/sessions/' + sessionId + '/budget', payload); } - /** 归档联系人 */ - async archiveContact(name: string, path: string): Promise { - await this.client.del('/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 { + const payload: ArchiveContactRequest = new ArchiveContactRequest(); + payload.session_id = sessionId; + await this.client.post('/contacts/archive', payload); } /** 上传附件(multipart/form-data,字段名 file)→ attachment_id */ diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index 33655f3..586775e 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -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); diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 2322834..abb007a 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -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 { 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) { diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index c2e27e8..31b831d 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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 { + 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`): + * 「归档
?」/「对应 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)