跨端: 改名建议条接到详情页(端到端实测:点「接受」别名真的改了)
上一批做完了接口层,这批接 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:
@ -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/,
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user