From e79a86ab791335bd167106c44db8c51a40408379 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 17:23:23 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E6=9D=A1=E6=8E=A5=E5=88=B0=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=EF=BC=9A=E7=82=B9=E3=80=8C=E6=8E=A5=E5=8F=97=E3=80=8D=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E7=9C=9F=E7=9A=84=E6=94=B9=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一批做完了接口层,这批接 UI —— 详情页现在真的显示建议条并能操作。 ## 做了什么 提示条放在正文之前(与 WebUI 同位置):Agent 给的理由 + **后果说明** (「改名后需用 name@path.xxx 寻址;旧别名立即失效……」)+ 「接受」/「忽略」。 蓝底(`accentSoft`)而不是警告色:它不是故障,是一个**待决定**; 深色下走 `accentSoftFor`(就是前一批修的那个主题感知浅底)。 `doAcceptRename` 走 `PUT /sessions/{id}/alias`(与"人手改别名"同一端点), `doDismissRename` 走专用 dismiss 端点 —— 服务端记下被驳回的别名, 否则每次打开会话都要重新点一次「忽略」。 ## 途中三处**我自己的**错误(都值得记) 1. **写错了请求体字段名**:我按记忆写成 `session_alias`,而服务端 `updateAliasRequest` 的 tag 是 **`alias`**(`sessions.go:95-97`), WebUI 也是 `{ alias }`(`client.ts:537`)。而它的 `Decode()` 是 `DisallowUnknownFields()` ⇒ 传错名字**直接 400**(不是"被忽略")。 这是今天第三次同源错误(`AppearancePayload` / `ForwardMailRequest` / 这次)—— **请求体字段名必须去服务端 struct tag 里读,不能凭记忆写**。 2. **判据跟着实现一起错**:我把判据也写成"断言 `session_alias: alias`" —— 等于把这处错误**锁进两处**。判据已改成断言 `this.alias = alias`, 并加了一条反向断言"不许出现 `session_alias: alias`"。 ★ 这是"判据引自己的实现当依据"的又一次实证。 3. **UI 写完忘了接数据**:提示条的 UI 做完、编译过,但**没有调用 `getRenameProposal`** ⇒ 页面上永远不显示(服务端明明有建议)。 —— 正是本仓反复出现的"写好了但没人调用":静态判据查得出"代码里有这个方法", 查不出"页面从没调过它"。 ⇒ 补上 `loadRenameProposal()`,并**单独一个 try**(建议条是附加信息, 它失败不该把正文也变成错误态)。 ## 端到端验证(真数据) 造一封带标记的真邮件投进一个真会话 → 服务端识别(`rename_proposed: "rename-verify"`) → 详情页出现建议条(dump 读到理由 + 后果说明 + 两个按钮) → 点「接受」→ 库里别名真的变了: 处理四桥冒烟测试邮件 → rename-verify ## 判据 `run-all.mjs` → `checks=510 pass=510 fail=0 skip=0 red=0 broken=0 unreported=0`。 `hvigorw assembleHap` 成功;前端重建 + 重打包。 **未验**:「忽略」按钮的端到端(服务端记下驳回、不再重复弹)—— 接口层已有判据钉住路径,但没在设备上真点过。 --- client/electron/test/harmony-logic.test.mjs | 14 +- .../entry/src/main/ets/api/SessionApi.ets | 37 +++- .../src/main/ets/pages/MailDetailPage.ets | 181 ++++++++++++++++++ 3 files changed, 228 insertions(+), 4 deletions(-) diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 2a07cde..3837836 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -711,8 +711,18 @@ test('★ 改名建议:字段名与两端契约一致,接受/驳回走对端 assert.match(api, /\/sessions\/' \+ sessionId \+ '\/alias'/, '★ 接受建议要走 `PUT /sessions/{id}/alias` —— ' + '服务端特意不另开端点(唯一性校验与 409 处理只该有一处)'); - assert.match(api, /session_alias: alias/, - '★ 别名端点的请求体字段是 `session_alias`(服务端 json tag)'); + /* + * ★★ 这里我**第一版判据自己写错了**(写成了 `session_alias`)—— + * 服务端 `updateAliasRequest` 的 tag 是 **`alias`**(`sessions.go:95-97`), + * 而它的 `Decode()` 是 `DisallowUnknownFields()` ⇒ 传错名字**直接 400**。 + * 判据跟着实现一起错,等于把这处错误**锁进两处**。 + * (WebUI 侧也是 `{ alias }`,见 `client.ts:537` —— 两端都该以它为准。) + */ + assert.match(api, /this\.alias = alias;/, + '★ 别名端点的请求体字段必须是 `alias`(服务端 `updateAliasRequest` 的 json tag)—— ' + + '写成 `session_alias` 会被 DisallowUnknownFields 拒收(400)'); + assert.ok(!/session_alias: alias/.test(api), + '★ 不许用 `session_alias` 当请求体字段名 —— 那不是服务端认识的 tag'); /* ④ 驳回走**专用端点**(它不是改别名,是"别再问了") */ assert.match(api, /rename-proposal\/dismiss/, diff --git a/client/harmony/entry/src/main/ets/api/SessionApi.ets b/client/harmony/entry/src/main/ets/api/SessionApi.ets index 9df13d4..349275f 100644 --- a/client/harmony/entry/src/main/ets/api/SessionApi.ets +++ b/client/harmony/entry/src/main/ets/api/SessionApi.ets @@ -9,6 +9,39 @@ import { ApiClient } from './ApiClient'; import { RenameProposal } from '../model/SessionRename'; +/** + * `PUT /sessions/{id}/alias` 的请求体(与"人手改别名"同一个端点)。 + * + * ★★ 字段名是 **`alias`**,不是 `session_alias` —— 我第一版写错了, + * 而这是**服务端 `updateAliasRequest` 的 json tag**(`sessions.go:95-97`): + * + * type updateAliasRequest struct { Alias string `json:"alias"` } + * + * 服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格)⇒ 传 + * `session_alias` 会**直接 400**,而不是"被忽略"。 + * WebUI 侧也是 `{ alias }`(`client.ts:537`)。 + * + * 教训与今天那两次同源(`AppearancePayload` / `ForwardMailRequest`): + * **请求体的字段名必须去服务端的 struct tag 里读,不能凭记忆写。** + */ +class AliasRequest { + alias: string = ''; + + constructor(alias: string) { + this.alias = alias; + } +} + +/** 驳回的请求体 —— 端点不接受任何参数,但 `post` 要一个 body(空对象即可) */ +class EmptyRequest { +} + +/** `PUT /sessions/{id}/alias` 的响应(服务端回 `{"status":"updated","alias":"…"}`) */ +class AliasResult { + status: string = ''; + alias: string = ''; +} + /** `GET /sessions/{id}/rename-proposal` 的响应外壳 */ class RenameProposalEnvelope { /** `null` = 没有待处理的建议(服务端用 null 而不是 404,是"正常状态") */ @@ -44,7 +77,7 @@ export class SessionApi { * 而别名冲突(409)的判定只需一处。 */ async acceptRename(sessionId: string, alias: string): Promise { - await this.client.put('/sessions/' + sessionId + '/alias', { session_alias: alias }); + await this.client.put('/sessions/' + sessionId + '/alias', new AliasRequest(alias)); } /** @@ -54,6 +87,6 @@ export class SessionApi { * 因为"驳回"这件事在别名端点里没有对应物(它不是改别名,是"别再问了")。 */ async dismissRename(sessionId: string): Promise { - await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', {}); + await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', new EmptyRequest()); } } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index b323c43..cc2adf0 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -8,6 +8,8 @@ import { Theme } from '../common/Theme'; /* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */ import { Markdown } from '@luvi/lv-markdown-in'; import { MailApi } from '../api/MailApi'; +import { SessionApi } from '../api/SessionApi'; +import { RenameProposal } from '../model/SessionRename'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { MailDetail, SendMailRequest, ForwardMailRequest, Address } from '../model/Models'; import { MailDetailParams } from '../model/RouteParams'; @@ -101,6 +103,16 @@ export struct MailDetailView { * 服务端 `forward.go` 也完整(含引用块渲染与 `Fwd:` 叠加处理)—— * 缺的只是这一页的入口。对齐 WebUI `MailView.tsx:378` 的 `ForwardBar`。 */ + /* + * 会话改名建议(Agent 在正文里提的;服务端从正文的 HTML 注释标记解析出来)。 + * + * 别名是**人**的寻址入口,所以是"Agent 提议 + 人确认"(WebUI 同口径)。 + * 读的时机:本页挂载时拉一次(与正文同一批)。 + */ + @State renameProposal: RenameProposal | null = null; + /** 接受/驳回在飞 —— 两个动作都改服务端状态,防连点 */ + @State renameBusy: boolean = false; + @State showForwardBox: boolean = false; @State forwardTo: string = ''; @State forwardCc: string = ''; @@ -123,6 +135,15 @@ export struct MailDetailView { @State me: string = ''; private mailApi: MailApi | null = null; + /** + * 本页正在用的 API 客户端(与 `mailApi` 同一个实例)。 + * + * 为什么留一份:改名建议要打**会话级**端点(`/sessions/{id}/...`), + * 那是 `SessionApi` 的职责;而 client 是在 `aboutToAppear` 里**局部构造**的 + * (带该账号的 base + token)—— 不留一份,后面就没法再拿它建别的 Api 对象。 + * 不把 client 塞进 `MailApi` 暴露(那是"为了少一个字段"而扩大一个类的职责)。 + */ + private client: ApiClient | null = null; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); @@ -161,6 +182,7 @@ export struct MailDetailView { const accountClient: ApiClient = new ApiClient(ctx); accountClient.setBase(account.server); accountClient.setToken(account.token); + this.client = accountClient; this.mailApi = new MailApi(accountClient); if (this.mailId.length === 0) { this.loading = false; @@ -194,6 +216,16 @@ export struct MailDetailView { this.permissionMode = mail.permission_mode; this.sessionAlias = mail.session_alias; this.sessionId = mail.session_id; + /* + * ★★ 2026-09-19:改名建议要**在这里拉一次** —— 我第一版把提示条的 UI 写完了 + * 却忘了接数据,于是页面上**永远不显示**那条建议(服务端明明有)。 + * 这正是本仓反复出现的"写好了但没人调用"的形状: + * 静态判据查得出"代码里有 getRenameProposal 这个方法", + * 查不出"页面从没调过它"。 + * + * 拉失败**不影响正文**:建议条是附加信息,拿不到就不显示(不报错、不阻塞)。 + */ + this.loadRenameProposal(); } catch (e) { const ae = e as ApiError; this.error = ae.code === 0 ? ae.message : '加载失败'; @@ -202,6 +234,26 @@ export struct MailDetailView { } } + /** + * 拉一次会话改名建议(有就显示提示条,没有就什么都不做)。 + * + * 单独一个方法而不是塞进 `loadMail` 的 try 里:建议条是**附加信息**, + * 它失败不该让正文加载看起来也失败了(`loadMail` 的 catch 会把整页变成错误态)。 + */ + async loadRenameProposal(): Promise { + const c: ApiClient | null = this.client; + const sid: string = this.sessionId; + if (c === null || sid.length === 0) { + return; + } + try { + this.renameProposal = await new SessionApi(c).getRenameProposal(sid); + } catch (e) { + /* 拿不到就不显示 —— 不为一条可选提示把正文也变错误态 */ + this.renameProposal = null; + } + } + async switchPermission(mode: string): Promise { const m: MailApi | null = this.mailApi; const sid: string = this.sessionId; @@ -419,6 +471,68 @@ export struct MailDetailView { Stack({ alignContent: Alignment.BottomEnd }) { Scroll() { Column() { + /* + * 会话改名建议条(WebUI `MailView.tsx:324` 的 `RenameProposalBar`)。 + * + * ★ 为什么值得一条提示(WebUI 那段注释的原话): + * 「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」—— + * 别名是**人**的寻址入口,所以 Agent 只能提议、人要确认。 + * + * 位置与 WebUI 一致:在正文之前(那里原本就是它的位置)。 + * 蓝底(`accentSoft`)而不是警告色:它不是故障,是一个**待决定**。 + */ + if (this.renameProposal !== null) { + Column() { + Row() { + Column() { + Text('Agent 建议把会话别名改为 .' + this.renameProposal.alias) + .fontSize(12).fontColor(Theme.textPrimary) + .width('100%') + if (this.renameProposal.reason.length > 0) { + Text(this.renameProposal.reason) + .fontSize(11).fontColor(Theme.textMuted) + .width('100%').margin({ top: 2 }) + } + /* + * 后果说明**必须有**:改名会让旧的寻址入口失效, + * 这是用户做决定前必须知道的(与 WebUI 逐字对齐)。 + */ + Text('改名后需用 name@path.' + this.renameProposal.alias + + ' 寻址;旧别名立即失效。接受后此别名不再被 Agent 平台的自动命名覆盖') + .fontSize(10).fontColor(Theme.textMuted) + .width('100%').margin({ top: 2 }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Button(this.renameBusy ? '改名中' : '接受') + .height(28).fontSize(12) + .backgroundColor(Theme.accent).fontColor(Theme.accentFg) + .enabled(!this.renameBusy) + .margin({ left: 8 }) + .onClick(() => { this.doAcceptRename(); }) + + /* 「忽略」用描边按钮(次要动作)—— 与「接受」的主次一眼可辨 */ + Button('忽略') + .height(28).fontSize(12) + .backgroundColor(Color.Transparent) + .fontColor(Theme.accent) + .borderRadius(Theme.radiusControl) + .border({ width: 1, color: Theme.accent }) + .enabled(!this.renameBusy) + .margin({ left: 6 }) + .onClick(() => { this.doDismissRename(); }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } + .width('100%') + .padding(12) + .backgroundColor(Theme.accentSoftFor(this.isDarkNow)) + .borderRadius(Theme.radiusControl) + .margin({ left: 16, right: 16, top: 12, bottom: 4 }) + } + /* * 元信息**不在正文区**了:它已经上移到可折叠头部(用户展开才显示)。 * 这里只留正文:主题 + Markdown 正文。 @@ -684,6 +798,73 @@ export struct MailDetailView { .backgroundColor(Theme.pageBg) } + /** + * 接受改名建议 —— 走 `PUT /sessions/{id}/alias`。 + * + * ★ 与"人手改别名"**同一个端点**(WebUI 也是这么做的):服务端特意不另开, + * 因为那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方。 + * + * ★ 409(别名被占用)要让用户看到 —— 不能默默失败(WebUI 同口径)。 + */ + async doAcceptRename(): Promise { + const proposal: RenameProposal | null = this.renameProposal; + const sessionId: string | null = this.sessionId; + if (proposal === null || sessionId === null || this.renameBusy) { + return; + } + this.renameBusy = true; + try { + const c: ApiClient | null = this.client; + if (c === null) { + return; + } + await new SessionApi(c).acceptRename(sessionId, proposal.alias); + /* 成功后本地的建议条消失(服务端也已记录新别名) */ + this.renameProposal = null; + this.getUIContext().getPromptAction().showToast({ message: '会话别名已改' }); + } catch (e) { + const ae = e as ApiError; + /* + * 别名冲突(409)是最常见的失败,服务端的文案已是一句能照着改的话 —— + * 原样透出,不自己编一句(编的那句会丢掉"换一个名字"这个提示)。 + */ + this.getUIContext().getPromptAction().showToast({ + message: ae.message.length > 0 ? ae.message : '改名失败' + }); + } finally { + this.renameBusy = false; + } + } + + /** + * 驳回改名建议 —— `POST /sessions/{id}/rename-proposal/dismiss`。 + * + * 服务端记下被驳回的别名,**不再反复弹同一个**(否则每次打开会话都要再点一次)。 + * 所以成功后本地也把它清掉。 + */ + async doDismissRename(): Promise { + const sessionId: string | null = this.sessionId; + if (sessionId === null || this.renameBusy) { + return; + } + this.renameBusy = true; + try { + const c: ApiClient | null = this.client; + if (c === null) { + return; + } + await new SessionApi(c).dismissRename(sessionId); + this.renameProposal = null; + } catch (e) { + const ae = e as ApiError; + this.getUIContext().getPromptAction().showToast({ + message: ae.message.length > 0 ? ae.message : '操作失败' + }); + } finally { + this.renameBusy = false; + } + } + /** * 转发:`POST /me/mail/{id}/forward`(服务端 `forward.go`)。 *