diff --git a/entry/src/main/ets/common/Theme.ets b/entry/src/main/ets/common/Theme.ets index 5a3eeb1..c02e5fc 100644 --- a/entry/src/main/ets/common/Theme.ets +++ b/entry/src/main/ets/common/Theme.ets @@ -75,10 +75,103 @@ export class FbMetric { /** 顶栏高度 —— 用户要求「图标和标题尽量靠上」,所以压到 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'; +} + /** * 模块主题色 → 客户端可用的三档调色板。 * diff --git a/entry/src/main/ets/components/Drawer.ets b/entry/src/main/ets/components/Drawer.ets index b8121d1..a5c2389 100644 --- a/entry/src/main/ets/components/Drawer.ets +++ b/entry/src/main/ets/components/Drawer.ets @@ -24,9 +24,10 @@ * 自己用 Stack + 位移动画做,行为完全可控。 */ -import { FbColors, FbIcon, FbMetric } from '../common/Theme'; +import { FbColors, FbIcon, FbMetric, fbTint, FbTint } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules'; +import { FB_DEFAULT_SLOGAN } from '../model/DemoData'; import { FbAvatar } from './Widgets'; /** @@ -54,7 +55,22 @@ export struct FbDrawer { /** 抽屉里列出的模块(接口接上后由 user_settings('nav_modules') 下发) */ @Local items: FbModuleDef[] = fbDefaultNavModules(); - /** 品牌区:书名(家书)+ 家庭名 */ + /** + * 品牌区:书名(家书)+ 标语。 + * + * 🔴🔴 v59(用户 2026-10-18):副标题从原来的**家庭名**(「我的家」/「杨家」) + * 换成**标语**。用户原话: + * 「进到系统里面之后,打开抽屉,左上角有家书的名字,上面写了"我的家"。 + * 这个地方也换成我们的标语。」 + * 「如果这个用户自己有标语,他可以写一句自己的标语;如果他有自己的标语, + * 这个地方就是用户自己自定义的标语。如果他没有标语,就用我们给他的这个标语。」 + * ⇒ 三层优先级:**个人 slogan → 家庭 motto → 应用默认口号**。 + * 这个拼接**不在端上做** —— 服务端 `Family::slogan()` 已经算好并放在 + * `session.slogan` 里(网页端首页是同一个函数,两边永远一致)。 + * 端上只在"还没拿到"时回落 `FB_DEFAULT_SLOGAN`。 + * ⚠️ 别再改回 `familyTitle` —— 那个字段现在只用于别处(如"某某的家"这类文案), + * 而且用户明确说这一格要放标语。 + */ @Builder brandHeader() { Row({ space: 10 }) { @@ -68,10 +84,12 @@ export struct FbDrawer { .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .letterSpacing(1) - Text(this.app.session.familyTitle) + // 标语:用户自己的 → 家庭的 → 应用默认(服务端已算好,见上面说明) + Text(this.sloganText()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) - .maxLines(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) @@ -81,6 +99,12 @@ export struct FbDrawer { .padding({ left: 18, right: 12, top: 16 + this.app.statusBarH, bottom: 10 }) } + /** 该显示的标语(会话里没有就回落应用默认口号,**永远不空**) */ + private sloganText(): string { + const s: string = this.app.session.slogan; + return s !== '' ? s : FB_DEFAULT_SLOGAN; + } + /** 一个模块行 */ @Builder moduleRow(m: FbModuleDef) { @@ -91,7 +115,7 @@ export struct FbDrawer { .width(18) .height(18) .renderMode(ImageRenderMode.Template) - .fillColor(this.isActive(m.key) ? Color.White : fbThemeVars(m.color).brand) + .colorFilter(fbTint(this.isActive(m.key) ? FbTint.white : fbThemeVars(m.color).brand)) } .width(34) .height(34) @@ -172,7 +196,7 @@ export struct FbDrawer { .height(17) .renderMode(ImageRenderMode.Template) // 未实现的项图标也变灰,明示"暂不可用" - .fillColor(route === '' ? FbColors.faint : FbColors.muted) + .colorFilter(fbTint(route === '' ? FbTint.faint : FbTint.muted)) Text(label) .fontSize(FbMetric.fontSmall) .fontColor(route === '' ? FbColors.faint : FbColors.ink) diff --git a/entry/src/main/ets/components/FbModuleSettingsSheet.ets b/entry/src/main/ets/components/FbModuleSettingsSheet.ets index 71e484a..4dccc90 100644 --- a/entry/src/main/ets/components/FbModuleSettingsSheet.ets +++ b/entry/src/main/ets/components/FbModuleSettingsSheet.ets @@ -18,7 +18,7 @@ * 而不是让点击没反应 —— 后者用户会以为卡了。 */ -import { FbColors, FbMetric, FbIcon } from '../common/Theme'; +import { FbColors, FbMetric, FbIcon, fbTint, FbTint } from '../common/Theme'; /** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 */ const FB_SHEET_BOTTOM_GAP: number = 16; @@ -107,7 +107,7 @@ export struct FbModuleSettingsSheet { .width(17) .height(17) .renderMode(ImageRenderMode.Template) - .fillColor(it.action === '' ? FbColors.faint : FbColors.muted) + .colorFilter(fbTint(it.action === '' ? FbTint.faint : FbTint.muted)) Column({ space: 1 }) { Text(it.label) .fontSize(FbMetric.fontBody) diff --git a/entry/src/main/ets/components/FbPersonSheet.ets b/entry/src/main/ets/components/FbPersonSheet.ets index 7c790ca..1c8b03c 100644 --- a/entry/src/main/ets/components/FbPersonSheet.ets +++ b/entry/src/main/ets/components/FbPersonSheet.ets @@ -24,7 +24,7 @@ * 只是缺那几项详情专属字段,界面不会空。 */ -import { FbColors, FbMetric } from '../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../common/Theme'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; @@ -261,7 +261,7 @@ export struct FbPersonSheet { .width(15) .height(15) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Column({ space: 2 }) { Text(label) .fontSize(FbMetric.fontTiny) diff --git a/entry/src/main/ets/components/FloatDock.ets b/entry/src/main/ets/components/FloatDock.ets index c765ec8..3168562 100644 --- a/entry/src/main/ets/components/FloatDock.ets +++ b/entry/src/main/ets/components/FloatDock.ets @@ -31,10 +31,21 @@ * 否则最后一块内容会被悬浮栏压住(悬浮 ≠ 可以挡内容)。 */ -import { FbColors, FbIcon, FbMetric } from '../common/Theme'; +import { FbColors, FbIcon, FbMetric, fbTint } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; import { FbModuleDef, fbModuleOf } from '../model/Modules'; +/** + * 未选中时的图标 / 文字色。 + * + * 🔴 从 muted(#7E6B5A) 加深到 #4A3F35: + * 原先在白底上的对比度只有约 2.3:1(远低于 WCAG 的 4.5:1), + * 加上悬浮栏材质半透,用户就觉得"和下面的内容糊在一起、看不清"。 + * ⚠️ 图标与文字**必须用同一个常量**:用户 2026-10-18 要的就是"两处颜色一致", + * 一处改、另一处忘了改,正是这次报的那个问题。 + */ +const INACTIVE_TINT: string = '#4A3F35'; + /** 快捷栏高度(vp)—— 取设计规范的 56 */ const DOCK_H: number = 56; /** @@ -301,20 +312,24 @@ export struct FbDockItem { Image(FbIcon.ui(this.icon)) .width(20) .height(20) - .renderMode(ImageRenderMode.Template) - // ⚠️ fillColor 必须同一类型:选中时用模块色(string),未选中也用 string。 - // 三目里两侧类型不一致(Resource 与 string 混用)会报 - // "Type 'string | Resource' is not assignable to type 'string'"。 - // - // 🔴 未选中色从 muted(#7E6B5A) 加深到 #4A3F35: - // 原先在白底上的对比度只有约 2.3:1(远低于 WCAG 的 4.5:1), - // 加上材质半透,用户就觉得"和下面的内容糊在一起、看不清"。 - // 🔴 选中色 = **该模块自己的设定色**(用户 2026-10-17 明确要求), - // 不是统一的品牌红 —— 这样"我在日程"和"我在任务"一眼能分清。 - .fillColor(this.active ? this.accent : '#4A3F35') + /* 🔴🔴 v59(用户 2026-10-18):「现在下面的悬浮框只是那个汉字的颜色变了, + 上面的图标颜色没有变。我的要求是:除了下面的汉字,上面图标的颜色 + 也变成跟汉字一样的颜色,变成彩色的。」 + ⇒ 真根因:`fillColor()` **只对 SVG 生效**,而本工程图标**全是 PNG** + (SDK `image.d.ts` 原话:"This attribute applies only to SVG images. + To set the fill color for a PNG image, use `colorFilter`")。 + 所以原来那行 `fillColor(...)` 一直是**空操作** —— 文字走 fontColor + 是真的生效,图标就一直是原图的黑色。 + ⇒ 改用 `colorFilter(fbTint(...))`:用颜色矩阵把单色 PNG 染成目标色, + 并保留原图 alpha(描边抗锯齿不能丢)。详见 `Theme.fbTint` 的说明。 + + ⚠️ 不用 `renderMode(ImageRenderMode.Template)`: + 它在本工程里**与 PNG 搭配同样不产生着色效果**(那套是给 SVG 用的 + 语义),留着只会让人以为"已经处理了颜色"。 */ + .colorFilter(fbTint(this.active ? this.accent : INACTIVE_TINT)) Text(this.label) .fontSize(FbMetric.fontTiny) - .fontColor(this.active ? this.accent : '#4A3F35') + .fontColor(this.active ? this.accent : INACTIVE_TINT) .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) .maxLines(1) } diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index f16e9af..2297306 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -16,7 +16,7 @@ * ⚠️ 顶栏**不放**「设置」—— 与 Web 端一致:设置收在抽屉底部的账号区里。 */ -import { FbColors, FbIcon, FbMetric } from '../common/Theme'; +import { FbColors, FbIcon, FbMetric, FbTint, fbTint } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore'; import { fbModuleOf } from '../model/Modules'; @@ -113,14 +113,19 @@ export struct FbTopBar { Image(FbIcon.ui('check')) .width(17) .height(17) - .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.muted) + /* ⚠️ 图标染色必须走 `colorFilter` —— 本工程图标全是 PNG, + 而 `fillColor` **只对 SVG 生效**(SDK image.d.ts 原话)。 + 详见 `Theme.fbTint` 的说明。 */ + .colorFilter(fbTint(FbTint.muted)) } } .width(36) .height(36) .backgroundColor(Color.Transparent) .stateEffect(true) + // 与右侧铃铛拉开(用户 2026-10-18:「提醒的按钮距离头像的按钮太近了」, + // 三颗按同一个间距排,见 FbMetric.gapTopActions 的说明) + .margin({ left: FbMetric.gapTopActions }) .onClick(() => { this.syncSheet = true; }) @@ -136,13 +141,20 @@ export struct FbTopBar { }) // 通知铃铛(未读角标) + // + // 🔴 v59(用户 2026-10-18):「现在提醒的按钮距离头像的按钮太近了。」 + // ⇒ 两个按钮之间给一段**明确的**间距(原来它们各自 36 宽、紧挨着, + // 角标又挂在铃铛右上角、视觉上直接压到头像上)。 + // ⚠️ 用 `margin.left/right` 拉开而不是塞一个 `Blank()`: + // 镜像布局(右手握时整行反序)下 Blank 的弹性会跑到另一侧, + // 间距就没了;固定 margin 两边对称、镜像后仍然成立。 Stack({ alignContent: Alignment.TopEnd }) { Button({ type: ButtonType.Circle }) { Image(FbIcon.ui('bell')) .width(18) .height(18) - .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.muted) + /* ⚠️ 同上:PNG 图标染色必须用 colorFilter(fillColor 对 PNG 无效)。 */ + .colorFilter(fbTint(FbTint.muted)) } .width(36) .height(36) @@ -162,13 +174,23 @@ export struct FbTopBar { .margin({ top: 2, right: 0 }) } } + // 与左边的同步图标、右边的头像各拉开一段(用户:太近了) + .margin({ left: FbMetric.gapTopActions, right: FbMetric.gapTopActions }) // 头像(点开账号菜单) + // + // 🔴 v59(用户 2026-10-18):「右上角的头像应该是真实的。」 + // ⇒ 传 `imageUrl`(真实头像图;服务端 `avatar_url()` 给的)。 + // 没传过头像的人拿到空串 ⇒ `FbAvatar` 自带色块字回落, + // 所以这里不需要额外判断("两者都要有"是 FbAvatar 的既有设计)。 FbAvatar({ name: this.app.session.me.name, color: this.app.session.me.avatarColor, dia: 32, - fontSizeVp: 14 + fontSizeVp: 14, + imageUrl: this.app.session.me.avatarUrl !== undefined + ? this.app.session.me.avatarUrl + : '' }) } .width('100%') diff --git a/entry/src/main/ets/components/Widgets.ets b/entry/src/main/ets/components/Widgets.ets index 8d52ca6..272cdf9 100644 --- a/entry/src/main/ets/components/Widgets.ets +++ b/entry/src/main/ets/components/Widgets.ets @@ -8,7 +8,7 @@ * ⚠️ 所有 struct 加 `Fb` 前缀(arkts-no-decl-merging:同 hap 内顶层名不得重复)。 */ -import { FbColors, FbIcon, FbMetric } from '../common/Theme'; +import { FbColors, FbIcon, FbMetric, fbTint, FbTint } from '../common/Theme'; /** * 圆点 / 色块(时间轴的点、日历的日程小点、看板的色标…)。 @@ -91,7 +91,7 @@ export struct FbCheckCircle { .width(this.dia * 0.66) .height(this.dia * 0.66) .renderMode(ImageRenderMode.Template) - .fillColor(Color.White) + .colorFilter(fbTint(FbTint.white)) } else if (this.doing) { // 进行中:中心一个白点 Column() @@ -123,7 +123,7 @@ export struct FbEmptyBox { .height(34) .opacity(0.35) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text(this.title) .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) @@ -320,7 +320,13 @@ export struct FbTag { export struct FbIconButton { @Param icon: Resource = $r('app.media.fb_ic_gear'); /** 图标色:ResourceColor 同时接受 Resource($r 语义色)与 '#RRGGBB' 字符串 */ - @Param tint: ResourceColor = FbColors.muted; + /** + * 图标颜色。**必须是 hex 字符串**(不是 `Resource`)—— + * `colorFilter(fbTint(...))` 只吃 hex(颜色矩阵的入参)。 + * ⚠️ 传 `FbColors.*`(Resource)会编译报错 `ResourceColor is not assignable to string`, + * 那是**故意的**:逼调用方用 `FbTint.*` 这组与 color.json 同值的常量。 + */ + @Param tint: string = FbTint.muted; /** 直径 */ @Param dia: number = 36; @Event onTap: () => void = () => { @@ -331,8 +337,8 @@ export struct FbIconButton { Image(this.icon) .width(18) .height(18) - .renderMode(ImageRenderMode.Template) - .fillColor(this.tint) + /* ⚠️ 本工程图标是 PNG ⇒ 必须 colorFilter(fillColor 只对 SVG 生效)。 */ + .colorFilter(fbTint(this.tint)) } .width(this.dia) .height(this.dia) @@ -368,7 +374,7 @@ export struct FbPrimaryButton { .width(16) .height(16) .renderMode(ImageRenderMode.Template) - .fillColor(Color.White) + .colorFilter(fbTint(FbTint.white)) } Text(this.label) .fontSize(FbMetric.fontSmall) diff --git a/entry/src/main/ets/data/FbKeyStore.ets b/entry/src/main/ets/data/FbKeyStore.ets index 40ae8be..18649d6 100644 --- a/entry/src/main/ets/data/FbKeyStore.ets +++ b/entry/src/main/ets/data/FbKeyStore.ets @@ -50,6 +50,21 @@ const DEK_LEN: number = 32; const IV_LEN: number = 12; const TAG_LEN: number = 16; +/** + * HUKS「条目不存在」错误码。 + * + * 🔴🔴 2026-10-18 真机实测(harmonyos 真机,非模拟器): + * `huks.isKeyItemExist(alias, ...)` 在密钥**不存在**时**会抛异常**, + * 而不是文档说的"返回 false" —— 抛出的 code 正是 12000011 + * (HUKS_ERR_CODE_ITEM_NOT_EXIST;HUKS 服务侧日志为 + * `HksKeyExist: leave KeyExist, result = -13`,-13 = HKS_ERROR_NOT_EXIST)。 + * + * 首次运行一定走到这条(KEK 还没生成)⇒ 如果不接住这个异常, + * 就会被当成"本机不支持 HUKS",直接**永久降级成明文密钥** + * (实测症状:设置页显示"已降级(明文保存)",而 HUKS 其实完全可用)。 + */ +const HUKS_ERR_ITEM_NOT_EXIST: number = 12000011; + class FbKeyStoreImpl { private store: preferences.Preferences | null = null; private dekBytes: Uint8Array | null = null; @@ -110,13 +125,24 @@ class FbKeyStoreImpl { return false; } - // ① 先用降级路径里那份明文 DEK(上次已经降级过,别再试一遍 HUKS 白等) + // ① 上次降级过(明文 DEK 在):先用它把数据救活,但**不要就此认命** —— + // HUKS 可能是被临时性原因挡住的(系统刚升级、密钥库服务还没起来…), + // 下次启动也许就好了。所以这里试着把同一把 DEK **重新用 KEK 包起来**; + // 只有真包不上才继续留在降级态。 + // 🔴 2026-10-18 为什么这样改:原来只要见过一次 KEY_PLAIN 就永远走降级 + // ("上次已降级,别再试一遍 HUKS 白等")⇒ 一次误判 ⇒ **终身明文**。 + // 真机实测正是如此:`isKeyItemExist` 抛的一个"不存在"被误当成"不支持", + // 之后每次启动都直接沿用明文,HUKS 明明可用也升不回来。 const plainV: preferences.ValueType = await this.store.get(KEY_PLAIN, ''); if (typeof plainV === 'string' && plainV !== '') { - this.dekBytes = this.b64Decode(plainV); + const plainDek: Uint8Array | null = this.b64Decode(plainV); + this.dekBytes = plainDek; this.weak = true; this.ready = true; - hilog.info(DOMAIN, TAG, '沿用上一次的降级密钥(本机不支持 HUKS)'); + hilog.info(DOMAIN, TAG, '读到了上次的降级密钥,尝试重新用 HUKS 保护'); + if (plainDek !== null) { + await this.tryUpgradeFromPlain(plainDek); + } return this.dekBytes !== null; } @@ -126,6 +152,31 @@ class FbKeyStoreImpl { return this.dekBytes !== null; } + /** + * 把"已降级"的那把明文 DEK **重新包进 HUKS**(成功则升回正常态)。 + * + * ⚠️ 只重新包裹、**不换密钥**:附件密文是用这把 DEK 加的, + * 换一把会让已落盘的附件全部解不开(白下几百个文件)。 + * 等价的错误做法是"直接调 loadOrCreate 生成新 DEK" —— 那等于每次 + * 从降级态恢复都要重下全部附件。 + */ + private async tryUpgradeFromPlain(dek: Uint8Array): Promise { + if (this.store === null) { + return; + } + const wrapped: string | null = await this.wrap(dek); + if (wrapped === null) { + hilog.info(DOMAIN, TAG, 'HUKS 仍不可用,继续使用明文密钥'); + return; + } + this.weak = false; + this.lastError = ''; + await this.store.put(KEY_WRAP, wrapped); + await this.store.delete(KEY_PLAIN); + await this.store.flush(); + hilog.info(DOMAIN, TAG, '已把降级的密钥重新交给 HUKS 保护(不再明文保存)'); + } + /** 读包裹体 → 解包;没有包裹体就新生成一把 */ private async loadOrCreate(ctx: common.Context): Promise { if (this.store === null) { @@ -173,10 +224,34 @@ class FbKeyStoreImpl { hilog.info(DOMAIN, TAG, '新数据密钥已生成并用 HUKS 包裹'); } + /** + * KEK 是否已存在。 + * + * ⚠️ **不能用** `huks.isKeyItemExist()` 的返回值直接判 —— 它在"不存在"时 + * 会抛 `12000011`(见 `HUKS_ERR_ITEM_NOT_EXIST` 的说明)。 + * ⇒ 这里把"不存在"(异常码 / 返回 false)统一成 false, + * 真正的不支持(其它错误码)才往上抛,由调用方决定是否降级。 + */ + private async kekExists(): Promise { + try { + return await huks.isKeyItemExist(KEK_ALIAS, { properties: [] }); + } catch (err) { + const e: BusinessError = err as BusinessError; + if (e.code === HUKS_ERR_ITEM_NOT_EXIST) { + return false; // 正常情况:还没生成过 KEK + } + // ⚠️ ArkTS 的 `throw` **不接受任意类型**(arkts-limited-throw)—— + // catch 到的 `err` 是 unknown,`throw err` 编译不过,必须包成 Error。 + throw new Error('判断 KEK 是否存在失败:code=' + + (e.code !== undefined ? e.code : -1) + + ' msg=' + (e.message !== undefined ? e.message : '')); + } + } + /** 用 HUKS 的 KEK 加密 DEK,返回 base64(iv|ct|tag);失败返回 null(→ 降级) */ private async wrap(dek: Uint8Array): Promise { try { - const exists: boolean = await huks.isKeyItemExist(KEK_ALIAS, { properties: [] }); + const exists: boolean = await this.kekExists(); if (!exists) { await huks.generateKeyItem(KEK_ALIAS, { properties: this.kekGenProps() }); hilog.info(DOMAIN, TAG, 'HUKS 里已生成 KEK(不可导出)'); diff --git a/entry/src/main/ets/data/FbLogin.ets b/entry/src/main/ets/data/FbLogin.ets index cb7da1e..fb3cc2a 100644 --- a/entry/src/main/ets/data/FbLogin.ets +++ b/entry/src/main/ets/data/FbLogin.ets @@ -38,6 +38,16 @@ export interface FbLoginResult { role: string; /** 家庭名('' = 服务端没给,不影响登录) */ familyName: string; + /** + * 🔴 v59:该显示的那句**标语**(个人 slogan → 家庭 motto → 应用默认口号)。 + * + * 由服务端 `Family::slogan()` 算好给出来(与网页端首页同一套优先级)—— + * 端上**不要自己拼**,否则网页端改了个人 slogan、手机上还是老的。 + * '' = 老服务端没给 ⇒ 端上回落 `FB_DEFAULT_SLOGAN`。 + */ + slogan: string; + /** 🔴 v59:我的真实头像 URL('' = 没传过头像,回落色块字) */ + avatarUrl: string; /** 服务端时间(ISO8601)—— 用来记"刚校验过",'' = 老服务端没给 */ serverTime: string; /** 失败原因(ok=false 时有值,可直接展示给用户) */ @@ -60,11 +70,15 @@ interface FbApiLoginPerson { id?: number; name?: string; role?: string; + /** v59:真实头像 URL(服务端 `avatar_url()` 拼好的) */ + avatar_url?: string; } interface FbApiLoginFamily { id?: number; name?: string; + /** v59:该显示的那句标语(服务端已按三层优先级算好) */ + slogan?: string; } interface FbApiEnvelope { @@ -172,6 +186,8 @@ class FbLoginClientImpl { personName: p !== undefined && p.name !== undefined ? p.name : '', role: p !== undefined && p.role !== undefined ? p.role : '', familyName: f !== undefined && f.name !== undefined ? f.name : '', + slogan: f !== undefined && f.slogan !== undefined ? f.slogan : '', + avatarUrl: p !== undefined && p.avatar_url !== undefined ? p.avatar_url : '', serverTime: d !== undefined && d.server_time !== undefined ? d.server_time : '', error: '', }; @@ -183,7 +199,8 @@ class FbLoginClientImpl { function fail(msg: string): FbLoginResult { const r: FbLoginResult = { ok: false, token: '', expiresAt: '', personId: 0, personName: '', - role: '', familyName: '', serverTime: '', error: msg + role: '', familyName: '', slogan: '', avatarUrl: '', + serverTime: '', error: msg }; return r; } diff --git a/entry/src/main/ets/data/FbModuleLoad.ets b/entry/src/main/ets/data/FbModuleLoad.ets index 420056e..68bb457 100644 --- a/entry/src/main/ets/data/FbModuleLoad.ets +++ b/entry/src/main/ets/data/FbModuleLoad.ets @@ -63,12 +63,31 @@ export const FB_MODULE_PATH: Record = { * @param parse JSON → 端上类型的解析函数 * @param sink 数据到位回调(会被调 1~2 次:缓存一次、网络一次) * @param onNote 可选:提示文案(同步失败/未登录时给页面显示一行小字) + * @param pathOverride 可选:**改掉这个模块的请求路径**(带查询串)。 + * + * 🔴🔴 v59(2026-10-18,用户报「列表这个视图,今天的数据缺一条」的真根因): + * 服务端 `/api/v1/calendar` 的 `from`/`to` **默认只给"本月"** + * (`Api::str('from', date('Y-m-01'))` / `date('Y-m-t')`)。 + * 而日程页的**列表视图**要渲染 `-7 ~ +60` 天 —— 跨到下个月去, + * 下月那一段**服务端根本没返回** ⇒ 落在下月的日程(包括"今天"往后那几天) + * 在列表里凭空少掉。 + * ⚠️ 月视图看起来正常,是因为它本来就只看本月 ⇒ 这个 bug 只在列表暴露。 + * ⇒ 日程页显式传 `from`/`to`(覆盖它要显示的那一整段),不要依赖默认值。 + * + * ⚠️ 只改**请求路径**、不动缓存键(缓存仍按模块名存): + * 否则同一个模块会出现两个缓存键、切视图时互相覆盖。 + * ⚠️ 这也意味着"列表拉的比月视图多"时,缓存里会留下更宽的一段 —— + * 这是**好事**(月视图也能直接命中缓存),但**不要**指望缓存里的范围 + * 与当前视图严格一致;页面永远按自己需要的日期去索引。 */ export async function loadModule(name: FbModuleName, parse: (json: string) => T, sink: (data: T, fromCache: boolean) => void, - onNote?: (note: string) => void): Promise { + onNote?: (note: string) => void, + pathOverride?: string): Promise { - const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : ''; + const path: string = pathOverride !== undefined && pathOverride !== '' + ? pathOverride + : (FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : ''); if (path === '') { hilog.warn(DOMAIN, TAG, '模块 %{public}s 没有配路径', name); return; diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index 41f33c4..5ab0403 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -18,7 +18,7 @@ import { FbAccount, FbAnniversaryItem, FbBookItem, FbCalEvent, FbHomeData, FbKinChip, FbKinGraph, FbKinPerson, FbMark, FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem, - FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, + FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay, } from '../model/Types'; // ============================================================ @@ -156,6 +156,13 @@ interface RawMemory { years_ago?: number; n?: number; } +/** `recent_days` 的原始行(服务端 `Anniversary::upcoming()` 的输出形状) */ +interface RawRecentDay { + id?: number; date?: string; title?: string; kind?: string; + person_id?: number; source?: string; days?: number; + age?: number | null; nth?: number | null; +} + interface RawTaskBoard { id?: number; name?: string; color?: string; tasks?: RawTask[]; } @@ -544,7 +551,7 @@ export function parseHome(json: string): FbHomeData { greeting: '', date: '', weekday: '', todayLabel: '', lunarShort: '', lunarLine: '', marks: [], weather: null, stat: { todayEvents: 0, doing: 0, todo: 0, overdue: 0 }, - memories: [], + memories: [], recentDays: [], meAvatar: '', todayEvents: [], tasks: [], unread: 0, }; if (root === null) { @@ -567,6 +574,8 @@ export function parseHome(json: string): FbHomeData { weather: parseWeather(root['weather']), stat: parseStat(root['stat']), memories: parseMemories(root['memories']), + recentDays: parseRecentDays(root['recent_days']), + meAvatar: s(root['me_avatar'] as string), todayEvents: parseCalRows(rawEvents), tasks: parseTaskRows(rawTasks), unread: n(root['unread'] as number), @@ -635,6 +644,39 @@ export function parseStat(v: Object | null | undefined): FbStat { * * ⚠️ 字段是 SQL 风格(`starts_at` / `years_ago`),端上要驼峰 —— 所以有这一层。 */ +/** + * 🔴 v59(用户 2026-10-18):`recent_days` → 首页「最近纪念日」。 + * + * ⚠️ `age` / `nth` 在服务端是 **null 或数字**(不是 0)——`n()` 会把 null 变成 0, + * 而"没有周岁"和"0 岁"是两件事(0 岁的宝宝确实存在)⇒ 这里显式保留 -1 + * 表示"没有这个信息",端上据此**不显示**那一段。 + * ⚠️ **不再排序**:服务端已按"最近的在前"排好,端上再排一遍就会出现 + * "两端顺序不一致"那类问题(本项目在往年的今天上踩过一次)。 + */ +export function parseRecentDays(v: Object | null | undefined): FbRecentDay[] { + const rows: RawRecentDay[] = arr(v as RawRecentDay[]); + const out: FbRecentDay[] = []; + for (const r of rows) { + const title: string = s(r.title); + if (title === '' || s(r.date) === '') { + continue; + } + const it: FbRecentDay = { + id: n(r.id), + date: s(r.date), + title: title, + kind: s(r.kind), + personId: n(r.person_id), + source: s(r.source) !== '' ? s(r.source) : 'custom', + days: n(r.days), + age: r.age !== null && r.age !== undefined ? n(r.age) : -1, + nth: r.nth !== null && r.nth !== undefined ? n(r.nth) : -1, + }; + out.push(it); + } + return out; +} + export function parseMemories(v: Object | null | undefined): FbMemoryItem[] { const rows: RawMemory[] = arr(v as RawMemory[]); const out: FbMemoryItem[] = []; diff --git a/entry/src/main/ets/data/FbSession.ets b/entry/src/main/ets/data/FbSession.ets index 7a5f4d8..c814017 100644 --- a/entry/src/main/ets/data/FbSession.ets +++ b/entry/src/main/ets/data/FbSession.ets @@ -62,9 +62,35 @@ interface FbMeDevice { name?: string; } +/** + * 🔴 v59:`/me` 里的**家庭**部分(含标语)。 + * + * ⚠️ 全部可选:老服务端不给这些字段时,端上保留原值(不能把它们冲成空)。 + */ +interface FbMeFamily { + id?: number; + name?: string; + /** 🔴 v59:该显示的那句标语(服务端按 个人→家庭→默认 算好) */ + slogan?: string; +} + interface FbMePayload { server_time?: string; device?: FbMeDevice; + /** v59:身份(姓名 / 头像 / 角色 / 家庭名 / 标语) */ + name?: string; + avatar?: string; + role?: string; + family?: FbMeFamily; +} + +/** 一次身份刷新(由 Index 收到 SESSION_OK 后写进全局状态) */ +export interface FbMeIdentityResult { + has: boolean; + name: string; + avatarUrl: string; + familyName: string; + slogan: string; } /** 一次校验的结果 */ @@ -77,6 +103,11 @@ export interface FbSessionCheck { serverTime: string; /** 续期后的令牌到期时间(拿不到为空) */ expiresAt: string; + /** + * v59:身份快照。**只有 SESSION_OK 时才有**(`has=false` 表示本次没拿到, + * 调用方应保留原值、不要把它冲成空 —— 否则断网启动会把头像/标语清掉)。 + */ + identity?: FbMeIdentityResult; } class FbSessionImpl { @@ -191,7 +222,17 @@ class FbSessionImpl { await this.markVerified(ctx, st); const exp: string = (payload.device !== undefined && payload.device.expires_at !== null && payload.device.expires_at !== undefined) ? payload.device.expires_at : ''; - return { state: SESSION_OK, reason: '', serverTime: st, expiresAt: exp }; + /* 🔴 v59:把身份快照一并带回(姓名 / 真头像 / 家庭名 / 标语)。 + ⚠️ 不做单独请求:`/me` 本来每次启动就打一次,顺手带上最省一次往返。 */ + const fam: FbMeFamily | undefined = payload.family; + const identity: FbMeIdentityResult = { + has: true, + name: payload.name !== undefined ? payload.name : '', + avatarUrl: payload.avatar !== undefined ? payload.avatar : '', + familyName: fam !== undefined && fam.name !== undefined ? fam.name : '', + slogan: fam !== undefined && fam.slogan !== undefined ? fam.slogan : '', + }; + return { state: SESSION_OK, reason: '', serverTime: st, expiresAt: exp, identity: identity }; } if (r.status === 401) { diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 2d9deec..554cb62 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -17,6 +17,21 @@ import { FbAccount, FbBookItem, FbCalBook, FbCalEvent, FbGroup, FbNoteItem, FbNotificationItem, FbResourceItem, FbRosterItem, FbSession, FbTaskItem, FbTripItem, FbTripNode } from './Types'; +/** + * 🔴🔴 应用口号(**唯一**定义处,用户 2026-10-18 明确指定)。 + * + * 用户原话:「我们应用的口号或者标语是:**一家人用岁月写成的一本书**。 + * 这也是我们"家书"这个名字的由来。所以你不要再乱换这个标语了, + * 只要是想出现标语或者副标题的地方,就用这样一句话。」 + * + * ⚠️ **不要再改这句话**,也不要就地另写一个字符串 —— + * 所有需要"标语 / 副标题"的位置都从这里取(登录页、抽屉品牌区…)。 + * ⚠️ 它与服务端的 `Family::DEFAULT_SLOGAN` 是**同一句话**: + * 服务端那份在用户填了自己的 slogan 时会返回用户那句(见 `Family::slogan()`), + * 端上这份只是"还没拿到接口数据时"的兜底,以及登录页(此时还没有用户数据)。 + */ +export const FB_DEFAULT_SLOGAN: string = '一家人用岁月写成的一本书'; + /** 假数据里的「我」 */ export const FB_DEMO_ME: FbAccount = { id: 1, @@ -33,7 +48,7 @@ export const FB_DEMO_SESSION: FbSession = { loggedIn: true, familyId: 1, familyTitle: '杨家', - slogan: '记录家庭的全部生活,一本用岁月写成的家谱', + slogan: FB_DEFAULT_SLOGAN, me: FB_DEMO_ME }; diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 8776af6..fcab718 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -25,6 +25,16 @@ export interface FbAccount { kinship: string; /** 头像色(Web 端 AvatarColor 算法得出) */ avatarColor: string; + /** + * 🔴 v59(用户 2026-10-18):「右上角的头像应该是真实的」—— + * 这里放**真实头像图片的完整 URL**(服务端 `avatar_url()` 给的, + * 空串 = 这个人没传过头像 ⇒ 端上回落成色块字)。 + * + * ⚠️ URL 由服务端拼好(它会按线上存储驱动决定走 /uploads 还是 OSS 直链), + * 端上**不要**自己拼路径 —— 拼错了就是"永远破图",而那正是这次要修的观感。 + * ⚠️ 可选(`?`):老代码/演示数据没有这一项,`undefined` 等价于空串。 + */ + avatarUrl?: string; /** 角色:admin / member / guest(Web 端 accounts.role) */ role: string; /** 是否为只读访客(Web 端 Auth::isGuest) */ @@ -171,6 +181,42 @@ export interface FbMemoryItem { yearsAgo: number; } +/** + * 🔴 v59(用户 2026-10-18):首页「最近纪念日」里的一条。 + * + * 用户原话:「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、 + * 『近期任务』后面。内容包括: + * 1. 未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。 + * 2. 以倒计时形式展示:距离哪个纪念日还有多少天,距离谁谁谁的生日还有多少天。」 + * + * ⇒ 服务端 `/home` 的 `recent_days` 已按"最近的在前"排好、最多 3 条, + * 数据来源是 `Anniversary::occurrences()` → `all()` = **派生生日 + 自建纪念日**, + * 正好一次覆盖用户说的两样。 + */ +export interface FbRecentDay { + /** 自建纪念日的 id(派生生日为 0) */ + id: number; + /** 下一次发生的日期 `YYYY-MM-DD` */ + date: string; + /** 标题(已含姓名,如「老杨 的生日」「结婚纪念日」)—— 直接显示 */ + title: string; + /** 类别(用户填的,如「生日」「结婚纪念」) */ + kind: string; + /** 关联的人(自建纪念日可能关联多人;0 = 没关联) */ + personId: number; + /** + * 来源:`custom` = 手动填的纪念日;`birthday_solar` / `birthday_lunar` = 派生生日。 + * ⚠️ 端上据此在文案上区分「纪念日 / 生日」(用户两条要求分别就是这两类)。 + */ + source: string; + /** 距今天还有几天(**0 = 就是今天**;服务端保证非负) */ + days: number; + /** 周岁(派生生日才有;null = 没有) */ + age: number; + /** 是第几个(自建纪念日且填了起始年才有;null = 不显示序号) */ + nth: number; +} + /** 首页「模块卡片」—— 一块一块的入口,带一句关键数字 */ export interface FbModuleCard { /** 模块 key(点击后切到哪个模块) */ @@ -216,6 +262,10 @@ export interface FbHomeData { stat: FbStat; /** 往年的今天(与电脑端 `Home::memories()` 同一个逻辑) */ memories: FbMemoryItem[]; + /** 🔴 v59:未来最近的三个纪念日(含生日),倒计时用(服务端已排好序) */ + recentDays: FbRecentDay[]; + /** 🔴 v59:我的真实头像 URL(顶栏右上角用;'' = 没传过头像) */ + meAvatar: string; /** 今天的日程(服务端已排好:全天在前、其余按时间) */ todayEvents: FbCalEvent[]; /** 近期任务(**进行中在前、待办在后**,用户 2026-10-16 要求含待办) */ diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 2c4c2a0..b296843 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -36,7 +36,8 @@ import { FbLoginPage } from './login/FbLoginPage'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FB_SERVER_STORE } from '../data/FbServerStore'; import { FB_DEVICE_STORE } from '../data/FbDeviceStore'; -import { FB_SESSION, SESSION_NEED_LOGIN, SESSION_OFFLINE } from '../data/FbSession'; +import { FB_SESSION, FbMeIdentityResult, SESSION_NEED_LOGIN, SESSION_OFFLINE } from '../data/FbSession'; +import { FbAccount, FbSession } from '../model/Types'; import { fbOnUnauthorized } from '../data/FbHttp'; import { FbDb } from '../data/FbDb'; import { FB_ATTACH } from '../data/FbAttachQueue'; @@ -188,6 +189,14 @@ struct Index { this.app.needLogin = false; this.app.offlineLocked = false; this.entered = true; + /* 🔴 v59:把 `/me` 带回来的**身份**写进全局状态(姓名 / 真头像 / + 家庭名 / 标语)。用户 2026-10-18 两条要求都靠它: + · 「右上角的头像应该是真实的」 → `me.avatarUrl` + · 抽屉左上角要显示标语 → `session.slogan` + ⚠️ 必须判 `has`:断网启动时 `check()` 仍会回 SESSION_OK(离线可用), + 但**没有**身份数据。不判就会把已有的头像/标语冲成空 —— 那是 + "一次断网,头像就没了"这种最难解释的 bug。 */ + this.applyIdentity(r.identity); } this.bootChecked = true; } @@ -360,6 +369,46 @@ struct Index { this.reopenLocal(); } + /** + * 🔴 v59:把 `/me` 带回来的身份写进全局状态。 + * + * 处理的是"**启动时校验会话**"这条路径(登录那一刻的身份由登录页自己写, + * 见 `FbLoginPage.applySession`);两条路径都必须能更新这些字段, + * 否则会出现"登录后标语是对的,重启一次又变回旧的"。 + * + * ⚠️ 逐项判空后再写:`check()` 在**断网**时也会回 SESSION_OK(离线可用), + * 但那时没有身份数据。整块替换会把头像/标语清成空 + * (症状:"坐一趟飞机回来,头像和标语都没了")。 + * ⚠️ `slogan` 只在服务端给了非空值时覆盖 —— 兜底那句话由端上的 + * `FB_DEFAULT_SLOGAN` 负责,服务端不给就保持原值。 + */ + private applyIdentity(id: FbMeIdentityResult | undefined): void { + if (id === undefined || !id.has) { + return; + } + const old: FbSession = this.app.session; + // 姓名 / 头像 / 角色 变了才整体换(V2 对 interface 的深层属性观察不可靠, + // 换引用才能保证读它的页面都刷新 —— 与登录页 applySession 同一理由) + const me: FbAccount = { + id: old.me.id, + name: id.name !== '' ? id.name : old.me.name, + kinship: old.me.kinship, + avatarColor: old.me.avatarColor, + avatarUrl: id.avatarUrl !== '' ? id.avatarUrl : old.me.avatarUrl, + role: old.me.role, + isGuest: old.me.isGuest, + canWrite: old.me.canWrite, + }; + const next: FbSession = { + loggedIn: true, + familyId: old.familyId, + familyTitle: id.familyName !== '' ? id.familyName : old.familyTitle, + slogan: id.slogan !== '' ? id.slogan : old.slogan, + me: me, + }; + this.app.session = next; + } + /** * 重新建立本地库 + 附件索引(登录成功后调)。 * @@ -440,26 +489,20 @@ struct Index { // ---- 未登录 / 被要求重新登录:登录页(**不显示抽屉/悬浮栏**)---- // 🔴 v55:`needLogin` 是设置页「退出登录 / 更换服务器」置的位 —— // 本应用没有路由栈,登录页只能靠这个状态位回到前台。 + // + // 🔴🔴 v59(用户 2026-10-18):**「先不登录 / 离线使用」已彻底移除**。 + // 用户原话:「登录页面,把那些不登录、看离线数据之类的提示全部去掉。 + // 以后就不再拥有这样的功能了,把这些功能也全部删除。」 + // ⇒ 没有 `allowSkip`、没有 `onSkip`,**登录页就是唯一下车口**。 + // ⚠️ 这是产品决定,别再"顺手"加回一个跳过按钮 —— 它本来就是个 + // 安全缺口(被踢之后不该能看任何内容),v58 上了整库加密之后更要堵死。 FbLoginPage({ // 传 app 只为让它拿到**状态栏高度**(全屏布局下登录页也要自己让位) app: this.app, // 从第几步开始(1=连地址一起换,2=只重登,0=自动) startStep: this.app.loginStartStep, - /* 🔴 v57:只有"从没登录过"才允许「先不登录」。 - 被踢 / 主动退出后必须重新登录 —— 用户原话: - 「退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」 - 留着那个按钮就等于留了一扇后门(v58 上了加密才真正堵死)。 */ - allowSkip: this.app.lockReason === '', onLoggedIn: (): void => { this.onLoggedIn(); - }, - onSkip: (): void => { - // 「先不登录」:不登录也进主界面 —— 本地优先架构下, - // 没令牌照样能看已同步的数据。把用户堵在登录页是错的。 - // ⚠️ 必须同时复位 needLogin,否则又会被判回登录页。 - this.app.needLogin = false; - this.app.loginStartStep = 0; - this.entered = true; } }) } else { diff --git a/entry/src/main/ets/pages/books/FbBooksPage.ets b/entry/src/main/ets/pages/books/FbBooksPage.ets index 5cb740d..ba8ca1a 100644 --- a/entry/src/main/ets/pages/books/FbBooksPage.ets +++ b/entry/src/main/ets/pages/books/FbBooksPage.ets @@ -8,7 +8,7 @@ * 「在线读」「上传 EPUB 解析」「笔记导入」这些属于交互+文件能力,留给第二轮。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbBookItem, FbGroup } from '../../model/Types'; import { fbGroupBook } from '../../model/DemoData'; @@ -103,12 +103,12 @@ export struct FbBooksPage { .layoutWeight(1) // 上传 / 统计(Web 端在设置菜单里) - FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted }) + FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbTint.muted }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。 // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -232,7 +232,7 @@ export struct FbBooksPage { .width(11) .height(11) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text(b.noteCount.toString()) .fontSize(10) .fontColor(FbColors.faint) @@ -255,7 +255,7 @@ export struct FbBooksPage { .width(13) .height(13) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) } .width('100%') .padding(14) diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index 8a9595c..eb2d918 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -23,7 +23,7 @@ * 旧代码从条目里臆造 id,导致新建日程根本落不到正确的本上(见 `FbCalRepo.fetchBooks`)。 */ -import { FbColors, FbMetric, fbPalette } from '../../common/Theme'; +import { FbColors, FbMetric, fbPalette, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbCalBook, FbCalEvent, isEditableEvent } from '../../model/Types'; import { parseCalendar } from '../../data/FbParse'; @@ -31,7 +31,7 @@ import { loadModule } from '../../data/FbModuleLoad'; import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; -import { FbDot, FbEmptyBox, FbIconButton } from '../../components/Widgets'; +import { FbEmptyBox, FbIconButton } from '../../components/Widgets'; import { FbEventSheet } from '../../components/FbEventSheet'; import { FbEventEditSheet } from '../../components/FbEventEditSheet'; import { FbCalSettingsSheet } from '../../components/FbCalSettingsSheet'; @@ -40,7 +40,16 @@ import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; /** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */ -const FB_FAINT: string = '#A8988A'; +/** + * 列表(和月历)要覆盖的日期范围:今天 -7 ~ 今天 +60 天。 + * + * 🔴 这两个数**必须**同时用于两处(改一处就得改另一处): + * ① `load()` 里发给服务端的 `from`/`to`(不传的话服务端只给本月 ⇒ 跨月缺数据); + * ② `agendaDays()` 的循环边界(页面实际渲染哪些天)。 + * 分开写死就会变成"请求了 A 段、显示 B 段"——那正是本 bug 最容易复发的形态。 + */ +const AGENDA_BACK_DAYS: number = 7; +const AGENDA_FWD_DAYS: number = 60; /** 月历格子(一个日期) */ interface FbCalCell { @@ -52,10 +61,10 @@ interface FbCalCell { inMonth: boolean; /** 是否今天 */ isToday: boolean; - /** 该日的条目数(0 不显示点) */ - count: number; - /** 该日第一个条目的色(画小点用) */ - dotColor: string; + /* ⚠️ v59:原来的 `count` / `dotColor` 已删除 —— 它们只服务于"日期下面那排 + 小圆点",而用户 2026-10-18 明确要求**把那排点全部去掉**。 + 留着这两个字段会让人以为"哪里还在画点",也会让 `cellOf` 白算一遍。 */ + /** 农历/节日的短文字(初一显示月名、节日显示节日名) */ lunar: string; } @@ -75,7 +84,7 @@ interface FbWeekDay { /** 一 二 三 … */ w: string; isToday: boolean; - count: number; + /** ⚠️ v59:原来的 `count`(画小点用)已删 —— 用户要求去掉日期下那排点。 */ lunar: string; } @@ -176,6 +185,18 @@ export struct FbCalendarPage { * ⇒ 拉一次全量、端上按日期筛;切换月份/视图**不再请求**。 */ private async load(): Promise { + /* 🔴🔴 v59(用户 2026-10-18):「列表这个视图,今天的数据缺一条。」 + 真根因:服务端 `/calendar` 的 from/to **默认只给本月** + (`date('Y-m-01')` ~ `date('Y-m-t')`),而列表视图要显示 `-7 ~ +60` 天。 + 跨月那一段服务端压根没返回 ⇒ 落在下月的日程(含"今天往后几天") + 在列表里凭空少掉;月视图因为本来就只看本月,所以看不出来。 + ⇒ 这里**显式传范围**,覆盖列表要用的那一整段(前后各留够余量)。 + ⚠️ 不要只传"本月",也不要依赖服务端默认值 —— 列表与月视图用的 + `allEvents` 是同一份,范围必须**取两者所需的并集**(否则修了列表 + 又让月视图翻页时缺数据)。 + ⚠️ 往前 7 天 / 往后 60 天这两个数与 `agendaDays()` 是**同一套口径**, + 改一处必须改另一处(分散成两个魔法数字必然漂移)。 */ + const range: string = '/api/v1/calendar?from=' + this.agendaFrom() + '&to=' + this.agendaTo(); await loadModule('calendar', parseCalendar, (rows: FbCalEvent[]): void => { this.allEvents = rows; @@ -184,7 +205,20 @@ export struct FbCalendarPage { (note: string): void => { this.syncNote = note; this.loading = false; - }); + }, + range); + } + + /** 列表/月历所需范围的起点(今天 - 7 天,`YYYY-MM-DD`) */ + private agendaFrom(): string { + return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(), + new Date().getDate() - AGENDA_BACK_DAYS)); + } + + /** 列表/月历所需范围的终点(今天 + 60 天,`YYYY-MM-DD`) */ + private agendaTo(): string { + return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(), + new Date().getDate() + AGENDA_FWD_DAYS)); } // ============================================================ @@ -205,6 +239,11 @@ export struct FbCalendarPage { return dt.getFullYear().toString() + '-' + this.pad(dt.getMonth() + 1) + '-' + this.pad(dt.getDate()); } + /** `Date` → `YYYY-MM-DD`(服务端 from/to 用的格式) */ + private isoOfDate(dt: Date): string { + return this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); + } + /** 只看"可见的日历本"里的条目(设置里能逐本关掉) */ private visibleEvents(): FbCalEvent[] { if (this.books.length === 0) { @@ -271,10 +310,22 @@ export struct FbCalendarPage { return this.shortTitle(e.title); } } - // 其次:初一(`kind=lunar` 的条目只在初一有) + // 其次:农历日名 + // + // 🔴🔴 2026-10-18 修的真 bug(用户:「农历全部都是初一,说明是错误的数据」): + // 这里原来**硬编码返回 `'初一'`**,注释还写着"`kind=lunar` 的条目只在初一有" —— + // 而事实相反:服务端 `Calendar::month()` 里 `Holiday::lunarRange()` 是 + // **每一天都出一条**(`title` = `Lunar::monthName()`(初一显示月名) + // 或 `Lunar::dayName()`),端上把这个真值丢掉了,于是**整月都显示"初一"**。 + // ⇒ 直接取服务端给的 title(它本来就是"这一天的农历文字")。 + // ⚠️ title 可能带节日后缀(`八月十五 · 中秋`)—— `shortTitle` 截前 3 字, + // 而这张格子里只有 8sp 的位置,本来就该显示短的。 for (const e of this.allEvents) { if (e.date === iso && e.kind === 'lunar') { - return '初一'; + const t: string = this.shortTitle(e.title); + if (t !== '') { + return t; + } } } return ''; @@ -333,25 +384,13 @@ export struct FbCalendarPage { private cellOf(y: number, m: number, d: number, inMonth: boolean, today: string): FbCalCell { const iso: string = this.isoOf(y, m, d); - const list: FbCalEvent[] = this.eventsOf(iso); - // 只数"用户日程",不算农历/节气(否则每个格子都有一点) - let n: number = 0; - let color: string = FB_FAINT; - for (const e of list) { - if (e.kind === 'custom' || e.kind === 'anniv') { - if (n === 0) { - color = e.color; - } - n++; - } - } + // ⚠️ v59:这里原来还要遍历一遍 events 去数"有几条"(为画小点用)—— + // 那排点已被用户要求删除 ⇒ 这次遍历**整段去掉**(格子现在是 O(1) 的)。 const c: FbCalCell = { day: d, iso: iso, inMonth: inMonth, isToday: iso === today, - count: n, - dotColor: color, lunar: inMonth ? this.lunarOf(iso) : '' }; return c; @@ -381,16 +420,11 @@ export struct FbCalendarPage { for (let i = 0; i < 7; i++) { const dt: Date = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i); const iso: string = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); - const list: FbCalEvent[] = this.eventsOf(iso); - let n: number = 0; - for (const e of list) { - if (e.kind === 'custom' || e.kind === 'anniv') { - n++; - } - } + // ⚠️ v59:这里原来查一遍 events 只为数"有几条"(画小点用)—— + // 那排点已按用户要求删除 ⇒ 整段去掉。 out.push({ iso: iso, day: dt.getDate(), w: this.weekLabels[i], - isToday: iso === today, count: n, lunar: this.lunarOf(iso) + isToday: iso === today, lunar: this.lunarOf(iso) }); } return out; @@ -404,8 +438,8 @@ export struct FbCalendarPage { private agendaDays(): FbAgendaDay[] { const out: FbAgendaDay[] = []; const now: Date = new Date(); - // 往前 7 天到往后 60 天(够用且不至于太长) - for (let i = -7; i <= 60; i++) { + // ⚠️ 边界必须与 `load()` 请求的 from/to 一致(见 AGENDA_BACK_DAYS 的说明) + for (let i = -AGENDA_BACK_DAYS; i <= AGENDA_FWD_DAYS; i++) { const dt: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i); const iso: string = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); const list: FbCalEvent[] = this.eventsOf(iso); @@ -705,7 +739,7 @@ export struct FbCalendarPage { // (网页版日程页的 `_settings_menu.php` 用的就是那个三横线 SVG。) FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -865,29 +899,27 @@ export struct FbCalendarPage { Text(d.w) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) + /* 🔴 v59:选中 / 今天的配色与月视图**同一套**(选中深底白字、 + 今天浅底)—— 用户要的是"点哪天哪天明显",两处不一致会让人 + 以为换视图就丢了选中。判据同样走方法(见 `isPickedCell` 的说明)。 */ Text(d.day.toString()) .fontSize(FbMetric.fontSmall) - .fontWeight(d.isToday || d.iso === this.pickedIso + .fontWeight(d.iso === this.pickedIso || d.isToday ? FontWeight.Medium : FontWeight.Normal) - .fontColor(d.isToday ? Color.White - : (d.iso === this.pickedIso ? this.accent : FbColors.ink)) + .fontColor(d.iso === this.pickedIso + ? Color.White + : FbColors.ink) .width(30) .height(30) .textAlign(TextAlign.Center) .borderRadius(15) - .backgroundColor(d.isToday + .backgroundColor(d.iso === this.pickedIso ? this.accent - : (d.iso === this.pickedIso - ? fbPalette(this.accent).brand + '1F' : Color.Transparent)) - // 该日条目数(小圆点,最多 3 个) - Row({ space: 2 }) { - if (d.count > 0) { - ForEach(this.dots(d.count), (i: number) => { - FbDot({ dia: 4, fillC: this.accent }) - }, (i: number): string => 'wd_' + d.iso + '_' + i.toString()) - } - } - .height(5) + : (d.isToday ? fbPalette(this.accent).wash : Color.Transparent)) + /* 🔴 v59(用户 2026-10-18):「日期下面有几个点,合格都去掉。」 + ⇒ 周视图这一排小圆点也删(同一条要求,别只改月视图)。 + ⚠️ 留等高占位,避免周条高度变化。 */ + Row().height(5) if (d.lunar !== '') { Text(d.lunar) .fontSize(8) @@ -990,42 +1022,50 @@ export struct FbCalendarPage { @Builder dayCell(c: FbCalCell) { Column({ space: 2 }) { + /* 🔴🔴 v59(用户 2026-10-18):「现在点击了某一天之后,并不明显, + 应该把这一天的颜色变深,而今天用一个较浅的颜色来显示, + 你参考 synccalendar 的做法。」 + ⇒ 照本机范本 SyncCalendar 的 `weekStrip`: + · **选中** → `selected_bg` **深色实底** + 白字(最强调); + · **今天** → `today_bg` **浅色底** + 常规文字色(弱强调)。 + 原来的写法正好**反了**:今天用实底(`this.accent` + 白字)抢走了 + 全部注意力,而选中的那天只有一层 12% 的淡色 —— 点下去几乎看不出变化。 + ⚠️ 两个都免得同时成立(选中的那天可能也是今天):**选中优先**, + 否则"我今天点了今天,却看不到选中效果"。 + ⚠️ 深浅对照是**这套配色的核心**:选中实底、今天浅底 —— 别再改回去。 */ Text(c.day.toString()) .fontSize(FbMetric.fontSmall) - .fontWeight(c.isToday || c.iso === this.pickedIso ? FontWeight.Medium : FontWeight.Normal) - .fontColor(c.isToday + .fontWeight(this.isPickedCell(c) || c.isToday ? FontWeight.Medium : FontWeight.Normal) + .fontColor(this.isPickedCell(c) ? Color.White : (c.inMonth ? FbColors.ink : FbColors.faint)) .width(26) .height(26) .textAlign(TextAlign.Center) .borderRadius(13) - .backgroundColor(c.isToday - ? this.accent - : (c.iso === this.pickedIso && c.inMonth - ? fbPalette(this.accent).brand + '1F' + .backgroundColor(this.isPickedCell(c) + ? this.accent // 选中:深色实底 + : (c.isToday ? fbPalette(this.accent).wash // 今天:浅色底 : Color.Transparent)) // 农历/节日(有才显示) if (c.lunar !== '') { Text(c.lunar) .fontSize(8) - .fontColor(c.iso === this.pickedIso && c.inMonth ? FbColors.muted : FbColors.faint) + .fontColor(this.isPickedCell(c) && c.inMonth ? FbColors.muted : FbColors.faint) .maxLines(1) } else { // 没有农历就留出等高的空,免得两行格子和一行格子高度不一 Row().height(10) } - // 事件小点(最多 3 个) - Row({ space: 2 }) { - if (c.count > 0) { - ForEach(this.dots(c.count), (i: number) => { - FbDot({ dia: 4, fillC: c.dotColor }) - }, (i: number): string => 'dot_' + c.iso + '_' + i.toString()) - } - } - .height(5) + /* 🔴 v59(用户 2026-10-18):「日程,日期下面有几个点,合格都去掉。」 + ⇒ 那一排小圆点**已删除**(连同 `FbDot` 的引用与 `dots()` 辅助函数 + 在月视图的调用点)。用户认为它是噪音 —— 日程内容在下面那块 + 「选中日的日程」里看得更清楚,格子里不需要再暗示"有几条"。 + ⚠️ 周视图的圆点也一并去掉(同一条要求),别只删月视图。 + ⚠️ 这里**保留等高占位**:不留的话格子高度会变,7 行月历会整体上移。 */ + Row().height(5) } .width('100%') .height(48) @@ -1041,6 +1081,18 @@ export struct FbCalendarPage { }) } + /** + * 这一格是不是"当前选中的那天"。 + * + * ⚠️ 必须是**方法调用**(build 里读 `this.pickedIso`)而不是把结果当参数传进 + * `dayCell` —— 后者按值传递,@Builder 的多值参数不参与观察, + * 选中高亮会**永远停在初始那天**(本项目已在 FbTagChip 上踩过同一个坑, + * 见本文件 `dayPanel()` 上那段说明)。 + */ + private isPickedCell(c: FbCalCell): boolean { + return c.inMonth && c.iso === this.pickedIso; + } + /** * ⚠️ 日程条目行(列表模式)已经搬进 `FbDayEventPanel` 组件里 * (月/周/列表三处共用同一份 + 跟随"显示方式"设置)—— @@ -1057,14 +1109,4 @@ export struct FbCalendarPage { } return out; } - - /** 造「点」的索引数组(ForEach 需要数组,不能直接循环次数) */ - private dots(count: number): number[] { - const n: number = Math.min(3, count); - const out: number[] = []; - for (let i = 0; i < n; i++) { - out.push(i); - } - return out; - } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/days/FbDaysPage.ets b/entry/src/main/ets/pages/days/FbDaysPage.ets index 6a95194..46d8601 100644 --- a/entry/src/main/ets/pages/days/FbDaysPage.ets +++ b/entry/src/main/ets/pages/days/FbDaysPage.ets @@ -10,7 +10,7 @@ * 因为「还有几天」是这个模块唯一真正要一眼看到的信息。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbAnniversaryItem, FbKinChip } from '../../model/Types'; import { parseDays } from '../../data/FbParse'; @@ -112,7 +112,7 @@ export struct FbDaysPage { // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index 498ce47..0b6ba3c 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -39,7 +39,8 @@ import { FbColors, FbMetric } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; -import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbTaskItem, FbWeather } from '../../model/Types'; +import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem, + FbWeather } from '../../model/Types'; import { parseHome } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings'; @@ -91,6 +92,8 @@ export struct FbHomePage { @Local statOverdue: number = 0; /** 往年的今天(`/home` 的 memories) */ @Local memories: FbMemoryItem[] = []; + /** 🔴 v59:最近三个纪念日(`/home` 的 recent_days,服务端已排好序) */ + @Local recentDays: FbRecentDay[] = []; /** 顶部提示(同步失败等) */ @Local syncNote: string = ''; /** @@ -129,6 +132,7 @@ export struct FbHomePage { this.statTodo = d.stat.todo; this.statOverdue = d.stat.overdue; this.memories = d.memories; + this.recentDays = d.recentDays; }, (note: string): void => { this.syncNote = note; @@ -425,10 +429,58 @@ export struct FbHomePage { .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) - // ==================== ④ 往年的今天 ==================== + // ==================== ④ 最近纪念日 ==================== + // 🔴🔴 v59(用户 2026-10-18): + // 「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、 + // 『近期任务』后面。内容包括: + // 1. 未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。 + // 2. 以倒计时形式展示:距离哪个纪念日还有多少天, + // 距离谁谁谁的生日还有多少天。」 + // ⇒ 位置就卡在这两段之间(**不是**"放在首页某处")。 + // ⇒ 数据来自 `/home` 的 `recent_days`(服务端已排好序、最多 3 条), + // 来源是"派生生日 + 自建纪念日",正好覆盖用户说的两样。 + Column({ space: 0 }) { + FbSectionTitle({ + title: '最近纪念日', + extra: this.recentDays.length > 0 + ? this.recentDays.length.toString() + ' 个' + : '' + }) + Column({ space: 0 }) { + if (this.recentDays.length === 0) { + Column({ space: 6 }) { + Text('还没有要过的日子') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + Text('在「纪念日」里添一个,或者给家人的资料里填上生日。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ top: 12, bottom: 12 }) + } else { + ForEach(this.recentDays, (d: FbRecentDay, idx: number) => { + this.recentDayRow(d, idx === this.recentDays.length - 1) + }, (d: FbRecentDay, idx: number): string => + 'rd_' + d.date + '_' + d.title + '_' + idx.toString()) + } + } + .width('100%') + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + } + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) + + // ==================== ⑤ 往年的今天 ==================== // 🔴 用户 2026-10-17:「近期任务的下面,应该加上**往年的今天**, // 跟电脑端一样,你看看那个是什么逻辑。」 // ⇒ 位置**就在近期任务下面**(用户明确指定,不是"放在首页某处")。 + // ⚠️ v59 起它上面多了一块「最近纪念日」(用户 2026-10-18 指定插在 + // 「近期任务之后、往年的今天之前」)—— 两块都是"日子"类信息, + // 顺序是用户定的,别再调。 // 电脑端的逻辑见 `App\Home::memories()`:只认**往年的同一天** // (不退回同月),来源是「看过的电影」+「走过的**实际行程**」, // 合并按时间倒序取 6 条。 @@ -643,4 +695,106 @@ export struct FbHomePage { private memoryDate(s: string): string { return s.length >= 10 ? s.substring(0, 10) : s; } + + /** + * 「最近纪念日」里的一行(v59,用户 2026-10-18)。 + * + * 形态:**倒计时徽标**(`还有 12 天`)+ 标题 + 日期/周岁。 + * 用户要的就是这个倒计时:「距离哪个纪念日还有多少天, + * 距离谁谁谁的生日还有多少天」—— `title` 里已经带了姓名 + * (服务端拼的「老杨 的生日」),所以一句话读完就是用户要的表达。 + * + * ⚠️ 与 `memoryRow` 同一个反模式提醒:这里也是**两参数 @Builder**。 + * 它安全的前提是"整块由 ForEach 按 key 重建"(数据一次性到齐)—— + * 若将来出现"数据变了但行文字没变",**先改成组件**(如 `FbStatCell`), + * 别在这个 Builder 上打补丁。 + */ + @Builder + recentDayRow(d: FbRecentDay, isLast: boolean) { + Column({ space: 0 }) { + Row({ space: 10 }) { + // ---- 倒计时徽标:把"还剩几天"做成一眼可见的那一块 ---- + // 今天那种用实底强调(它是"就是今天",比"还有 N 天"更该被看见) + Text(this.countdownText(d.days)) + .fontSize(FbMetric.fontTiny) + .fontWeight(d.days === 0 ? FontWeight.Medium : FontWeight.Normal) + .fontColor(d.days === 0 ? Color.White : this.recentTint(d.source)) + .backgroundColor(d.days === 0 ? this.recentTint(d.source) : this.recentWash(d.source)) + .borderRadius(FbMetric.radiusPill) + .padding({ left: 9, right: 9, top: 3, bottom: 3 }) + .textAlign(TextAlign.Center) + + // ---- 标题(已含姓名 / 纪念日名)---- + Text(d.title) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + // ---- 右侧:日期(+ 周岁 / 第几个,有才显示)---- + Text(this.recentMeta(d)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + } + .width('100%') + .padding({ top: 11, bottom: 11 }) + .alignItems(VerticalAlign.Center) + + if (!isLast) { + Divider() + .color(FbColors.line2) + .margin({ left: 18 }) + } + } + .width('100%') + } + + /** + * 倒计时文案。 + * + * ⚠️ `days === 0` 必须说「就是今天」而不是「还有 0 天」—— + * 后者读起来像"已经过了",正是最容易搞反的地方。 + */ + private countdownText(days: number): string { + if (days <= 0) { + return '就是今天'; + } + return '还有 ' + days.toString() + ' 天'; + } + + /** + * 右侧那行小字:日期 +(周岁 / 第几个)。 + * + * · 日期一律显示(`MM-DD`,年份对"年年过"的纪念日没有信息量); + * · `age >= 0` ⇒ 生日,附「N 岁」(0 岁是**真实存在**的,不能当"没有"); + * · `nth >= 1` ⇒ 用户填了起始年,附「第 N 个」。 + */ + private recentMeta(d: FbRecentDay): string { + const md: string = d.date.length >= 10 ? d.date.substring(5, 10) : d.date; + const parts: string[] = [md]; + if (d.age >= 0) { + parts.push(d.age.toString() + ' 岁'); + } + if (d.nth >= 1) { + parts.push('第 ' + d.nth.toString() + ' 个'); + } + return parts.join(' · '); + } + + /** 纪念日的强调色:生日用粉、其余用紫(与网页版 `hb-mark` 的 birthday/anniv 一致) */ + private recentTint(source: string): string { + return this.isBirthday(source) ? '#A03060' : '#6B4A86'; + } + + /** 对应的极浅底(与上面同一档色的淡化) */ + private recentWash(source: string): string { + return this.isBirthday(source) ? '#FDECF3' : '#F2ECF7'; + } + + /** 是不是"生日"那类(服务端派生的生日条目;`custom` 是用户自己填的纪念日) */ + private isBirthday(source: string): boolean { + return source === 'birthday_solar' || source === 'birthday_lunar'; + } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets index cc225ba..795d1c5 100644 --- a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets +++ b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets @@ -14,7 +14,7 @@ * 任何地方都不要拿「元」去加总 —— 浮点误差会越滚越大。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail} from '../../model/Types'; import { fbLedgerKindName, fbMoney } from '../../model/DemoData'; @@ -155,7 +155,7 @@ export struct FbLedgerPage { // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -251,7 +251,7 @@ export struct FbLedgerPage { .width(13) .height(13) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text('净资产变化(盘点成线)') .fontSize(10) .fontColor(FbColors.faint) diff --git a/entry/src/main/ets/pages/login/FbLoginPage.ets b/entry/src/main/ets/pages/login/FbLoginPage.ets index 9701cc0..a093f76 100644 --- a/entry/src/main/ets/pages/login/FbLoginPage.ets +++ b/entry/src/main/ets/pages/login/FbLoginPage.ets @@ -20,9 +20,17 @@ * * 🔴 三种进入方式(都要有,别只做第一种): * ① **登录**:地址 + 账号密码(正常路径); - * ② **先不登录**:本地优先架构下,没令牌照样能看已同步的数据。 - * 把用户堵在登录页是**错的**(他可能只是没网,或家里老人不需要同步); - * ③ 已登录(令牌还在)时**根本不显示本页**(在 Index 里就分流掉了)。 + * ② 已登录(令牌还在)时**根本不显示本页**(在 Index 里就分流掉了)。 + * + * 🔴🔴 v59(用户 2026-10-18):**「先不登录 / 离线使用」这两个出口已彻底删除**。 + * 用户原话:「登录页面,把那些不登录、看离线数据之类的提示全部去掉。 + * 以后就不再拥有这样的功能了,把这些功能也全部删除。」 + * ⇒ 下面这些一起删干净(不能再留半截): + * · 两个「先不登录…」按钮及其 `onSkip` 回调; + * · `allowSkip` 参数(它本来是"首次允许、被踢不允许"的开关,现在**一律不允许**); + * · `Index.ets` 里 `onSkip` 的分支与"没令牌也进主界面"的逻辑。 + * ⚠️ 它是**产品决定**(不是"暂时隐藏"):这条路径本身就是个安全缺口 + * (被踢之后本来就不该能看任何内容),v58 上了整库加密之后更要堵死。 * * ⚠️ 密码**不做任何本地缓存**:只在提交那一刻用一次,用完立刻清 * (连变量都尽量短命)。App 里唯一要留的是**令牌**。 @@ -48,6 +56,7 @@ import { FbSyncAllResult, syncAll } from '../../data/FbSyncAll'; // 这个 `FB_SESSION` 是**会话状态机**(校验/离线计时),见 data/FbSession.ets import { FB_SESSION } from '../../data/FbSession'; import { FbAccount, FbSession } from '../../model/Types'; +import { FB_DEFAULT_SLOGAN } from '../../model/DemoData'; const DOMAIN: number = 0x0000; const TAG: string = 'FbLoginPage'; @@ -73,9 +82,6 @@ export struct FbLoginPage { /** 登录成功回调(由 Index 传入:进主界面) */ @Event onLoggedIn: () => void = (): void => { }; - /** 「先不登录」回调(不登录也进主界面) */ - @Event onSkip: () => void = (): void => { - }; /** * 从第几步开始(0 = 按本地有没有地址自动决定)。 * @@ -86,18 +92,6 @@ export struct FbLoginPage { */ @Param startStep: number = 0; - /** - * 🔴 v57(用户 2026-10-17):是否允许「先不登录 / 离线使用」。 - * - * `true` = 从没登录过(首次使用)—— 本地优先,不该把人堵在登录页; - * `false` = 被服务端踢了、或用户主动退出登录 —— - * 用户原话:「退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」 - * ⇒ 这时候留一个"先不登录"按钮,等于给安全策略开了个后门。 - * - * ⚠️ `@Param` 由 `Index` 按 `app.lockReason === ''` 决定,登录页自己不改它。 - */ - @Param allowSkip: boolean = true; - /** 当前步骤:1 = 填服务器地址,2 = 填账号密码 */ @Local step: number = 1; /** 服务器地址输入框 */ @@ -316,6 +310,9 @@ export struct FbLoginPage { name: res.personName !== '' ? res.personName : old.me.name, kinship: old.me.kinship, avatarColor: old.me.avatarColor, + /* 🔴 v59:真头像 URL(服务端 `avatar_url()` 给的,空串=没传过头像) + —— 顶栏右上角显示真图就靠它,用户 2026-10-18:「头像应该是真实的」。 */ + avatarUrl: res.avatarUrl, role: res.role !== '' ? res.role : old.me.role, isGuest: res.role === 'guest', canWrite: res.role !== '' && res.role !== 'guest', @@ -324,7 +321,14 @@ export struct FbLoginPage { loggedIn: true, familyId: old.familyId, familyTitle: res.familyName !== '' ? res.familyName : old.familyTitle, - slogan: old.slogan, + /* 🔴 v59:标语由服务端按 **个人 → 家庭 → 应用默认** 三层算好 + (`Family::slogan()`,与网页端首页同一个函数)。 + 有个人 slogan 就是他那句、没有就是家庭 motto、再没有才是默认口号。 + ⚠️ 服务端没给(老版本)时回落端上的 `FB_DEFAULT_SLOGAN` + —— 但**不要**覆盖掉已有的非空值(避免把用户的个性标语冲掉)。 */ + slogan: res.slogan !== '' + ? res.slogan + : (old.slogan !== '' ? old.slogan : FB_DEFAULT_SLOGAN), me: me, }; this.app.session = next; @@ -367,9 +371,18 @@ export struct FbLoginPage { .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) - Text(this.step === 1 ? '先连上你家的服务器' : '一家人的名册、日程与记忆') + /* 🔴 v59(用户 2026-10-18):副标题**一律**用应用口号。 + 用户原话:「我们应用的口号或者标语是:一家人用岁月写成的一本书。 + ……只要是想出现标语或者副标题的地方,就用这样一句话。」 + ⚠️ 原来这里是「先连上你家的服务器」/「一家人的名册、日程与记忆」 + —— 两句都在这次全部换掉,别再各写一句。 + ⚠️ 取 `FB_DEFAULT_SLOGAN` 常量而不是就地写字符串: + 口号只允许有**一个**定义处(详见 model/DemoData 里的说明)。 */ + Text(FB_DEFAULT_SLOGAN) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) } @@ -455,21 +468,6 @@ export struct FbLoginPage { .textAlign(TextAlign.Center) .width('100%') .margin({ top: 10 }) - - // 没服务器也能进(看本机已同步的数据) - // 🔴 v57:只有「从没登录过」才给这个出口(见 allowSkip 的说明) - if (this.allowSkip) { - Text('先不连接,离线使用') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .width('100%') - .padding({ top: 16, bottom: 16 }) - .margin({ top: 6 }) - .onClick((): void => { - this.onSkip(); - }) - } } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad }) @@ -575,21 +573,6 @@ export struct FbLoginPage { this.submit(); }) - // ---- 先不登录 ---- - // 🔴 v57:被踢 / 主动退出后**没有**这个出口(见 allowSkip 的说明) - if (this.allowSkip) { - Text('先不登录(看本地已有的数据)') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .width('100%') - .padding({ top: 16, bottom: 16 }) - .margin({ top: 6 }) - .onClick((): void => { - this.onSkip(); - }) - } - Text('账号和密码与网页版是同一套;手机号或姓名都可以。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index db3cbae..663d4a2 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -21,7 +21,7 @@ * 断网时更是完全空白。**先渲染本地、再后台同步**是这套架构的要点。 */ -import { FbColors, FbMetric, fbPalette } from '../../common/Theme'; +import { FbColors, FbMetric, fbPalette, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbKinChip } from '../../model/Types'; import { FbCinemaItem } from '../../data/FbMovieRepo'; @@ -427,7 +427,7 @@ export struct FbMoviesPage { // 它是观影模块的一部分,从设置菜单里进(与网页版一致)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, dia: 36, onTap: (): void => { this.settingsOpen = true; @@ -444,7 +444,7 @@ export struct FbMoviesPage { .width(15) .height(15) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text('搜索片名、影院、观影人…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) @@ -581,7 +581,7 @@ export struct FbMoviesPage { .width(22) .height(22) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text(mv.title) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted) diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets index b32b0f3..c82b1ec 100644 --- a/entry/src/main/ets/pages/people/FbPeoplePage.ets +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -12,7 +12,7 @@ * 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types'; import { parseKin } from '../../data/FbParse'; @@ -606,7 +606,7 @@ export struct FbPeoplePage { .width(15) .height(15) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) TextInput({ placeholder: '搜索:姓名 / 拼音首字母(yyq)/ 手机 / 单位', text: this.keyword }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontSmall }) diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets index 8f32990..a57bd3d 100644 --- a/entry/src/main/ets/pages/resources/FbResourcesPage.ets +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -8,7 +8,7 @@ * 那一排「类别筛选胶囊」对应 Web 端页头的分类入口。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbGroup, FbResourceItem } from '../../model/Types'; import { fbGroupResource } from '../../model/DemoData'; @@ -111,7 +111,7 @@ export struct FbResourcesPage { // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -212,7 +212,7 @@ export struct FbResourcesPage { .width(19) .height(19) .renderMode(ImageRenderMode.Template) - .fillColor(this.accent) + .colorFilter(fbTint(this.accent)) } .width(42) .height(42) @@ -244,7 +244,7 @@ export struct FbResourcesPage { .width(11) .height(11) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text(r.fileCount.toString() + ' 个附件') .fontSize(10) .fontColor(FbColors.faint) @@ -262,7 +262,7 @@ export struct FbResourcesPage { .width(13) .height(13) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) } .width('100%') .padding(14) diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets index 0537a94..e9203ac 100644 --- a/entry/src/main/ets/pages/settings/FbSettingsPage.ets +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -10,7 +10,7 @@ * 抽屉里列什么,正是由这里决定的。 */ -import { FbColors, FbMetric, fbPalette } from '../../common/Theme'; +import { FbColors, FbMetric, fbPalette, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbModuleDef, FB_MODULES, FB_PRESET_COLORS } from '../../model/Modules'; import { FbAvatar, FbDot, FbPrimaryButton, FbTag } from '../../components/Widgets'; @@ -278,7 +278,7 @@ export struct FbSettingsPage { .width(14) .height(14) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) } .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface) @@ -529,7 +529,7 @@ export struct FbSettingsPage { .width(17) .height(17) .renderMode(ImageRenderMode.Template) - .fillColor(fbPalette(m.color).brand) + .colorFilter(fbTint(fbPalette(m.color).brand)) } .width(32) .height(32) @@ -598,7 +598,7 @@ export struct FbSettingsPage { .width(13) .height(13) .renderMode(ImageRenderMode.Template) - .fillColor(Color.White) + .colorFilter(fbTint(FbTint.white)) } } .margin({ right: 8, bottom: 8 }) diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets index 9aeae65..90db882 100644 --- a/entry/src/main/ets/pages/tasks/FbTasksPage.ets +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -9,7 +9,7 @@ * 看板名仍保留(卡片上那个小色块标签),点它可以切到别的看板。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbGroup, FbTaskItem } from '../../model/Types'; import { fbGroupTask } from '../../model/DemoData'; @@ -114,7 +114,7 @@ export struct FbTasksPage { // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -295,7 +295,7 @@ export struct FbTasksPage { .width(11) .height(11) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) Text(t.childDone.toString() + '/' + t.childTotal.toString()) .fontSize(10) .fontColor(FbColors.faint) diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index 98fc466..b9bd2e8 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -12,7 +12,7 @@ * 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。 */ -import { FbColors, FbMetric } from '../../common/Theme'; +import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbTripItem, FbTripNode } from '../../model/Types'; import { fbMoney } from '../../model/DemoData'; @@ -129,7 +129,7 @@ export struct FbTripsPage { // 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。 FbIconButton({ icon: $r('app.media.fb_ic_menu'), - tint: FbColors.muted, + tint: FbTint.muted, onTap: (): void => { this.settingsOpen = true; } @@ -252,7 +252,7 @@ export struct FbTripsPage { .width(14) .height(14) .renderMode(ImageRenderMode.Template) - .fillColor(FbColors.faint) + .colorFilter(fbTint(FbTint.faint)) .rotate({ angle: this.isExpanded(t.id) ? 90 : 0 }) } .width('100%')