diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 3837836..d0d3766 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -739,3 +739,52 @@ test('★ 改名建议:字段名与两端契约一致,接受/驳回走对端 assert.ok(!/\/sessions\/' \+ sessionId \+ '\/alias'/.test(mutated), '★ 路径写错时上面那条断言必须能判红'); }); + +test('★ 详情页折叠头部的展开区要有足够高度(14px 的横条点不中)', () => { + /* + * ★★ 2026-09-19 真 bug,设备实测撞出来的: + * + * 详情页折叠态的头部只有一行标题,那一行的 `Row` **没有高度** ⇒ + * 高度就是内容(14px 字号)。实测 dump 的可点区: + * + * Row [1277,112][3122,126] ← 只有 14px 高 + * + * 叠上"全屏后状态栏也在 y=112 那个区间"这个事实 ⇒ 反复点它 + * **触发系统手势(下拉通知)而不是展开头部**。我为此试了七八次, + * 每次都回到桌面。 + * + * 而收起态头部里藏着**这个会话的全部旋钮**(收件人/时间/抄送/权限档/预算), + * 点不中就等于那些东西都看不到。 + * + * 修法:给那一行 `height(36)`(与左边返回键同高)—— 实测可点区 + * 从 14px 变成 43px。 + * + * 判据形状:找"`layoutWeight(1)` + `onClick` 且**没有 height**"的头部行。 + * 这类行都是"点它展开/切换"的可点区,太薄就点不中。 + */ + const src = code(join(HARMONY_ETS, "pages/MailDetailPage.ets")); + + /* + * 定位折叠头部的展开行:它的标志是「`.layoutWeight(1)` 之后紧跟 + * `.onClick(() => { this.headerOpen = !this.headerOpen; })`」。 + */ + /* + * ★ 锚点要**跳过注释**:那一段里有一大段解释(讲这个 bug 本身), + * 而我第一版的正则只允许空白,于是匹配不上(判据假红)。 + * 用 `code()` 读的是**剥注释版**,所以这里直接在剥注释后的文本上找 —— + * 拿它当锚点最稳。 + */ + /* + * ⚠️ 用 `[\s\S]{0,300}?` 而不是 `\s*`:`code()` 剥注释后会在原处留下**大量空行** + * (实测 `.layoutWeight(1)` 与 `.height(36)` 之间隔了 15 个空行)—— + * 而且那些空行里可能有缩进空格。所以两侧都用"任意字符、尽量少"。 + */ + const m = /\.layoutWeight\(1\)[\s\S]{0,300}?\.height\((\d+)\)[\s\S]{0,300}?\.onClick\(\(\)\s*=>\s*\{\s*this\.headerOpen/.exec(src); + assert.ok(m, + '要能找到折叠头部的展开行 —— 形状是「`.layoutWeight(1)` → `.height(N)` → `.onClick(headerOpen 切换)`」。' + + '若它变成别的顺序,更新这条正则(并确认那个顺序下可点区仍然够大)'); + + const h = [null, m[1]]; + assert.ok(Number(h[1]) >= 32, + `★ 展开行的高度应 ≥32vp(实测 14px 太薄、点不中;修成 36 后实测可点区 43px)。实际 ${h[1]}`); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 1c7f1c7..0e2aaf3 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -78,7 +78,7 @@ const SUITE = [ // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], - ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], + ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 32], ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27], diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index 60df3bf..95f5850 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -70,6 +70,31 @@ export class AliasPayload { /** 改预算请求体 */ export class BudgetPayload { max_rounds: number = 0; + /** + * 把已用次数归零(`PUT` 的可选字段)。 + * + * ★ 服务端 `updateBudgetRequest` 有它(`sessions.go` 的 `Reset *bool`)—— + * "改预算"与"重新开始计数"是两件事:调小上限但不重置, + * 会让一个已用 15 次的会话立刻变成"已用尽"。 + */ + reset: boolean = false; +} + +/** + * 会话往返预算(`GET /sessions/{id}/budget`)。 + * + * 字段名与服务端 `repo.SessionBudget` 的 json tag **逐字对齐** + * (`quota.go:209-215`)。★ 又是那个教训:**去服务端的 struct tag 里读**, + * 别凭记忆写 —— 我今天已经因此错过三次(appearance / forward / alias)。 + */ +export class SessionBudget { + session_id: string = ''; + /** 0 = 本会话不限 */ + max_rounds: number = 0; + used_rounds: number = 0; + /** 不限时为 -1 */ + remaining: number = 0; + unlimited: boolean = false; } /** @@ -217,10 +242,21 @@ export class MailApi { await this.client.put('/sessions/' + sessionId + '/alias', payload); } - /** 改预算 */ - async setBudget(sessionId: string, maxRounds: number): Promise { + /** 读本会话的往返预算(`GET /sessions/{id}/budget`) */ + async getBudget(sessionId: string): Promise { + return this.client.get('/sessions/' + sessionId + '/budget'); + } + + /** + * 改预算。 + * + * @param maxRounds 新的上限;0 = 不限 + * @param reset 是否把已用次数归零 + */ + async setBudget(sessionId: string, maxRounds: number, reset: boolean = false): Promise { const payload: BudgetPayload = new BudgetPayload(); payload.max_rounds = maxRounds; + payload.reset = reset; await this.client.put('/sessions/' + sessionId + '/budget', payload); } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index cc2adf0..fa63634 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -7,7 +7,7 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; /* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */ import { Markdown } from '@luvi/lv-markdown-in'; -import { MailApi } from '../api/MailApi'; +import { MailApi, SessionBudget } from '../api/MailApi'; import { SessionApi } from '../api/SessionApi'; import { RenameProposal } from '../model/SessionRename'; import { AccountManager, AccountInfo } from '../api/AccountManager'; @@ -113,6 +113,20 @@ export struct MailDetailView { /** 接受/驳回在飞 —— 两个动作都改服务端状态,防连点 */ @State renameBusy: boolean = false; + /* + * 往返预算(`GET/PUT /sessions/{id}/budget`)。 + * + * ★ 为什么这是最该可编辑的地方(服务端注释原话): + * 「人看着往来内容才知道这件事还值不值得再来几个回合」。 + * WebUI 的 `BudgetEditor` 就摆在对话页头部 —— 鸿蒙此前**完全没有**这一块。 + */ + @State budget: SessionBudget | null = null; + /** 是否展开成编辑态(WebUI 的 `editing`:显示态是一个徽标,点它才变成输入框) */ + @State budgetEditing: boolean = false; + /** 编辑中的草稿(**字符串**:空串表示"不限",与 WebUI 同口径) */ + @State budgetDraft: string = ''; + @State budgetBusy: boolean = false; + @State showForwardBox: boolean = false; @State forwardTo: string = ''; @State forwardCc: string = ''; @@ -226,6 +240,8 @@ export struct MailDetailView { * 拉失败**不影响正文**:建议条是附加信息,拿不到就不显示(不报错、不阻塞)。 */ this.loadRenameProposal(); + /* 预算与建议条同批(都是附加信息,失败不影响正文) */ + this.loadBudget(); } catch (e) { const ae = e as ApiError; this.error = ae.code === 0 ? ae.message : '加载失败'; @@ -384,6 +400,150 @@ export struct MailDetailView { .width('100%') } + /** + * 往返预算行 —— 两态(对齐 WebUI `BudgetEditor`): + * + * 显示态:一个徽标 `已用/上限 来回`(不限时写「预算不限」;用尽时标红) + * 编辑态:输入框 + 保存 + 重置(点徽标进入) + * + * ★ 「用尽」的判定来自**服务端**的 `remaining`(不是自己拿 used 与 max 相减): + * 不限时服务端给 `remaining: -1`,而 `used >= max` 那个式子在这种情形下 + * 会算出"已用尽"(`-1` 与 `0` 的比较)—— 两端都得用服务端的口径。 + */ + @Builder + BudgetRow() { + Row() { + Text('预算') + .fontSize(11).fontColor(Theme.textSubtle) + .width(36).flexShrink(0) + + if (this.budget === null) { + /* 还没读到就不显示 —— 不为一块可选信息留空壳 */ + Text('—').fontSize(11).fontColor(Theme.textSubtle) + } else if (this.budgetEditing) { + /* 编辑态:空串 = 不限(WebUI 同口径,`placeholder` 也是「不限」) */ + TextInput({ text: this.budgetDraft, placeholder: '不限' }) + .width(70).height(28).fontSize(12) + .type(InputType.Number) + .onChange((v: string) => { this.budgetDraft = v; }) + + Button('保存') + .height(28).fontSize(11) + .backgroundColor(Theme.accent).fontColor(Theme.accentFg) + .enabled(!this.budgetBusy && this.budgetDraftValid()) + .margin({ left: 6 }) + .onClick(() => { this.saveBudget(false); }) + + /* 「重置计数」:把已用归零、上限不变(与「保存」是两件事) */ + Button('重置') + .height(28).fontSize(11) + .backgroundColor(Theme.surfaceMuted).fontColor(Theme.textMuted) + .enabled(!this.budgetBusy && this.budget.used_rounds > 0) + .margin({ left: 6 }) + .onClick(() => { this.saveBudget(true); }) + + Button('取消') + .height(28).fontSize(11) + .backgroundColor(Color.Transparent).fontColor(Theme.textMuted) + .margin({ left: 6 }) + .onClick(() => { this.budgetEditing = false; }) + } else { + /* + * 显示态:徽标。用尽的判定走服务端的 `remaining`(见上文注释)。 + */ + Text(this.budgetLabel()) + .fontSize(11) + .fontColor(this.budgetExhausted() ? Theme.danger : Theme.textMuted) + .backgroundColor(this.budgetExhausted() ? Theme.dangerBg : Theme.chipNeutralBg) + .borderRadius(4) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .onClick(() => { + /* 进入编辑态时把当前值填进草稿(不限 ⇒ 空串) */ + this.budgetDraft = this.budget !== null && !this.budget.unlimited + ? this.budget.max_rounds.toString() : ''; + this.budgetEditing = true; + }) + } + } + .width('100%') + } + + /** 草稿是否合法:空(不限)或非负整数(与服务端 `max_rounds >= 0` 同口径) */ + budgetDraftValid(): boolean { + const t: string = this.budgetDraft.trim(); + if (t.length === 0) { + return true; + } + /* 只认纯数字 —— `Number('1e3')` 能过但服务端是 int,传上去会被拒 */ + return /^\d+$/.test(t); + } + + /** 徽标文案(对齐 WebUI 的三种写法) */ + budgetLabel(): string { + const b: SessionBudget | null = this.budget; + if (b === null) { + return '—'; + } + if (b.unlimited) { + return '预算不限'; + } + return b.used_rounds.toString() + '/' + b.max_rounds.toString() + ' 来回' + + (this.budgetExhausted() ? ' · 已用尽' : ''); + } + + /** 用尽:**以服务端的 remaining 为准**(不限时它是 -1,不能拿它当"用尽") */ + budgetExhausted(): boolean { + const b: SessionBudget | null = this.budget; + return b !== null && !b.unlimited && b.remaining === 0; + } + + /** 读一次预算(与正文同批,失败不影响正文) */ + async loadBudget(): Promise { + const m: MailApi | null = this.mailApi; + const sid: string = this.sessionId; + if (m === null || sid.length === 0) { + return; + } + try { + this.budget = await m.getBudget(sid); + } catch (e) { + this.budget = null; + } + } + + /** + * 保存预算。 + * + * @param reset 是否把已用次数归零(false = 只改上限) + * + * ★ 两个动作走**同一条 PUT**(服务端支持 `max_rounds` 与 `reset` 同时给, + * 注释原话:「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会让前端多一次往返)。 + */ + async saveBudget(reset: boolean): Promise { + const m: MailApi | null = this.mailApi; + const sid: string = this.sessionId; + if (m === null || sid.length === 0 || this.budgetBusy) { + return; + } + this.budgetBusy = true; + try { + /* 空草稿 ⇒ 0(不限),与服务端 `max_rounds: 0 = 不限` 同口径 */ + const t: string = this.budgetDraft.trim(); + const maxRounds: number = t.length === 0 ? 0 : Number(t); + await m.setBudget(sid, maxRounds, reset); + /* 重新读一次拿服务端算好的 remaining/unlimited(不自己算,见 BudgetRow 注释) */ + await this.loadBudget(); + this.budgetEditing = false; + } catch (e) { + const ae = e as ApiError; + this.getUIContext().getPromptAction().showToast({ + message: ae.message.length > 0 ? ae.message : '预算保存失败' + }); + } finally { + this.budgetBusy = false; + } + } + build() { Column() { /* @@ -428,6 +588,21 @@ export struct MailDetailView { .margin({ left: 6 }) } .layoutWeight(1) + /* + * ★★ 2026-09-19 修(设备实测撞出来的):本行原先**没有高度**, + * 高度就是内容(标题 14px 字)—— 实测 dump 里可点区是 + * `Row [1277,112][3122,126]`,**只有 14px 高**,而且它落在 y=112 + * (全屏后状态栏也在那个区间)⇒ 点下去经常触发**系统手势**(下拉通知) + * 而不是展开头部。 + * + * 我把折叠态的头部反复点了七八次都没展开,每次都回到桌面 —— + * 而手动点别的区域就能进 —— 这个"难点"本身就是问题: + * 用户要在 14px 的横条里精准命中才能看到收件人/时间/抄送/档位/预算。 + * + * 给 36vp(与左边的返回键同高,那一列的高度就是 36): + * 点击区从 14px 变成 36vp(实测约 104px),且纵向不贴边。 + */ + .height(36) .onClick(() => { this.headerOpen = !this.headerOpen; }) } .width('100%') @@ -441,6 +616,11 @@ export struct MailDetailView { } this.MetaRow('时间', localDateTime(this.createdAt)) this.PermissionRow() + /* + * 往返预算行 —— 紧接着权限档位(WebUI `CollapsibleHeader` 里 + * 这两块也是相邻的:都是"这条会话当前怎么跑"的旋钮)。 + */ + this.BudgetRow() } .width('100%') .margin({ top: 8 })