跨端: 往返预算编辑上线(详情页缺的第三块)+ 修折叠头部点不中的真 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:
@ -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]}`);
|
||||
});
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user