跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 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:
@ -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