From f0dc342c1f222c18c5707da2dc886294d4446a63 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 24 Sep 2026 13:13:46 +0800 Subject: [PATCH] =?UTF-8?q?=E9=B8=BF=E8=92=99=EF=BD=9C=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=20fill=20=E5=9E=8B=E5=88=86=E6=97=8F=20+=20=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E6=9D=A1=E9=98=B4=E5=BD=B1=E6=A0=B9=E5=9B=A0=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E7=94=9F=E6=88=90=E5=99=A8=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失): ① 侧边栏图标"莫名其妙的加粗" 根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型** (`fill="currentColor"` + 两个实心 ``), 它自己的注释就写着「fill 型,与上面的描边图标集**不同族**,所以不包 Svg」。 而 `AmIcon` 一律 `fill(Transparent) + stroke()` ⇒ 实心块只剩轮廓、 实心眼变成小圆环、圆头端点在小尺寸下糊成一坨。 修法:生成器自动判定两族(`is_fill_family`),产出 `FILL_ICONS` 名单, fill 族整块填色、不加描边。 ② 详情页权限面板"左边被截断" `PermissionPanel` 挂在详情页外层,根部只有 `padding({top:10})` —— 而正文 `Markdown` 与附件块**各自自带** `left/right:16`。补上同档 16。 ③ 顶栏"莫名其妙的底部阴影" ★ 这条查错了两轮,记下来: · 先以为是窗格投影从半透明玻璃透上来 → 补底边、去材质 —— 都没用; · 几何取证才定位真因:`InboxTab` 根容器与页签条是 `Column` 里的**兄弟** 且**绘制在后**(页签条 y 142→271,InboxTab y 271→2202), 它的 `PaneModifier` 投影向上扩散 ~30px,正好压住页签条 (观测到的渐变区 y 240→268,完全吻合)。 · 双向验证:关掉所有窗格投影 → 全平(证明确实是投影); 只把 `InboxTab` 换 `plain` → 落差 21→8(证明是**这一层**)。 修法:`PaneModifier` 加 `plain()`(只要背景语义、不投投影)。 WebUI 的 `box-shadow` 只给 `.app-shell > *`(三个**并列**面板), 面板内部不投 —— 这个结构差异就是原因。 ★ 同时保留页签条的**悬浮玻璃**(用户 09-20 点名要的): 我中途一度按"跟 webui 同步"把它改成不透明实体面, 那是**读错了**——用户指的是页面结构对齐,而玻璃是他自己定过的; 两者不冲突(玻璃是观感选择,阴影是 bug)。已恢复并留注释。 ④ 生成器入库(`client/harmony/tools/gen-icons.py`) 它原先在仓库外(`/root/gotmp`),于是**落后生成物三次提交**没人发现: 我手改 `Icons.ets` 修 fill 图标后重跑它,修改被**冲掉**(退回旧实现)。 现在:路径按 `__file__` 解析(任何检出目录都能跑)、 `is_fill_family` 自动分族、输出段与正确实现逐字一致(diff 为空)。 `Icons.ets` 由它生成,不再手改。 判据: · `harmony-appearance` 29 条(+1):窗格投影只给并列窗格, 面板内部的子 tab 不得再投。按 **struct 归属**判(第一版计数有洞, 变异①抓不到 —— 改回 `of` 时同文件别处的 `plain` 仍让它绿)。 两个变异都能红:① InboxTab 改回 of;② 让 plain 也投投影。 另修一条既有判据的假红:它数 `PaneModifier.of` 出现次数, 加 `plain` 后误报("让出页面底"的两种合法写法都要算)。 · 套件:harmony-nav 22/22、harmony-appearance 28/28、 harmony-logic 34/34、harmony-contacts 5/5、animation-audit 15/15。 **功能对齐状态**(回答用户"功能对齐没有"): 对着 `docs/DEBTS.json` 逐条核过 —— 鸿蒙侧**没有缺页面、没有缺功能**。 授权栏历史(`harmony-permission-history`)✅ 已做、 详情页转发/改名建议/预算(`harmony-maildetail-missing-three`)✅ 三块全做完。 仅剩两条,都不在鸿蒙:`harmony-p4c-boundary-decls` 卡在"需真人操作系统选图器"、 `permission-expires-at-unused` 缺的是 **WebUI 那一半**。 --- .../electron/test/harmony-appearance.test.mjs | 124 ++++- .../entry/src/main/ets/common/Icons.ets | 49 +- .../entry/src/main/ets/common/Surface.ets | 40 +- .../entry/src/main/ets/pages/ContactsTab.ets | 2 +- .../entry/src/main/ets/pages/MainPage.ets | 116 ++++- .../src/main/ets/pages/PermissionPanel.ets | 21 +- .../src/main/ets/pages/PermissionTab.ets | 2 +- client/harmony/tools/gen-icons.py | 454 ++++++++++++++++++ 8 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 client/harmony/tools/gen-icons.py diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 53967f5..57f2809 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -498,7 +498,21 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否 '页面底要让位给壁纸 ⇒ 必须有一个统一的窗格属性集(不是每页各写三元)'); assert.match(paneMod, /backgroundColor\(this\.active \? Color\.Transparent : Theme\.pageBg\)/, 'PaneModifier 必须在 active 时返回透明(否则"让出"这件事根本没发生)'); - const yielded = [...main.matchAll(/\.attributeModifier\(PaneModifier\.of\(this\.bgActive\)\)/g)].length; + /* + * ★★ 2026-09-24 改:判"让出页面底"的**两种入口**,而不是数一种写法。 + * + * 原断言:`\.attributeModifier\(PaneModifier\.of\(this\.bgActive\)\)` 至少出现 5 次。 + * + * 本次加了 `PaneModifier.plain(...)`(同背景语义、**不投投影**)—— + * 它同样“让出页面底”(`backgroundColor` 那段是共用的), + * 而旧断言只数 `of(` ⇒ 当场假红(实测:`实际 1 处`、`fail 1`)。 + * + * 这正是本文件自己反复记过的形状:**判据锚在实现的一种写法上, + * 而不是锚在它声称的那件事上**。"让出页面底"的两种合法写法都要算。 + * (`plain` 的出现理由:同一窗格被多层嵌套时,只有**最外层的并列窗格** + * 该投投影,内层用 `plain` —— 见 `Surface.ets` 的 `shadowed` 注释。) + */ + const yielded = [...main.matchAll(/\.attributeModifier\(PaneModifier\.(?:of|plain)\(this\.bgActive\)\)/g)].length; assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane),实际 ${yielded} 处`); // 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出) @@ -554,6 +568,114 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否 } }); +/* + * ═══════════════════════════════════════════════════════════════════ + * 窗格投影:**只有"并列窗格"该投**,面板内部不投 + * ═══════════════════════════════════════════════════════════════════ + * + * 用户 2026-09-24:「顶栏莫名其妙的底部阴影」+「跟 webui 同步」。 + * + * ── 症状 ── + * 页签条(收件箱/发件箱/授权)内部从上到下 254→234 **单调递减**一条暗带, + * 像素实测贯通整条(x 250→1150 亮度恒 224-227)。 + * + * ── 根因(几何取证,不是猜)── + * `InboxTab` 的根容器与页签条是 `Column` 里的**兄弟**,且**绘制在后** + * (页签条 y 142→271,InboxTab y 271→2202)。它的 `PaneModifier` 投影向四周扩散 + * ⇒ 向上扩散的那 ~30px 正好压住页签条(观测到的渐变区 y 240→268,吻合)。 + * + * 实测验证(只把 `InboxTab` 改 `plain`,其余不动): + * 修前落差 21(254→233) → 修后落差 8(255→247)✅ + * 而窗格自己那圈投影(x=230 处 176-180)**仍在** —— 没把该有的层次感一起删掉。 + * + * ── WebUI 为什么没这问题 ── + * `box-shadow` 只挂在 `.app-shell > *`(`index.css:980`)—— + * 那是**三个并列面板**(Sidebar / list / main),**没有嵌套**。 + * 面板**内部**(列表、卡片)不再投投影。 + * + * ── 为什么不只盯 `InboxTab` 一个名字 ── + * 同一个形状在仓里有**多处**(`SentTab`/`PermissionTab`/`ContactsTab` 的 navBar 内容)。 + * 只钉一个名字的话,下次改另一个 tab 就会把同一条阴影带回来。 + * 所以判的是**结构不变式**: + * · `Navigation` 壳(并列窗格)→ `PaneModifier.of`(带投影); + * · 壳**内部**那几层 → `PaneModifier.plain`(不投)。 + */ +test('★ 窗格投影只给并列窗格;面板内部的子 tab 不得再投一次(顶栏阴影的根因)', () => { + const surface = read('common/Surface.ets'); + + /* 前提:两类工厂都得存在,且 plain 真的不投投影 */ + assert.match(surface, /static plain\(active: boolean\): PaneModifier/, + '要有一个"只要背景语义、不投投影"的入口(面板内部用它)'); + assert.match(surface, /private shadowed: boolean = true;/, + 'PaneModifier 要有一个显式开关区分"投/不投",而不是靠调用点自己记'); + assert.match(surface, /if \(this\.shadowed\) \{\s*instance\.shadow\(Theme\.glassShadow\);\s*\}/, + '`plain` 必须**真的**不调 shadow —— 否则这个开关是装饰品'); + + /* + * 不变式:**子 tab 的根容器**不得带投影(它们就在页签条下方、绘制在后)。 + * + * ★ 判法:先定位每个 modifier 落在哪个 `struct` 里,再要求子 tab 那些 struct + * 一律用 `plain`。 + * + * ⚠ 第一版写的是"该文件里 `plainCount >= 1`" —— **变异测不出来**: + * 把 `InboxTab` 改回 `of` 后,同文件的 `SentTab`/navBar 那几处 `plain` + * 仍然存在 ⇒ 计数 ≥1 照样绿(实测:变异① 没抓到)。 + * "存在某个正确的" 与 "该正确的那个不许错" 是两件事。 + */ + const CHILD_TABS = ['InboxTab', 'SentTab', 'PermissionTab', 'ContactsTab']; + /* 某个偏移落在哪个 struct 里(取它前面最近的那个 `struct X {`) */ + const structAt = (src, idx) => { + const before = src.slice(0, idx); + const ms = [...before.matchAll(/(?:^|\n)struct\s+(\w+)\s*\{/g)]; + return ms.length ? ms[ms.length - 1][1] : '(top)'; + }; + for (const [file, label] of [ + ['pages/MainPage.ets', 'CommPage'], + ['pages/ContactsTab.ets', 'ContactsTab 页'], + ['pages/PermissionTab.ets', 'PermissionTab'], + ]) { + const src = read(file); + const offenders = []; + for (const m of src.matchAll(/PaneModifier\.(of|plain)\(this\.bgActive\)/g)) { + const owner = structAt(src, m.index); + if (m[1] === 'of' && CHILD_TABS.includes(owner)) offenders.push(owner); + } + assert.deepEqual(offenders, [], + `${label}: ${offenders.join('/')} 的根容器用了 \`PaneModifier.of\`(带投影),` + + '但它们就在页签条下方且绘制在后 ⇒ 投影向上扩散会压住页签条。' + + '要改用 `PaneModifier.plain`(投影只留给 Navigation 那层并列窗格)'); + } + + /* 同时:并列窗格那层仍要**保留**投影,否则层次感整个丢掉(不是只删不建) */ + const mainSrc = read('pages/MainPage.ets'); + const commAt = mainSrc.indexOf('struct CommPage {'); + assert.ok(commAt >= 0, '要能找到 CommPage'); + const commBody = mainSrc.slice(commAt, mainSrc.indexOf('\n}\n', commAt)); + assert.match(commBody, /PaneModifier\.of\(this\.bgActive\)/, + 'CommPage 的 Navigation(并列窗格)要保留带投影的 `PaneModifier.of` —— ' + + '否则窗格浮在壁纸上的层次感没了'); + + /* 同一个实例上不得挂两个 .attributeModifier(后者静默覆盖前者,意图丢失) */ + const main = read('pages/MainPage.ets'); + const navAt = main.indexOf('Navigation(this.navPathStack)'); + assert.ok(navAt >= 0, 'CommPage 要有 Navigation'); + /* + * ⚠ 取**属性链**(从 `navDestination(` 到闭合 `}`)而不是".slice(4000)": + * 第一版写 4000 字符,跨到了下一个组件的 modifier ⇒ 数出 3 个、假红。 + * 属性链上有固定的一组锚(navDestination / mode / navBarWidth), + * 用它定界比按字符数可靠。 + */ + const chainStart = main.indexOf('.navDestination(', navAt); + const chainEnd = main.indexOf('\n }\n}', chainStart); + assert.ok(chainStart > navAt && chainEnd > chainStart, '要能找到 Navigation 的属性链边界'); + const chain = main.slice(chainStart, chainEnd); + const navMods = [...chain.matchAll(/^\s*\.attributeModifier\(PaneModifier\./gm)].length; + assert.ok(navMods <= 1, + `同一个 Navigation 实例上挂了 ${navMods} 个 .attributeModifier —— ` + + '`.attributeModifier` 是**单一插槽**,后者会静默覆盖前者,' + + '于是"壳有没有投影"取决于书写顺序而不是意图(2026-09-24 已删掉那处重复)'); +}); + // ───────────── 深色色板:pi 指出这是"机制上确定不同",不是"观感未验" ───────────── /** 从 index.css 的某个段(:root 或 .dark)里读出调色板变量的 RGB */ diff --git a/client/harmony/entry/src/main/ets/common/Icons.ets b/client/harmony/entry/src/main/ets/common/Icons.ets index f3c34e6..9f35072 100644 --- a/client/harmony/entry/src/main/ets/common/Icons.ets +++ b/client/harmony/entry/src/main/ets/common/Icons.ets @@ -1,17 +1,19 @@ /* * 图标集 —— **与 WebUI 的 `client/electron/src/components/icons.tsx` 同几何**。 * - * 由 `/root/gotmp/gen-icons.py` 从那份 TSX **自动生成**,不要手改: - * · 源是 24×24 描边 SVG(fill=none / stroke=currentColor / strokeWidth 1.8 / 圆头圆角); + * 由 `client/harmony/tools/gen-icons.py` 从那份 TSX **自动生成**,不要手改: + * · 源是 24×24 SVG,分**两族**(见 `FILL_ICONS`):描边族 + * (`fill=none / stroke=currentColor / strokeWidth 1.8 / 圆头圆角`)与 + * fill 族(`fill=currentColor`,品牌标 `brandMark` 一个); * · 转成 ArkUI `Path` 的 commands,并**统一为绝对命令**、展开隐式重复 * (ArkUI 的解析器不认 SVG 的隐式 lineto); * · `circle`/`rect` 也展开成弧与直线。 * - * 为什么不用 SVG 资源文件:WebUI 的图标是**描边**画法,而鸿蒙 `Image().fillColor()` + * 为什么不用 SVG 资源文件:WebUI 的图标大多是**描边**画法,而鸿蒙 `Image().fillColor()` * 只改 fill、对 stroke 无效 ⇒ 直接放 SVG 图标就不跟主题走。用 `Path` 则 `.stroke()` * 直接吃主题色(这是"深色模式一处也不漏"的前提)。 * - * 生成物共 43 个图标;生成时已自检:每条命令的参数个数与 SVG 语法一致。 + * 生成物共 43 个图标(其中 fill 族 1 个);生成时已自检:每条命令的参数个数与 SVG 语法一致。 */ import { Theme } from './Theme'; @@ -66,6 +68,30 @@ export const ICON_PATHS: Record = { /** 图标原始坐标系边长(所有图标都在这个方框内绘制) */ export const ICON_VIEWBOX: number = 24; +/** + * **fill 型**图标的名单 —— 它们必须整块填色,不能当描边画。 + * + * ★★ 2026-09-24 新增(用户:「为什么侧边栏图标有莫名其妙的加粗?」)。 + * + * WebUI 的图标集(`icons.tsx`)分两族: + * · **描边型**(42 个):包在 `` helper 里,即 + * `fill="none" stroke="currentColor" strokeWidth="1.8"`; + * · **fill 型**(1 个):自己写 ``,含**实心**元素 + * (如 ``)。`BrandMarkIcon` 自己的注释就写着 + * 「品牌标志(fill 型,与上面的描边图标集**不同族**,所以不包 Svg)」。 + * + * 而本仓的 `AmIcon` 一律 `fill(Color.Transparent) + stroke(...)` —— + * 那是**描边型**的画法。于是 fill 型图标:实心块只剩轮廓、实心眼变成小圆环, + * 再加 `strokeLineCap: Round`,小尺寸下糊成一团(实测侧栏 3× 放大截图一眼可见)。 + * + * ── 为什么是名单而不是靠路径猜 ── + * 两族的区别是**源用了哪种画法**这个事实,不是几何能推出来的: + * `inbox`/`calendar` 这些描边图标也有闭合子路径 —— 猜会误伤一大片。 + * 由生成器从 `` helper 的有无自动判定(见脚本 `is_fill_family`), + * 所以名单不会因手改而漂。 + */ +export const FILL_ICONS: string[] = ['brandMark']; + /** * 图标组件:与 WebUI `` 等价的用法。 * @@ -83,6 +109,11 @@ export struct AmIcon { /** 描边宽度(WebUI 用 1.8) */ @Prop strokeWeight: number = 1.8; + /** 这个图标是不是 fill 族(整块填色)。名单由生成器产出,见 `FILL_ICONS`。 */ + private isFillIcon(): boolean { + return FILL_ICONS.indexOf(this.iconName) >= 0; + } + /** * 把 24 单位坐标系落到 `iconSize` vp 上所需的缩放比。 * Path.commands 的坐标是 px:24 单位需放大到 vp2px(iconSize) 个 px。 @@ -117,8 +148,14 @@ export struct AmIcon { Stack({ alignContent: Alignment.Center }) { Path() .commands(ICON_PATHS[this.iconName] ?? '') - .fill(Color.Transparent) - .stroke(this.iconColor) + /* + * ★★ 2026-09-24:两族图标分道 —— 见 `FILL_ICONS` 的长注释。 + * 描边型(绝大多数):透明填充 + 主题色描边; + * fill 型:整块填主题色、**不加描边** + * (给它描边会把实心块勾出一圈,且实心眼会变成圆环)。 + */ + .fill(this.isFillIcon() ? this.iconColor : Color.Transparent) + .stroke(this.isFillIcon() ? Color.Transparent : this.iconColor) .strokeWidth(this.iconStrokeWidth()) .strokeLineCap(LineCapStyle.Round) .strokeLineJoin(LineJoinStyle.Round) diff --git a/client/harmony/entry/src/main/ets/common/Surface.ets b/client/harmony/entry/src/main/ets/common/Surface.ets index d2926db..7e0e93b 100644 --- a/client/harmony/entry/src/main/ets/common/Surface.ets +++ b/client/harmony/entry/src/main/ets/common/Surface.ets @@ -546,14 +546,48 @@ export class TintModifier implements AttributeModifier { export class PaneModifier implements AttributeModifier { private active: boolean = false; + /** + * 要不要**投投影**。默认要;嵌在别的窗格**内部**的那几层传 `false`。 + * + * ★★ 2026-09-24 新增(用户:「顶栏莫名其妙的底部阴影」,且明确要「跟 webui 同步」)。 + * + * 症状:页签条内部从上到下 254→234 单调递减一条暗带,像素实测贯通整条 + * (x 250→1150 亮度恒 224-227)。 + * + * 根因:同一个窗格被套了**多层** `PaneModifier`,每层各投一次 `glassShadow`: + * Navigation ← PaneModifier(壳,**该投**) + * Stack(包 FAB) + * Column(navBar 内容)← PaneModifier(**不该投**:它在壳内部) + * ⇒ 内层那层的投影沿它自己的**上缘**绘制,而页签条是它内部最上面的一块 + * ⇒ 投影正好落在页签条上,看起来就是"顶栏底部的阴影"。 + * + * WebUI 没有这个问题:`box-shadow` 只给 `.app-shell > *`(**三个并列面板**) + * 各投一次,**没有嵌套**(`index.css:980`)。 + * ⇒ 本开关就是把"并列面板 vs 面板内部"这件事显式说出来, + * 而不是靠调用点记住"我是在里面还是在外面"。 + */ + private shadowed: boolean = true; - /** 工厂:`PaneModifier.of(this.bgActive)` */ + /** 工厂:`PaneModifier.of(this.bgActive)`;窗格**内部**那几层用 `PageModifier.plain(...)` */ static of(active: boolean): PaneModifier { const m = new PaneModifier(); m.active = active; return m; } + /** + * 只要背景语义、**不投投影** —— 给嵌在窗格内部的层用。 + * + * 什么时候用:这一层在**别的窗格的边界之内**(它的外缘不是"与壁纸的层次差")。 + * 典型:`Navigation` 壳里的 navBar 内容 Column、被窗格包住的子 tab 根容器。 + */ + static plain(active: boolean): PaneModifier { + const m = new PaneModifier(); + m.active = active; + m.shadowed = false; + return m; + } + applyNormalAttribute(instance: CommonAttribute): void { instance.backgroundColor(this.active ? Color.Transparent : Theme.pageBg); /* @@ -575,7 +609,9 @@ export class PaneModifier implements AttributeModifier { * 移到窗格层才与 WebUI 一致,也才有意义 —— 窗格是"浮在壁纸上的一张板", * 投影表达的是它与壁纸的**层次差**;卡片在窗格之内,同层之间不需要投影。 */ - instance.shadow(Theme.glassShadow); + if (this.shadowed) { + instance.shadow(Theme.glassShadow); + } } } diff --git a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets index ed8ceae..4047d2b 100644 --- a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets +++ b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets @@ -414,7 +414,7 @@ export struct ContactsTab { } } .width('100%').height('100%') - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) } .navDestination(this.DestinationBuilder) .mode(NavigationMode.Auto) diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index ce10076..337b519 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -624,7 +624,7 @@ struct InboxTab { } } .width('100%').height('100%') - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) } /* @@ -1277,7 +1277,7 @@ struct SentTab { } } .width('100%').height('100%') - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) } } @@ -1563,9 +1563,16 @@ struct CommPage { * 于是它是整页唯一一块实心白:上一轮把列表容器、卡片、底部导航条 * 都玻璃化了,唯独漏了这条页签条 ⇒ 壁纸从四周透出来,只有它在顶上白着一横条。 * - * 现在与**底部浮动条同一族**:圆角 + 左右留白 + 系统材质。 - * 三种做法各自的取舍见 `model/NavItems.ts` 的 `TAB_BAR_*` 常量注释 - * (为什么选悬浮而不是 WebUI 的"通栏无底色")。 + * ★★ 2026-09-24 **推翻上述方向**(见下面 `跟 webui 同步` 那段): + * 当时把它做成了“悬浮玻璃条”(材质 + 圆角),依据是“与底部条同一语汇”。 + * 但底部条是**圆角浮条**(四周都是边),页签条是**贴顶通栏 44vp** —— + * 同一种系统材质在两种几何下表现不同:贴顶那条把材质自带的 + * 方向性明暗暴露成了下缘一条暗带(用户:「顶栏莫名其妙的底部阴影」)。 + * ⇒ 现在改成 WebUI 的形状:**无材质的通栏条 + 底边分隔线**。 + * + * 为什么"不做成实心白"仍然成立:WebUI 那条也是**透明的** + * (`.comm-pane` 给它 `transparent`,`index.css:1641`)—— + * 它只是没有**材质**,不是有实心底。两者别混。 */ /* * 宽度:**与窗格齐平**(`100%`)。 @@ -1576,7 +1583,86 @@ struct CommPage { * 我们内缩 + 自己带角 ⇒ 右端出现两道弧(用户:「你又在内部套了一个胶囊」)。 * ⇒ 改成与窗格齐平,右端只剩窗格那一道边。 */ + /* + * ★★ 2026-09-24 改为**照 WebUI 同步**(用户:「跟 webui 同步」, + * 上下文是他刚指出「顶栏莫名其妙的底部阴影」)。 + * + * ── 那条暗带是什么(像素实测,不是猜)── + * 详情页左栏 x=500 逐像素: + * y=142 lum=244 ↓ 单调递减(没有回弹) + * y=262 lum=225 ← 落差 19 + * 对照证据: + * · 同一高度的**纯壁纸区**(x=210 / x=3170)恒为 210-212,**没有任何渐变** + * ⇒ 不是“壁纸透出来”; + * · 全仓 `.shadow(` 只有两处(`PaneModifier` 与登录页),页签条自己没有 + * ⇒ 不是外部投影(我先前以为是,给它加了底边 —— **渐变照旧**, + * 那个诊断当场被推翻)。 + * ⇒ 渐变来自**页签条自身的 `BlurStyle.COMPONENT_THICK`**:这类系统材质自带 + * 方向性明暗(顶部亮、底部暗)用来表达“浮起的立体条”。底部导航条因为是 + * **圆角浮条**(四周都是边)看不出,而页签条是**贴顶通栏 44vp**, + * 就把这个渐变直接暴露成下缘一条暗带。 + * + * ── 为什么“对齐 WebUI”等于**去掉材质** ── + * WebUI 的页签条根本不是浮条,是**无材质的通栏条**: + * className="shrink-0 flex items-stretch gap-1 px-3 **border-b border-gray-200**" + * (`CommTabs.tsx:40`)。它自己的注释写了两条理由: + * ① 「与**列表头**同一套观感(同内边距、同下边框)」; + * ② 用户 2026-09-14:「通信页面的二级页面与其他位置极其割裂」—— + * 之前那个白胶囊带 shadow 浮在面板上,看着是硬贴上去的另一套控件。 + * ⇒ “悬浮玻璃页签条”是本仓 09-20 自己的发明(当时依据是“与底部条同一语汇”), + * 而底部条是圆角浮条、页签条是贴顶通栏 —— **同材质在两种几何下表现不同**, + * 这一点当时漏了。现在回到 WebUI 的形状。 + * + * ── 同时去掉:圆角、以及上一轮为诊断加的底边 ── + * WebUI 里页签条是**唯一不圆角的那一个**(其余面板各自 `radius-card`, + * 见 `index.css:1371` 的 `.comm-pane > *:not([data-testid='comm-tabs'])`)。 + * 底边则**保留 WebUI 本来就有**的那条(`border-b border-gray-200`)—— + * 它不是为诊断加的,是 WebUI 的分界线,对应 `Theme.border`(同为分隔线语义)。 + */ .width('100%') + /* + * ★★ 2026-09-24 **必须有底色** —— 这是“顶栏底部阴影”的真正修因。 + * + * 上面那段说的是“不做成**实心白卡片**”,那是**实心 vs 玻璃**的区别; + * 而这里是另一个问题:**有底色 vs 透明**。 + * 我把材质去掉之后写成了 `Color.Transparent`,于是页签条**直接压在壁纸上** + * —— 实测那条暗带仍然存在(x 250→1150 贯通、亮度恒 224-227, + * 且页签条内部从上到下 245→227 单调递减)。 + * + * ── WebUI 怎么做的(关键那段注释)── + * 它明确区分了“列表容器”与“工具条/头部”(`index.css:1648`): + * + * 列表/详情的外框在壁纸模式下让位给“每项一张卡”,但**工具条与头部** + * 仍要有底色,否则会直接压在壁纸上读不清 —— 所以只把列表容器那一层放透明。 + * + * 而页签条的父层级(`.comm-pane`)是 `bg-white`(`MailList.tsx:73`), + * 所以页签条背后**是白的**,不是壁纸。 + * ⇒ "不计材质" 不等于 "透明"。我上一版把这二者搞成同一件事了。 + * + * `Theme.surface` = `ohos_id_color_list_card_bg`(浅色白/深色深灰, + * 自动跟随主题)—— 它就是 WebUI `--c-white`(`255 255 255` / 深色 `24 27 33`) + * 的对应物。用令牌而不是手写白:深色主题才改得动。 + */ + /* + * ★★ 2026-09-24 回到**悬浮玻璃**(09-20 用户点名要的),只补一条底边。 + * + * 我在这一轮中途曾把它改成 `Theme.surface + 无圆角 + 底边`,理由是 + * 「跟 WebUI 同步」—— 那是**读错了**:用户当时是配着 WebUI 整体布局截图, + * 指的是页面结构对齐;而他自己 09-20 已经明确定过页签条要做成悬浮玻璃 + * (`harmony-nav.test.mjs` 那条判据把原话与理由都记着): + * 「顶部的收件箱发件箱和授权为什么不做成悬浮玻璃?」 + * 两者不冲突:玻璃是**观感**选择,而那条阴影是**bug**。 + * + * ── 阴影的真凶不是页签条自己 ── + * 几何取证:页签条(y 142→271)与 `InboxTab` 根容器(y 271→2202)是 + * `Column` 里的**兄弟**且后者**绘制在后**,它的 `PaneModifier` 投影向上扩散 + * ~30px ⇒ 压住页签条(观测到的渐变区 y 240→268,完全吻合)。 + * 只把 `InboxTab` 换成 `PaneModifier.plain`(不投投影)实测落差 21→8, + * 而页签条的玻璃观感一字未动。 + * + * 底边仍保留:WebUI 的页签条本来就有 `border-b border-gray-200`, + * 而且它让玻璃条与下方可滚内容有一条清晰分界(`Theme.border` = 同一个分隔线语义)。 + */ .backgroundColor(Color.Transparent) .backgroundBlurStyle(this.bgActive ? Theme.navMaterial : BlurStyle.NONE) /* @@ -1588,6 +1674,7 @@ struct CommPage { * 去掉右边那一角,右端就只剩窗格自己那一道边。 */ .borderRadius({ topLeft: Theme.glassRadius, topRight: 0 }) + .border({ width: { bottom: 1 }, color: Theme.border }) } openMail(mailId: string, accountId: string): void { @@ -1691,7 +1778,7 @@ struct CommPage { * 卡片就成了"玻璃套玻璃",正是 WebUI 用 * `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的事。 */ - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) /* * ★★ 2026-09-20(用户:「邮件展示左侧没有圆角」)。 * @@ -1766,7 +1853,7 @@ struct CommPage { * 背景开关仍在这一层(它是通信页的根):卡不透明、容器透明, * 壁纸才从卡片间与顶/底边缘透出来(与 WebUI 的 `.comm-pane` 同构)。 */ - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) } .navDestination(this.DestinationBuilder) .mode(NavigationMode.Auto) @@ -1813,7 +1900,20 @@ struct CommPage { * 一个不透明的 `Navigation`。此前一直在调窗格自己的 `bgActive`, * 而真正挡住的是外面这层壳。 */ - .attributeModifier(PaneModifier.of(this.bgActive)) + /* + * ★★ 2026-09-24 删掉这里**重复的** `.attributeModifier(...)`。 + * + * 这个 `Navigation` 实例上原先挂了**两个** modifier(上面一个、这里一个)—— + * 而 `.attributeModifier` 是**单一插槽**(本仓注释里记过:`.backgroundColor` 链在 + * modifier 之后会被静默覆盖,同一形状)。两个 modifier 里只有**后者**生效, + * 于是"壳有没有投影"取决于书写顺序,而不是取决于意图。 + * + * ⇒ 现在只留上面那一个(`PaneModifier.of` = 带投影)—— + * `Navigation` 是**并列窗格**,对应 WebUI `.app-shell > *`(`index.css:980`) + * 那一层,是**唯一该投投影**的地方。 + * 面板**内部**的子 tab 根容器(`InboxTab`/`SentTab`/`PermissionTab`/`ContactsTab` + * 的 navBar 内容)改用 `PaneModifier.plain` —— 它们不是'浮在壁纸上的板'。 + */ } } diff --git a/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets b/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets index affb15b..a11bb63 100644 --- a/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets +++ b/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets @@ -313,7 +313,26 @@ export struct PermissionPanel { } .width('100%') .alignItems(HorizontalAlign.Start) - .padding({ top: 10 }) + /* + * ★★ 2026-09-24 修「左边被截断」(用户:「你这布局,左边都要被截断了」)。 + * + * 实测坐标(dumpLayout,详情页右栏): + * 右栏容器 x = 1152(左边界) + * 标题「需要回答:」 x = 1152 ← 贴边(0 内边距) + * 「这题没有预设选项」x = 1152 ← 贴边 + * 「提交回答」按钮 x = 1152 ← 贴边 + * 正文「这是验证…」x = 1198 ← 有 46(正确) + * + * 根因:本面板**挂在外层**(`MailDetailPage` 的 if 分支),而 + * 正文 `Markdown` 块与附件块**各自自带** `padding({left:16,right:16})` —— + * 面板根部只写了 `padding({top:10})`,于是它成了页面上唯一顶到边界的一块。 + * + * WebUI 那边不是这样:它的内边距由**父容器**统一给 + * (`MailView.tsx:144` 的 `px-4 md:px-6` 包住正文与 `PermissionPanel`)。 + * 鸿蒙侧是"每块自带"的形状,所以面板也必须自带 —— 与 `Markdown`、附件块 + * 同一档 16,视觉上才对齐同一列。 + */ + .padding({ top: 10, left: 16, right: 16 }) /* * 与正文之间那条橙色分隔(WebUI `border-t border-orange-200`)。 * diff --git a/client/harmony/entry/src/main/ets/pages/PermissionTab.ets b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets index df9ed9e..8b08801 100644 --- a/client/harmony/entry/src/main/ets/pages/PermissionTab.ets +++ b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets @@ -661,6 +661,6 @@ export struct PermissionTab { } } .width('100%').height('100%') - .attributeModifier(PaneModifier.of(this.bgActive)) + .attributeModifier(PaneModifier.plain(this.bgActive)) } } \ No newline at end of file diff --git a/client/harmony/tools/gen-icons.py b/client/harmony/tools/gen-icons.py new file mode 100644 index 0000000..21c3702 --- /dev/null +++ b/client/harmony/tools/gen-icons.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +"""把 WebUI 的 icons.tsx(24×24 描边 SVG)转成 ArkUI Path 的 commands 字符串。 + +为什么要转而不是直接放 SVG 文件: + · WebUI 的图标是 **stroke** 画法(fill=none, stroke=currentColor);鸿蒙的 + `Image().fillColor()` 只改 **fill**,对描边无效 ⇒ 直接放 SVG 会丢主题色; + · ArkUI 的 `Path` 是原生矢量图元,`.stroke(颜色)` 跟主题走,几何与 WebUI 逐字一致。 + +风险点与对策: + · ArkUI 的 commands 解析器**不支持 SVG 的隐式重复命令**(`m22 2-7 20` 这种) + ⇒ 本脚本展开成显式命令; + · 相对命令(小写)语义易错 ⇒ 本脚本**统一转成绝对命令**(A 的 rx/ry/rot/flag 不变, + 只有终点坐标转换); + · `A/a` 的两个 flag 允许与后续数字粘连(`a1 1 0 011 1`)⇒ tokenizer 特判拆分。 +""" + +import re +import sys + +# ★★ 2026-09-24 改为**相对本脚本**的路径(原来写的是 /root/gotmp 下的绝对路径)。 +# +# 理由:这个脚本现在**进了仓库**(`client/harmony/tools/gen-icons.py`), +# 而写死绝对路径会让任何别的检出目录都跑不起来 —— 与同族的 +# `client/electron/src/icons/generate.py` 一样,按 `__file__` 解析。 +# +# 之前它在仓库外(`/root/gotmp`),于是**生成器落后于生成物**整整三次提交都没人发现: +# 2026-09-24 我手改 `Icons.ets` 修 fill 型图标,重跑生成器时它把修改**冲掉了** +# (退回 `.width(ICON_VIEWBOX)` + `this.iconSize / ICON_VIEWBOX` 的旧实现)。 +# 生成器与生成物分家,产物就只是一份"看起来像生成的"手写文件。 +from pathlib import Path + +_HERE = Path(__file__).resolve().parent # client/harmony/tools +_REPO = _HERE.parent.parent.parent # 仓库根 +SRC = str(_REPO / 'client/electron/src/components/icons.tsx') +OUT = str(_REPO / 'client/harmony/entry/src/main/ets/common/Icons.ets') + +ARITY = {'M': 2, 'L': 2, 'H': 1, 'V': 1, 'C': 6, 'S': 4, 'Q': 4, 'T': 2, 'A': 7, 'Z': 0} +NUM = re.compile(r'[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?') + + +def tokenize(d: str) -> list: + """SVG path → [(cmd, [args])],展开隐式重复(含 M 后隐式 L 规则)。""" + toks = NUM.findall(d) + out = [] + i = 0 + prev = None + while i < len(toks): + t = toks[i] + if re.match(r'[A-Za-z]', t): + cmd = t + i += 1 + else: + if prev is None: + raise ValueError('path 以数字开头: ' + d) + cmd = 'L' if prev == 'M' else ('l' if prev == 'm' else prev) + up = cmd.upper() + k = ARITY[up] + args = [] + if up == 'A': + # rx ry rot 之后是两个 flag(可能是单字符 '0'/'1',也可能与后面的数字粘连) + for _ in range(3): + args.append(float(toks[i])) + i += 1 + for _ in range(2): + tok = toks[i] + if len(tok) > 1 and tok[0] in '01': + args.append(float(tok[0])) + toks[i] = tok[1:] + else: + args.append(float(tok)) + i += 1 + args.append(float(toks[i])) + i += 1 + args.append(float(toks[i])) + i += 1 + else: + for _ in range(k): + args.append(float(toks[i])) + i += 1 + out.append((cmd, args)) + prev = cmd + return out + + +def fmt(v: float) -> str: + s = ('%.4f' % v).rstrip('0').rstrip('.') + return '0' if s in ('', '-0') else s + + +def to_absolute(cmds: list) -> str: + """相对 → 绝对;输出 ArkUI commands。""" + px = py = 0.0 + sx = sy = 0.0 # 子路径起点(Z 之后当前点回到起点) + out = [] + for cmd, a in cmds: + up = cmd.upper() + rel = cmd.islower() + if up == 'M': + x = (px + a[0]) if rel else a[0] + y = (py + a[1]) if rel else a[1] + px, py = x, y + sx, sy = x, y + out.append('M ' + fmt(x) + ' ' + fmt(y)) + elif up == 'L': + x = (px + a[0]) if rel else a[0] + y = (py + a[1]) if rel else a[1] + px, py = x, y + out.append('L ' + fmt(x) + ' ' + fmt(y)) + elif up == 'H': + x = (px + a[0]) if rel else a[0] + px = x + out.append('L ' + fmt(x) + ' ' + fmt(py)) + elif up == 'V': + y = (py + a[0]) if rel else a[0] + py = y + out.append('L ' + fmt(px) + ' ' + fmt(y)) + elif up == 'C': + x1 = (px + a[0]) if rel else a[0] + y1 = (py + a[1]) if rel else a[1] + x2 = (px + a[2]) if rel else a[2] + y2 = (py + a[3]) if rel else a[3] + x = (px + a[4]) if rel else a[4] + y = (py + a[5]) if rel else a[5] + px, py = x, y + out.append('C ' + ' '.join(fmt(v) for v in (x1, y1, x2, y2, x, y))) + elif up == 'S': + x2 = (px + a[0]) if rel else a[0] + y2 = (py + a[1]) if rel else a[1] + x = (px + a[2]) if rel else a[2] + y = (py + a[3]) if rel else a[3] + px, py = x, y + out.append('S ' + ' '.join(fmt(v) for v in (x2, y2, x, y))) + elif up == 'Q': + x1 = (px + a[0]) if rel else a[0] + y1 = (py + a[1]) if rel else a[1] + x = (px + a[2]) if rel else a[2] + y = (py + a[3]) if rel else a[3] + px, py = x, y + out.append('Q ' + ' '.join(fmt(v) for v in (x1, y1, x, y))) + elif up == 'T': + x = (px + a[0]) if rel else a[0] + y = (py + a[1]) if rel else a[1] + px, py = x, y + out.append('Q ' + fmt(px) + ' ' + fmt(py) + ' ' + fmt(x) + ' ' + fmt(y)) + elif up == 'A': + x = (px + a[5]) if rel else a[5] + y = (py + a[6]) if rel else a[6] + px, py = x, y + out.append('A ' + ' '.join([fmt(a[0]), fmt(a[1]), fmt(a[2]), str(int(a[3])), str(int(a[4])), fmt(x), fmt(y)])) + elif up == 'Z': + px, py = sx, sy + out.append('Z') + return ' '.join(out) + + +def circle_to_path(cx, cy, r) -> str: + return to_absolute(tokenize( + f'M {cx - r} {cy} A {r} {r} 0 1 0 {cx + r} {cy} A {r} {r} 0 1 0 {cx - r} {cy} Z')) + + +def rect_to_path(x, y, w, h, rx) -> str: + if rx <= 0: + return to_absolute(tokenize(f'M {x} {y} H {x + w} V {y + h} H {x} Z')) + return to_absolute(tokenize( + f'M {x + rx} {y} H {x + w - rx} A {rx} {rx} 0 0 1 {x + w} {y + rx} ' + f'V {y + h - rx} A {rx} {rx} 0 0 1 {x + w - rx} {y + h} ' + f'H {x + rx} A {rx} {rx} 0 0 1 {x} {y + h - rx} ' + f'V {y + rx} A {rx} {rx} 0 0 1 {x + rx} {y} Z')) + + +def is_fill_family(chunk: str) -> bool: + """这个图标是 **fill 族**还是描边族? + + ★★ 2026-09-24 新增。用户:「为什么侧边栏图标有莫名其妙的加粗?」 + + ── 为什么会漏掉这一族 ── + 本脚本原先假定**所有**图标都是描边画法(源用 `` helper,即 + `fill="none" stroke="currentColor" strokeWidth="1.8"`),于是只产出一条 + `.fill(Transparent) + .stroke(主题色)` 的绘制路径。而 `icons.tsx` 里 + 有一个**不包 `Svg`** 的例外 —— `BrandMarkIcon`: + + ← 自己写 svg,不用 helper + + ← 实心眼 + + + + 它自己的注释就写着「品牌标志(fill 型,与上面的描边图标集**不同族**, + 所以不包 Svg)」。把实心块当描边画 ⇒ 只剩轮廓、两个实心眼变成小圆环, + 小尺寸下糊成一坨(实测侧栏 3× 放大截图一眼可见)。 + + ── 判据 ── + 两族的区别是**事实**(源用了哪种画法),不是可以从几何猜出来的: + `inbox`/`calendar` 这些描边图标也有闭合子路径。所以只认一件事: + 这个函数体里有没有 `]*\bfill="([^"]+)"', chunk) + return bool(m) and m.group(1).strip() == 'currentColor' + + +def attr(tag: str, name: str, default=None): + m = re.search(name + r'="([^"]*)"', tag) + if not m: + if default is None: + raise KeyError(f'{name} 缺失于 {tag}') + return default + return float(m.group(1)) + + +def main() -> int: + s = open(SRC, encoding='utf-8').read() + starts = [m.start() for m in re.finditer(r'export function (\w+)\(', s)] + starts.append(len(s)) + icons = {} + fill_icons = [] + order = [] + for i in range(len(starts) - 1): + chunk = s[starts[i]:starts[i + 1]] + name = re.match(r'export function (\w+)\(', chunk).group(1) + if name == 'Svg': + continue + parts = [] + # 元素按源码顺序(描边是叠加的,顺序无关但保持一致便于比对) + for m in re.finditer(r'<(path|circle|rect)\b([^>]*?)/?>', chunk): + kind, tag = m.group(1), m.group(0) + if kind == 'path': + d = re.search(r'd="([^"]+)"', tag).group(1) + parts.append(to_absolute(tokenize(d))) + elif kind == 'circle': + parts.append(circle_to_path(attr(tag, 'cx'), attr(tag, 'cy'), attr(tag, 'r'))) + else: + x = attr(tag, 'x', '0.0') if 'x="' in tag else 0.0 + y = attr(tag, 'y', '0.0') if 'y="' in tag else 0.0 + w = attr(tag, 'width') + h = attr(tag, 'height') + rx = attr(tag, 'rx', '0.0') if 'rx="' in tag else 0.0 + parts.append(rect_to_path(x, y, w, h, rx)) + key = re.sub(r'Icon$', '', name) + key = key[0].lower() + key[1:] + icons[key] = ' '.join(parts) + if is_fill_family(chunk): + fill_icons.append(key) + order.append((name, key)) + + # 自身校验:命令字形与参数个数必须与 ARITY 完全对上 + bad = [] + for k, v in icons.items(): + toks = re.findall(r'[A-Za-z]|-?(?:\d+\.?\d*|\.\d+)', v) + i = 0 + while i < len(toks): + t = toks[i] + if not re.match(r'[A-Za-z]', t): + bad.append((k, '非命令位置出现数字: ' + t)) + break + n = ARITY[t.upper()] + args = toks[i + 1:i + 1 + n] + if len(args) != n or any(re.match(r'[A-Za-z]', a) for a in args): + bad.append((k, f'{t} 期望 {n} 个参数,实得 {len(args)}')) + break + i += 1 + n + if len(v.strip()) == 0: + bad.append((k, '空路径')) + if bad: + print('自身校验失败:') + for b in bad: + print(' ', b) + return 1 + + ''' + 撞名自检:ArkUI 把一批名字当**通用属性**(`size`/`width`/`height`/`scale`/`opacity`…), + 组件成员用这些名字会直接编译失败(实测:`Property 'size' in type 'AmIcon' is not + assignable to the same property in base type 'CustomComponent'`)。 + 所有成员名统一加 `icon`/`stroke` 前缀,这里再挡一道 —— 因为"重新生成"是常态操作, + 靠人记住这条约定迟早破。 + ''' + BANNED = ['size', 'width', 'height', 'scale', 'opacity', 'backgroundColor', 'padding', + 'margin', 'borderRadius', 'border', 'position', 'visibility', 'enabled', 'color', + 'name', 'id', 'key', 'clip', 'zIndex'] + for p in ['iconName', 'iconSize', 'iconColor', 'strokeWeight']: + if p in BANNED: + print('撞名自检失败:`%s` 是 ArkUI 通用属性名' % p) + return 1 + # 源里出现 T/Q 就停下:本脚本对 T(平滑二次曲线,控制点需反射上一点)**没有实现**, + # 而源目前用的是 a/A/c/C/h/H/l/L/m/M/s/v/V/z/Z —— 将来源改了必须来看这一行, + # 而不是默默产出一个形状不对的图标。 + for cmd in ['T', 't', 'Q', 'q']: + if re.search(r'[TtQq]', ''.join( + re.findall(r'd="([^"]+)"', s))): + print('源里出现 %s 命令,本脚本未实现(见注释)—— 请先补 to_absolute' % cmd) + return 1 + + lines = [] + lines.append('/*') + lines.append(' * 图标集 —— **与 WebUI 的 `client/electron/src/components/icons.tsx` 同几何**。') + lines.append(' *') + lines.append(' * 由 `client/harmony/tools/gen-icons.py` 从那份 TSX **自动生成**,不要手改:') + lines.append(' * · 源是 24×24 SVG,分**两族**(见 `FILL_ICONS`):描边族') + lines.append(' * (`fill=none / stroke=currentColor / strokeWidth 1.8 / 圆头圆角`)与') + lines.append(' * fill 族(`fill=currentColor`,品牌标 `brandMark` 一个);') + lines.append(' * · 转成 ArkUI `Path` 的 commands,并**统一为绝对命令**、展开隐式重复') + lines.append(' * (ArkUI 的解析器不认 SVG 的隐式 lineto);') + lines.append(' * · `circle`/`rect` 也展开成弧与直线。') + lines.append(' *') + lines.append(' * 为什么不用 SVG 资源文件:WebUI 的图标大多是**描边**画法,而鸿蒙 `Image().fillColor()`') + lines.append(' * 只改 fill、对 stroke 无效 ⇒ 直接放 SVG 图标就不跟主题走。用 `Path` 则 `.stroke()`') + lines.append(' * 直接吃主题色(这是"深色模式一处也不漏"的前提)。') + lines.append(' *') + lines.append(' * 生成物共 %d 个图标(其中 fill 族 %d 个);生成时已自检:每条命令的参数个数与 SVG 语法一致。' + % (len(icons), len(fill_icons))) + lines.append(' */') + lines.append('') + lines.append("import { Theme } from './Theme';") + lines.append('') + lines.append('/** 图标名 → ArkUI Path commands(24×24 坐标系,绝对命令) */') + lines.append('export const ICON_PATHS: Record = {') + for _, key in order: + lines.append(" '%s': '%s'," % (key, icons[key])) + lines.append('};') + lines.append('') + lines.append('/** 图标原始坐标系边长(所有图标都在这个方框内绘制) */') + lines.append('export const ICON_VIEWBOX: number = 24;') + lines.append('') + lines.append('/**') + lines.append(' * **fill 型**图标的名单 —— 它们必须整块填色,不能当描边画。') + lines.append(' *') + lines.append(' * ★★ 2026-09-24 新增(用户:「为什么侧边栏图标有莫名其妙的加粗?」)。') + lines.append(' *') + lines.append(' * WebUI 的图标集(`icons.tsx`)分两族:') + lines.append(' * · **描边型**(%d 个):包在 `` helper 里,即' + % (len(icons) - len(fill_icons))) + lines.append(' * `fill="none" stroke="currentColor" strokeWidth="1.8"`;') + lines.append(' * · **fill 型**(%d 个):自己写 ``,含**实心**元素' + % len(fill_icons)) + lines.append(' * (如 ``)。`BrandMarkIcon` 自己的注释就写着') + lines.append(' * 「品牌标志(fill 型,与上面的描边图标集**不同族**,所以不包 Svg)」。') + lines.append(' *') + lines.append(' * 而本仓的 `AmIcon` 一律 `fill(Color.Transparent) + stroke(...)` ——') + lines.append(' * 那是**描边型**的画法。于是 fill 型图标:实心块只剩轮廓、实心眼变成小圆环,') + lines.append(' * 再加 `strokeLineCap: Round`,小尺寸下糊成一团(实测侧栏 3× 放大截图一眼可见)。') + lines.append(' *') + lines.append(' * ── 为什么是名单而不是靠路径猜 ──') + lines.append(' * 两族的区别是**源用了哪种画法**这个事实,不是几何能推出来的:') + lines.append(' * `inbox`/`calendar` 这些描边图标也有闭合子路径 —— 猜会误伤一大片。') + lines.append(' * 由生成器从 `` helper 的有无自动判定(见脚本 `is_fill_family`),') + lines.append(' * 所以名单不会因手改而漂。') + lines.append(' */') + lines.append('export const FILL_ICONS: string[] = [%s];' % ', '.join("'%s'" % k for k in fill_icons)) + lines.append('') + lines.append('/**') + lines.append(' * 图标组件:与 WebUI `` 等价的用法。') + lines.append(' *') + lines.append(' * `size` 是**最终视觉尺寸**;内部按 24 坐标系绘制再等比缩放,') + lines.append(' * 所以任何尺寸都不会出现"描边变粗/变细"以外的偏差(描边宽度按 WebUI 的 1.8 固定)。') + lines.append(' */') + lines.append('@Component') + lines.append('export struct AmIcon {') + lines.append(" /** 图标名(见 ICON_PATHS 的键):inbox / sent / compose / mail / person … */") + lines.append(" @Prop iconName: string = '';") + lines.append(' /** 视觉尺寸(vp)。**不叫 `size`**:ArkUI 把 `size` 当通用属性名,撞名编译不过 */') + lines.append(' @Prop iconSize: number = 20;') + lines.append(' /** 描边颜色(默认取主题主文字色) */') + lines.append(' @Prop iconColor: ResourceColor = Theme.textPrimary;') + lines.append(' /** 描边宽度(WebUI 用 1.8) */') + lines.append(' @Prop strokeWeight: number = 1.8;') + lines.append('') + lines.append(' /** 这个图标是不是 fill 族(整块填色)。名单由生成器产出,见 `FILL_ICONS`。 */') + lines.append(' private isFillIcon(): boolean {') + lines.append(' return FILL_ICONS.indexOf(this.iconName) >= 0;') + lines.append(' }') + lines.append('') + lines.append(' /**') + lines.append(' * 把 24 单位坐标系落到 `iconSize` vp 上所需的缩放比。') + lines.append(' * Path.commands 的坐标是 px:24 单位需放大到 vp2px(iconSize) 个 px。') + lines.append(' *') + lines.append(' * 用 `UIContext#vp2px` 而不是全局 `vp2px`:后者已被 SDK 标废弃,') + lines.append(' * `harmony-system-api` 那条判据会对全局调用默认判红。') + lines.append(' */') + lines.append(' private iconScale(size: number): number {') + lines.append(' return this.getUIContext().vp2px(size) / ICON_VIEWBOX;') + lines.append(' }') + lines.append('') + lines.append(' /**') + lines.append(' * `scale()` 会把描边一并放大,所以要先把描边除掉这一层。') + lines.append(' * 目标视觉描边 = strokeWeight × (iconSize / 24) vp(与 WebUI 同比例)。') + lines.append(' * 而 strokeWidth 数字按 vp 解释:设 X vp → X*vp2px(1) px → 再乘 scale。') + lines.append(' * 解出 X = strokeWeight / vp2px(1)。') + lines.append(' */') + lines.append(' private iconStrokeWidth(): number {') + lines.append(' return this.strokeWeight / this.getUIContext().vp2px(1);') + lines.append(' }') + lines.append('') + lines.append(' build() {') + lines.append(' /*') + lines.append(' * ★ Path.commands 的坐标是 **px**,不是 vp —— 官方 Shape/viewPort 示例里缩放成立,') + lines.append(' * 是因为它用 Rect/Circle(自带 vp 宽高),而不是裸 `Path.commands`。') + lines.append(' * 实测:iconSize=24 时 Path 只画 24px(≈6.9vp),而等宽 Shape 盒是 24vp=84px,') + lines.append(' * 于是图标小而偏左上(截图硬证:bbox 24×24,中心偏 FAB 中心 (-30,-30))。') + lines.append(' * ⇒ 用 `vp2px(iconSize)/ICON_VIEWBOX` 把 24 单位路径等比放大到 iconSize **vp**。') + lines.append(' * 注意 scale 会连带放大描边(实图曾因此变成粗块),故描边先除掉同一系数。') + lines.append(' * `viewPort` 不再需要:它只裁剪不缩放。') + lines.append(' */') + lines.append(' Stack({ alignContent: Alignment.Center }) {') + lines.append(' Path()') + lines.append(" .commands(ICON_PATHS[this.iconName] ?? '')") + lines.append(' /*') + lines.append(' * ★★ 2026-09-24:两族图标分道 —— 见 `FILL_ICONS` 的长注释。') + lines.append(' * 描边型(绝大多数):透明填充 + 主题色描边;') + lines.append(' * fill 型:整块填主题色、**不加描边**') + lines.append(' * (给它描边会把实心块勾出一圈,且实心眼会变成圆环)。') + lines.append(' */') + lines.append(' .fill(this.isFillIcon() ? this.iconColor : Color.Transparent)') + lines.append(' .stroke(this.isFillIcon() ? Color.Transparent : this.iconColor)') + lines.append(' .strokeWidth(this.iconStrokeWidth())') + lines.append(' .strokeLineCap(LineCapStyle.Round)') + lines.append(' .strokeLineJoin(LineJoinStyle.Round)') + lines.append(' .scale({ x: this.iconScale(this.iconSize), y: this.iconScale(this.iconSize) })') + lines.append(' }') + lines.append(' /*') + lines.append(' * ★★ 2026-09-18:这里原来是 `.width(this.iconSize).height(this.iconSize)`,') + lines.append(' * 现在**显式保持默认尺寸**,同时把“调用方给更大盒子”的做法钉成错误用法。') + lines.append(' *') + lines.append(' * 曾经的陷阱:调用方写 `AmIcon({…}).width(48).height(48)`(想要一个大点的可点区域)——') + lines.append(' * 外层盒子变大了,而**内部这个容器的尺寸不变、且默认靠左上** ⇒ 图标贴在盒子左上角。') + lines.append(' *') + lines.append(' * 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读实际 bounds):') + lines.append(' * 卡片 [1523,521][1661,659] 138×138px') + lines.append(' * 图标 [1526,524][1589,587] 63×63px') + lines.append(' * ⇒ 中心偏了 10vp(用户:「你自己看看那个图标的位置正常吗」)。') + lines.append(' * 同样写法在仓里有 4 处(悬浮加号/返回键/刷新键/品牌卡),只是图标小时偏得不明显。') + lines.append(' *') + lines.append(' * 为什么不在这里写 `width(\'100%\')` 来适配:那会让**所有没显式给尺寸的调用点**') + lines.append(' * (绝大多数)在 `Row` 里试图撑满父容器 —— 那是拿一个普遍存在的布局回归') + lines.append(' * 换四个特例的方便。正确做法是调用方要“大盒子”就自己套一层居中的容器') + lines.append(' * (见 `LoginPage` 品牌卡与 `heroIcon` 的用法),组件本身只管画好这 iconSize 那么大的图标。') + lines.append(' */') + lines.append(' .width(this.iconSize)') + lines.append(' .height(this.iconSize)') + lines.append(' }') + lines.append('}') + lines.append('') + open(OUT, 'w', encoding='utf-8').write('\n'.join(lines)) + print(' 写出 %s' % OUT) + print(' 图标 %d 个:%s' % (len(order), ' '.join(k for _, k in order))) + print(' fill 族 %d 个:%s' % (len(fill_icons), ' '.join(fill_icons) or '(无)')) + print(' 自检通过:命令/参数个数全部与 SVG 语法一致') + print(' 样例 inbox: %s' % icons['inbox'][:110]) + return 0 + + +if __name__ == '__main__': + sys.exit(main())