Files
FamilyBook_harmonyos/entry/src/main/ets/common/Theme.ets
T
laoyang d9ca9dbe31 下拉框与网页端对齐(照 FeedLite 那套观感)(#86)
用户 2026-11-05:「好了,鸿蒙端也用类似的方式调,效果让它保持一致。」

端上**不需要像网页那样手写浮层** —— 网页端必须自绘是因为
「原生 select 的展开列表由浏览器绘制,CSS 改不动」;
ArkUI 的 Select 本身就是自绘的、选项列表也归框架管。
⇒ 把对应属性都设上就等价了:

  网页端                          ArkUI Select
  ──────────────────────────────────────────────────────
  按钮 40 高 / 圆角 / 细边框       height(40) + borderRadius + border
  箭头打开转 180°                 框架内置(自带动画)
  悬停 tint-1                     不支持(触屏没有 hover)→ 用打开态代替
  选中项底 tint-2                 selectedOptionBgColor(fbMixWhite(accent,0.80))
  选中项文字 主题色                selectedOptionFontColor(accent)
  选中项右侧对勾                   showDefaultSelectedIcon(true)
  浮层白底 + 细边框               menuBackgroundColor(White) + border
  浮层与按钮同宽                   optionWidth(OptionWidthMode.FIT_TRIGGER)
  未选值次要色                     fontColor(有值 ? accent : Faint)

① Theme 新增 fbMixWhite(hex, ratio)
   与网页端 fb_color_tint() **同算法同比例**(混 91/80/72% 白)。
   ⚠️ 为什么不用现成的 fbAlpha():那是「原色 + 透明度」,
      会透出父容器底色 —— 下拉浮层背景不确定,那样会时深时浅;
      这里是实色混合,颜色确定。
   ⚠️ 实测 16 组(4 个主题色 × 4 档比例)与 PHP 输出逐个比对,全部一致。

② 边框单独用一档
   按钮边框 fbMixWhite(accent, 0.62) —— 比 tint-3 再深一点,
   否则在 tint-1 的浅底上几乎看不见边界。

③ 修一个类型坑
   本来写了个 pickFontColor(): string 返回 accent / FbColors.muted,
   但 FbColors.muted 是 Resource($r(...)),返回 string | Resource
   会让 fontColor() 报「Type 'string | Resource' is not assignable to 'string'」。
   ⇒ 改成内联三元,且未选用 FbColors.faint(那个是 string 常量)。

编译通过,已装真机。
2026-11-05 20:25:59 +08:00

475 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 主题与设计令牌(鸿蒙端)
*
* 对应 Web 端 `assets/css/app.css` 里的 `:root { --paper / --ink / --brand … }`。
*
* 配色取自图标:宣纸米 / 砖红封面 / 深褐书脊 / 纸白。
* 原则:扁平、留白充足、字重克制(正文 400,标题 500-600)。
*
* ⚠️ 深浅色两套值取自资源文件(`resources/base|dark/element/color.json`),
* 这里只做**语义名 → $r 资源**的映射,不硬编码 hex ——
* 这样系统切深色时不用改任何页面代码。
*/
/** 语义色令牌(全部指向资源,随系统深浅色自动切换) */
export class FbColors {
/** 宣纸底(页面背景) */
static readonly paper: Resource = $r('app.color.page_bg');
/** 纸白(卡片背景) */
static readonly surface: Resource = $r('app.color.surface');
/** 略暖的白(次级卡片 / 内嵌块) */
static readonly surfaceAlt: Resource = $r('app.color.surface_alt');
/** 输入框底 */
static readonly inputBg: Resource = $r('app.color.input_bg');
/** 主文字(墨) */
static readonly ink: Resource = $r('app.color.ink');
/** 次级文字 */
static readonly muted: Resource = $r('app.color.muted');
/** 最弱的文字(占位、时间戳) */
static readonly faint: Resource = $r('app.color.faint');
/** 品牌色(砖红)—— 未进具体模块时的默认色 */
static readonly brand: Resource = $r('app.color.brand');
static readonly brand2: Resource = $r('app.color.brand_2');
static readonly brandInk: Resource = $r('app.color.brand_ink');
/** 分割线 */
static readonly line: Resource = $r('app.color.line');
static readonly line2: Resource = $r('app.color.line_2');
/** 语义色:绿=成功 / 红=删除 / 蓝=编辑 / 黄=提醒 */
static readonly green: Resource = $r('app.color.green');
static readonly red: Resource = $r('app.color.red');
static readonly blue: Resource = $r('app.color.blue');
static readonly yellow: Resource = $r('app.color.yellow');
}
/** 间距 / 圆角 / 字号令牌(数值常量,vp/fp 由调用处加单位) */
export class FbMetric {
/** 页面左右内边距 */
static readonly pagePad: number = 16;
/** 卡片内边距 */
static readonly cardPad: number = 16;
/** 卡片间距 */
static readonly cardGap: number = 12;
/** 大圆角(卡片) */
static readonly radius: number = 16;
/** 小圆角(内嵌块 / 输入框) */
static readonly radiusS: number = 10;
/**
* 列表项圆角(**一行一张卡**那种,如名册的人员行)。
*
* 🔴 为什么单独一档(2026-10-18 用户「名册太丑」):
* 列表项**一行一张卡**、上下密排;用 16(区块卡的圆角)会显得"胖"、
* 相邻两卡的圆角挤在一起很吵;用 10(内嵌小块的圆角)在 12vp 的行内边距下
* 又显得方、像"被裁过的白条"。14 是这两个之间的甜点。
*/
static readonly radiusCard: number = 14;
/** 胶囊圆角 */
static readonly radiusPill: number = 999;
/** 正文 */
static readonly fontBody: number = 15;
/** 次级信息 */
static readonly fontSmall: number = 13;
/** 最小号(时间戳 / 计数) */
static readonly fontTiny: number = 11;
/** 卡片标题 */
static readonly fontTitle: number = 17;
/** 页面大标题 */
static readonly fontH1: number = 24;
/** 顶栏高度 —— 用户要求「图标和标题尽量靠上」,所以压到 48vp(常规是 56)。
* 顶栏下面的内容因此整体上移,首屏能多看到一行。 */
static readonly topBarH: number = 48;
/**
* 顶栏右侧「同步图标 / 铃铛 / 头像」三颗之间的间距(vp)。
*
* 🔴 v59(用户 2026-10-18):「现在提醒的按钮距离头像的按钮太近了。」
* ⇒ 统一在这里给一个值,三个按钮**两两之间**都用它。
* ⚠️ 不要只在铃铛上单独加一个 margin:三颗的间距不统一时,
* 视觉上会变成"两个挤在一起、一个孤零零"(本次要修的正是这个观感)。
*/
static readonly gapTopActions: number = 8;
/** 抽屉宽度 */
static readonly drawerW: number = 264;
}
/**
* 把一张**单色 PNG 图标**染成指定颜色(返回喂给 `Image.colorFilter()` 的矩阵)。
*
* 🔴🔴 为什么必须有这个函数(2026-10-18 真机实测,用户报的"图标不变色"真根因):
* `Image.fillColor()` **只对 SVG 生效** —— 本机 SDK 的 `image.d.ts` 原话:
* > This attribute applies only to SVG images. To set the fill color for a
* > PNG image, use `colorFilter`.
* 而本工程 30 多个图标(`fb_ic_*.png`)**全是 PNG**(`ls resources/base/media`
* 里一个 .svg 都没有)⇒ 所有 `fillColor(...)` 调用**一直是空操作**。
*
* 这解释了用户看到的两处不一致(同一个 bug 的两种表现):
* · 悬浮快捷栏:「只有下面的汉字颜色变了,上面的图标颜色没有变」
* (文字走 `fontColor` —— 那个是真的生效;图标走 `fillColor` —— 无效);
* · 抽屉侧栏里"未选中图标应有模块色"也一直没生效(同样是 PNG + fillColor)。
* ⚠️ 所以**不要把这里的图换成 SVG 就当修好了**:图标是设计给的位图,
* 换格式会连带影响所有引用点;用颜色矩阵染色是**改动最小**且**通用**的做法。
*
* 做法(4×5 颜色矩阵,[r,g,b,a] 各行):把整张图的 RGB 一律压成目标色,
* 保留原图的**alpha**(描边抗锯齿的柔和边缘不能丢)——
* 等价于"用目标色给这张单色图的形状填色"。
*
* @param color `#RRGGBB` 或 `#AARRGGBB`;解析失败回落品牌红
*/
export function fbTint(color: string): ColorFilter {
let s: string = color.startsWith('#') ? color.substring(1) : color;
// 8 位(#AARRGGBB)时丢掉 alpha 段:染色**不**改透明度,透明度永远来自原图
if (s.length === 8) {
s = s.substring(2);
}
if (s.length !== 6) {
s = 'A84A28';
}
const r: number = parseInt(s.substring(0, 2), 16) / 255;
const g: number = parseInt(s.substring(2, 4), 16) / 255;
const b: number = parseInt(s.substring(4, 6), 16) / 255;
/* 🔴🔴 这里踩过两次(真机实测逐次定位),最终形态是**用 alpha 当载体**:
*
* ❌ 第一版(系数乘 RGB):`R' = R_原 × r`
* 对**白色**图标成立;但本工程图标是**纯黑** (0,0,0)
* ⇒ 黑 × 任何系数 = 黑 ⇒ 真机上图标一点没变(实测像素仍是 (0,0,0))。
* ❌ 第二版(第 5 列加性偏移 `…, r*255`):本想让输出恒等于目标色,
* 但**第 5 列的量纲在文档里没有明确**(0..1 还是 0..255)——
* 猜错就又是"看着对、真机不生效",同一个坑踩第三次不值。
*
* ✅ 本版:`R' = (r/255) × A_原`,**只用第 4 列(alpha),一个偏移都不用**。
* · 系数是**归一化**约定(官方灰度示例 `0.2126/0.7152/0.0722` 三者和为 1
* 即证明输入输出都在 0..1)⇒ 这一列的量纲是确定的;
* · 形状完全由**原图 alpha** 决定 ⇒ 黑图标、白图标都成立,
* 且描边抗锯齿(半透明像素)自然过渡,不会变成硬边;
* · `A' = 1 × A_原` ⇒ 透明度保持不变(不会把图案外的空白也填上色)。
*/
return new ColorFilter([
0, 0, 0, r, 0,
0, 0, 0, g, 0,
0, 0, 0, b, 0,
0, 0, 0, 1, 0
]);
}
/**
* 图标染色的 hex 色板 —— 与 `resources/base/element/color.json` **同值**。
*
* 🔴 为什么需要这一份(不能直接用 `FbColors.*`):
* `Image.colorFilter()` **只吃数字矩阵**(`ColorFilter`),拿不到 `$r(...)`
* 资源的解析值;而 `$r` 的 `Resource` 在运行期也**取不到**它的名字/值。
* ⇒ 需要染色的地方必须拿 hex。这里把这几个常用的抄成常量。
*
* ⚠️ **改了 color.json 就要同步改这里**(两处必须一致)。为把风险压到最小,
* 这里只保留"图标要染色"用到的那几个;其余一律传 hex(模块色本来就是 hex)。
*/
export class FbTint {
/** 与 color.json 的 `ink` 同值(主文字墨色) */
static readonly ink: string = '#2C231D';
/** 与 color.json 的 `muted` 同值(次级文字) */
static readonly muted: string = '#7E6B5A';
/** 与 color.json 的 `faint` 同值(最弱文字) */
static readonly faint: string = '#A8988A';
/** 与 color.json 的 `brand` 同值(砖红) */
static readonly brand: string = '#A84A28';
/** 白(图标压在实底上时用) */
static readonly white: string = '#FFFFFF';
}
/**
* 模块主题色 → 客户端可用的三档调色板。
*
* 与 Web 端 `Module::cssVars()` 的差别:那边填进 CSS 变量(浏览器按色值渲染),
* 这边直接算成 hex 字符串给 ArkUI 用(ArkUI 的 ResourceColor 接受 '#RRGGBB')。
* 算法本身共用(见 `Modules.ets::fbThemeVars`)。
*/
export interface FbPalette {
/** 模块主色(图标 / 强调) */
main: string;
/** 按钮底色 */
brand: string;
/** hover / 浅底 */
brand2: string;
/** 文字色(深一档,保证在浅底上看得清) */
ink: string;
/** 极浅底色(选中行背景) */
wash: string;
}
/**
* 由模块主题色算出整套调色板。
*
* `wash` 是「选中行 / 标签底」用的极浅色 —— 做法是把主色降饱和、抬明度到 L≈0.94,
* 这样在宣纸米底色上只是一层淡淡的染色,不会抢内容。
*/
export function fbPalette(moduleColor: string): FbPalette {
const v = fbThemeVarsLocal(moduleColor);
return {
main: moduleColor,
brand: v.brand,
brand2: v.brand2,
ink: v.ink,
wash: fbWashOf(moduleColor)
};
}
/** 极浅染色底(L≈0.94,饱和度压到 0.35 上限) */
function fbWashOf(hex: string): string {
const hsl: number[] = fbHexToHslLocal(hex);
const sat: number = Math.min(0.35, hsl[1]);
return fbHslToHexLocal(hsl[0], sat, 0.94);
}
/** 局部副本:避免 Theme 依赖 Modules(两文件都能独立使用) */
function fbThemeVarsLocal(hex: string): FbThemeVarsLocal {
const hsl: number[] = fbHexToHslLocal(hex.startsWith('#') ? hex : '#A84A28');
const h: number = hsl[0];
const sat: number = Math.max(0.28, Math.min(0.72, hsl[1]));
return {
brand: '#' + fbHslToHexLocal(h, sat, 0.42),
brand2: '#' + fbHslToHexLocal(h, sat, 0.58),
ink: '#' + fbHslToHexLocal(h, sat, 0.28)
};
}
interface FbThemeVarsLocal {
brand: string;
brand2: string;
ink: string;
}
function fbHexToHslLocal(hex: string): number[] {
const s: string = hex.startsWith('#') ? hex.substring(1) : hex;
const r: number = parseInt(s.substring(0, 2), 16) / 255;
const g: number = parseInt(s.substring(2, 4), 16) / 255;
const b: number = parseInt(s.substring(4, 6), 16) / 255;
const mx: number = Math.max(r, g, b);
const mn: number = Math.min(r, g, b);
const l: number = (mx + mn) / 2;
const d: number = mx - mn;
if (d === 0) {
return [0, 0, l];
}
const den: number = 1 - Math.abs(2 * l - 1);
const sat: number = den > 1e-9 ? d / den : 0;
let h: number;
if (mx === r) {
h = ((g - b) / d) % 6;
} else if (mx === g) {
h = ((b - r) / d) + 2;
} else {
h = ((r - g) / d) + 4;
}
h = h * 60;
if (h < 0) {
h = h + 360;
}
return [h / 360, sat, l];
}
function fbHslToHexLocal(h: number, sat: number, l: number): string {
const hh: number = h - Math.floor(h);
if (sat <= 0) {
const v: number = Math.round(l * 255);
return fbH2(v) + fbH2(v) + fbH2(v);
}
const q: number = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
const p: number = 2 * l - q;
const conv = (t0: number): number => {
let t: number = t0 - Math.floor(t0);
if (t < 1 / 6) {
return p + (q - p) * 6 * t;
}
if (t < 1 / 2) {
return q;
}
if (t < 2 / 3) {
return p + (q - p) * (2 / 3 - t) * 6;
}
return p;
};
const r: number = Math.max(0, Math.min(255, Math.round(conv(hh + 1 / 3) * 255)));
const g: number = Math.max(0, Math.min(255, Math.round(conv(hh) * 255)));
const b: number = Math.max(0, Math.min(255, Math.round(conv(hh - 1 / 3) * 255)));
return '#' + fbH2(r) + fbH2(g) + fbH2(b);
}
function fbH2(v: number): string {
const t: string = v.toString(16).toUpperCase();
return t.length < 2 ? '0' + t : t;
}
/** 图标资源名 → Resource(模块图标与通用图标都在这张表里) */
export class FbIcon {
/** 取模块图标(key 同 FB_MODULES 的 key) */
static module(key: string): Resource {
return fbIconOf('fb_ic_' + key);
}
/** 取通用图标(search / menu / close / bell / user / gear …) */
static ui(name: string): Resource {
return fbIconOf('fb_ic_' + name);
}
}
/** 动态资源名 → Resource(`$r` 需要字面量,这里用 switch 展开) */
function fbIconOf(name: string): Resource {
switch (name) {
case 'fb_ic_home': return $r('app.media.fb_ic_home');
case 'fb_ic_people': return $r('app.media.fb_ic_people');
case 'fb_ic_movies': return $r('app.media.fb_ic_movies');
case 'fb_ic_trips': return $r('app.media.fb_ic_trips');
case 'fb_ic_calendar': return $r('app.media.fb_ic_calendar');
case 'fb_ic_resources': return $r('app.media.fb_ic_resources');
case 'fb_ic_tasks': return $r('app.media.fb_ic_tasks');
case 'fb_ic_days': return $r('app.media.fb_ic_days');
case 'fb_ic_ledger': return $r('app.media.fb_ic_ledger');
case 'fb_ic_books': return $r('app.media.fb_ic_books');
case 'fb_ic_nav': return $r('app.media.fb_ic_nav');
case 'fb_ic_notes': return $r('app.media.fb_ic_notes');
case 'fb_ic_search': return $r('app.media.fb_ic_search');
case 'fb_ic_menu': return $r('app.media.fb_ic_menu');
case 'fb_ic_close': return $r('app.media.fb_ic_close');
case 'fb_ic_back': return $r('app.media.fb_ic_back');
case 'fb_ic_forward': return $r('app.media.fb_ic_forward');
case 'fb_ic_plus': return $r('app.media.fb_ic_plus');
case 'fb_ic_bell': return $r('app.media.fb_ic_bell');
case 'fb_ic_gear': return $r('app.media.fb_ic_gear');
case 'fb_ic_user': return $r('app.media.fb_ic_user');
case 'fb_ic_chart': return $r('app.media.fb_ic_chart');
case 'fb_ic_check': return $r('app.media.fb_ic_check');
case 'fb_ic_dot': return $r('app.media.fb_ic_dot');
case 'fb_ic_share': return $r('app.media.fb_ic_share');
case 'fb_ic_filter': return $r('app.media.fb_ic_filter');
case 'fb_ic_lock': return $r('app.media.fb_ic_lock');
case 'fb_ic_wallet': return $r('app.media.fb_ic_wallet');
case 'fb_ic_book': return $r('app.media.fb_ic_book');
case 'fb_ic_edit': return $r('app.media.fb_ic_edit');
case 'fb_ic_trash': return $r('app.media.fb_ic_trash');
default: return $r('app.media.fb_ic_dot');
}
}
// ============================================================
// 透明度色的**唯一正确写法**
// ============================================================
/**
* 🔴🔴 ArkUI 的 8 位十六进制颜色是 `#AARRGGBB`(**透明度在前**),
* 不是 Web/CSS 的 `#RRGGBBAA`。
*
* 本项目为此踩了一个"看不见的 bug"(2026-10-18 真机像素采样定位):
* · 遮罩写的是 `'#00000066'` —— 本意"黑色 40%",实际是 **alpha=0x00**
* ⇒ **完全透明** ⇒ 全应用所有"页面内浮层"的遮罩**从来没显示过**;
* 用户说的「弹框和下面白色的列表糊在一起,根本看不出是弹框」,
* 根因就是这个 —— 不是卡片样式不够,而是**遮罩压根没画出来**。
* · `brand + '1A'`(浅色底)实际变成"30% 不透明的橄榄色"
* ⇒ 主题色浅底看着发黄发脏,而不是"很淡的同色"。
* · `shadow({color:'#00000033'})` 同理 ⇒ 阴影也不存在。
*
* 证据(同一次真机截图里取像素):
* 实底 `brand` 采样 (77,131,137);浅底按钮采样 (217,219,186)。
* 按 AARRGGBB 算:`#4D83891A` = alpha 0x4D(30%) × (131,137,26) 叠白
* = (218,219,186) ✓ 与采样一致;按 RRGGBBAA 算应是 (237,242,243) ✗。
*
* ⚠️ 所以:**任何带透明度的颜色都必须走下面这两个工具**,不要手写 8 位 hex,
* 也不要再"在颜色串后面拼两位" —— 拼在后面就是拼到了蓝色通道上。
*/
/**
* 给 `#RRGGBB` 加透明度,产出 ArkUI 认识的 `#AARRGGBB`。
*
* @param hex 六位十六进制(`#RRGGBB`;也容忍八位,会先截成前六位)
* @param alpha 0~1(0.4 = 40% 不透明)
*/
/**
* 把主题色往白色方向混,得到一档浅色。
*
* 🔴 #86(用户 2026-11-05):「鸿蒙端也用类似的方式调,效果让它保持一致。」
*
* 与网页端 `fb_color_tint()`(app/helpers.php)**必须是同一个算法**,
* 否则同一条任务在两端的下拉项颜色不一样 —— 那就白"保持一致"了。
*
* 网页端的三档:
* tint-1(混 91% 白)→ 悬停 / 打开态底
* tint-2(混 80% 白)→ 选中项底
* tint-3(混 72% 白)→ 选中项悬停
*
* ⚠️ 与 `fbAlpha()` 的区别:那个是"原色 + 透明度"(透出背景色,会受
* 父容器底色影响);这个是"实色混合"(不透明,颜色确定)。
* 下拉项底色必须用实色 —— 用透明度的话叠在白色面板上还行,
* 但它自己是浮层,背景不确定,会时深时浅。
*
* @param hex 六位十六进制(`#RRGGBB`)
* @param ratio 往白色混多少(0.91 = 91% 白 + 9% 原色)
*/
export function fbMixWhite(hex: string, ratio: number): string {
let base: string = hex.trim();
if (base.startsWith('#')) {
base = base.substring(1);
}
if (base.length > 6) {
base = base.substring(base.length - 6);
}
if (base.length !== 6) {
return hex; // 不是 6 位 hex(如 rgba())→ 原样返回,调用方自己兜底
}
const r: number = parseInt(base.substring(0, 2), 16);
const g: number = parseInt(base.substring(2, 4), 16);
const b: number = parseInt(base.substring(4, 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) {
return hex;
}
let q: number = ratio;
if (q < 0) {
q = 0;
}
if (q > 1) {
q = 1;
}
const nr: number = Math.round(r + (255 - r) * q);
const ng: number = Math.round(g + (255 - g) * q);
const nb: number = Math.round(b + (255 - b) * q);
const h2 = (v: number): string => {
const s2: string = v.toString(16).toUpperCase();
return s2.length < 2 ? '0' + s2 : s2;
};
return '#' + h2(nr) + h2(ng) + h2(nb);
}
export function fbAlpha(hex: string, alpha: number): string {
let base: string = hex.trim();
if (base.startsWith('#')) {
base = base.substring(1);
}
if (base.length > 6) {
// 已经是八位:取**后六位**当颜色(历史写法把透明度拼在了末尾)
base = base.substring(base.length - 6);
}
let a: number = Math.round(alpha * 255);
if (a < 0) {
a = 0;
}
if (a > 255) {
a = 255;
}
let aa: string = a.toString(16).toUpperCase();
if (aa.length < 2) {
aa = '0' + aa;
}
return '#' + aa + base.toUpperCase();
}
/** 页面内浮层的遮罩(黑色 40%)—— 全应用**同一档**,两级浮层不能深浅不一 */
export const FB_MASK: string = '#66000000';
/** 浮层卡片阴影(黑色 20%) */
export const FB_SHADOW: string = '#33000000';
/** 卡片描边(黑色 8%,同色背景上仍有一条可见界线) */
export const FB_HAIRLINE: string = '#14000000';