跨端: 往返预算编辑上线(详情页缺的第三块)+ 修折叠头部点不中的真 bug

审计发现详情页缺三块功能之三:**往返预算**(WebUI `BudgetEditor`)。
`MailApi.setBudget` 早就有了,缺的是 `getBudget` 与 UI。

## 一、预算条(对齐 WebUI,两态)

显示态是徽标(`已用/上限 来回` / 不限时「预算不限」/ 用尽时标红),
点它进入编辑态:输入框 + 保存 + 重置 + 取消。

★ 「用尽」的判定**走服务端的 `remaining`**,不自己拿 `used >= max` 算:
不限时服务端给 `remaining: -1`,那个式子在那种情形下会算出"已用尽"。
两端都得用服务端的口径。

★ 「保存」与「重置计数」走**同一条 PUT**(服务端支持两者同时给,
它的注释原话:「加到 20 并从头算」是一次很自然的操作,
拆成两个请求只会让前端多一次往返)。

**契约实测**(不是形态检查):
    GET  → {"max_rounds":0,"used_rounds":1,"remaining":-1,"unlimited":true}
    PUT {"max_rounds":7,"reset":false} → {"max_rounds":7,"remaining":6,"unlimited":false}
    PUT {"max_rounds":0,"reset":false} → {"remaining":-1,"unlimited":true}      ← 0 = 不限

## 二、真 bug:折叠头部的展开区只有 **14px** 高

设备实测 dump:`Row [1277,112][3122,126]` —— 可点区高度 = 文字高度(14px 字号)。
而全屏之后**状态栏也在 y=112 那个区间** ⇒ 点它反复触发**系统手势(下拉通知)**
而不是展开头部。我为此试了七八次,每次都回到桌面。

而收起态头部里藏着**这个会话的全部旋钮**(收件人/时间/抄送/权限档/预算)——
点不中就等于那些都看不到。

修:给那一行 `height(36)`(与左边返回键同高)。实测可点区
**14px → 43px**(`[1277,112][3122,155]`)。

★ 判据 + **变异验证**:去掉 `.height(36)` ⇒ 判红;还原 ⇒ 绿。

## 三、判据

`harmony-logic` 新增「折叠头部展开区高度 ≥32vp」一条(含变异自检)。
`run-all.mjs` → `checks=511 pass=511 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:预算条在设备上的**观感与点击**(模拟器顶部 155px 是系统手势区,
而头部恰在其中 —— 坐标式点击在那里会被系统抢走;真机上头部在状态栏之下)。
预算条的**数据链路**已用 curl 逐条实测(见上),但"点保存按钮后徽标变化"
这一步没在设备上走通。
This commit is contained in:
2026-09-19 17:41:09 +08:00
parent e79a86ab79
commit ac62dafde1
4 changed files with 269 additions and 4 deletions

View File

@ -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]}`);
});

View File

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

View File

@ -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<Object>('/sessions/' + sessionId + '/alias', payload);
}
/** 改预算 */
async setBudget(sessionId: string, maxRounds: number): Promise<void> {
/** 读本会话的往返预算(`GET /sessions/{id}/budget`) */
async getBudget(sessionId: string): Promise<SessionBudget> {
return this.client.get<SessionBudget>('/sessions/' + sessionId + '/budget');
}
/**
* 改预算。
*
* @param maxRounds 新的上限;0 = 不限
* @param reset 是否把已用次数归零
*/
async setBudget(sessionId: string, maxRounds: number, reset: boolean = false): Promise<void> {
const payload: BudgetPayload = new BudgetPayload();
payload.max_rounds = maxRounds;
payload.reset = reset;
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
}

View File

@ -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<void> {
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<void> {
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 })