v0.8.1 界面六条:删离线入口 / 标语统一 / 最近纪念日 / 图标真上色 / 真头像 / 日程四修
用户 2026-10-18 六条(全部已在真机逐条验证):
① 登录页「先不登录 / 离线使用」彻底删除
用户:「把那些不登录、看离线数据之类的提示全部去掉。以后就不再拥有这样的
功能了,把这些功能也全部删除。」
⇒ 两个按钮 + onSkip + allowSkip 参数 + Index 里的 skip 分支一起删净。
这是产品决定(不是隐藏):该路径本身就是安全缺口,v58 上了整库加密后更要堵死。
② 标语统一为「一家人用岁月写成的一本书」
用户:「这也是我们"家书"这个名字的由来。所以你不要再乱换这个标语了,
只要是想出现标语或者副标题的地方,就用这样一句话。」
· 端上 FB_DEFAULT_SLOGAN 是唯一定义处(登录页副标题 + 抽屉回落都用它);
· 抽屉左上角原来是家庭名,现在显示**标语**;
· 三层优先级(个人 slogan → 家庭 motto → 应用默认口号)**在服务端算**
(Family::slogan,与网页端首页 heroSlogan 同一套),/login 与 /me 都返回。
用户还说:「如果这个用户自己有标语……这个地方就是用户自己自定义的标语。」
真机实测:登录者杨永全自己写了「修身,齐家」⇒ 抽屉就显示他那句。
③ 首页新增「最近纪念日」(放在近期任务之后、往年的今天之前)
用户:「未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。
以倒计时形式展示:距离哪个纪念日还有多少天,距离谁谁谁的生日还有多少天。」
· /home.recent_days = Anniversary::upcoming(fam,pid,370) 取前 3 ——
取满一年:窗口太小会让"刚过完生日"的家庭一个都取不到,模块直接空着;
· 数据源 all() = 派生生日 + 自建纪念日,正好覆盖用户说的两样;
· 倒计时:0 天显示「就是今天」(写「还有 0 天」读起来像已经过了)。
④ 悬浮栏图标也上色(用户:「除了下面的汉字,上面图标的颜色也变成跟汉字一样的颜色」)
🔴🔴 真根因两层,都是"无报错失效":
· 第一层:Image.fillColor() **只对 SVG 生效**,而本工程 30+ 图标**全是 PNG**
⇒ 全工程 24 处图标染色一直是空操作(文字走 fontColor 所以只有文字变了)。
· 第二层(真机才现形):改用 colorFilter 后,矩阵写成了"系数乘 RGB"
(R' = R_原 × 系数) —— 对白色图标成立,但本工程图标是**纯黑**,
黑 × 任何系数 = 仍是黑 ⇒ 真机上图标像素依旧是 (0,0,0)。
⇒ 最终形态:`fbTint` 用 **alpha 当载体**(R' = (r/255) × A_原):
系数是归一化约定(官方灰度示例系数和为 1,量纲确定);
形状完全由原图 alpha 决定 ⇒ 黑/白图标都成立,描边抗锯齿自然过渡。
真机像素实测:日程项图标与文字同为 (176,120,64)≈#B0763A;
未选中项图标 (44,39,35) 深灰(不再是纯黑)。
⚠️ 全工程 24 处 fillColor 一并改掉;FbIconButton.tint 类型改成 string,
让"误传 Resource"变成编译错误而不是又一个静默失效。
⑤ 顶栏:真实头像 + 拉开按钮间距
用户:「右上角的头像应该是真实的……现在提醒的按钮距离头像的按钮太近了。」
· /home 给 me_avatar、/login 给 avatar_url(avatar_url() 按线上存储驱动拼好);
· 三颗按钮(同步 / 铃铛 / 头像)统一用 FbMetric.gapTopActions = 8。
⑥ 日程四处
· 「日期下面有几个点」⇒ 月视图与周视图的圆点全部删除,
连 FbDot / dots() / count / dotColor 一起清(不留死代码);
· 「农历全部都是初一」⇒ 真根因是端上 lunarOf() **硬编码 return '初一'**,
而服务端 lunarRange() 本来就每天给真实农历(title)⇒ 改取 title;
· 「列表这个视图,今天的数据缺一条」⇒ 服务端 /calendar 的 from/to **默认只给本月**,
而列表视图要显示 -7 ~ +60 天 ⇒ 端上显式传范围
(loadModule 新增 pathOverride 参数);
· 「点击了某一天之后并不明显,应该把这一天的颜色变深,而今天用一个较浅的颜色」
⇒ 照 SyncCalendar:选中 = 深色实底白字、今天 = 浅色底。
真机实测:17 号(选中)bg=#FFB0763A,18 号(今天)bg=#FFF5F0EA。
另:FbKeyStore 补上一个真机抓到的 HUKS 坑 —— isKeyItemExist 在密钥不存在时
**会抛 12000011**(不是返回 false),首次运行必被误判成"本机不支持 HUKS"
⇒ 永久明文降级。改成把"不存在"当正常分支,且降级态每次启动重试一次
(把同一把 DEK 重新用 KEK 包起来,不换密钥以免重下全部附件)。
验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 328/0;
_fb_v59ui_falsify.py 19 项注入 23/0;_fb_home_api.py 60/0;
六条已在真机逐条取像素/布局树确认。
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块主题色 → 客户端可用的三档调色板。
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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%')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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(不可导出)');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -63,12 +63,31 @@ export const FB_MODULE_PATH: Record<string, string> = {
|
||||
* @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<T>(name: FbModuleName, parse: (json: string) => T,
|
||||
sink: (data: T, fromCache: boolean) => void,
|
||||
onNote?: (note: string) => void): Promise<void> {
|
||||
onNote?: (note: string) => void,
|
||||
pathOverride?: string): Promise<void> {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<RawRecentDay>(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<RawMemory>(v as RawMemory[]);
|
||||
const out: FbMemoryItem[] = [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 要求含待办) */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
/* 🔴🔴 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<FbCalEvent[]>('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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user