鸿蒙|图标 fill 型分族 + 页签条阴影根因修复 + 生成器入库

用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):

① 侧边栏图标"莫名其妙的加粗"
   根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型**
   (`fill="currentColor"` + 两个实心 `<circle r=".9">`),
   它自己的注释就写着「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 那一半**。
This commit is contained in:
2026-09-24 13:13:46 +08:00
parent 7151d3f91c
commit f0dc342c1f
8 changed files with 788 additions and 20 deletions

View File

@ -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<string, string> = {
/** 图标原始坐标系边长(所有图标都在这个方框内绘制) */
export const ICON_VIEWBOX: number = 24;
/**
* **fill 型**图标的名单 —— 它们必须整块填色,不能当描边画。
*
* ★★ 2026-09-24 新增(用户:「为什么侧边栏图标有莫名其妙的加粗?」)。
*
* WebUI 的图标集(`icons.tsx`)分两族:
* · **描边型**(42 个):包在 `<Svg>` helper 里,即
* `fill="none" stroke="currentColor" strokeWidth="1.8"`;
* · **fill 型**(1 个):自己写 `<svg fill="currentColor">`,含**实心**元素
* (如 `<circle r=".9">`)。`BrandMarkIcon` 自己的注释就写着
* 「品牌标志(fill 型,与上面的描边图标集**不同族**,所以不包 Svg)」。
*
* 而本仓的 `AmIcon` 一律 `fill(Color.Transparent) + stroke(...)` ——
* 那是**描边型**的画法。于是 fill 型图标:实心块只剩轮廓、实心眼变成小圆环,
* 再加 `strokeLineCap: Round`,小尺寸下糊成一团(实测侧栏 3× 放大截图一眼可见)。
*
* ── 为什么是名单而不是靠路径猜 ──
* 两族的区别是**源用了哪种画法**这个事实,不是几何能推出来的:
* `inbox`/`calendar` 这些描边图标也有闭合子路径 —— 猜会误伤一大片。
* 由生成器从 `<Svg>` helper 的有无自动判定(见脚本 `is_fill_family`),
* 所以名单不会因手改而漂。
*/
export const FILL_ICONS: string[] = ['brandMark'];
/**
* 图标组件:与 WebUI `<Icon className="w-5 h-5" />` 等价的用法。
*
@ -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)

View File

@ -546,14 +546,48 @@ export class TintModifier implements AttributeModifier<CommonAttribute> {
export class PaneModifier implements AttributeModifier<CommonAttribute> {
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<CommonAttribute> {
* 移到窗格层才与 WebUI 一致,也才有意义 —— 窗格是"浮在壁纸上的一张板",
* 投影表达的是它与壁纸的**层次差**;卡片在窗格之内,同层之间不需要投影。
*/
instance.shadow(Theme.glassShadow);
if (this.shadowed) {
instance.shadow(Theme.glassShadow);
}
}
}

View File

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

View File

@ -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` —— 它们不是'浮在壁纸上的板'。
*/
}
}

View File

@ -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`)。
*

View File

@ -661,6 +661,6 @@ export struct PermissionTab {
}
}
.width('100%').height('100%')
.attributeModifier(PaneModifier.of(this.bgActive))
.attributeModifier(PaneModifier.plain(this.bgActive))
}
}