用户 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 常量)。 编译通过,已装真机。
475 lines
19 KiB
Plaintext
475 lines
19 KiB
Plaintext
/**
|
||
* 家书 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';
|