跨端: 登录页那个"emoji"是 Unicode 符号 —— 顺手修好图标贴左上角(共 4 处)

用户两条反馈,都是**看着界面**报出来的,而编译器与所有既有判据全绿:
①「为什么登陆页不是app图标,而是一个emojy?」
②「你自己看看那个图标的位置正常吗?」

## ① `Text('✉')` 被系统渲染成彩色 emoji

登录页的品牌标识原先写的是 `Text('✉')`(Unicode U+2709)。HarmonyOS 字体链里有
**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒ 渲染成一枚黄白色风信封 emoji:

- `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色);
- 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言;
- 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。

而本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标上那个信封,专为品牌标识画的)。
换成 `AmIcon({ iconName: 'brandMark' })` 即可 —— 走 `Path.stroke()`,跟主题色走。

## ② 图标贴在卡片左上角(量出来的)

`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

**不是一处**:全仓扫出 4 个同样写法。修法是套一层
`Stack({ alignContent: Alignment.Center })`(仓里回复球与悬浮加号本来就是这么写的,
所以它们一直是对的):

| 位置 | 图标 | 盒子 | 原状态 |
|---|---|---|---|
| 登录页品牌卡 | brandMark 24 | 48×48 | 贴左上 ✗ |
| 用户管理刷新键 | repeat 20 | 40×40 | 贴左上 ✗ |
| 联系人视图切换 | cardView 18 | 40×40 | 贴左上 ✗ |
| 收件箱组头箭头 | chevronRight 12 | 20 槽位 | 贴左 ✗ |
| 回复球 / 悬浮加号 | chatBubble / compose | 56×56 | 本来就对(尺寸在外层 Button 上) |

修后实测:偏移 **−34.5px → 0.5px**(0.14vp,亚像素级)。

## 判据(harmony-arkts 3 → 5 条)

- **图标不许用 Unicode 符号充当**:扫 `Text('…')` 里单个符号的情况。
  ★ 只框 U+2600–U+27BF / U+2B00–U+2BFF / U+FE0F,**刻意不含基本箭头段**(U+2190–U+21FF)——
  `→` 在正文里是标点不是图标,框进来会误伤大量正常文案。
  (我第一版把箭头段也框了,结果自检自己先红 —— 断言写错就是写错,不靠放宽它来「修」。)
- **尺寸不许直接链在 `AmIcon` 上**:把"图标贴左上角"这个坑的**形状**钉住,
  并自检"套了 `Stack` 的正确写法不许被误判"。

**两个变异方向都跑过**:放回 `Text('✉')` ⇒ 红 ✓;给 `AmIcon` 直接加 `.width(48)` ⇒ 红 ✓。
This commit is contained in:
2026-09-18 11:47:51 +08:00
parent 2da38bba83
commit 0e5eec61bd
6 changed files with 199 additions and 18 deletions

View File

@ -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 的正确写法');
});

View File

@ -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 都要在清单里")

View File

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

View File

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

View File

@ -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)

View File

@ -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)