跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 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:
173
client/electron/test/harmony-contacts.test.mjs
Normal file
173
client/electron/test/harmony-contacts.test.mjs
Normal 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)必须判红');
|
||||
});
|
||||
@ -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 都要在清单里")
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user