Files
FamilyBook_harmonyos/entry/src/main/ets/common/Theme.ets
T
laoyang 2b0401052c v0.8.2 头像点进我的详情 / 未读数接真实数据 / 名册圆角卡(容器要换成页面底色)
用户 2026-10-18 夜三条:

①「右上角,显示的应该是登录者的头像,点击头像进入我的详情页面。」
   头像**本来就是登录者本人的**(v59 已接 session.me.avatarUrl);
   真正缺的是**点击** —— TopBar 里那个 FbAvatar **根本没有 onClick**,
   只有一句写着"点开账号菜单"的注释,而"账号菜单"这个东西在代码里并不存在。
   ⇒ 接 openMyDetail() → bindSheet 弹 FbPersonSheet(复用名册那个人详情组件)。
   🔴 数据必须走 repo.byId(meId)(本次为它新增 byId),不能从名册列表那批里找:
      **"我"常常不在自己的名册里**(名册是"我挑出来的那一小撮"),
      从列表里查会**静默落空**、点了没反应 —— 最难查的那种失败。
   ⚠️ 三种状态都说清楚:加载中 / 拿到了 / 拿不到,绝不留空白浮层。

②「提醒按钮,明明没提醒,怎么还显示个 2?」
   🔴 真根因:`@Trace unread: number = 2;` 是**写死的占位初值**,
      而全工程只有一处给它赋值(通知页「全部已读」清 0)。
      没进过通知页的人**永远看到 2**;进了通知页又"永远 0"(把真实未读也盖掉)
      —— 两个方向都错。
   ⇒ ① 初值改 0(拿到真值前必须是"没有未读",不能凭空多出 2);
     ② 首页把 /home 的真实 unread 写进全局状态。
        (服务端 Notification::unreadCount,与通知页同源;/home 本来就返回它、
          FbParse 也早就解析了 —— 只是**没人往全局状态里写**。)

③「名册页面,不好看,太丑了,应该是每个人都是圆角矩形,往中间收一点,
   距离左右都有点距离,每个人中间都有一点空隙。」
   🔴🔴 真凶**不是卡片形状**(v51 就做了 borderRadius + margin)——
      而是**列表容器也是纯白** ⇒ 白卡压白底 ⇒ 圆角与左右收边**全看不见**,
      看上去就是一整块通栏白。这解释了"改过一轮还是丑":上次改的是卡片,
      问题在"背景没有对比"。
   ⇒ 容器 + 外层兜底改 FbColors.paper(页面底色),卡片保持白;
     圆角 radiusS(10) → 新增 radiusCard(14)(列表项专用档:16 显胖、10 显方);
     左右各 pagePad(16)、卡间 10(原 8 偏挤)、行内边距 11→12。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 347/0(本批新增 18 条,
另**收紧 2 条**旧判据 —— M 组"圆角卡"那条原来锚"逐字相邻两行"、且**测错了对象**:
没查容器背景,所以对"白卡压白底"这个真实缺陷毫无把关能力);
_fb_v59b_falsify.py 8 项注入 ⇒ 8/0。

⚠️ 本轮反向验证头一轮漏了 3 条(都是"判据太宽"),已逐条收紧并记入 pitfalls:
  · 删掉头像 onClick 后仍绿 —— openMyDetail 这个标识符在**方法定义**里也出现;
  · 容器改回白底后仍绿 —— 本页有**两处** FbColors.paper(容器 / 兜底);
  · /home unread 改成写死 2 后仍绿 —— Notification::unreadCount 在 PHP 里也有两处。
2026-10-18 20:58:40 +08:00

357 lines
15 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_kin': return $r('app.media.fb_ic_kin');
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');
}
}