跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了 此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么", 不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。 新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` / `hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。 **它当场证明了它的价值**:`cross-client-theme` 新增的设备判据 用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。 ## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用 `Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` —— 一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。 - 浅色下当白字用**碰巧对**(白字压蓝底) - **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见** 设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**; 读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。 `Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在, 却**一处都没用**。 修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg` (`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。 另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。 ## 三、判据(两条,都做了变异验证) 1. **设备条**(`cross-client-theme`):读悬浮球像素 —— ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染); ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。 把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。 2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。 设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。 ## 四、判据自身踩的三个坑(都写进注释了) - **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。 截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。 - **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`), 实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。 原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。 - **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页), 而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。 ## 五、欠账 - `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了 (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。 如实记着**未验**的那点:预算条的**点击**没在设备上走通 (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。 - `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 —— `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。 - `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。 `run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`; Go 侧 `./internal/repo/...` 通过。
This commit is contained in:
@ -878,3 +878,220 @@ test('★ 品牌浅底必须有深色变体(深色下白底卡片 = 刺眼的
|
|||||||
offenders.join('\n ') +
|
offenders.join('\n ') +
|
||||||
'\n改成 Theme.accentSoftFor(this.isDarkNow)(页面要有一个 isDarkNow 状态)');
|
'\n改成 Theme.accentSoftFor(this.isDarkNow)(页面要有一个 isDarkNow 状态)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('★ 设备:品牌色**真的画成那个色**(令牌写对了 ≠ 渲染对了)', async (t) => {
|
||||||
|
/*
|
||||||
|
* 补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
|
||||||
|
* 「跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐」
|
||||||
|
*
|
||||||
|
* 上面那些判据判的都是**声明**(`Theme.accent` 等于 `#2563EB`、
|
||||||
|
* 两端令牌同名…)。它们全绿时有一件事从未验过:
|
||||||
|
* **屏幕上真的画成那个色吗**。
|
||||||
|
*
|
||||||
|
* ★ 为什么这不是多余的(本仓的实证):
|
||||||
|
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没那串字
|
||||||
|
* —— 我为它追了很久,最后靠截图才发现。
|
||||||
|
* · 「多账号选中卡片是白底」那个 bug:令牌写对了(`accentSoft` 确实是浅蓝),
|
||||||
|
* 但深色下**渲染出来**是刺眼的白 —— 静态判据全绿。
|
||||||
|
* ⇒ 声明与渲染会分叉,而"观感类"结论只能靠**像素**。
|
||||||
|
*
|
||||||
|
* 判据形状:找一个**品牌色实心元素**(写意最明确的那个:悬浮球的 `Theme.accent`),
|
||||||
|
* 读它中心像素,与 `Theme.accent` 的取值比对。
|
||||||
|
*/
|
||||||
|
const D = await import('./lib/harmony-device.mjs');
|
||||||
|
const hdc = D.findHdc();
|
||||||
|
if (!hdc || !D.hasTarget(hdc)) {
|
||||||
|
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住声明)');
|
||||||
|
}
|
||||||
|
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
|
||||||
|
assert.ok(await D.backToMain(hdc), '要能回到主界面');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ★★ **自己导到列表页** —— 悬浮球只在通信页的列表窗格内。
|
||||||
|
* 第一版没做这一步,于是套件里它**永远跳过**(前面判据把前台留在管理页),
|
||||||
|
* 而那看起来像"功能没了"。这正是本仓那条纪律:
|
||||||
|
* **设备判据要自己搭现场**,不能等人摆好。
|
||||||
|
*/
|
||||||
|
const inListPane = () => [...D.walk(D.dumpLayout(hdc))]
|
||||||
|
.some((n) => (n.attributes?.text || '').includes('收件箱'));
|
||||||
|
if (!inListPane()) {
|
||||||
|
/* 通信是底栏/侧栏第一项 —— 用文案点(不写死坐标) */
|
||||||
|
if (!D.tapText(hdc, '通信')) {
|
||||||
|
return t.skip('点不到「通信」入口 —— 无法导到列表页');
|
||||||
|
}
|
||||||
|
let landed = false;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
if (inListPane()) { landed = true; break; }
|
||||||
|
}
|
||||||
|
assert.ok(landed, '要能导到通信页的列表窗格(悬浮球只在那一屏)');
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
|
||||||
|
/* 品牌色的**预期取值**(从 `Theme.ets` 读,不在判据里再写一份) */
|
||||||
|
const themeSrc = prose(join(HARMONY_ETS, 'common/Theme.ets'));
|
||||||
|
const accentHex = /static readonly accent: string = '(#[0-9A-Fa-f]{6})'/.exec(themeSrc);
|
||||||
|
assert.ok(accentHex, '要能从 Theme.ets 读到 `accent` 的取值');
|
||||||
|
const want = D.hexToRgb(accentHex[1]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 找那个品牌色实心元素。**用形状找**(不写死坐标):
|
||||||
|
* 悬浮球是"圆形 + 尺寸 56vp 左右 + 在屏幕右下"。实测(密度 2.875)
|
||||||
|
* 它是约 161×161px 的 `Button`。
|
||||||
|
*
|
||||||
|
* ★ 用形状而不是"找某个文案":球上没有文字(只有图标)——
|
||||||
|
* 按文案找会找不到,然后我会误以为"功能没了"。
|
||||||
|
*/
|
||||||
|
const root = D.dumpLayout(hdc);
|
||||||
|
let screenW = 0;
|
||||||
|
let screenH = 0;
|
||||||
|
for (const n of D.walk(root)) {
|
||||||
|
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
|
||||||
|
if (m) {
|
||||||
|
screenW = Math.max(screenW, Number(m[1]));
|
||||||
|
screenH = Math.max(screenH, Number(m[2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* ★★ 第二版(第一版假设错了):我按"右下角"找球(`x1 > 屏宽*0.6`),
|
||||||
|
* 实测它是 `Button [942,1997][1103,2158]` —— `x1=942`,
|
||||||
|
* 而 60% 屏宽是 1910 ⇒ **永远找不到**。
|
||||||
|
*
|
||||||
|
* 原因:通信页的列表窗格是**左栏**(宽屏下右栏是详情),
|
||||||
|
* 所以悬浮球在"左栏的右下角",不是"屏幕的右下角"。
|
||||||
|
* ⇒ 形状判据只该用**能站得住的那部分**:圆形(宽高相等)
|
||||||
|
* + 在屏幕**下半部**。左侧/右侧是布局决定的,不该写进形状。
|
||||||
|
*/
|
||||||
|
const fab = [...D.walk(root)].find((n) => {
|
||||||
|
const a = n.attributes || {};
|
||||||
|
const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(a.bounds || '');
|
||||||
|
if (!m) return false;
|
||||||
|
const [x1, y1, x2, y2] = m.slice(1).map(Number);
|
||||||
|
const w = x2 - x1;
|
||||||
|
const h = y2 - y1;
|
||||||
|
return Math.abs(w - h) <= 10 && w > 120 && w < 220
|
||||||
|
&& y1 > screenH * 0.6; // 只要求在下半部
|
||||||
|
});
|
||||||
|
if (!fab) {
|
||||||
|
return t.skip('找不到右下角的悬浮球(可能不在列表页)—— 本次不跑,但不假装通过');
|
||||||
|
}
|
||||||
|
const c = D.boundsCenter(fab.attributes.bounds);
|
||||||
|
assert.ok(c, '要能解析出悬浮球的坐标');
|
||||||
|
/* `boundsCenter` 只给圆心 —— 宽度自己从 bounds 里算(采样偏移要用它) */
|
||||||
|
const fb = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(fab.attributes.bounds).slice(1).map(Number);
|
||||||
|
const fabW = fb[2] - fb[0];
|
||||||
|
|
||||||
|
const png = D.screenshot(hdc, '/tmp/theme-accent-shot.png');
|
||||||
|
assert.ok(png, '要能截屏(观感类判据没有截屏就没有依据)');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 读**中心**像素。为什么不是别处:球的边缘是圆角/抗锯齿,
|
||||||
|
* 取边缘会读到背景混色。
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* ★★ 采样点必须**避开图标**!
|
||||||
|
* 第一版我取球的正中心,实测读到 `rgb(32,34,36)`(深灰)——
|
||||||
|
* 差点当成"品牌色没渲染"。截图一看:球是**蓝的**,中心那个深色是
|
||||||
|
* **铅笔图标**(`compose`)。圆心正是图标所在。
|
||||||
|
*
|
||||||
|
* ⇒ 往中心**左侧**偏 50px(图标宽 24vp≈69px,偏 50px 仍在圆内、
|
||||||
|
* 但在图标之外)。半径 ~80px,所以 50px 是安全的。
|
||||||
|
* 这个偏移量要**小于半径、大于图标的半宽**:写死一个值不如
|
||||||
|
* 按半径算(下同)。
|
||||||
|
*/
|
||||||
|
const off = Math.max(30, Math.round(fabW * 0.3));
|
||||||
|
const got = D.pixelAt(png, c.cx - off, c.cy);
|
||||||
|
assert.ok(got, '要能读到像素');
|
||||||
|
|
||||||
|
assert.ok(D.closeColor(got, want),
|
||||||
|
`★ 悬浮球应是品牌色 ${accentHex[1]}(${JSON.stringify(want)}),` +
|
||||||
|
`实测 rgb(${got.r},${got.g},${got.b})。\n` +
|
||||||
|
' 两者不符说明**声明与渲染分叉了** —— 令牌写对了但没画成那个色' +
|
||||||
|
'(被父层覆盖 / 透明度抹掉 / 换了别的色)。这正是静态判据看不到的那一层。');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ★★ 第二半:图标**必须与底色可分辨**。
|
||||||
|
*
|
||||||
|
* 这是本轮真撞到的 bug:12 处把 `Theme.surface` 当**前景色**用
|
||||||
|
* (`sys.color.ohos_id_color_list_card_bg`,一个**会跟随系统主题翻转**的
|
||||||
|
* Resource)—— 浅色下是白的(碰巧对),**深色下变成近黑** ⇒
|
||||||
|
* 深色铅笔压在蓝球上,看起来像"图标消失了"。
|
||||||
|
*
|
||||||
|
* `Theme.accentFg`(#FFFFFF)就是为"品牌底上的文字/图标"存在的,
|
||||||
|
* 但没人用它。
|
||||||
|
*
|
||||||
|
* 判据形状:读**圆心**的像素(那里是图标),要求它与底色**可分辨**
|
||||||
|
* (对比度足够)。这条不指定图标必须是白 —— 只要"看得见"。
|
||||||
|
* 用相对亮度算 WCAG 对比度,阈值取 3:1(图形元素的下限)。
|
||||||
|
*/
|
||||||
|
const onIcon = D.pixelAt(png, c.cx, c.cy);
|
||||||
|
assert.ok(onIcon, '要能读到图标处的像素');
|
||||||
|
const lum = (c1) => {
|
||||||
|
const f = (v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||||
|
return 0.2126 * f(c1.r) + 0.7152 * f(c1.g) + 0.0722 * f(c1.b);
|
||||||
|
};
|
||||||
|
const l1 = lum(onIcon);
|
||||||
|
const l2 = lum(want);
|
||||||
|
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 若圆心读到的**就是**底色,说明图标没画(或采样的不是图标位置)——
|
||||||
|
* 那两种都该问一句,但今天的现场是真有图标(截图可见),
|
||||||
|
* 所以这里只判"可分辨",并附上实测值。
|
||||||
|
*/
|
||||||
|
assert.ok(ratio >= 3,
|
||||||
|
`★ 悬浮球上的图标要对底色**可分辨**(WCAG 图形对比度 ≥3:1)。\n` +
|
||||||
|
` 实测:图标 rgb(${onIcon.r},${onIcon.g},${onIcon.b}),` +
|
||||||
|
`底色 rgb(${got.r},${got.g},${got.b}),对比度 **${ratio.toFixed(2)}:1**。\n` +
|
||||||
|
' 本轮撞到的真 bug:12 处把 `Theme.surface` 当**前景色**用 ——\n' +
|
||||||
|
' 它是 `sys.color.ohos_id_color_list_card_bg`,一个**跟随系统主题翻转**的 Resource:\n' +
|
||||||
|
' 浅色下是白的(碰巧对),**深色下变成近黑** ⇒ 深色图标压在品牌蓝上,' +
|
||||||
|
'看起来像图标没了。\n' +
|
||||||
|
' 正确的前景色是 `Theme.accentFg`(#FFFFFF,它的注释原话就是"品牌底上的文字")。');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('C|`Theme.surface` 不得当代的前景色(它是会翻转的「面」)', () => {
|
||||||
|
/*
|
||||||
|
* ★★ 这是设备判据(上面那条)抓到的 bug 的**静态防线** ——
|
||||||
|
* 设备条只能看一处(悬浮球),而这个错法当时有 **12 处**。
|
||||||
|
*
|
||||||
|
* 错法:`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg`,
|
||||||
|
* **跟随系统主题翻转**(浅色近白 / 深色近黑)。把它当
|
||||||
|
* `fontColor` / `iconColor`("压在彩色底上的字"):
|
||||||
|
* · 浅色下碰巧对(白字压蓝底)
|
||||||
|
* · **深色下字变黑**,压在蓝/红/绿底上几乎看不见
|
||||||
|
*
|
||||||
|
* 正确:`Theme.accentFg`(#FFFFFF,注释原话就是"品牌底上的字/图标")。
|
||||||
|
*
|
||||||
|
* 判据形状:全局 grep「`fontColor(Theme.surface)` / `iconColor: Theme.surface`」,
|
||||||
|
* 一处都不许有。`backgroundColor(Theme.surface)` 是合法用法(那才是"面")。
|
||||||
|
*/
|
||||||
|
const files = [];
|
||||||
|
const walkDir = (d) => {
|
||||||
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||||
|
const p = join(d, e.name);
|
||||||
|
if (e.isDirectory()) walkDir(p);
|
||||||
|
else if (e.name.endsWith('.ets')) files.push(p);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkDir(HARMONY_ETS);
|
||||||
|
|
||||||
|
const bad = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const src = prose(f); // 保留注释:注释里举例不算错
|
||||||
|
const lines = src.split('\n');
|
||||||
|
lines.forEach((ln, i) => {
|
||||||
|
if (/\.fontColor\(Theme\.surface\)/.test(ln) || /iconColor:\s*Theme\.surface/.test(ln)) {
|
||||||
|
bad.push(`${f.replace(HARMONY_ETS, '')}:${i + 1} ${ln.trim().slice(0, 90)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(bad, [],
|
||||||
|
'★ `Theme.surface` 是**会跟随系统主题翻转的面色**(浅色近白 / 深色近黑),' +
|
||||||
|
'只能当背景,不能当字/图标色。\n' +
|
||||||
|
' 压在彩色底上的前景要用 `Theme.accentFg`(#FFFFFF)。\n' +
|
||||||
|
' 深色下症状:字/图标几乎看不见(2026-09-19 真撞到 12 处)。\n' +
|
||||||
|
' 违规处:\n ' + bad.join('\n '));
|
||||||
|
});
|
||||||
|
|||||||
@ -32,6 +32,8 @@ const REGISTERED = new Map([
|
|||||||
['background.test.mjs', 3], // 两条反向断言的未覆盖路(route B / route C)+ 说明
|
['background.test.mjs', 3], // 两条反向断言的未覆盖路(route B / route C)+ 说明
|
||||||
['harmony-appearance.test.mjs', 4], // bgBlur 消费侧/映射、运行期形态类边界
|
['harmony-appearance.test.mjs', 4], // bgBlur 消费侧/映射、运行期形态类边界
|
||||||
['harmony-logic.test.mjs', 1], // `.ets` 状态机要跑起来才算数
|
['harmony-logic.test.mjs', 1], // `.ets` 状态机要跑起来才算数
|
||||||
|
['cross-client-theme.test.mjs', 1], // 设备条里那句「声明层全绿时,渲染那一层没人看过」的出处说明——
|
||||||
|
// 它正是本轮补上的那个缺口(渲染 vs 声明),留着当出处
|
||||||
['debt-visibility.test.mjs', 12], // 本文件:N 处是词表定义 + 报错文案 + 上面那段解释(第 N+1 处即红)
|
['debt-visibility.test.mjs', 12], // 本文件:N 处是词表定义 + 报错文案 + 上面那段解释(第 N+1 处即红)
|
||||||
['harmony-admin.test.mjs', 1], // 用户管理页:本机无设备 ⇒ 只能证明"代码里这么写"
|
['harmony-admin.test.mjs', 1], // 用户管理页:本机无设备 ⇒ 只能证明"代码里这么写"
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -17,9 +17,13 @@
|
|||||||
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
|
// 这套是**到期之后跑行为判据时用的**手 —— 探针说"到期"了,这里替你
|
||||||
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
|
// 真去点。两条逻辑刻意分开,免得"探针和手是同一只"时改一处引坏另一处。
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync, statSync, unlinkSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { bytes } from './read.mjs';
|
||||||
// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。
|
// 读盘走 test/lib 的具名入口(本仓纪律:判据目录里不许裸 readFileSync)。
|
||||||
// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。
|
// 这里要的是**原文**(账本是个 JSON 文本,读出来自己解析)⇒ `prose()` 正是那个入口。
|
||||||
// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了
|
// ★ 实测教训:我第一版在这儿裸用了 readFileSync,`criteria-hygiene` 立刻红了
|
||||||
@ -472,3 +476,79 @@ export function shellOn(hdc, cmd, timeout = 20000) {
|
|||||||
if (!hdc) return { stdout: '', stderr: 'no hdc', status: -1 };
|
if (!hdc) return { stdout: '', stderr: 'no hdc', status: -1 };
|
||||||
return sh(hdc, ['shell', cmd], timeout);
|
return sh(hdc, ['shell', cmd], timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截屏并把 PNG 拉到本地,返回本地路径(失败返回 null)。
|
||||||
|
*
|
||||||
|
* ★ 为什么需要它(2026-09-19 加):
|
||||||
|
* 本目录此前**只能**通过 `dumpLayout` 看界面 —— 而 dump 是**结构化描述**,
|
||||||
|
* 它报的是"组件声明了什么属性",不是"屏幕上真的画成什么样"。
|
||||||
|
* 两者会分叉的地方恰恰是最要紧的:
|
||||||
|
* · `Slider` 节点的 `text='56.000000'` 是**无障碍文本**,屏幕上根本没这串字
|
||||||
|
* (我为它追了很久,最后靠截图才发现真相);
|
||||||
|
* · 颜色令牌写对了,但渲染时被父层覆盖 / 被透明度抹掉;
|
||||||
|
* · 元素在屏幕外(dump 里仍报它的 bounds)。
|
||||||
|
* ⇒ "观感类"判据必须有截屏这一层。
|
||||||
|
*
|
||||||
|
* **这是写操作**(会占用设备屏幕一小会儿)。调用方按本模块的边界①
|
||||||
|
* 先确认前台是自己的应用。
|
||||||
|
*/
|
||||||
|
export function screenshot(hdc, localPath = '/tmp/hm-shot.png') {
|
||||||
|
if (!hdc) return null;
|
||||||
|
const remote = '/data/local/tmp/__hm_shot.png';
|
||||||
|
sh(hdc, ['shell', 'uitest', 'screenCap', '-p', remote], 30000);
|
||||||
|
const r = sh(hdc, ['file', 'recv', remote, localPath], 30000);
|
||||||
|
if (!existsSync(localPath)) return null;
|
||||||
|
/* 再确认一次不是空文件(recv 失败时会留下 0 字节或旧文件) */
|
||||||
|
try {
|
||||||
|
return statSync(localPath).size > 100 ? localPath : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一张 PNG 上某个点的像素(返回 `{ r, g, b }`,失败返回 null)。
|
||||||
|
*
|
||||||
|
* 实现用 `ffmpeg` 把它转成 1×1 的原始 RGB 再读三个字节 ——
|
||||||
|
* 不引 PNG 解码依赖(本仓的判据目录不装 node_modules)。
|
||||||
|
*
|
||||||
|
* ★ 坐标是**屏幕像素**(与 `dumpLayout` 给的一致),不是 vp。
|
||||||
|
*/
|
||||||
|
export function pixelAt(pngPath, x, y) {
|
||||||
|
if (!existsSync(pngPath)) return null;
|
||||||
|
const out = join(tmpdir(), `hm-px-${process.pid}-${x}-${y}.raw`);
|
||||||
|
const r = spawnSync('ffmpeg', [
|
||||||
|
'-loglevel', 'error', '-y', '-i', pngPath,
|
||||||
|
'-vf', `crop=1:1:${x}:${y}`,
|
||||||
|
'-f', 'rawvideo', '-pix_fmt', 'rgb24', out,
|
||||||
|
], { encoding: 'utf8', timeout: 20000 });
|
||||||
|
if (r.error || !existsSync(out)) return null;
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
* ★ 走 `lib/read.mjs` 的 `bytes()`,不裸用 `readFileSync` ——
|
||||||
|
* `criteria-hygiene` 盯着这条(它是对的:本仓有两套读法,
|
||||||
|
* `code()` 剥注释、`prose()` 保注释,裸读会让"判代码"退化成"判文本")。
|
||||||
|
* 二进制只有 `bytes()` 这一个合法入口。
|
||||||
|
*/
|
||||||
|
const buf = bytes(out);
|
||||||
|
unlinkSync(out);
|
||||||
|
if (buf.length < 3) return null;
|
||||||
|
return { r: buf[0], g: buf[1], b: buf[2] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 `#RRGGBB` 解析成 `{r,g,b}`(判据里常拿它跟 `pixelAt` 的结果比)。 */
|
||||||
|
export function hexToRgb(hex) {
|
||||||
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||||
|
if (!m) return null;
|
||||||
|
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两个颜色的通道差是否都在容差内(渲染有抗锯齿/取整,不能用全等比)。 */
|
||||||
|
export function closeColor(a, b, tol = 12) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return Math.abs(a.r - b.r) <= tol && Math.abs(a.g - b.g) <= tol && Math.abs(a.b - b.b) <= tol;
|
||||||
|
}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ const SUITE = [
|
|||||||
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
|
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
|
||||||
['test/theme.test.mjs', [], 38],
|
['test/theme.test.mjs', [], 38],
|
||||||
['test/background.test.mjs', [], 44],
|
['test/background.test.mjs', [], 44],
|
||||||
['test/cross-client-theme.test.mjs', [], 17],
|
['test/cross-client-theme.test.mjs', [], 19],
|
||||||
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
|
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
|
||||||
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
|
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
|
||||||
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。
|
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。
|
||||||
@ -1327,7 +1327,7 @@ if (process.argv.includes('--skip-selftest')) process.exit(skipSelfTest() ? 1 :
|
|||||||
const STATIC_ONLY = [
|
const STATIC_ONLY = [
|
||||||
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
|
['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'],
|
||||||
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
|
['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'],
|
||||||
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'],
|
['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:`.ets` 那半除悬浮球外仍未上设备(悬浮球已钉住「声明不等于渲染」这一类)', 'device'],
|
||||||
['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'],
|
['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'],
|
||||||
// P4c 同批的两条:判的是 `.ets` 里的页面/组件,本机没有设备也没有模拟器
|
// P4c 同批的两条:判的是 `.ets` 里的页面/组件,本机没有设备也没有模拟器
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -39,7 +39,25 @@ export class Theme {
|
|||||||
|
|
||||||
/** 页面底色(系统 `ohos_id_color_background`:浅色白/深色深灰,自动跟随主题) */
|
/** 页面底色(系统 `ohos_id_color_background`:浅色白/深色深灰,自动跟随主题) */
|
||||||
static readonly pageBg: Resource = $r('sys.color.ohos_id_color_background');
|
static readonly pageBg: Resource = $r('sys.color.ohos_id_color_background');
|
||||||
/** 承载文字的面 = **列表卡片底色**(对应"每项一张卡/气泡",而不是通栏底色) */
|
/**
|
||||||
|
* 承载文字的**面** = 列表卡片底色(对应"每项一张卡/气泡")。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ **它只能当背景色,绝不能当前景色(`fontColor` / `iconColor`)**。
|
||||||
|
*
|
||||||
|
* 它是一个**跟随系统主题翻转**的 Resource:浅色下接近白、深色下接近黑。
|
||||||
|
* 我们曾经在 **12 处**把它当"压在彩色底上的字/图标色"用 ——
|
||||||
|
* 浅色下碰巧对(白字压蓝底),**深色下字变成黑的**,看起来像元素消失了。
|
||||||
|
*
|
||||||
|
* 2026-09-19 设备实测到的现场:写邮件悬浮球是品牌蓝 `#2563EB`,
|
||||||
|
* 但球心读出来是 `rgb(32,34,36)`(近黑)—— 那是深色主题下的 `surface`。
|
||||||
|
* 截图里那个铅笔图标几乎是隐形的。
|
||||||
|
*
|
||||||
|
* 压在**品牌色**底上要用 `accentFg`;压在别的语义色(danger/warn/approve)
|
||||||
|
* 底上同理 —— 那些底都是深色,前景都该是浅的。
|
||||||
|
*
|
||||||
|
* 判据:`cross-client-theme` 的设备条会读悬浮球**圆心**的像素,
|
||||||
|
* 要求图标与底色 WCAG 对比度 ≥3:1。
|
||||||
|
*/
|
||||||
static readonly surface: Resource = $r('sys.color.ohos_id_color_list_card_bg');
|
static readonly surface: Resource = $r('sys.color.ohos_id_color_list_card_bg');
|
||||||
/** 次级面(分组底、列表行 hover) */
|
/** 次级面(分组底、列表行 hover) */
|
||||||
static readonly surfaceMuted: Resource = $r('sys.color.ohos_id_color_sub_background');
|
static readonly surfaceMuted: Resource = $r('sys.color.ohos_id_color_sub_background');
|
||||||
@ -192,6 +210,16 @@ export class Theme {
|
|||||||
* 这是整个文件里**唯一必须与 WebUI 逐字一致**的取值(判据钉住)。
|
* 这是整个文件里**唯一必须与 WebUI 逐字一致**的取值(判据钉住)。
|
||||||
*/
|
*/
|
||||||
static readonly accent: string = '#2563EB';
|
static readonly accent: string = '#2563EB';
|
||||||
|
/**
|
||||||
|
* 品牌底上的字/图标 —— **浅色**(白色)。
|
||||||
|
*
|
||||||
|
* ★ 底色深、前景浅。`danger` / `warnFg` / `approve` 那些语义色底同属这一类,
|
||||||
|
* 所以压在它们上面的字/图标**也用这个**(写 `accentFg` 比另起一个
|
||||||
|
* `onDangerFg` 更实际:此项目里那些底的颜色都够深,一个白就够)。
|
||||||
|
*
|
||||||
|
* ⚠️ 别写成 `Theme.surface` —— 那是**面**(会跟随系统主题翻转),
|
||||||
|
* 2026-09-19 有 12 处这么写错了(见 `surface` 的注释)。
|
||||||
|
*/
|
||||||
static readonly accentFg: string = '#FFFFFF';
|
static readonly accentFg: string = '#FFFFFF';
|
||||||
/** 品牌蓝的浅底 / 深前景(与 WebUI 的 blue-50 / blue-700 成对) */
|
/** 品牌蓝的浅底 / 深前景(与 WebUI 的 blue-50 / blue-700 成对) */
|
||||||
static readonly accentSoft: string = '#EFF6FF';
|
static readonly accentSoft: string = '#EFF6FF';
|
||||||
|
|||||||
@ -94,7 +94,7 @@ struct InboxPage {
|
|||||||
Blank()
|
Blank()
|
||||||
if (this.unread > 0) {
|
if (this.unread > 0) {
|
||||||
Text(this.unread + ' 未读')
|
Text(this.unread + ' 未读')
|
||||||
.fontSize(13).fontColor(Theme.surface)
|
.fontSize(13).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.danger)
|
.backgroundColor(Theme.danger)
|
||||||
.borderRadius(10)
|
.borderRadius(10)
|
||||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
|
|||||||
@ -418,7 +418,7 @@ struct InboxTab {
|
|||||||
}
|
}
|
||||||
if (this.unread > 0) {
|
if (this.unread > 0) {
|
||||||
Text(this.unread > 99 ? '99+' : this.unread.toString())
|
Text(this.unread > 99 ? '99+' : this.unread.toString())
|
||||||
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.surface)
|
.fontSize(10).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.danger).borderRadius(9)
|
.backgroundColor(Theme.danger).borderRadius(9)
|
||||||
.constraintSize({ minWidth: 18 }).height(18)
|
.constraintSize({ minWidth: 18 }).height(18)
|
||||||
.textAlign(TextAlign.Center).margin({ left: 8 })
|
.textAlign(TextAlign.Center).margin({ left: 8 })
|
||||||
@ -585,7 +585,7 @@ struct InboxTab {
|
|||||||
Row() {
|
Row() {
|
||||||
if (g.unreadCount > 0) {
|
if (g.unreadCount > 0) {
|
||||||
Text(g.unreadCount > 99 ? '99+' : g.unreadCount.toString())
|
Text(g.unreadCount > 99 ? '99+' : g.unreadCount.toString())
|
||||||
.fontSize(9).fontWeight(FontWeight.Bold).fontColor(Theme.surface)
|
.fontSize(9).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.accent).borderRadius(8)
|
.backgroundColor(Theme.accent).borderRadius(8)
|
||||||
.constraintSize({ minWidth: 16 }).height(16).textAlign(TextAlign.Center)
|
.constraintSize({ minWidth: 16 }).height(16).textAlign(TextAlign.Center)
|
||||||
.margin({ right: 6 })
|
.margin({ right: 6 })
|
||||||
@ -1108,7 +1108,7 @@ struct PermissionTab {
|
|||||||
Row() {
|
Row() {
|
||||||
Button('同意')
|
Button('同意')
|
||||||
.fontSize(13).height(36).layoutWeight(1)
|
.fontSize(13).height(36).layoutWeight(1)
|
||||||
.backgroundColor(Theme.approve).fontColor(Theme.surface)
|
.backgroundColor(Theme.approve).fontColor(Theme.accentFg)
|
||||||
.onClick(() => { this.decide(req, 'allow', ''); })
|
.onClick(() => { this.decide(req, 'allow', ''); })
|
||||||
Text('拒绝')
|
Text('拒绝')
|
||||||
.fontSize(13).height(36).layoutWeight(1)
|
.fontSize(13).height(36).layoutWeight(1)
|
||||||
@ -1148,7 +1148,7 @@ struct PermissionTab {
|
|||||||
if (this.requests.length > 0) {
|
if (this.requests.length > 0) {
|
||||||
// 待决策的橙色徽标:这不是"有东西要读",是"有人被卡住"
|
// 待决策的橙色徽标:这不是"有东西要读",是"有人被卡住"
|
||||||
Text(this.requests.length + ' 待决策')
|
Text(this.requests.length + ' 待决策')
|
||||||
.fontSize(12).fontColor(Theme.surface)
|
.fontSize(12).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.warnFg)
|
.backgroundColor(Theme.warnFg)
|
||||||
.borderRadius(10)
|
.borderRadius(10)
|
||||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
@ -1387,7 +1387,7 @@ struct CommPage {
|
|||||||
.fontColor(this.commTab === key ? Theme.accent : Theme.textMuted)
|
.fontColor(this.commTab === key ? Theme.accent : Theme.textMuted)
|
||||||
if (badgeText(badgeCount(key, this.unreadCount, this.pendingCount)).length > 0) {
|
if (badgeText(badgeCount(key, this.unreadCount, this.pendingCount)).length > 0) {
|
||||||
Text(badgeText(badgeCount(key, this.unreadCount, this.pendingCount)))
|
Text(badgeText(badgeCount(key, this.unreadCount, this.pendingCount)))
|
||||||
.fontSize(10).fontColor(Theme.surface)
|
.fontSize(10).fontColor(Theme.accentFg)
|
||||||
// 红=有东西要读、橙=有人被卡住(更急);色值来自 Theme,页面不自己挑
|
// 红=有东西要读、橙=有人被卡住(更急);色值来自 Theme,页面不自己挑
|
||||||
.backgroundColor(badgeTone(key) === 'warn' ? Theme.warnFg : Theme.danger)
|
.backgroundColor(badgeTone(key) === 'warn' ? Theme.warnFg : Theme.danger)
|
||||||
.borderRadius(9)
|
.borderRadius(9)
|
||||||
@ -1472,7 +1472,7 @@ struct CommPage {
|
|||||||
* 离底留白 + 余量(`navReserve`)—— 否则它会压在条上。
|
* 离底留白 + 余量(`navReserve`)—— 否则它会压在条上。
|
||||||
*/
|
*/
|
||||||
Button() {
|
Button() {
|
||||||
AmIcon({ iconName: 'compose', iconSize: 24, iconColor: Theme.surface })
|
AmIcon({ iconName: 'compose', iconSize: 24, iconColor: Theme.accentFg })
|
||||||
}
|
}
|
||||||
.width(56).height(56)
|
.width(56).height(56)
|
||||||
.borderRadius(28)
|
.borderRadius(28)
|
||||||
@ -1940,7 +1940,7 @@ struct ContactsTab {
|
|||||||
.margin({ left: 6 }).layoutWeight(1)
|
.margin({ left: 6 }).layoutWeight(1)
|
||||||
if (c.unread_count > 0) {
|
if (c.unread_count > 0) {
|
||||||
Text(c.unread_count + '')
|
Text(c.unread_count + '')
|
||||||
.fontSize(10).fontColor(Theme.surface)
|
.fontSize(10).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.accent)
|
.backgroundColor(Theme.accent)
|
||||||
.borderRadius(9).width(18).height(18)
|
.borderRadius(9).width(18).height(18)
|
||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
@ -2083,8 +2083,8 @@ struct ContactsTab {
|
|||||||
.width('100%')
|
.width('100%')
|
||||||
Row() {
|
Row() {
|
||||||
Row() {
|
Row() {
|
||||||
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.surface })
|
AmIcon({ iconName: 'check', iconSize: 12, iconColor: Theme.accentFg })
|
||||||
Text('确认归档').fontSize(12).fontColor(Theme.surface).margin({ left: 4 })
|
Text('确认归档').fontSize(12).fontColor(Theme.accentFg).margin({ left: 4 })
|
||||||
}
|
}
|
||||||
.height(30).padding({ left: 12, right: 12 })
|
.height(30).padding({ left: 12, right: 12 })
|
||||||
.borderRadius(Theme.radiusControl)
|
.borderRadius(Theme.radiusControl)
|
||||||
@ -2121,7 +2121,7 @@ struct ContactsTab {
|
|||||||
Blank()
|
Blank()
|
||||||
if (c.unread_count > 0) {
|
if (c.unread_count > 0) {
|
||||||
Text(c.unread_count + '')
|
Text(c.unread_count + '')
|
||||||
.fontSize(11).fontColor(Theme.surface)
|
.fontSize(11).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.danger)
|
.backgroundColor(Theme.danger)
|
||||||
.borderRadius(10).width(20).height(20)
|
.borderRadius(10).width(20).height(20)
|
||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
|
|||||||
@ -138,7 +138,7 @@ struct SessionsPage {
|
|||||||
// 未读
|
// 未读
|
||||||
if (s.unread_count > 0) {
|
if (s.unread_count > 0) {
|
||||||
Text(s.unread_count + '')
|
Text(s.unread_count + '')
|
||||||
.fontSize(11).fontColor(Theme.surface)
|
.fontSize(11).fontColor(Theme.accentFg)
|
||||||
.backgroundColor(Theme.danger)
|
.backgroundColor(Theme.danger)
|
||||||
.borderRadius(10).width(20).height(20)
|
.borderRadius(10).width(20).height(20)
|
||||||
.textAlign(TextAlign.Center)
|
.textAlign(TextAlign.Center)
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
|
"due": "本工作区能装、能点设备(探针三值转 true 时自动变红)",
|
||||||
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(2026-09-19 起 harmony-admin 那条已升级为设备判据、移出名单 ⇒ 6 → 5:test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-imageprep.test.mjs)",
|
"where": "client/electron/test/run-all.mjs 的 STATIC_ONLY(2026-09-19 起 harmony-admin 那条已升级为设备判据、移出名单 ⇒ 6 → 5:test/harmony-appearance.test.mjs、test/harmony-logic.test.mjs、test/cross-client-theme.test.mjs、test/appearance-defaults.test.mjs、test/harmony-imageprep.test.mjs)",
|
||||||
"kind": "scope",
|
"kind": "scope",
|
||||||
"note": "2026-09-19:`harmony-admin` 的条目**已升级**(管理页真的能打开、列表真的渲染出用户行)并移出 STATIC_ONLY ⇒ 本笔 6 → 5。剩下的五次升级按\"每条缺什么设备侧验证\"逐条来,不为了把数字消成 0 而凑 —— 凑出来的设备判据只是把\"没验\"换成\"假装验了\"。"
|
"note": "2026-09-19:`harmony-admin` 的条目**已升级**(管理页真的能打开、列表真的渲染出用户行)并移出 STATIC_ONLY ⇒ 本笔 6 → 5。剩下的五次升级按\"每条缺什么设备侧验证\"逐条来,不为了把数字消成 0 而凑 —— 凑出来的设备判据只是把\"没验\"换成\"假装验了\"。\n\n2026-09-19 再更新:`cross-client-theme` **升级了一半** —— 新增一条设备判据(读悬浮球像素:品牌色真的画成 `#2563EB`;且球上图标与底色 WCAG 对比度 ≥3:1),并顺手钉了一条静态防线(`Theme.surface` 不得当代的前景色)。**它仍留在 STATIC_ONLY 名单里**,因为 `.ets` 那半只有悬浮球这一处上了设备 —— 其余令牌仍是静态对齐。「一半」要写出来,不能让名单看起来像没动过。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mails-status-derived",
|
"id": "mails-status-derived",
|
||||||
@ -130,11 +130,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "harmony-maildetail-missing-three",
|
"id": "harmony-maildetail-missing-three",
|
||||||
"count": 3,
|
"count": 0,
|
||||||
"due": "鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)",
|
"due": "鸿蒙详情页对齐 WebUI 时(三块功能,服务端都已支持)",
|
||||||
"where": "client/harmony/entry/src/main/ets/pages/MailDetailPage.ets (对照 client/electron/src/components/MailView.tsx)",
|
"where": "client/harmony/entry/src/main/ets/pages/MailDetailPage.ets (对照 client/electron/src/components/MailView.tsx)",
|
||||||
"kind": "scope",
|
"kind": "scope",
|
||||||
"note": "2026-09-19 审计发现:邮件详情页缺**三块 WebUI 有的功能**,而且**服务端三块都已支持**(不是做不了):① `RenameProposalBar`(MailView.tsx:324)—— Agent 建议改会话别名,人确认/驳回。服务端 `server/internal/handler/rename_proposal.go` 已在解析 `propose_alias` 标记并把提议从正文剥掉。**这条对寻址稳定性很重要**(WebUI 那里的注释:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。② `ForwardBar`(MailView.tsx:378)—— 转发。服务端 `forward.go` 完整(含 `forwardSubject` 的 Fwd: 叠加处理)。③ `BudgetEditor`(MailView.tsx:235)—— 会话往返预算编辑(`max_rounds`,服务端 `forward.go:304` 有此字段)。三条都不是顺手能补的量级(各含交互 + 接口 + 状态),所以先登记,不在审计那一批里硬塞 —— 硬塞的结果是每条都半成品。"
|
"note": "2026-09-19 审计发现:邮件详情页缺**三块 WebUI 有的功能**,而且**服务端三块都已支持**(不是做不了):① `RenameProposalBar`(MailView.tsx:324)—— Agent 建议改会话别名,人确认/驳回。服务端 `server/internal/handler/rename_proposal.go` 已在解析 `propose_alias` 标记并把提议从正文剥掉。**这条对寻址稳定性很重要**(WebUI 那里的注释:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。② `ForwardBar`(MailView.tsx:378)—— 转发。服务端 `forward.go` 完整(含 `forwardSubject` 的 Fwd: 叠加处理)。③ `BudgetEditor`(MailView.tsx:235)—— 会话往返预算编辑(`max_rounds`,服务端 `forward.go:304` 有此字段)。三条都不是顺手能补的量级(各含交互 + 接口 + 状态),所以先登记,不在审计那一批里硬塞 —— 硬塞的结果是每条都半成品。\n\n2026-09-19 结算:三块**都做完了** ——\n① 转发(commit cc7ff25,`ForwardMailRequest` + 弹层;修了两个真 bug:两个动作球几乎完全重叠、弹层没高度导致键盘一弹按钮被顶出屏幕);\n② 会话改名建议(commit c26e857 接口 + fe4342a UI,端到端实测:服务端识别 rename_proposed → 建议条出现 → 点接受 → 库里别名真的改了);\n③ 往返预算(commit 4e84369,GET/PUT 契约用 curl 逐条实测)。\n★ 未验:预算条的**点击**在设备上没走通 —— 模拟器顶部 155px 是系统手势区,而折叠头部恰在其中(坐标式点击会被系统抢走)。数据链路已实测,观感没验。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user