跨端: 改名建议条接到详情页(端到端实测:点「接受」别名真的改了)

上一批做完了接口层,这批接 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` 成功;前端重建 + 重打包。

**未验**:「忽略」按钮的端到端(服务端记下驳回、不再重复弹)——
接口层已有判据钉住路径,但没在设备上真点过。
This commit is contained in:
2026-09-19 17:23:23 +08:00
parent c2f35d1023
commit e79a86ab79
3 changed files with 228 additions and 4 deletions

View File

@ -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/,

View File

@ -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<void> {
await this.client.put('/sessions/' + sessionId + '/alias', { session_alias: alias });
await this.client.put<AliasResult>('/sessions/' + sessionId + '/alias', new AliasRequest(alias));
}
/**
@ -54,6 +87,6 @@ export class SessionApi {
* 因为"驳回"这件事在别名端点里没有对应物(它不是改别名,是"别再问了")。
*/
async dismissRename(sessionId: string): Promise<void> {
await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', {});
await this.client.post<EmptyRequest>('/sessions/' + sessionId + '/rename-proposal/dismiss', new EmptyRequest());
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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`)。
*