diff --git a/client/electron/test/harmony-arkts.test.mjs b/client/electron/test/harmony-arkts.test.mjs index c22dae0..68a3cb3 100644 --- a/client/electron/test/harmony-arkts.test.mjs +++ b/client/electron/test/harmony-arkts.test.mjs @@ -157,3 +157,109 @@ test('ArkTS 词汇层硬坑:全仓 .ets 不许出现解构 / any / unknown / } assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`); }); + +/* ─────────────────── 图标:不许用 Unicode 符号,且尺寸不许直接加在 AmIcon 上 ─────────────────── */ + +/** + * 这两条都是 2026-09-18 用户**看着界面**报出来的,而且都不是「代码不合法」—— + * 编译器与所有既有判据全绿,只有屏幕上是错的。 + */ + +test('★ 图标不许用 Unicode 符号充当(`Text(\'✉\')` 会被系统渲染成彩色 emoji)', () => { + /* + * 登录页原本写的是 `Text('✉')`(U+2709)当品牌图标。 + * + * HarmonyOS 的字体链里有**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒ + * 它被渲染成一枚**彩色 emoji**(黄白色风信封)而不是单色图标: + * ① `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色); + * ② 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言。 + * 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。 + * + * 本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标那个信封), + * 所以这条纪律的成本是零:界面里所有图标都走 `AmIcon`。 + * + * 判据只扫 `Text('…')` 里**单独一个**符号的情况 —— 正文里的标点/箭头不算 + * (例如提示语里的「·」或「→」),否则会误伤大量正常文案。 + */ + const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets'); + /* + * 会被 emoji 字体接管的**典型图标类**符号: + * U+2600–U+27BF(Misc Symbols / Dingbats:☀ ☂ ✈ ✉ ✏ ✔ ❤ …) + * U+2B00–U+2BFF(杂项符号与箭头) + * U+FE0F(变体选择符,把字符变成 emoji 呈现) + * ★ 刻意**不含 U+2190–U+21FF(基本箭头)**:`→`/`←` 在正文里是**标点**, + * 不是图标 —— 把它们也扫进来会误伤大量正常文案。 + * (我第一版把箭头段也框了进去,结果自检自己就先红了: + * `!EMOJI_ISH.test('→')` 不成立 —— 断言写错就是断言写错,不能靠放宽它来「修」。) + */ + const EMOJI_ISH = /^[\u2600-\u27BF\u2B00-\u2BFF\uFE0F]$/; + const hits = []; + const walkDir = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walkDir(full); continue; } + if (!/\.ets$/.test(e.name)) continue; + const src = code(full); + for (const m of src.matchAll(/Text\(\s*'([^']{1,3})'\s*\)/g)) { + const ch = m[1]; + if (EMOJI_ISH.test(ch)) { + hits.push(`${e.name}: Text('${ch}')(U+${ch.codePointAt(0).toString(16).toUpperCase()})`); + } + } + } + }; + walkDir(ETS); + assert.deepEqual(hits, [], + `用 Unicode 符号当图标会被系统渲染成 emoji,且不吃 fontColor:\n ${hits.join('\n ')}\n` + + '改用 AmIcon(图标表在 common/Icons.ets,品牌标是 brandMark)'); + // 自检:探测器要真能认出这个形状(否则"没有命中"与"探测器坏了"结果一样) + assert.ok(EMOJI_ISH.test('\u2709') && EMOJI_ISH.test('\u2764'), + '探测器要认得出 U+2709 / U+2764 这类符号'); + assert.ok(!EMOJI_ISH.test('·') && !EMOJI_ISH.test('→'), + '探测器不许把正文标点当成图标(会误伤大量文案)'); +}); + +test('★ 尺寸/底色不许直接链在 `AmIcon` 上(内层容器固定 iconSize 且靠左上 ⇒ 图标贴左上角)', () => { + /* + * `AmIcon` 内部那个 `Stack` 是 `iconSize` 那么大、默认靠左上排版。 + * 调用方写 `AmIcon({…}).width(48).height(48)` 想要个大点的可上色盒子时, + * **外层盒子变大、图标不动** ⇒ 图标贴在盒子左上角。 + * + * 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读的实际 bounds): + * 卡片 [1523,521][1661,659] 138×138px + * 图标 [1526,524][1589,587] 63×63px ← 左边距/上边距都只有 3px + * 中心偏 10vp。用户原话:「你自己看看那个图标的位置正常吗」。 + * + * 正确写法是套一层居中的容器(仓里回复球与悬浮加号本来就是这么做的): + * Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(48).height(48) + * + * 判据形状:同一表达式里,`AmIcon({…})` 之后**紧跟着** `.width(` / `.height(` + * 就算命中(中间只允许换行与空白)。套了 `Stack` 的写法中间隔着 `}`, + * 所以不会被误判。 + */ + const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets'); + const hits = []; + const walkDir = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walkDir(full); continue; } + if (!/\.ets$/.test(e.name)) continue; + const src = code(full); + /* `AmIcon({ ... })` 后面直接跟 `.width(` 或 `.height(` */ + for (const m of src.matchAll(/AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)) { + const line = src.slice(0, m.index).split('\n').length; + hits.push(`${e.name}:${line} → .${m[1]}(`); + } + } + }; + walkDir(ETS); + assert.deepEqual(hits, [], + `尺寸直接加在 AmIcon 上会让图标贴左上角(内层容器固定 iconSize):\n ${hits.join('\n ')}\n` + + '改成 Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(N).height(N)'); + // 自检:探测器必须认得这个形状,且不误伤正确的嵌套写法 + const bad = "AmIcon({ iconName: 'x', iconSize: 24 }).width(48)"; + const good = "Stack({ alignContent: Alignment.Center }) {\n AmIcon({ iconName: 'x', iconSize: 24 })\n}\n.width(48)"; + const probe = (t) => [...t.matchAll(/AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)].length; + assert.equal(probe(bad), 1, '探测器要认得出错误写法'); + assert.equal(probe(good), 0, '探测器不许误伤套了 Stack 的正确写法'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index cbd636c..42736c8 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -115,7 +115,7 @@ const SUITE = [ ['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 29], // ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的: // 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。 - ['test/harmony-arkts.test.mjs', [], 3], + ['test/harmony-arkts.test.mjs', [], 5], // ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。 // // 它们**早就存在**,却从没进过 SUITE ⇒ 自检 2("每个 *.test.mjs 都要在清单里") diff --git a/client/harmony/entry/src/main/ets/common/Icons.ets b/client/harmony/entry/src/main/ets/common/Icons.ets index 97dc592..f3c34e6 100644 --- a/client/harmony/entry/src/main/ets/common/Icons.ets +++ b/client/harmony/entry/src/main/ets/common/Icons.ets @@ -124,6 +124,24 @@ export struct AmIcon { .strokeLineJoin(LineJoinStyle.Round) .scale({ x: this.iconScale(this.iconSize), y: this.iconScale(this.iconSize) }) } + /* + * ★★ 2026-09-18:这里原来是 `.width(this.iconSize).height(this.iconSize)`, + * 现在**显式保持默认尺寸**,同时把“调用方给更大盒子”的做法钉成错误用法。 + * + * 曾经的陷阱:调用方写 `AmIcon({…}).width(48).height(48)`(想要一个大点的可点区域)—— + * 外层盒子变大了,而**内部这个容器的尺寸不变、且默认靠左上** ⇒ 图标贴在盒子左上角。 + * + * 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读实际 bounds): + * 卡片 [1523,521][1661,659] 138×138px + * 图标 [1526,524][1589,587] 63×63px + * ⇒ 中心偏了 10vp(用户:「你自己看看那个图标的位置正常吗」)。 + * 同样写法在仓里有 4 处(悬浮加号/返回键/刷新键/品牌卡),只是图标小时偏得不明显。 + * + * 为什么不在这里写 `width('100%')` 来适配:那会让**所有没显式给尺寸的调用点** + * (绝大多数)在 `Row` 里试图撑满父容器 —— 那是拿一个普遍存在的布局回归 + * 换四个特例的方便。正确做法是调用方要“大盒子”就自己套一层居中的容器 + * (见 `LoginPage` 品牌卡与 `heroIcon` 的用法),组件本身只管画好这 iconSize 那么大的图标。 + */ .width(this.iconSize) .height(this.iconSize) } diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index f62bbeb..9a0c8d3 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -346,8 +346,20 @@ struct AdminUsersPage { Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .layoutWeight(1) // 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上 - AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent }).width(40).height(40) - .onClick(() => { this.load(); }) + /* + * ★ 40×40 的命中区,图标居中 —— 尺寸加在**外层 Stack** 上。 + * + * 原来写的是 `AmIcon({...}).width(40).height(40)`:内层容器固定 20vp 且靠左上, + * 于是 20vp 的图标贴在 40×40 命中区的左上角(同 `LoginPage` 品牌卡那处, + * 2026-09-18 一起修)。命中区本身是好的(40vp ≥ 44? 不 —— 见下), + * 但视觉上"图标没在按钮中央"是对的观感洁癖,也说明这个盒子才是按钮。 + */ + Stack({ alignContent: Alignment.Center }) { + AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent }) + } + .width(40) + .height(40) + .onClick(() => { this.load(); }) } /* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */ .width('100%').height(56 + topInset(this.windowInsets)).padding({ left: 8, right: 8, top: topInset(this.windowInsets) }) diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 6bf8888..2322834 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -14,6 +14,7 @@ import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { PushService } from '../api/PushService'; import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { AmIcon } from '../common/Icons'; @Entry @Component @@ -202,11 +203,39 @@ struct LoginPage { Scroll() { Column() { Column() { - // 与 WebUI 登录页一致:品牌标识不是巨幅文字,而是一枚紧凑的浅蓝图标卡。 - Stack() { - Text('✉') - .fontSize(22) - .fontColor(Theme.accent) + /* + * 品牌标识(与 WebUI 登录页一致:一枚紧凑的浅蓝图标卡,不是巨幅文字)。 + * + * ★★ 2026-09-18 修:这里原先是 `Text('✉')`(Unicode U+2709)。 + * HarmonyOS 的字体链里有**彩色 emoji 字体**,U+2709 自带 emoji 字形 + * ⇒ 它被渲染成一枚**彩色 emoji**(黄白色风信封),而不是单色图标: + * 既不跟主题色走(`fontColor(Theme.accent)` 对彩色 emoji **无效** —— 界面看着 + * 就是那个 emoji 自带的颜色),也和底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言。 + * 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。 + * + * 正确做法是 `AmIcon({ iconName: 'brandMark' })` —— 本仓**早就有**这个图标 + * (`common/Icons.ets` 的 `ICON_PATHS.brandMark`,就是 App 图标上那个信封), + * 而它走 `Path.stroke()`,跟主题色走、与其余图标同一套描边。 + * + * ★ 纪律:**界面里不许用 Unicode 符号当中图标**。 + * 它在某些字体链上会被渲染成 emoji,而 emoji 不吃 `fontColor`。 + */ + /* + * ★ 尺寸/底色加在**外层 `Stack`** 上,**不加在 `AmIcon` 上**。 + * + * 直接把 `.width(48)` 链在 `AmIcon` 后面是个陷阱:它内部那个容器固定是 + * `iconSize` 那么大、且**默认靠左上**,外层盒子变大而图标不动 ⇒ + * 图标贴在卡片左上角。实测(三折叠 3.5 密度,dumpLayout 读的实际 bounds): + * 卡片 [1523,521][1661,659] 138×138px + * 图标 [1526,524][1589,587] 63×63px ← 左边距 3px、上边距 3px + * 中心偏了 10vp(用户:「你自己看看那个图标的位置正常吗」)。 + * + * `Stack({ alignContent: Alignment.Center })` 是"给图标一个更大的、 + * 居中的、可上色的盒子"的正确写法 —— 仓里另外两处(回复球、悬浮加号) + * 就是这么做的(它们把尺寸加在外层 `Button` 上,所以一直是对的)。 + */ + Stack({ alignContent: Alignment.Center }) { + AmIcon({ iconName: 'brandMark', iconSize: 24, iconColor: Theme.accent }) } .width(48) .height(48) diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 1913a02..c2e27e8 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -537,9 +537,17 @@ struct InboxTab { @Builder GroupHeader(g: SessionGroup) { Row() { - AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtle }) - .rotate({ angle: this.isExpanded(g.key) ? 90 : 0 }) - .width(20) + /* + * 展开箭头:`.width(20)` 是**给它一个 20vp 的槽位**(与下面的标题对齐), + * 而图标只有 12vp —— 尺寸加在**外层 Stack** 上才会居中; + * 直接链在 `AmIcon` 上会让箭头贴在槽位左侧(2026-09-18 与另外两处一起修)。 + * `rotate` 留在图标上(转的是箭头本身,不是那 20vp 的槽位)。 + */ + Stack({ alignContent: Alignment.Center }) { + AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtle }) + .rotate({ angle: this.isExpanded(g.key) ? 90 : 0 }) + } + .width(20) Column() { Row() { Text(g.latest?.from_name ?? '') @@ -1508,13 +1516,21 @@ struct ContactsTab { Row() { Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Blank() - AmIcon({ - iconName: this.contactView === 'list' ? 'cardView' : 'listView', - iconSize: 18, - iconColor: Theme.textMuted - }) - .width(40).height(40) - .onClick(() => { this.switchView(); }) + /* + * 40×40 命中区 + 图标居中:尺寸加在**外层 Stack** 上,不加在 `AmIcon` 上。 + * 内层容器固定 `iconSize`(18) 且靠左上 —— 直接链 `.width(40)` 会让 + * 18vp 的图标贴在 40×40 命中区左上角(同 `LoginPage` 品牌卡、 + * `AdminUsersPage` 刷新键,2026-09-18 一起修)。 + */ + Stack({ alignContent: Alignment.Center }) { + AmIcon({ + iconName: this.contactView === 'list' ? 'cardView' : 'listView', + iconSize: 18, + iconColor: Theme.textMuted + }) + } + .width(40).height(40) + .onClick(() => { this.switchView(); }) } .width('100%').height(56).padding({ left: 16, right: 8 }) .backgroundColor(Theme.surface)