v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据

从「演示数据壳子」走到「真的能连家里的服务器用」。

登录(用户 2026-10-17「完整的登录功能」)
  · 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
  · 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
  · FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
    设备名取机型(marketName),并上报 platform / app_version

设备会话(v57,用户 2026-10-17)
  · 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
  · 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
    (离线超 24h → 只锁界面、不清数据、联网自动恢复)——
    混成一种就会违背用户要的"一直联网就不用每天输密码"
  · 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
    让登录/校验类请求跳过,避免"两个地方同时跳登录页")
  · 退出登录:先清本地令牌(主防线),再尽力通知服务端
  · 设置页显示版本号与「这台设备」

数据层
  · FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
  · 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
  · 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致

验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
2026-10-18 03:36:04 +08:00
parent 7eaa641437
commit 776c95ba0b
64 changed files with 18297 additions and 1519 deletions
+358 -15
View File
@@ -18,7 +18,8 @@ import { FbColors, FbMetric } from '../common/Theme';
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
import { FbDrawerShell } from '../components/Drawer';
import { FbTopBar } from '../components/TopBar';
import { FbFloatDock } from '../components/FloatDock';
import { FB_GRIP, fbStartGripWatch } from '../common/FbGrip';
import { FbFloatDock, FB_DOCK_HIDE_Y } from '../components/FloatDock';
import { FbHomePage } from './home/FbHomePage';
import { FbMoviesPage } from './movies/FbMoviesPage';
import { FbTripsPage } from './trips/FbTripsPage';
@@ -31,6 +32,25 @@ import { FbLedgerPage } from './ledger/FbLedgerPage';
import { FbBooksPage } from './books/FbBooksPage';
import { FbNotesPage, FbNotificationsPage } from './misc/FbMiscPages';
import { FbSettingsPage } from './settings/FbSettingsPage';
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 { fbOnUnauthorized } from '../data/FbHttp';
import { common } from '@kit.AbilityKit';
/**
* 小睡一会儿(毫秒)。
*
* ⚠️ ArkTS 里没有 `setTimeout` 的 Promise 版,启动检查要"轮询等就绪"
* 就需要这样一个工具。用它而不是忙等(忙等会占满 CPU)。
*/
function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
}
@Entry
@ComponentV2
@@ -38,8 +58,255 @@ struct Index {
/** 全局状态(抽屉 / 当前模块 / 宽窄屏) */
@Local app: FbAppState = fbAppState();
/**
* 是否已经"进到应用里"。
*
* 🔴 三种状态,必须分开(别用两个布尔硬凑):
* · `checking` —— 启动时正在看"有没有令牌"(很短,但**必须有**,
* 否则会先闪一下登录页再跳进主界面);
* · 有令牌 / 用户选了"稍后再说" ⇒ `entered = true` ⇒ 显示主界面;
* · 没令牌且用户没跳过 ⇒ 显示登录页。
*/
@Local entered: boolean = false;
/** 启动检查是否已完成(false = 还在看令牌,先显示空白/logo) */
@Local bootChecked: boolean = false;
aboutToAppear(): void {
this.measureScreen();
this.readStatusBar();
// 🔴 「质感握姿」(用户 2026-10-16 第 4 条):左手握→抽屉从左出、右手握→从右出。
// ⇒ 它决定**顶栏家书图标贴哪边**(见 TopBar)与抽屉从哪侧滑出(见 Drawer)。
// ⚠️ 能力不支持(API<20)时会自己 catch 住,不影响启动。
fbStartGripWatch();
/* 🔴 v57:注册「令牌失效」回调。
任何业务请求收到 401 → 网络层直接调这里 → 清本地令牌 + 回登录页。
⚠️ 只注册一次(重复注册会覆盖成新的 this,无害但没必要)。 */
fbOnUnauthorized((): void => {
this.lockOut();
});
this.bootCheck();
}
/** 离开页面时注销握姿监听(⚠️ 不注销会在重建后叠加回调) */
aboutToDisappear(): void {
FB_GRIP.stop();
}
/**
* 读状态栏高度(由 `EntryAbility` 在全屏生效后量好、放进 AppStorage)。
*
* 🔴 为什么要读它(用户 2026-10-13 要"全沉浸"):
* 打开 `setWindowLayoutFullScreen(true)` 之后窗口**不再为状态栏让位**,
* 内容会真的画到状态栏底下。各页顶栏必须自己往下让出这一段,
* 否则首屏"家书"标题会被时间/信号图标压住。
* ⚠️ 存进 `app.statusBarH` 而不是各页自己读 AppStorage:
* 它是 `@Trace` 的,量出来(异步)之后各页会**自动重排**;
* 各页自己读一次的话,量完不会通知它们刷新。
* ⚠️ 量不到(=0)也能用:内容仍从最顶上开始画,只是标题贴上去了 ——
* 比猜一个数字把标题推到屏幕中间好。所以要**轮询等**一小会儿。
*/
private async readStatusBar(): Promise<void> {
for (let i = 0; i < 20; i++) {
const h: number = AppStorage.get<number>('fbStatusBarH') ?? -1;
if (h >= 0) {
this.app.statusBarH = h;
return;
}
await sleep(50);
}
this.app.statusBarH = 0;
}
/**
* 启动检查:有令牌就直接进主界面,否则显示登录页。
*
* ⚠️ 令牌与**服务器地址**的读取都是**同步**的(见两个 store 的说明),
* 所以这里其实很快;但要 `await` 一下它们的**预加载**,
* 否则会误判成"没登录 / 没配地址"(EntryAbility 里是异步预加载的)。
* ⇒ 用一个小轮询等它们就绪(最多约 1 秒),避免"有令牌却显示登录页"
* 以及"明明配过地址却让人重填"。
*/
private async bootCheck(): Promise<void> {
for (let i = 0; i < 40; i++) {
/* 🔴 四个都要就绪:
· 令牌 —— 有没有登录过(决定要不要验);
· 地址 —— 打哪个服务器(v55 起地址是持久化配置);
· 设备身份 —— 登录/校验请求要带 device_id(v57);
· 会话时间 —— 离线多久了(v57,离线超 24h 判锁)。
只等其中一部分,会出现"有令牌却显示登录页"或"没 device_id 就去登录"
这种看起来莫名其妙的状态。 */
if (FB_TOKEN_STORE.isReady() && FB_SERVER_STORE.isReady()
&& FB_DEVICE_STORE.isReady() && FB_SESSION.isReady()) {
break;
}
await sleep(50);
}
if (!FB_TOKEN_STORE.has()) {
// 从没登录过(或已退出登录):停在登录页。
// ⚠️ 首次使用允许"先不登录"(本地优先),但**被踢/退出后不允许** ——
// 那会把"v57 的安全策略"变成一个可绕过的按钮(见 build 里的 allowSkip)。
this.entered = false;
this.bootChecked = true;
return;
}
/* 🔴 v57(用户 2026-10-17):「每过 24 小时,我们就要请求一下服务器,
去验证一下这个 Token 是不是还是有效的」+「应用一打开就发现 Token 失效」。
⇒ 有令牌就**每次启动都验一次**(不是每 24h 才验一次)。
只有"打开时联网"才可能发现被踢,这一步就是那个时机。 */
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx === undefined) {
// 拿不到上下文(极罕见):不要把用户卡死,按本地数据先用着
this.entered = true;
this.bootChecked = true;
return;
}
const r = await FB_SESSION.check(ctx);
if (r.state === SESSION_NEED_LOGIN) {
this.app.lockReason = r.reason;
this.app.loginStartStep = 2;
this.app.needLogin = true;
this.app.offlineLocked = false;
this.entered = false;
} else if (r.state === SESSION_OFFLINE) {
/* 离线超过 24 小时:**锁界面但不删令牌**。
联网校验通过后自动恢复 —— 用户不用重新输密码(这正是他要的
"一直联网就不用每天登录")。 */
this.app.lockReason = r.reason;
this.app.offlineLocked = true;
this.app.needLogin = false;
this.entered = false;
} else {
this.app.lockReason = '';
this.app.needLogin = false;
this.app.offlineLocked = false;
this.entered = true;
}
this.bootChecked = true;
}
/** 取 Ability 上下文(失败返回 undefined,不抛) */
private abilityCtx(): common.Context | undefined {
try {
return this.getUIContext().getHostContext();
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '取 Ability Context 失败:%{public}s', JSON.stringify(err));
return undefined;
}
}
/**
* v57:离线超过 24 小时的锁屏。
*
* 用户原话:「离线超过 24 个小时就硬退出。」
*
* ⚠️ 这里**不做成登录页**:用户只是没联网,不该被要求重输密码。
* 联网后校验通过会自动回主界面(见 `retrySession`);
* 只有当服务端明确回 401(= 真被踢了)才转去登录页。
*/
@Builder
offlineLockView() {
Column() {
Blank()
Column() {
Text('需要联网确认')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.app.lockReason)
.fontSize(14)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.lineHeight(21)
.margin({ top: 12 })
Button('重新连接')
.fontSize(15)
.height(44)
.width(170)
.margin({ top: 26 })
.onClick((): void => {
this.retrySession();
})
Text('数据还在。联网确认后自动恢复,不用重新登录。')
.fontSize(12)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.lineHeight(18)
.margin({ top: 14 })
}
.padding({ left: 32, right: 32 })
Blank()
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 离线锁屏上的「重新连接」:再验一次,通过就自动放行 */
private async retrySession(): Promise<void> {
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx === undefined) {
return;
}
const r = await FB_SESSION.check(ctx);
if (r.state === SESSION_NEED_LOGIN) {
// 联网之后服务端说"这枚令牌没了" → 这才是真的被踢,转登录页
this.app.offlineLocked = false;
this.app.needLogin = true;
this.app.loginStartStep = 2;
this.app.lockReason = r.reason;
this.entered = false;
} else if (r.state === SESSION_OFFLINE) {
this.app.lockReason = r.reason; // 还是连不上,留在锁屏
} else {
this.app.offlineLocked = false;
this.app.needLogin = false;
this.app.lockReason = '';
this.entered = true;
}
}
/**
* 🔴 v57:令牌失效(任何请求 401)→ 立刻锁定回登录页。
*
* 用户原话:「运行期间任何一个请求返回 401,立刻锁定跳登录页 ——
* 这是真正的兜底,因为同步/写入本来就在不断发请求。」
*
* ⚠️ 由 `FbHttp` 的网络层统一回调(不是每个页面自己判),
* 所以**漏不掉**;登录接口自己传了 `authProbe` 不会触发。
*/
private lockOut(): void {
if (this.app.needLogin) {
return; // 已经在登录页了,别反复设状态(会让界面闪)
}
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx !== undefined) {
// ⚠️ 先清本地令牌(主防线:捡到手机的人拿不到它),再通知界面
FB_TOKEN_STORE.clear(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '清令牌失败:%{public}s', JSON.stringify(err));
});
FB_SESSION.clear(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '清会话记录失败:%{public}s', JSON.stringify(err));
});
}
this.app.lockReason = '这台设备已经被退出登录,请重新登录';
this.app.loginStartStep = 2;
this.app.needLogin = true;
this.app.offlineLocked = false;
this.entered = false;
}
/** 登录成功:进主界面(会话信息已由登录页写进 `app.session`) */
private onLoggedIn(): void {
this.app.session.loggedIn = true;
// 🔴 复位"要登录"的请求 —— 不复位的话,下一次渲染又会判回登录页(死循环)
this.app.needLogin = false;
this.app.loginStartStep = 0;
// v57:登录成功 = 会话刚校验过,两种锁都要撤掉
this.app.lockReason = '';
this.app.offlineLocked = false;
this.entered = true;
}
/**
@@ -68,27 +335,97 @@ struct Index {
// 🔴 用 Stack 而不是 Column:底部快捷栏要**浮在内容之上**(用户要求"不要固定在下边,
// 要浮着")。写在 Column 里它就是"占一行的固定底栏"(老样式)。
Stack({ alignContent: Alignment.Bottom }) {
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
// ---- 启动检查还没完:先给一块底色,别闪 ----
if (!this.bootChecked) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
} else if (this.app.offlineLocked) {
// ---- v57:离线超过 24 小时 —— 锁界面(**不清令牌**,联网即恢复)----
this.offlineLockView()
} else if (!this.entered || this.app.needLogin) {
// ---- 未登录 / 被要求重新登录:登录页(**不显示抽屉/悬浮栏**)----
// 🔴 v55:`needLogin` 是设置页「退出登录 / 更换服务器」置的位 ——
// 本应用没有路由栈,登录页只能靠这个状态位回到前台。
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 {
// ---- 主界面 ----
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
}
}
}
.width('100%')
.height('100%')
.width('100%')
.height('100%')
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
if (!this.app.wideLayout && !this.app.drawerOpen) {
FbFloatDock({ app: this.app })
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
// 🔴 **浮层开着时也不显示**(用户 2026-10-15 实测的真 bug):
// 悬浮栏在**比页面浮层更外层**的 Stack 里 ⇒ 层级更高。
// 详情卡片底部的「编辑 / 关闭」(y≈2207-2331)与悬浮栏(y≈2240-2350)
// 在屏幕上重叠,点按钮被悬浮栏抢走 ⇒ **直接跳到日历页**。
// ⇒ 用 `sheetOpen()` 让位(收起 + 不接收点击)。
if (!this.app.wideLayout && !this.app.drawerOpen && !this.app.sheetOpen()) {
// ⚠️ 外面这层 Column 是**必需的**:Stack 里的子组件会按内容宽度收缩,
// 不给它一个 width('100%') 的宿主,`FbFloatDock` 内部的左右内缩就无处生效
// (悬浮栏会缩成一小团靠在某一边)。
Column() {
FbFloatDock({ app: this.app })
}
.width('100%')
// 🔴 **随滚动收起 / 唤回**(用户 2026-10-13 要的 IT 之家那套):
// 向上滚内容 ⇒ 向下平移出屏幕(连同透明度一起淡出);
// 向下滚回看 ⇒ 平移回原位。
//
// ⚠️ 用 `translate` 平移而不是 `visibility` 开关:
// 平移是**连续的**(有动画过程),看起来是"滑走";
// 显隐是突变的,看着像"啪一下没了"。
// ⚠️ 平移量取 `FB_DOCK_HIDE_Y`(= 栏高 + 下边距 + 余量)——
// 必须**完全移出可视区**,否则底下会露出半截胶囊边。
// 用导出的常量而不是写死数字:它和栏高/边距是同一个几何量,
// 写死两处必然漂移。
.translate({ y: this.app.dockHidden ? FB_DOCK_HIDE_Y + 'vp' : '0vp' })
.opacity(this.app.dockHidden ? 0 : 1)
// 位移动画:出入对称、约 220ms 是"跟手但不拖沓"的区间
.animation({ duration: 220, curve: Curve.Friction })
}
}
}
.width('100%')
.height('100%')
// 沉浸式:让**根容器背景**延伸到状态栏/手势条区域,但不移动子组件。
// 配合 EntryAbility 里"系统栏与顶栏同色",通知栏与标题栏融为一体。
.backgroundColor(FbColors.paper)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 🔴 这里**不再用 `expandSafeArea`**(用户 2026-10-13 改"全沉浸"后)。
//
// 原因:安全区扩展只在**窗口仍然避让系统栏**的前提下才有意义
// (它做的事是"让背景多画进系统栏那条,但子组件不动")。
// 现在窗口已经开了 `setWindowLayoutFullScreen(true)`——
// 内容本来就铺满整屏、系统栏也全透明,再叠加 `expandSafeArea` 是多余的;
// 而且两者叠加会出现"背景扩一次、内容又扩一次"的重复计算风险。
// ⇒ 全屏这条路线下,**让位交给顶栏自己的 padding.top**(见 FbTopBar),
// 边界一清二楚。
}
/** 内容区:当前模块页(含顶栏 —— 顶栏由页面放进自己的滚动流,见 topBarSlot) */
@@ -112,6 +449,12 @@ struct Index {
* ⚠️ 状态栏由系统直接涂成白色(EntryAbility 里 `statusBarColor = surface`,
* 与顶栏同色)。所以顶栏滚走之后,通知栏区域仍由系统保持白色,
* 不会露出页面底色 —— 不需要在页面上再铺"白色压条"。
*
* ⚠️ v55 顺手清掉了一段残留:这上面原本还有一个**孤立的 `@Builder`**
* (是早期"临时诊断条"删剩的壳)。它让编译器报
* "Duplicate '@Builder' decorators for method are not allowed" ——
* 行为上没坏(生效的是下面这个),但每轮编译都刷一条假错误,
* 真出问题时很容易被它盖住。
*/
@Builder
topBarSlot() {
+143 -52
View File
@@ -11,9 +11,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbBookItem, FbGroup } from '../../model/Types';
import { FB_DEMO_BOOKS, fbGroupBook } from '../../model/DemoData';
import { fbGroupBook } from '../../model/DemoData';
import { parseBooks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbProgressLine, FbRating, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_BOOKS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbBooksPage {
@@ -27,71 +32,157 @@ export struct FbBooksPage {
/** 模块主题色(读书 = 藕紫) */
private accent: string = '#6B5B95';
private all: FbBookItem[] = FB_DEMO_BOOKS;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbBookItem[] = [];
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbBookItem[]>('books', parseBooks,
(rows: FbBookItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('书架')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 本 · '
+ this.readingCount().toString() + ' 本在读 · '
+ this.noteCount().toString() + ' 条笔记')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted })
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('书架')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 本 · '
+ this.readingCount().toString() + ' 本在读 · '
+ this.noteCount().toString() + ' 条笔记')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// ---- 分组列表 ----
if (this.all.length === 0) {
FbEmptyBox({
title: '书架还是空的',
hint: '传一本进来,或先记下想读的书',
icon: $r('app.media.fb_ic_books')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbBookItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count) }) {
ForEach(g.items, (b: FbBookItem) => {
ListItem() {
this.bookRow(b)
}
}, (b: FbBookItem): string => 'bk_' + b.id.toString())
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted })
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (g: FbGroup<FbBookItem>): string => 'bg_' + g.key)
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 分组列表 ----
if (this.all.length === 0) {
FbEmptyBox({
title: '书架还是空的',
hint: '传一本进来,或先记下想读的书',
icon: $r('app.media.fb_ic_books')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbBookItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count) }) {
ForEach(g.items, (b: FbBookItem) => {
ListItem() {
this.bookRow(b)
}
}, (b: FbBookItem): string => 'bk_' + b.id.toString())
}
}, (g: FbGroup<FbBookItem>): string => 'bg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '读书设置',
items: FB_SET_BOOKS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一本书:书脊色块(代替封面)+ 书名/作者 + 评分/笔记/进度 */
File diff suppressed because it is too large Load Diff
+121 -35
View File
@@ -13,9 +13,13 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_ANNIVERSARIES } from '../../model/DemoData';
import { parseDays } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_DAYS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbDaysPage {
@@ -29,41 +33,95 @@ export struct FbDaysPage {
/** 模块主题色(纪念日 = 陶土橙) */
private accent: string = '#B4633C';
private all: FbAnniversaryItem[] = FB_DEMO_ANNIVERSARIES;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbAnniversaryItem[] = [];
/** 类别筛选('' = 全部) */
@Local kindFilter: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbAnniversaryItem[]>('days', parseDays,
(rows: FbAnniversaryItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
// ---- 即将到来(30 天内)----
@@ -95,6 +153,8 @@ export struct FbDaysPage {
}
}
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
@@ -138,17 +198,43 @@ export struct FbDaysPage {
.width('100%')
}
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '纪念日设置',
items: FB_SET_DAYS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 即将到来的大数字卡 */
+481 -262
View File
@@ -1,25 +1,53 @@
/**
* 家书 FamilyBook · 首页(鸿蒙端)
*
* 对应 Web 端 `templates/home.php`。版面照搬它的三段:
* ① hero(家庭名 + 格言 + 我的资料/家谱两个快捷入口)
* ② 信息面板(问候 + 日期/星期/农历 + 日子标记 | 右侧天气)
* ② 今天的日程 + 近期任务(两块并排;窄屏上下堆叠)
* ④ 「往年的今天」回忆条
* ⑤ 模块入口卡片(12 个模块的关键数字)
* 对应 Web 端 `templates/home.php`。版面:
* ① 个人信息卡(问候 + 姓名 + 日期/星期/农历 + 日子标记 + 天气 + 近七天数字)
* ② 今天的日程(🔴 **直接用日程页那块面板组件** —— 用户 2026-10-16)
* ③ 近期任务(进行中 + 待办,进行中在前)
* ④ 往年的今天(用户 2026-10-17 要求加,与电脑端同一个逻辑)
*
* ⚠️ 只做**界面**,不做交互实现:卡片点了切模块(前端状态),不发请求。
* 🔴🔴 v54(用户 2026-10-16)—— 第一轮修的四件事:
* 1. 「概述块**太高了**……其实是因为我们调整之后,把家书那条调到上面去了,
* 所以概述块变得特别高。」
* 根因:`.padding({ bottom: FB_DOCK_RESERVE })`(**96vp** 的底部悬浮栏预留)
* 被接在了「天气地点 + 日子标记」那个小 Flex 上(缩进看着像它的修饰)——
* 它本该在**页面根部**(给最后一屏留白)。
* ⇒ 概述卡中间凭空多出 96vp 的空白,卡片被撑得老高。
* **修法:挪回页面根部的 Column。**
* 2. 「里面的数据好像有些不符,比如**寒露、重阳节、儿子的生日**,
* 这些都是**乱写的**,要用**真实数据**填写一下。」
* 根因:农历 / 日子标记 / 天气 / 周统计这四样接口**从来没给过**
* (网页版是在 index.php 里算好直接渲染的)⇒ 端上拿 `DemoData` 演示常量顶着。
* ⇒ 服务端 `/home` 已补齐(见 `ApiHandler::home()`),这里全部改接真实数据。
* 3. 「**今天的日程**……把今天那个块拿到首页上来就行了。」
* ⇒ 复用 `FbDayEventPanel`(日程页月/周/列表三处用的同一个组件),
* 于是时间轴/列表显示方式、冲突并排、当前时间红线在首页**与日程页完全一致**。
* 4. 「**我的家人、未来7天,还有全部模块**,这几个就是去掉了,不要了。」
* ⇒ 三个区块连同它们的演示常量一起删掉。
*
* 🔴🔴 v54b(用户 2026-10-17)—— 第二轮:
* 5. 「概述卡……**日程、在做、待办的数量是错误的**。」
* 真机实测:接口与端上缓存都是 `{1,3,2,0}`(正确),界面却是 `0/0/0`。
* 根因:`FbStatTriple` 内部 `@Builder cell(label, value, alarm)` 是**多值参数**
* ⇒ SDK 规则「两个及以上参数不触发刷新」⇒ 永远停在初始的 0。
* ⇒ 抽 `FbStatCell` **组件**(@Param 参与观察)—— 本项目第 6 次同一类修法。
* 6. 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样。」
* ⇒ 服务端把 `home_memories()` 搬成 `Home::memories()`(两端同一份实现),
* `/home` 给 `memories` 字段,这里加第 ④ 块。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleCard, FbCalEvent, FbTaskItem, FbKinChip } from '../../model/Types';
import {
FB_DEMO_EVENTS_TODAY, FB_DEMO_EVENTS_SOON, FB_DEMO_KIN, FB_DEMO_MARKS,
FB_DEMO_MODULE_CARDS, FB_DEMO_TASKS, FB_DEMO_WEATHER, FB_DEMO_WEEK_STAT
} from '../../model/DemoData';
import { FbAvatar, FbDot, FbDotLine, FbSectionTitle, FbStatTriple, FbTag } from '../../components/Widgets';
import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbTaskItem, FbWeather } from '../../model/Types';
import { parseHome } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings';
import { FbSectionTitle } from '../../components/Widgets';
import { FbStatCell } from '../../components/FbStatCell';
import { FbDayEventPanel } from '../../components/FbDayEventPanel';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
@ComponentV2
export struct FbHomePage {
@@ -31,10 +59,81 @@ export struct FbHomePage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private cards: FbModuleCard[] = FB_DEMO_MODULE_CARDS;
private todayEvents: FbCalEvent[] = FB_DEMO_EVENTS_TODAY;
private soonEvents: FbCalEvent[] = FB_DEMO_EVENTS_SOON;
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
// ---------------- 数据(全部来自 `/api/v1/home`)----------------
/** 今天的日程(服务端已排好:全天在前、其余按时间) */
@Local todayEvents: FbCalEvent[] = [];
/** 近期任务(进行中在前、待办在后) */
@Local tasks: FbTaskItem[] = [];
/** 首页问候语 */
@Local greeting: string = '';
/** 抬头日期(`10 月 16 日`) */
@Local todayLabel: string = '';
/** 星期几(`星期五`) */
@Local weekday: string = '';
/** 农历短写(`八月廿二`) */
@Local lunarShort: string = '';
/** 日子标记(节气/节日/纪念日/生日) */
@Local marks: FbMark[] = [];
/** 天气(null = 没配地点 ⇒ 整块不画) */
@Local weather: FbWeather | null = null;
/**
* 概述卡的四个数字。
*
* 🔴 v54b:**拆成四个独立的 `@Local number`**,而不是一个 `@Local stat: FbStat`
* 对象。理由(本次真机 bug 的教训):数字是最可靠被观察的类型;
* 用对象时,刷新链路要经过"对象整体替换 → 子组件 @Param 收到新值"两道,
* 任何一道出问题都表现为"界面永远停在初始的 0"。
* ⚠️ 拆开之后,四个格子各自只依赖一个 number,链路最短。
*/
@Local statToday: number = 0;
@Local statDoing: number = 0;
@Local statTodo: number = 0;
@Local statOverdue: number = 0;
/** 往年的今天(`/home` 的 memories) */
@Local memories: FbMemoryItem[] = [];
/** 顶部提示(同步失败等) */
@Local syncNote: string = '';
/**
* 日程显示方式(时间轴 / 列表)—— 与日程页**读同一个设置**。
* ⚠️ 首页显示"今天的日程",用户在哪一处把显示方式调了,另一处也该跟着变
* (它们本来就是同一份数据的不同入口)。
*/
@Local calStyle: FbCalStyle = 'timeline';
aboutToAppear(): void {
// 显示方式来自日程设置(启动时已预载进内存,可同步读)
this.calStyle = FB_CAL_SETTINGS.getStyle();
this.load();
}
/**
* 拉首页数据。
*
* ⚠️ 首页**只要一个接口**了(v54)—— 原来还要并发拉 `/calendar`(未来七天)
* 和 `/tasks`(任务),因为那两块数据接口没给。
* 现在服务端 `/home` 把它们都备齐了,少两个请求就是首页更快一截。
*/
private async load(): Promise<void> {
await loadModule<FbHomeData>('home', parseHome,
(d: FbHomeData): void => {
this.todayEvents = d.todayEvents;
this.tasks = d.tasks;
this.greeting = d.greeting;
this.todayLabel = d.todayLabel;
this.weekday = d.weekday;
this.lunarShort = d.lunarShort;
this.marks = d.marks;
this.weather = d.weather;
this.statToday = d.stat.todayEvents;
this.statDoing = d.stat.doing;
this.statTodo = d.stat.todo;
this.statOverdue = d.stat.overdue;
this.memories = d.memories;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 问候语时段(与 Web 端 home.php 里那段 PHP 同口径) */
private hello(): string {
@@ -54,24 +153,81 @@ export struct FbHomePage {
return '晚上好';
}
/** 今天的日期与星期 */
/** 日期行:`10 月 16 日 · 星期五`(服务端给的字段;没有就本地算兜底) */
private dateLine(): string {
if (this.todayLabel !== '') {
return this.weekday !== '' ? (this.todayLabel + ' · ' + this.weekday) : this.todayLabel;
}
const d: Date = new Date();
const week: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · '
+ week[d.getDay()];
const w: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · ' + w[d.getDay()];
}
/** 天气图标(按文案粗略映射到已有的几个图标上,不引外部图) */
private weatherIcon(): Resource {
// TODO(接口):真实天气码 → 图标映射。现在只区分「有太阳/没太阳」两种。
return this.weather().text.indexOf('晴') >= 0
? $r('app.media.fb_ic_days')
: $r('app.media.fb_ic_calendar');
/** 天气图标颜色(按服务端给的 tone 分档,与网页版 `.hb-weather.is-*` 一一对应) */
private weatherTint(): string {
const w: FbWeather | null = this.weather;
const tone: string = w !== null ? w.tone : 'cloud';
if (tone === 'sunny') {
return '#E8A33D';
}
if (tone === 'rain') {
return '#3B7FBF';
}
if (tone === 'snow') {
return '#5AA6B8';
}
if (tone === 'storm') {
return '#7A5BB5';
}
if (tone === 'fog') {
return '#8C8C8C';
}
return '#7C8AA0';
}
private weather() {
return FB_DEMO_WEATHER;
/**
* 日子标记的配色(与网页版 `.hb-mark.is-*` 同一套,抄自 `assets/css/app.css`)。
*
* ⚠️ 用**具体的背景/前景两色**而不是 `FbTag` 的 tone —— 网页版那五档
* (holiday 红 / term 绿 / festival 橙 / anniv 紫 / birthday 粉)
* 比 FbTag 现有的四档更细,硬套会丢掉"生日和纪念日不是一回事"这个区分。
*/
private markBg(kind: string): string {
if (kind === 'holiday') {
return '#FDECEC';
}
if (kind === 'term') {
return '#EDF4E9';
}
if (kind === 'festival') {
return '#FDF2E3';
}
if (kind === 'birthday') {
return '#FDECF3';
}
return '#F2ECF7'; // anniv 及其它
}
private markFg(kind: string): string {
if (kind === 'holiday') {
return '#A8232B';
}
if (kind === 'term') {
return '#3F6B34';
}
if (kind === 'festival') {
return '#96551A';
}
if (kind === 'birthday') {
return '#A03060';
}
return '#6B4A86'; // anniv 及其它
}
/** 当前时刻(当天第几分钟)—— 时间轴画"当前时间红线"用 */
private nowMinutes(): number {
const d: Date = new Date();
return d.getHours() * 60 + d.getMinutes();
}
build() {
@@ -79,7 +235,6 @@ export struct FbHomePage {
Column({ space: 0 }) {
this.topBar()
// ==================== ① 个人信息卡(问候 + 今天 + 天气)====================
// 🔴 用户 2026-10-12 定的:这一块**要白色卡片**("这样显得好看")。
// ⚠️ 曾一度做成"不套卡片的页面头部",理由是省首屏高度;
@@ -107,72 +262,100 @@ export struct FbHomePage {
Text(this.dateLine())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
Text('八月廿二')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
// 🔴 农历:真实值(接口给)。算不出时**不显示**,不编一个
if (this.lunarShort !== '') {
Text(this.lunarShort)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 天气:靠右,温度大字 + 文案小字
Row({ space: 6 }) {
Image(this.weatherIcon())
.width(26)
.height(26)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.brand2)
Column({ space: 0 }) {
Text(this.weather().temp.toString() + '°')
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather().text)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
// ⚠️ 没配地点 / 取不到天气时**整块不画**(与网页版一致)——
// 画一个"——"占位反而让人以为坏了。
if (this.weather !== null) {
Row({ space: 6 }) {
Text(this.weather!.icon)
.fontSize(22)
.fontColor(this.weatherTint())
Column({ space: 0 }) {
Text(this.weather!.temp)
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather!.text)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.End)
}
.alignItems(HorizontalAlign.End)
.alignItems(VerticalAlign.Center)
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 第二行:天气地点 + 日子标记(节气 / 节日 / 生日)
// 第二行:天气地点 + 日子标记(节气 / 节日 / 纪念日 / 生日)
// 🔴🔴 这里**绝不能**再出现 `FB_DOCK_RESERVE` 的 padding ——
// 它 96vp,接在这会把这个小方块撑成一大片空白(v54 修的那个"概述块太高")。
Column({ space: 6 }) {
Text(this.weather().place + ' · ' + this.weather().wind)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
if (this.weather !== null && (this.weather!.place !== '' || this.weather!.wind !== '')) {
Text(this.weatherLine())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.width('100%')
}
// 日子标记:今天是什么节气/节日/纪念日/谁的生日(真实数据)
if (this.marks.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.marks, (mk: FbMark, idx: number) => {
Text(mk.word)
.fontSize(FbMetric.fontTiny)
.fontColor(this.markFg(mk.kind))
.backgroundColor(this.markBg(mk.kind))
.borderRadius(FbMetric.radiusPill)
.padding({ left: 9, right: 9, top: 2, bottom: 2 })
.margin({ right: 6, top: 2 })
}, (mk: FbMark, idx: number): string => 'mk_' + idx.toString() + '_' + mk.word)
}
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_MARKS, (mk: string, idx: number) => {
FbTag({ text: mk, tone: idx === 0 ? 'green' : 'brand' })
.margin({ right: 6, top: 2 })
}, (mk: string, idx: number): string => 'mk_' + idx.toString() + '_' + mk)
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 第三行:三个数字(未来七天日程 / 待办 / 逾期)——
// 贴在卡片底部,作为"今天要不要操心的快速判断"
// (卡片内用浅分隔线分开,视觉上比留白更"有层次")
// 第三行:四个数字(**与网页版 `hb-stats` 同源**)
// `N 条日程(今天)· N 件在做 · N 件待办 · N 件逾期`
// 🔴 用户 2026-10-16:「下面……**未来7天**……这几个就是去掉了,不要了。」
// ⇒ 原来这里有"未来七天"那一格(today..+7 天的条数),已去掉;
// 换成与网页版一样的口径(网页版概述卡本来也是这四个)。
//
// 🔴🔴 v54b(用户 2026-10-17):「里面的**日程、在做、待办的数量是错误的**」
// 真机实测:接口与端上缓存里都是 `{today_events:1, doing:3, todo:2}`(正确),
// 而界面显示 `0 / 0 / 0` —— 同一份数据里「今天的日程 1 条」却是对的。
// 根因:原来是 `FbStatTriple`,它内部用
// `@Builder cell(label, value, alarm)` —— **多值参数**,
// SDK 规则明写「**两个及以上参数不触发刷新**」
// ⇒ 初始渲染的 0 永远不会被后来的真实值替换。
// (本项目第 6 次踩同一个坑:@Builder 值参数不参与状态观察。)
// ⇒ 抽成 **`FbStatCell` 组件**(@ComponentV2 + @Param,@Param 参与观察)
// —— 与 `FbEventChip` / `FbCalOptionRow` / `FbTagPickChip` 同一套修法。
// ⚠️ 不要改回 `@Builder cell(...)`;也不要用**本页的 @Builder**(同样是按值传递)。
Divider().color(FbColors.line2)
FbStatTriple({
labelA: '未来七天',
valueA: FB_DEMO_WEEK_STAT.events.toString(),
labelB: '待办',
valueB: FB_DEMO_WEEK_STAT.todos.toString(),
labelC: '逾期',
valueC: FB_DEMO_WEEK_STAT.overdue.toString(),
cAlarm: FB_DEMO_WEEK_STAT.overdue > 0
})
Row() {
FbStatCell({ value: this.statToday, label: '今天日程' })
FbStatCell({ value: this.statDoing, label: '在做' })
FbStatCell({ value: this.statTodo, label: '待办' })
FbStatCell({ value: this.statOverdue, label: '逾期', alarm: true })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
// 🔴 不要 `.width('100%')` —— 它会先把宽度占满,导致后面的 margin 失效,
// 卡片于是**顶到屏幕两侧边缘**(实测 x=0..1080),而别的区块是留边 16vp 的,
@@ -189,166 +372,106 @@ export struct FbHomePage {
})
.alignItems(HorizontalAlign.Start)
// ==================== ② 今天的日程 + 近期任务 ====================
// ==================== ② 今天的日程 ====================
// 🔴 用户 2026-10-16:「今天的日程,现在显示的是日程里面的展示内容。
// 把**今天那个块**拿到首页上来就行了。」
// ⇒ 直接用日程页那块面板组件(月/周/列表三处用的同一个)——
// 于是显示方式(时间轴/列表)、冲突横向并排、当前时间红线
// 在首页与日程页**完全一致**,不会再出现"两个地方长得不一样"。
// ⚠️ 面板自带卡片样式与外边距,这里**不要再套一层卡片**。
// ⚠️ `showHead: false` —— 本页已有 `FbSectionTitle`,两个头会重复。
// ⚠️ 空态文案也换掉:首页没有"右下角 +"那个按钮。
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
FbDayEventPanel({
label: this.todayEvents.length > 0 ? '今天' : '',
events: this.todayEvents,
style: this.calStyle,
isToday: true,
nowMin: this.nowMinutes(),
showHead: false,
emptyTitle: '今天没有安排',
emptyHint: '过得轻松一点',
onPick: (e: FbCalEvent): void => {
// 点条目 ⇒ 切到日程模块(首页不做就地编辑:那是日程页的职责)
this.app.switchModule('calendar');
}
})
// ==================== ③ 近期任务 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
if (this.todayEvents.length === 0) {
FbDotLine({ label: '今天没有安排,过得轻松一点', muted: true })
FbSectionTitle({ title: '近期任务', extra: this.taskExtra() })
Column({ space: 0 }) {
if (this.tasks.length === 0) {
Row() {
Text('没有进行中的任务,也没有待办。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.todayEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(46)
Column() {
FbDot({ dia: 8, fillC: ev.color })
}
.width(8)
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.calName)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(ev.color)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'tev_' + ev.id.toString())
ForEach(this.tasks, (t: FbTaskItem, idx: number) => {
this.taskRow(t, idx === this.tasks.length - 1)
}, (t: FbTaskItem, idx: number): string => 'htk_' + t.id.toString())
}
}
.width('100%')
.padding(FbMetric.cardPad)
.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: 8 })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 往年的今天 ====================
// 🔴 用户 2026-10-17:「近期任务的下面,应该加上**往年的今天**,
// 跟电脑端一样,你看看那个是什么逻辑。」
// ⇒ 位置**就在近期任务下面**(用户明确指定,不是"放在首页某处")。
// 电脑端的逻辑见 `App\Home::memories()`:只认**往年的同一天**
// (不退回同月),来源是「看过的电影」+「走过的**实际行程**」,
// 合并按时间倒序取 6 条。
// ⚠️ 数据来自 `/home` 的 `memories` —— 与网页版**同一份实现**,
// 不要端上自己再查一遍(会出现"网页 2 年前、手机 3 年前")。
Column({ space: 0 }) {
FbSectionTitle({
title: '近期任务',
extra: '进行中与已逾期'
title: '往年的今天',
extra: this.todayLabel !== '' ? this.todayLabel : ''
})
Column({ space: 10 }) {
ForEach(this.recentTasks(), (t: FbTaskItem) => {
Row({ space: 10 }) {
FbDot({ dia: 8, fillC: t.overdue ? '#C0533F' : t.boardColor })
Text(t.title)
Column({ space: 0 }) {
if (this.memories.length === 0) {
// 空态与网页版一字不差(两句:一句说没有,一句给个盼头)
Column({ space: 6 }) {
Text('往年的这一天还没有记录')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? FbColors.red : FbColors.faint)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (t: FbTaskItem): string => 'htk_' + t.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ③ 我的家人 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '我的家人', extra: '以我为中心 · 上下三代' })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_KIN, (k: FbKinChip) => {
Column({ space: 4 }) {
FbAvatar({ name: k.shown, color: k.avatarColor, dia: 42, fontSizeVp: 17 })
Text(k.shown)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
Text(k.kinship)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
.width(64)
.padding({ top: 6, bottom: 6 })
}, (k: FbKinChip): string => 'kin_' + k.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 未来七天 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '未来七天', extra: this.soonEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
ForEach(this.soonEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.date)
Text('日子还长,等有了第一次,明年这时候就会自己冒出来。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(44)
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(40)
FbDot({ dia: 7, fillC: ev.color })
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'sev_' + ev.id.toString())
.alignItems(HorizontalAlign.Start)
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.memories, (m: FbMemoryItem, idx: number) => {
this.memoryRow(m, idx === this.memories.length - 1)
}, (m: FbMemoryItem, idx: number): string => 'mem_' + m.kind + '_' + m.id.toString())
}
}
.width('100%')
.padding(FbMetric.cardPad)
.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 })
// ==================== ⑤ 模块入口 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '全部模块', extra: this.cards.length.toString() + ' 个' })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.cards, (c: FbModuleCard) => {
this.moduleCard(c)
}, (c: FbModuleCard): string => 'mc_' + c.key)
}
.width('100%')
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
// 🔴🔴 **底部悬浮快捷栏的预留** —— 它必须留在**页面最根部**。
// v54 之前它被接在了概述卡中间那个小 Flex 上(96vp 凭空撑高卡片),
// 真正的页面底部反而没有留白、被悬浮栏压住最后一屏。
// ⚠️ 别再把它挪到任何中间区块上。
Blank().height(FB_DOCK_RESERVE)
}
.width('100%')
}
@@ -356,72 +479,168 @@ export struct FbHomePage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
/** 一块模块入口卡(图标 + 模块名 + 一句关键数字) */
/** 天气地点行:`青岛市 · 崂山区 · 东南风 12 km/h` */
private weatherLine(): string {
const w: FbWeather | null = this.weather;
if (w === null) {
return '';
}
const parts: string[] = [];
if (w.place !== '') {
parts.push(w.place);
}
if (w.tmax !== '' || w.tmin !== '') {
parts.push(w.tmax + ' / ' + w.tmin);
}
if (w.wind !== '') {
parts.push(w.wdir !== '' ? (w.wdir + ' ' + w.wind) : w.wind);
}
return parts.join(' · ');
}
/** 「近期任务」右上角小字:`3 件在做 · 5 件待办`(进行中与待办**分开数**) */
private taskExtra(): string {
let doing: number = 0;
let todo: number = 0;
for (const t of this.tasks) {
if (t.status === 'doing') {
doing++;
} else {
todo++;
}
}
if (todo > 0) {
return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待办';
}
return doing.toString() + ' 件在做';
}
/**
* 一条任务(首页版)。
*
* 🔴 与网页版首页同一套信息:圆点(看板色,逾期变红)+ 标题 + **状态词**
* (用户第 3 条:「除了显示任务的标题之外,还要显示他的状态」)
* + 子任务进度 + 截止时间。
* ⚠️ 看板色**必须**用真实派生色(`boardColor`)—— 用户 2026-10-08 报过
* 「首页近期任务……颜色并没有跟看板颜色一致」。
*/
@Builder
moduleCard(c: FbModuleCard) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Image(this.cardIcon(c.key))
.width(18)
.height(18)
.renderMode(ImageRenderMode.Template)
.fillColor(fbPalette(c.color).brand)
Text(c.name)
taskRow(t: FbTaskItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:逾期用红(状态语义优先),否则跟看板色
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(t.overdue ? '#C0533F' : t.boardColor)
Text(t.title)
.fontSize(FbMetric.fontSmall)
.fontColor(t.overdue ? '#C0533F' : FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 状态词(待办 / 进行中)—— 用户第 3 条要求
Text(t.status === 'doing' ? '进行中' : '待办')
.fontSize(10)
.fontColor(t.status === 'doing' ? '#A8574F' : FbColors.faint)
.backgroundColor(t.status === 'doing' ? '#14A8574F' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? '#C0533F' : FbColors.faint)
}
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('100%')
}
/**
* 一条「往年的今天」。
*
* 形态照网页版 `templates/home.php` 的 `.dash-row`:
* 圆点(旅程=绿 #3F7D5A / 观影=紫 #7A4E8C)→「N 年前」→ 🧳/🎬 + 标题 → 日期。
*
* ⚠️ 这里是 `@Builder` 带 **2 个参数** —— 与本次概述卡数字那个 bug 同一个反模式。
* 但两者的**后果不同**:本区块的数据(`this.memories`)是在页面首次加载后
* 随其它字段一起到的,且整块由 `ForEach` 按 `m.id` 重建
* (数据变化 ⇒ 列表项增删 ⇒ 走 ForEach 的重建路径,不依赖 Builder 参数刷新)。
* ⚠️ 若将来出现"memories 变了、这一行文字没变"的现象,**先改成组件**
* (参照 `FbStatCell`),不要在这个 Builder 上打补丁。
*/
@Builder
memoryRow(m: FbMemoryItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:两类的颜色与网页版一致(旅程绿 / 观影紫)
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(m.kind === 'trip_node' ? '#3F7D5A' : '#7A4E8C')
// 「N 年前」
Text(m.yearsAgo.toString() + ' 年前')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(52)
// 🧳 / 🎬 + 标题
Text((m.kind === 'trip_node' ? '🧳 ' : '🎬 ') + m.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 那天的日期(`YYYY-MM-DD`)
Text(this.memoryDate(m.startsAt))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
Text(c.big)
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(fbPalette(c.color).brand)
.maxLines(1)
Text(c.lines.length > 0 ? c.lines[0] : '')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('48.5%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.app.switchModule(c.key);
})
.width('100%')
}
/** 卡片图标(按模块 key 取) */
private cardIcon(key: string): Resource {
switch (key) {
case 'people': return $r('app.media.fb_ic_people');
case 'kin': return $r('app.media.fb_ic_kin');
case 'movies': return $r('app.media.fb_ic_movies');
case 'trips': return $r('app.media.fb_ic_trips');
case 'calendar': return $r('app.media.fb_ic_calendar');
case 'resources': return $r('app.media.fb_ic_resources');
case 'tasks': return $r('app.media.fb_ic_tasks');
case 'days': return $r('app.media.fb_ic_days');
case 'ledger': return $r('app.media.fb_ic_ledger');
case 'books': return $r('app.media.fb_ic_books');
case 'notes': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_home');
}
}
/** 首页只放「进行中 + 已逾期」两类任务(与 Web 端同一取舍) */
private recentTasks(): FbTaskItem[] {
return this.tasks.filter((t: FbTaskItem): boolean => t.status === 'doing' || t.overdue);
/** `YYYY-MM-DD HH:MM:SS` → `YYYY-MM-DD`(只取日期,与网页版 `substr(...,0,10)` 一致) */
private memoryDate(s: string): string {
return s.length >= 10 ? s.substring(0, 10) : s;
}
}
+173 -49
View File
@@ -16,12 +16,15 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbLedgerBook, FbLedgerEntry } from '../../model/Types';
import {
FB_DEMO_LEDGER_BOOKS, FB_DEMO_LEDGER_ENTRIES, fbLedgerKindName, fbMoney
} from '../../model/DemoData';
import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail} from '../../model/Types';
import { fbLedgerKindName, fbMoney } from '../../model/DemoData';
import { parseLedger, parseLedgerEntries } from '../../data/FbParse';
import { loadModule, loadPath } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_LEDGER } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbLedgerPage {
@@ -35,45 +38,139 @@ export struct FbLedgerPage {
/** 模块主题色(财务 = 橄榄绿) */
private accent: string = '#5E7A3C';
private books: FbLedgerBook[] = FB_DEMO_LEDGER_BOOKS;
private entries: FbLedgerEntry[] = FB_DEMO_LEDGER_ENTRIES;
/**
* 账本列表(`/api/v1/ledger`)。
* ⚠️ 初值空数组:先渲染假数据会"闪一下"。
*/
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local books: FbLedgerBook[] = [];
/** 当前这本账的明细(`/ledger/<id>/entries`) */
@Local entries: FbLedgerEntry[] = [];
/** 顶部提示(同步失败 / 未登录) */
@Local syncNote: string = '';
/** 当前账本 id(0 = 用第一本) */
@Local bookId: number = 0;
build() {
Scroll() {
Column() {
this.topBar()
/**
* 页面出现:**两级加载**。
*
* ⚠️ 账本必须走两级:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(明细 + 汇总)。
* 净资产只在这第二级里 ⇒ 拿到第一本之后要立刻拉一次明细。
*/
aboutToAppear(): void {
this.load();
}
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.currentBook().name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.books.length.toString() + ' 本账 · '
+ this.currentBook().entryCount.toString() + ' 条账目')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
private async load(): Promise<void> {
await loadModule<FbLedgerBook[]>('ledger', parseLedger,
(rows: FbLedgerBook[]): void => {
this.books = rows;
// 选中当前本(没选过就用第一本)
let target: number = this.bookId;
if (target <= 0 && rows.length > 0) {
target = rows[0].id;
this.bookId = target;
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
if (target > 0) {
this.loadEntries(target);
}
},
(note: string): void => {
this.syncNote = note;
});
}
// ---- 账本切换(横向)----
Scroll() {
/** 拉某本账的明细(含汇总) */
private async loadEntries(id: number): Promise<void> {
// ⚠️ 用 `loadPath`(路径带 id,没法登记在静态模块表里)。
const key: string = 'ledger_detail_' + id.toString();
await loadPath<FbLedgerDetail>(key, '/api/v1/ledger/' + id.toString() + '/entries',
parseLedgerEntries,
(d: FbLedgerDetail): void => {
this.entries = d.entries;
// ⚠️ 净资产写回**这本账**(页面的 `netCent()` 是从 `currentBook()` 上取的)。
// 概览接口不返回 net,所以这里补上 —— 不新增页面字段,
// 避免和已有的 `netCent()` 方法重名(实测撞过一次)。
for (const bk of this.books) {
if (bk.id === id) {
bk.netCent = d.netCent;
}
}
},
(note: string): void => {
this.syncNote = note;
});
}
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.currentBook().name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.books.length.toString() + ' 本账 · '
+ this.currentBook().entryCount.toString() + ' 条账目')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 账本切换(横向滚动条)----
// ⚠️ 这层 `Scroll` **必须保留**:它是**横向**滚动(账本多时排不下)。
// 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。
// 也**不挂** `fbOnScroll` —— 横向滚动不该影响纵向的悬浮栏收起逻辑。
Scroll() {
Row({ space: 8 }) {
ForEach(this.books, (b: FbLedgerBook) => {
Row({ space: 6 }) {
Text(b.name)
@@ -95,15 +192,16 @@ export struct FbLedgerPage {
this.bookId = b.id;
})
}, (b: FbLedgerBook): string => 'bk_' + b.id.toString())
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
Scroll() {
Column({ space: 0 }) {
// ---- 概览卡 ----
@@ -234,17 +332,43 @@ export struct FbLedgerPage {
.width('100%')
}
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '账本设置',
items: FB_SET_LEDGER,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一条账目:名字 + 归属人 + 金额 + 盘点入口 */
@@ -0,0 +1,674 @@
/**
* 家书 FamilyBook · 登录页(鸿蒙端)
*
* 完整链路(用户 2026-10-17「完整的登录功能」):
* ① 第一次打开 → **输入服务器地址** → `GET /api/v1/ping` 验"这上面真是家书";
* ② 验通了把地址**持久化** → 第二步输「账号 + 密码」;
* ③ `POST /api/v1/login` → 拿到 API 令牌 → 存进 `FbTokenStore`;
* ④ **立刻全量同步**(12 个模块,带实时进度)→ 进主界面;
* ⑤ 之后每个请求都由 `fbApi()` 自动带上 `Authorization: Bearer <token>`
* (见 `FbHttp.fbApi`,令牌不在各页面里各传一遍)。
*
* 🔴 为什么地址要问用户,而不是写死在代码里(v55 的核心改动):
* 写死意味着这个 App 只能连我们自己那一台 —— 别人装上去就是
* "登录永远失败",而且**连失败原因都看不到**(他甚至不知道地址能改)。
* ⇒ 地址是运行期配置(`FbServerStore`),第一次问一次,之后一直用。
*
* 🔴 为什么第二步既认手机号也认用户名:
* 一家人里老人记不住手机号,但记得住自己叫什么。服务端 `login` 两步查找
* (先手机号哈希、再姓名),重名时明确要求改用手机号(见 ApiHandler 的说明)。
*
* 🔴 三种进入方式(都要有,别只做第一种):
* ① **登录**:地址 + 账号密码(正常路径);
* ② **先不登录**:本地优先架构下,没令牌照样能看已同步的数据。
* 把用户堵在登录页是**错的**(他可能只是没网,或家里老人不需要同步);
* ③ 已登录(令牌还在)时**根本不显示本页**(在 Index 里就分流掉了)。
*
* ⚠️ 密码**不做任何本地缓存**:只在提交那一刻用一次,用完立刻清
* (连变量都尽量短命)。App 里唯一要留的是**令牌**。
* ⚠️ 换服务器 = 换了一台机器,旧令牌在新机器上无效 ——
* 所以设置页的「更换服务器」会连带清掉令牌(见 FbSettingsPage)。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbLoginClient, FbLoginResult } from '../../data/FbLogin';
import { FB_DEFAULT_SERVER, fbProbeServer, FbProbeResult } from '../../data/FbHttp';
import { FB_SERVER_STORE, fbNormalizeServer, fbServerLooksValid } from '../../data/FbServerStore';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
// ⚠️ `KeyboardAvoidMode` 有两个同名枚举:
// · `component/common.d.ts` 里的(DEFAULT/NONE);
// · `@ohos.arkui.UIContext` 里的(OFFSET/RESIZE/NONE)—— **setKeyboardAvoidMode 要的是这个**。
// 两者同名但不同型,不导入就会报 "not assignable to parameter of type"。
import { KeyboardAvoidMode } from '@kit.ArkUI';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FbSyncAllResult, syncAll } from '../../data/FbSyncAll';
// ⚠️ 注意与 `model/Types` 里的 `FbSession`(数据模型)不是一回事:
// 这个 `FB_SESSION` 是**会话状态机**(校验/离线计时),见 data/FbSession.ets
import { FB_SESSION } from '../../data/FbSession';
import { FbAccount, FbSession } from '../../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbLoginPage';
/**
* 登录后"原地等同步"的上限(毫秒)。
*
* 🔴 为什么要有个上限(而不是一直等同步跑完):
* 12 个模块最坏情况要几分钟(每模块自带 20 秒网络超时)。
* 让用户在登录页盯着转圈几分钟,跟"卡死"没有区别。
* ⇒ 15 秒还没完就**放他进去**(数据在后台继续落地,本地优先架构本就能这么干),
* 同时给一个「立即进入」让他自己决定要不要等。
*/
const SYNC_WAIT_MS: number = 15000;
@ComponentV2
export struct FbLoginPage {
/**
* 全局状态(只为拿**状态栏高度**——应用开了全屏布局,
* 登录页也要自己给状态栏让位,见 `build()` 里品牌区的 padding)。
*/
@Param app: FbAppState = fbAppState();
/** 登录成功回调(由 Index 传入:进主界面) */
@Event onLoggedIn: () => void = (): void => {
};
/** 「先不登录」回调(不登录也进主界面) */
@Event onSkip: () => void = (): void => {
};
/**
* 从第几步开始(0 = 按本地有没有地址自动决定)。
*
* 由 `Index` 从 `FbAppState.loginStartStep` 传进来 ——
* 设置页点「更换服务器」是 1(连地址一起换),点「退出登录」是 2(地址还留着)。
* ⚠️ 它是 `@Param`:只在组件**创建**时参与 `aboutToAppear`,
* 运行中改它不会把用户从第二步弹回第一步(那正是我们要的)。
*/
@Param startStep: number = 0;
/**
* 🔴 v57(用户 2026-10-17):是否允许「先不登录 / 离线使用」。
*
* `true` = 从没登录过(首次使用)—— 本地优先,不该把人堵在登录页;
* `false` = 被服务端踢了、或用户主动退出登录 ——
* 用户原话:「退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」
* ⇒ 这时候留一个"先不登录"按钮,等于给安全策略开了个后门。
*
* ⚠️ `@Param` 由 `Index` 按 `app.lockReason === ''` 决定,登录页自己不改它。
*/
@Param allowSkip: boolean = true;
/** 当前步骤:1 = 填服务器地址,2 = 填账号密码 */
@Local step: number = 1;
/** 服务器地址输入框 */
@Local serverInput: string = '';
/** 账号(手机号或用户名) */
@Local account: string = '';
@Local password: string = '';
/** 是否正在提交(提交中禁用按钮,避免重复请求) */
@Local busy: boolean = false;
/** 错误提示(空串 = 无) */
@Local error: string = '';
/** 探测通过后服务端自报的应用名(通常「家书」) */
@Local serverName: string = '';
/** 是否正在"登录后同步"这一段 */
@Local syncing: boolean = false;
/** 同步进度文案(当前模块名) */
@Local syncNote: string = '';
@Local syncDone: number = 0;
@Local syncTotal: number = 12;
/**
* 是否已经"进到应用里"(防重复进入)。
*
* ⚠️ 不是 @Local:它只用于代码里判重(同步跑完 与 用户点「立即进入」
* 可能同时发生),不参与渲染,加状态标记只会触发多余重绘。
*/
private entered: boolean = false;
/** 主题色(与「名册」模块一致,登录页用砖红) */
private accent: string = '#A84A28';
/**
* 页面出现时:决定从哪一步开始。
*
* ⚠️ 已配过地址(老用户 / 退出登录后重进)⇒ **直接从第二步开始**,
* 并预填地址:让用户看到"我连的是哪台",而不是每次重填一遍。
*/
aboutToAppear(): void {
try {
// 🔴 取 `OFFSET`(**页面上移**),不是 RESIZE(压缩高度)。
// SDK 注释:RESIZE 会重排页面、且与 `expandSafeArea(KEYBOARD)` 不能共用;
// OFFSET 只是把页面往上推,对"表单在下方"的登录页更稳(默认值也正是 OFFSET)。
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.OFFSET);
} catch (err) {
hilog.warn(DOMAIN, TAG, '设置键盘避让失败(不影响登录):%{public}s', JSON.stringify(err));
}
const saved: string = FB_SERVER_STORE.get();
// ⚠️ 三步判断,顺序不能反:
// ① 调用方**显式指定**了起始步(设置页的「更换服务器 / 退出登录」);
// ② 否则按"本地有没有地址"自动决定(正常启动)。
if (this.startStep === 1) {
this.serverInput = saved !== '' ? saved : FB_DEFAULT_SERVER;
this.step = 1;
} else if (this.startStep === 2 && saved !== '') {
this.serverInput = saved;
this.step = 2;
} else if (saved !== '') {
this.serverInput = saved;
this.step = 2;
} else {
// 首次:预填一个默认地址(老用户升级后不用手打域名)。
// ⚠️ 这只是输入框的**初始值**,必须由用户点「连接」确认后才写入 store。
this.serverInput = FB_DEFAULT_SERVER;
this.step = 1;
}
}
/** 拿应用上下文(拿不到就返回 undefined,由调用方给提示) */
private ctx(): common.Context | undefined {
return this.getUIContext().getHostContext();
}
/** 第一步能不能提交 */
private canConnect(): boolean {
return !this.busy && this.serverInput.trim() !== '';
}
/** 第二步能不能提交 */
private canSubmit(): boolean {
return !this.busy && !this.syncing && this.account.trim() !== '' && this.password !== '';
}
/**
* 第一步:验证并保存服务器地址。
*
* 🔴 只有 `fbProbeServer` 验通才写 store:
* 先存再验的话,用户填错一次地址,之后每次启动都会对着一个错的地址
* 发请求(而且他很难意识到"错在地址")。
*/
private async connect(): Promise<void> {
if (!this.canConnect()) {
return;
}
this.error = '';
const base: string = fbNormalizeServer(this.serverInput);
if (!fbServerLooksValid(base)) {
this.error = '地址看起来不对,应该形如 https://family.example.com';
return;
}
const c: common.Context | undefined = this.ctx();
if (c === undefined) {
this.error = '应用上下文不可用,请重启应用再试';
return;
}
this.busy = true;
const r: FbProbeResult = await fbProbeServer(base);
if (!r.ok) {
this.busy = false;
this.error = r.error !== '' ? r.error : '连接失败,请检查地址';
return;
}
const saved: string = await FB_SERVER_STORE.set(c, base);
this.busy = false;
if (saved === '') {
this.error = '地址没能保存(本地存储异常)';
return;
}
this.serverInput = saved;
this.serverName = r.app;
this.step = 2;
hilog.info(DOMAIN, TAG, '服务器地址已确认:%{public}s', saved);
}
/** 已确认的服务器主机部分(显示用:去掉协议,省一行宽度) */
private serverHost(): string {
const base: string = FB_SERVER_STORE.get();
return base.replace(/^https?:\/\//i, '');
}
/**
* 第二步:登录 → 存令牌 → 同步。
*
* 🔴 登录成功后**不立刻跳转**,而是停在原地显示同步进度:
* 用户刚点了"登录",最想看的是"我的数据进来了"。
* 直接跳进主界面(各页还是空的、慢慢填)会让人觉得"没连上"。
* ⇒ 给 15 秒的同步窗口(见 `SYNC_WAIT_MS` 的说明),完事或有超时就进去。
*/
private async submit(): Promise<void> {
if (!this.canSubmit()) {
return;
}
const c: common.Context | undefined = this.ctx();
if (c === undefined) {
this.error = '应用上下文不可用,请重启应用再试';
return;
}
this.busy = true;
this.error = '';
const res: FbLoginResult = await FbLoginClient.login(this.account.trim(), this.password);
// ⚠️ 无论成功失败,**立刻清掉内存里的密码**(它没有任何理由继续留在页面上)
this.password = '';
if (!res.ok) {
this.busy = false;
this.error = res.error !== '' ? res.error : '登录失败,请重试';
return;
}
const saved: boolean = await FB_TOKEN_STORE.set(c, res.token);
if (!saved) {
// 令牌拿回来了却存不下 —— 这不是"登录失败",是**本地存储**的问题,
// 提示要能让用户/我们区分开。
this.busy = false;
this.error = '登录成功但令牌没能保存(本地存储异常)';
return;
}
/* 🔴 v57:记下"刚跟服务器通过话"。
离线 24 小时判定靠的就是这个时间(`FbSession.offlineMs()`)。
不记的话,登录后第一次断网启动就会被判成"离线超时"。
⚠️ 用服务端返回的时间(`res.serverTime`)而不是本机时间 ——
本机时钟可以改,服务端时间不能。 */
await FB_SESSION.markVerified(c, res.serverTime);
this.busy = false;
this.applySession(res);
hilog.info(DOMAIN, TAG, '登录成功,person_id=%{public}d', res.personId);
// ---- 登录成功 → 全量同步(带实时进度)----
this.syncing = true;
this.syncNote = '准备同步…';
this.syncDone = 0;
const job: Promise<FbSyncAllResult> = syncAll(
(label: string, done: number, total: number): void => {
this.syncNote = label;
this.syncDone = done;
this.syncTotal = total;
});
// ⚠️ 同步**不等它跑完**(可能几分钟):设定窗口到点就放人进去,
// 同步本身在后台继续(`syncAll` 的 Promise 自己会跑到底)。
await Promise.race([job, this.wait(SYNC_WAIT_MS)]);
this.finish();
}
/** 等一会儿(ArkTS 没有 setTimeout 的 Promise 版) */
private wait(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
}
/**
* 把登录返回的身份写进全局会话。
*
* ⚠️ **整体替换对象**而不是改字段:`FbSession`/`FbAccount` 是 interface,
* V2 对"对象的深层属性"观察不可靠;换掉引用才能保证读到它的页面都刷新
* (设置页顶部的头像与姓名就靠这个 —— 否则永远显示演示数据里的那个名字)。
*/
private applySession(res: FbLoginResult): void {
const old: FbSession = this.app.session;
const me: FbAccount = {
id: res.personId > 0 ? res.personId : old.me.id,
name: res.personName !== '' ? res.personName : old.me.name,
kinship: old.me.kinship,
avatarColor: old.me.avatarColor,
role: res.role !== '' ? res.role : old.me.role,
isGuest: res.role === 'guest',
canWrite: res.role !== '' && res.role !== 'guest',
};
const next: FbSession = {
loggedIn: true,
familyId: old.familyId,
familyTitle: res.familyName !== '' ? res.familyName : old.familyTitle,
slogan: old.slogan,
me: me,
};
this.app.session = next;
}
/** 进主界面(幂等:同步跑完 与 用户点「立即进入」可能同时发生) */
private finish(): void {
if (this.entered) {
return;
}
this.entered = true;
this.syncing = false;
this.onLoggedIn();
}
/** 回第一步换服务器(账号密码保留,万一只是地址写错了) */
private backToServer(): void {
this.step = 1;
this.error = '';
this.serverName = '';
}
build() {
Stack({ alignContent: Alignment.TopStart }) {
Scroll() {
Column({ space: 0 }) {
// ---- 品牌区:logo + 应用名(用**同一张**桌面图标,见 AppScope 的说明)----
// 🔴 用**横向**排布(logo 在左、文字在右),不用纵向堆叠。
// 原因(实测数据说话):键盘弹出后 Scroll 的可视高度只有 **1365px**
// (屏幕 2444、键盘占 1488 起);而**纵向**品牌区要 548px,
// 再加表单与按钮,总高超过 1365 ⇒ **按钮被裁在可视区之外,用户点不到**。
Row({ space: 12 }) {
Image($r('app.media.fb_logo'))
.width(48)
.height(48)
.borderRadius(12)
.border({ width: 1, color: FbColors.line })
Column({ space: 2 }) {
Text('家书')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.step === 1 ? '先连上你家的服务器' : '一家人的名册、日程与记忆')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 🔴 top 加**状态栏高度**:应用已开 `setWindowLayoutFullScreen(true)`,
// 窗口不再为状态栏让位 ⇒ 登录页品牌区也要自己让出这一截,
// 否则 logo 与「家书」会被时间/信号图标压住。
.padding({
left: FbMetric.pagePad,
right: FbMetric.pagePad,
top: 24 + this.app.statusBarH,
bottom: 20
})
if (this.step === 1) {
this.serverStep()
} else {
this.accountStep()
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
// ---- 同步遮罩(登录成功后浮在上面,不打断下面的表单状态)----
if (this.syncing) {
this.syncOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 第一步:服务器地址 */
@Builder
serverStep() {
Column({ space: 0 }) {
Text('服务器地址')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ bottom: 6 })
TextInput({ placeholder: 'https://family.example.com', text: this.serverInput })
// ⚠️ 用 `Normal` 而不是 `URL`:`URL` 键盘上**没有 `/` 和 `.` 以外的符号**,
// 内网地址(`http://192.168.1.8:8080`)需要冒号和数字,Normal 更宽松。
.type(InputType.Normal)
.maxLength(200)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.serverInput = v;
})
.onSubmit((): void => {
this.connect();
})
this.errorBox()
Text(this.busy ? '正在连接…' : '连 接')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.backgroundColor(this.canConnect() ? this.accent : FbColors.faint)
.borderRadius(FbMetric.radiusS)
.margin({ top: 18 })
.onClick((): void => {
this.connect();
})
Text('就是你在浏览器里打开家书网页版时用的那个地址。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.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 })
}
/** 第二步:账号 + 密码 */
@Builder
accountStep() {
Column({ space: 0 }) {
// ---- 已连的服务器(可点「更换」)----
Row({ space: 8 }) {
Text('服务器')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(this.serverHost())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('更换')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.padding({ left: 6, right: 2 })
.onClick((): void => {
this.backToServer();
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
/* 🔴 v57:为什么突然要重新登录 —— 必须写出来。
用户被远程踢掉时,App 只会"跳回登录页"。不说明原因的话,
他第一反应是"App 坏了/我不记得密码了",而不是"我的号被退了"。 */
if (this.app.lockReason !== '') {
Text(this.app.lockReason)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.textAlign(TextAlign.Center)
.lineHeight(18)
.width('100%')
.padding({ top: 12, bottom: 2 })
}
// ---- 账号 ----
Text('账号')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 16, bottom: 6 })
TextInput({ placeholder: '手机号或姓名', text: this.account })
// ⚠️ 这里**不能**用 `InputType.PhoneNumber`:有的人是用姓名登录的,
// 数字键盘上打不出汉字(实测会被键盘挡住、只能退出去复制粘贴)。
.type(InputType.Normal)
.maxLength(60)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.account = v;
})
// ---- 密码 ----
Text('密码')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 14, bottom: 6 })
TextInput({ placeholder: '登录密码', text: this.password })
// ⚠️ `InputType.Password`:系统负责遮蔽显示;
// 别自己用 `***` 替换 —— 那样光标位置/输入法都会出问题。
.type(InputType.Password)
.maxLength(72)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.password = v;
})
// 键盘上的"完成"直接提交,省一次点击
.onSubmit((): void => {
this.submit();
})
this.errorBox()
// ---- 登录按钮 ----
Text(this.busy ? '正在登录…' : '登 录')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.backgroundColor(this.canSubmit() ? this.accent : FbColors.faint)
.borderRadius(FbMetric.radiusS)
.margin({ top: 18 })
.onClick((): void => {
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)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ bottom: 8 })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
/** 错误提示条(空串时不占位) */
@Builder
errorBox() {
if (this.error !== '') {
Row({ space: 6 }) {
Text('!')
.fontSize(FbMetric.fontSmall)
.fontColor('#A32D2D')
.fontWeight(FontWeight.Bold)
Text(this.error)
.fontSize(FbMetric.fontSmall)
.fontColor('#A32D2D')
.layoutWeight(1)
.maxLines(4)
}
.width('100%')
.margin({ top: 12 })
.alignItems(VerticalAlign.Top)
}
}
/**
* 同步遮罩:登录成功后的"正在把数据搬到这台手机"。
*
* ⚠️ 必须**挡住**下面的表单(半透明底 + 全屏),否则用户会去点「登录」第二次。
* ⚠️ 给「立即进入」:网络慢的家庭(农村宽带/4G)可能真要跑一两分钟,
* 不能把人锁在登录页 —— 数据在后台继续同步,进去看得到多少是多少。
*/
@Builder
syncOverlay() {
Column({ space: 0 }) {
Column({ space: 14 }) {
Text('正在把家里的数据同步到这台手机')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.textAlign(TextAlign.Center)
Text(this.syncNote !== '' ? this.syncNote : '准备中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
// 进度条(用 `Progress` 而不是自制矩形:它与系统动效一致)
Progress({ value: this.syncDone, total: this.syncTotal > 0 ? this.syncTotal : 12,
type: ProgressType.Linear })
.width('80%')
.color(this.accent)
Text(this.syncDone.toString() + ' / ' + this.syncTotal.toString() + ' 个模块')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('立即进入(后台继续同步)')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.margin({ top: 6 })
.onClick((): void => {
this.finish();
})
}
.width('86%')
.padding({ top: 26, bottom: 20, left: 18, right: 18 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: FbColors.line })
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
// 半透明遮住整页(含状态栏区域,因为窗口是全屏的)
.backgroundColor('#B3F7F3EA')
}
}
+55 -3
View File
@@ -14,9 +14,11 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbNoteItem, FbNotificationItem } from '../../model/Types';
import { FB_DEMO_NOTES, FB_DEMO_NOTIFICATIONS } from '../../model/DemoData';
import { parseNotes, parseNotifications } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
// ============================================================
// 笔记
@@ -34,7 +36,28 @@ export struct FbNotesPage {
/** 模块主题色(笔记 = 褐灰) */
private accent: string = '#7A6A4F';
private all: FbNoteItem[] = FB_DEMO_NOTES;
/** 笔记列表(`/api/v1/notes`)。空初值:别先闪假数据。 */
@Local all: FbNoteItem[] = [];
/** 顶部提示 */
@Local syncNote: string = '';
/**
* 页面出现:读缓存 → 拉接口(本地优先)。
* ⚠️ 不自己开库:`EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbNoteItem[]>('notes', parseNotes,
(rows: FbNoteItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
build() {
Scroll() {
@@ -100,6 +123,12 @@ export struct FbNotesPage {
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
}
@Builder
@@ -171,7 +200,24 @@ export struct FbNotificationsPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private all: FbNotificationItem[] = FB_DEMO_NOTIFICATIONS;
/** 通知列表(`/api/v1/notifications`) */
@Local all: FbNotificationItem[] = [];
/** 顶部提示 */
@Local syncNote: string = '';
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbNotificationItem[]>('notifications', parseNotifications,
(rows: FbNotificationItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
build() {
Scroll() {
@@ -233,6 +279,12 @@ export struct FbNotificationsPage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
+461 -36
View File
@@ -7,30 +7,399 @@
*
* 页头右侧两个入口:搜索、设置(影院管理 / 统计 / 年度报告 —— 与 Web 端一致,
* 「影院」不单独占抽屉位置,它是观影模块的一部分)。
*
* 🔴 **本地优先(local-first)的第一个落地模块**(用户 2026-10-12 定):
* 数据流是「接口 → 本地库 → UI」,而不是「UI 直接读接口」。
* · `aboutToAppear` 时先读**本地库**渲染(有数据就立刻显示,不等网络);
* · 然后**后台**跑一次 `sync()` 把服务端数据补进来;
* · 同步完再读一次本地库刷新界面。
* 这样断网时页面照常可用(因为读的本来就是本地),
* 有网时数据会自动变新。
*
* ⚠️ 为什么不在 `aboutToAppear` 里 `await sync()` 再渲染:
* 那样就成了"网络优先"——转圈等 2 秒才出内容,
* 断网时更是完全空白。**先渲染本地、再后台同步**是这套架构的要点。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbMovieItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_CINEMAS, FB_DEMO_MOVIES } from '../../model/DemoData';
import { FbKinChip } from '../../model/Types';
import { FbCinemaItem } from '../../data/FbMovieRepo';
import { FbDot, FbEmptyBox, FbIconButton, FbPersonChip, FbPrimaryButton, FbRating, FbSectionTitle } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { FbDb } from '../../data/FbDb';
import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo';
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbCinemaSheet } from '../../components/FbCinemaSheet';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_MOVIES } from '../../model/FbSettingsMenus';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fbImageUrl } from '../../data/FbHttp';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMoviesPage';
/**
* 把接口的完整时间('YYYY-MM-DD HH:mm:ss')裁成卡片上够用的短形式。
*
* ⚠️ 只在**显示**时裁,存库仍存完整值 —— 否则详情页/排序会丢精度。
* ⚠️ 输入可能为空(老数据没填时间)⇒ 必须返回空串而不是 'undefined'。
*/
function shortTime(v: string): string {
if (v.length < 16) {
return v; // 短于 'YYYY-MM-DD HH:mm' 就原样返回(含空串)
}
return v.substring(0, 16); // 去掉最后的 ':ss'
}
@ComponentV2
export struct FbMoviesPage {
@Param app: FbAppState = fbAppState();
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
* 🔴 用户要求「向上滑动时滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
private movies: FbMovieItem[] = FB_DEMO_MOVIES;
private cinemas: string[] = FB_DEMO_CINEMAS;
/**
* 观影列表(**来自本地库**)。
*
* ⚠️ 初值给空数组而不是演示数据:一上来就显示假数据、随后被真数据替换,
* 用户会看到"闪一下"。空数组 + 一个明确的"加载中/暂无"状态更诚实。
* (演示数据仍保留在 DemoData 里,供后续做空态预览用。)
*/
@Local movies: FbMovieListItem[] = [];
/** 是否正在首次加载(决定显示"加载中"还是"暂无记录") */
@Local loading: boolean = true;
/** 观影页顶部一行提示(自检/同步状态共用;谁最后写谁显示) */
@Local syncNote: string = '';
/** 临时自检结果(与同步提示分开,避免互相覆盖) */
/**
* 海报加载失败的电影 id。
*
* 🔴 为什么需要它:海报是网络图,取不到是**常态**(没网、图被删、令牌失效)。
* 不处理的话这张卡会一直显示"半张白图",用户以为页面坏了。
* ⇒ 记下失败的 id,那张卡改回"图标 + 片名"的占位(与"本来就没海报"同观感)。
* ⚠️ 用 `@Local` + 数组整体替换来触发刷新:
* ArkUI 的 `@Local` 只代理第一层,直接 `push` 到数组里**不会刷新界面**
* (§05-state 的规则)。所以每次都用新数组赋值。
*/
@Local posterFailIds: number[] = [];
// ---- 详情浮层 ----
/** 浮层是否显示 */
@Local sheetOpen: boolean = false;
/** 浮层里的详情(null = 还没加载出来) */
@Local sheetDetail: FbMovieDetail | null = null;
/** 详情是否加载中 */
@Local sheetLoading: boolean = false;
/** 详情加载失败信息(空串 = 没失败) */
@Local sheetError: string = '';
/** 浮层对应的列表项评分(详情接口不给评分,从列表带,见 FbMovieRepo.detail 说明) */
@Local sheetRating: number = 0;
@Local sheetReviewCount: number = 0;
/**
* 浮层正在展示的电影 id。
*
* ⚠️ 两个用途,缺一不可:
* ① **防重入**:连点同一条时不重复叠浮层(实测会叠层,照片数翻倍);
* ② **防串数据**:详情是异步拉的,回来时要确认"还是这条"才写入
* (否则点 A 又点 B,A 的数据会糊到 B 的浮层上)。
*/
@Local sheetMovieId: number = 0;
/**
* 影院列表(**从本地库读**,`/api/v1/cinemas` 的同步结果)。
*
* 🔴 之前这里是 `FB_DEMO_CINEMAS`(写死的演示数组),只用来显示"影院 N 家"
* —— 而「5 家」那个数字**恰好**跟真实值一样,所以看不出是假的。
* 改成真实列表后,数字与列表同源,不会再对不上。
*/
@Local cinemas: FbCinemaItem[] = [];
/**
* 模块设置浮层是否打开。
*
* 🔴 用户 2026-10-16:右上角按钮统一改成网页版那种**三条横线**(☰),
* 点开是**这个模块的设置菜单**(照 `templates/movies/_settings_menu.php`)。
*/
@Local settingsOpen: boolean = false;
/** 影院浮层是否打开 */
@Local cinemaSheetOpen: boolean = false;
/**
* 🔴 浮层开合时**让底部悬浮栏让位**(与名册页同一处 bug,见 `FbPeoplePage` 的说明)。
*
* 浮层装在**页面自己的 Stack** 里,而悬浮栏在**更外层 `Index` 的 Stack** 里
* ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。
* ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。
*/
@Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen')
onSheetChanged(): void {
const app: FbAppState = fbAppState();
app.sheetCount = (this.sheetOpen || this.cinemaSheetOpen || this.settingsOpen) ? 1 : 0;
}
/** 模块主题色(观影 = 紫) */
private accent: string = '#7A4E8C';
private repo: FbMovieRepo = FbMovieRepo.inst();
/**
* 页面出现:先读本地库渲染,再后台同步。
* ⚠️ 不能声明成 async 让框架 await —— 生命周期钩子返回 void 即可,
* 异步逻辑在内部自己跑(否则会影响页面首帧时机)。
*/
aboutToAppear(): void {
this.bootstrap();
}
/** 该电影的海报是否已确认加载失败 */
private posterFailed(id: number): boolean {
return this.posterFailIds.indexOf(id) >= 0;
}
/**
* 记下"这张海报加载失败"。
*
* ⚠️ 必须**换成新数组**赋值(`concat`),不能 `push` ——
* `@Local` 不代理数组内部变化,push 进去界面不会刷新
* (表现为"图片还是白的,占位也没出现",看着像没生效)。
* ⚠️ 已经记过就不重复加:`onError` 可能被多次触发,
* 重复添加会让数组无限增长(虽然去重判断也要 O(n),但这里 n 很小)。
*/
private markPosterFailed(id: number): void {
if (this.posterFailed(id)) {
return;
}
this.posterFailIds = this.posterFailIds.concat([id]);
}
/**
* 点卡片 → 打开详情浮层。
*
* 🔴 先"立刻开浮层 + 显示加载中",再异步拉详情:
* 如果等详情回来才开浮层,用户点下去会有**明显延迟**(网络快则一闪、
* 慢则好几秒毫无反应,用户以为没点到)。
* 先把浮层弹出来、里面写"正在加载详情…",体感完全不同。
*
* ⚠️ 未登录(没令牌)时**不开浮层**,而是给一句提示:
* 详情要连服务端拉,没令牌必然失败;让用户看到"加载失败"不如
* 直接说清"没连服务器"。
*/
private openDetail(mv: FbMovieListItem): void {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.syncNote = '未连接服务器,详情需要联网查看';
return;
}
// 🔴 **防重入**:已经是这条、且浮层已经开着 ⇒ 直接返回。
// 不加这个判断时,连点卡片会**反复叠加浮层**
// (2026-10-12 实测:浮层里的照片数从 5 变成 9,就是叠了两层)。
if (this.sheetOpen && this.sheetMovieId === mv.id) {
return;
}
this.sheetMovieId = mv.id;
// 先把浮层弹出来(带加载态),再拉数据
this.sheetRating = mv.reviewAvg;
this.sheetReviewCount = mv.reviewCount;
this.sheetDetail = null;
this.sheetError = '';
this.sheetLoading = true;
this.sheetOpen = true;
this.loadDetail(mv.id, token, mv);
}
/** 拉详情并填进浮层 */
private async loadDetail(id: number, token: string, mv: FbMovieListItem): Promise<void> {
const d: FbMovieDetail = await this.repo.detail(id, token, mv);
// ⚠️ 用户可能在等待期间**已经关掉浮层**(或点了别的卡片)
// ⇒ 回来时先确认浮层还开着、且**还是这一条**,否则会把旧数据糊到新浮层上。
if (!this.sheetOpen || this.sheetMovieId !== id) {
return;
}
this.sheetLoading = false;
if (!d.ok) {
this.sheetError = d.error;
return;
}
this.sheetDetail = d;
}
/** 启动流程:开库 → 读本地 → 后台同步 → 再读本地 */
private async bootstrap(): Promise<void> {
hilog.info(DOMAIN, TAG, 'bootstrap 开始');
try {
// ① 取 Ability 上下文。
// 🔴 用 `getUIContext().getHostContext()`,**不用全局 `getContext(this)`**:
// 全局 getContext 在 Stage 模型多 ArkUI 实例下可能取错/取不到,
// 规则文件明确要求换掉(r/quick-rules/02-uicontext.md)。
// RDB / preferences 都要靠这个 Context 定位应用沙箱目录。
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
hilog.error(DOMAIN, TAG, '拿不到 Ability Context,本地库无法打开');
this.loading = false;
this.syncNote = '本地存储不可用(上下文获取失败)';
return;
}
hilog.info(DOMAIN, TAG, '拿到 Context,准备打开本地库');
// ② 打开本地库(首装会建表;这一步本身很快)
await FbDb.inst().open(ctx);
hilog.info(DOMAIN, TAG, '本地库打开成功');
} catch (err) {
// ⚠️ 把**真实错误**显示到界面上:
// 这台设备的 hilog 缓冲区读不出内容(已确认),界面是唯一可靠通道。
// 临时诊断用,定位完就换回友好文案。
const e: BusinessError = err as BusinessError;
const detail: string = (e.code !== undefined ? 'code=' + e.code.toString() + ' ' : '') +
(e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '本地库打开失败:%{public}s', detail);
this.loading = false;
this.syncNote = '本地库打开失败:' + detail;
return;
}
// ③ 先读本地(有数据立刻显示)
await this.reloadFromLocal();
// ④ 后台同步(不阻塞界面)
this.syncInBackground();
}
/** 临时:把本地库自检结果拼成一行显示(定位完删除) */
/** 从本地库读列表刷新界面 */
private async reloadFromLocal(): Promise<void> {
try {
const rows: FbMovieListItem[] = await this.repo.list(300);
this.movies = rows;
} catch (err) {
hilog.error(DOMAIN, TAG, '读本地观影失败:%{public}s', JSON.stringify(err));
}
// 🔴 影院与观影列表**分开 try**。
// 为什么:影院是"附属信息"(只是页头那行"影院 N 家"),
// 它读失败**不该**影响观影列表的显示。
// ⚠️ 踩过的坑:一开始把这段塞进了上面的 `catch` 里
// (脚本按"插在 finally 前"定位,结果落进了 catch)——
// 表现为"只有读失败时才有影院",而正常路径永远是 0 家。
// ⇒ **自动插入代码后必须看插入点的上下文**,别只看"插进去了"。
try {
this.cinemas = await this.repo.cinemaList();
} catch (err) {
hilog.warn(DOMAIN, TAG, '读本地影院失败:%{public}s', JSON.stringify(err));
}
this.loading = false;
}
/**
* 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。
*
* ⚠️ 失败**不弹窗、不清空已有数据** —— 本地优先下"同步失败"只是
* "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字说明。
*/
private async syncInBackground(): Promise<void> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
// 还没有令牌:不做同步,但**本地数据照样能看**(这就是落盘的价值)
this.syncNote = '未登录,显示的是本地数据';
return;
}
const res: FbSyncResult = await this.repo.sync(token);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '观影同步失败:%{public}s', res.error);
this.syncNote = '同步失败:' + res.error;
return;
}
this.syncNote = '';
await this.reloadFromLocal(); // 同步成功再刷一次,把新数据显示出来
}
build() {
// 🔴 用 Stack 把"页面内容"和"详情浮层"叠起来:
// 浮层是页面内的一个覆盖层(**不是系统弹窗**,理由见
// FbMovieDetailSheet 的文件头说明)。
Stack({ alignContent: Alignment.Bottom }) {
this.pageContent()
// ---- 详情浮层 ----
if (this.sheetOpen) {
// 半透明遮罩:点它也能关(大屏上比只能点"关闭"更顺手)
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.sheetOpen = false;
})
FbMovieDetailSheet({
detail: this.sheetDetail,
loading: this.sheetLoading,
error: this.sheetError,
listRating: this.sheetRating,
listReviewCount: this.sheetReviewCount,
onClose: (): void => {
this.sheetOpen = false;
}
})
}
// ---- 影院列表浮层 ----
// ⚠️ 与详情浮层**同一个 Stack**、同一个层级:
// 两者不会同时打开(详情由点电影触发、影院由点"影院 N 家"触发),
// 所以不需要额外处理"谁盖谁"。
if (this.cinemaSheetOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.cinemaSheetOpen = false;
})
FbCinemaSheet({
cinemas: this.cinemas,
onClose: (): void => {
this.cinemaSheetOpen = false;
}
})
}
// ---- 模块设置浮层(三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '观影设置',
items: FB_SET_MOVIES,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 目前只有「影院管理」是已实现的;其余在菜单里显示「暂未开放」
if (action === 'cinema') {
this.settingsOpen = false;
this.cinemaSheetOpen = true;
}
}
})
}
}
.width('100%')
.height('100%')
}
/** 页面主体(原 build 的内容,抽出来给 Stack 用) */
@Builder
pageContent() {
Scroll() {
Column() {
this.topBar()
@@ -54,11 +423,15 @@ export struct FbMoviesPage {
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 「影院」不单独占抽屉位置 —— 它是观影模块的一部分,从这里进
// 🔴 设置按钮 = 网页版那种**三条横线**(☰);「影院」也不单独占抽屉位置,
// 它是观影模块的一部分,从设置菜单里进(与网页版一致)。
FbIconButton({
icon: $r('app.media.fb_ic_gear'),
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
dia: 36
dia: 36,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
@@ -85,18 +458,51 @@ export struct FbMoviesPage {
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 4 })
.alignItems(VerticalAlign.Center)
// ---- 影院数提示 ----
// ---- 影院数(可点:看影院列表)----
// 🔴 之前这是个**纯文字**(只能看数字);现在点它能看到名单 ——
// Web 端「影院管理」就在观影模块里,这里给它一个入口。
Row({ space: 6 }) {
Blank()
Text('影院 ' + this.cinemas.length.toString() + ' 家')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('›')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.onClick((): void => {
this.cinemaSheetOpen = true;
})
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 6, bottom: 6 })
// ---- 同步状态提示(只在有话说时显示,不占位)----
if (this.syncNote !== '') {
Row() {
Text(this.syncNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
// ---- 海报网格 ----
if (this.movies.length === 0) {
if (this.loading) {
// ⚠️ "加载中"与"暂无记录"必须分开:本页先读本地库(通常几毫秒),
// 所以 loading 几乎一闪而过;若把它和空态混在一起,
// 用户会看到"还没有观影记录"闪一下再变成列表。
Row() {
Text('正在读取本地数据…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else if (this.movies.length === 0) {
FbEmptyBox({
title: '还没有观影记录',
hint: '一家人一起看过的电影,都会以海报墙的方式排在这里',
@@ -104,11 +510,11 @@ export struct FbMoviesPage {
})
} else {
Grid() {
ForEach(this.movies, (mv: FbMovieItem) => {
ForEach(this.movies, (mv: FbMovieListItem) => {
GridItem() {
this.movieCard(mv)
}
}, (mv: FbMovieItem): string => 'mv_' + mv.id.toString())
}, (mv: FbMovieListItem): string => 'mv_' + mv.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
@@ -118,7 +524,6 @@ export struct FbMoviesPage {
// Column 的高度由内容决定,layoutWeight(1) 会去分"剩余空间"(此时为 0)
// ⇒ Grid 高度塌成 0、整页只有顶栏和页头。
.width('100%')
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
@@ -131,38 +536,52 @@ export struct FbMoviesPage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
/** 一张电影卡:海报位(2:3)+ 片名 + 元信息 + 同看的人 */
@Builder
movieCard(mv: FbMovieItem) {
movieCard(mv: FbMovieListItem) {
Column({ space: 0 }) {
// 海报位:有海报就放图,没有就画占位(Web 端用「影」字占位,端上用图标)
// 海报位:有海报就放真图,没有/加载失败就画占位(与 Web 端一致)
Stack({ alignContent: Alignment.Center }) {
if (mv.hasPoster) {
// TODO(接口):接上 media.php 的海报地址后换掉这个纯色块
Column()
if (mv.posterKey !== '' && !this.posterFailed(mv.id)) {
// 🔴 真海报:走服务端 `media.php` 代理,令牌用 URL query 带过去
// (`Image()` 无法加自定义请求头,见 fbImageUrl 的说明)。
Image(fbImageUrl(mv.posterKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.linearGradient({
angle: 145,
colors: [[fbPalette(this.accent).brand2, 0.0], [fbPalette(this.accent).ink, 1.0]]
.objectFit(ImageFit.Cover)
// ⚠️ `alt` 给一张底图:图片没加载出来时先显示它,不会闪白块
.alt($r('app.media.fb_ic_movies'))
// ⚠️ 不 `syncLoad`:海报是大图,同步加载会卡住列表滚动。
// ⚠️ 加载失败**必须记下来**(见 onError):否则这张卡会一直
// 显示"半张白图",用户以为坏了,其实只是这张图取不到。
.onError((): void => {
this.markPosterFailed(mv.id);
})
} else {
// 无海报 / 加载失败:底图 + 图标/片名占位
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.inputBg)
}
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(mv.hasPoster ? 26 : 22)
.height(mv.hasPoster ? 26 : 22)
.renderMode(ImageRenderMode.Template)
.fillColor(mv.hasPoster ? '#FFFFFFB3' : FbColors.faint)
if (!mv.hasPoster) {
// 占位层:只在"没有海报"或"海报加载失败"时显示(有真图时不遮挡)
if (mv.posterKey === '' || this.posterFailed(mv.id)) {
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
@@ -187,7 +606,9 @@ export struct FbMoviesPage {
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(mv.watchedAt)
// ⚠️ 显示上只取「日期 + 时:分」:接口给的是完整 DATETIME(含秒),
// 秒在卡片上纯属噪音。
Text(shortTime(mv.startsAt))
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
@@ -210,15 +631,15 @@ export struct FbMoviesPage {
Row({ space: 8 }) {
if (mv.reviewCount > 0) {
FbRating({ score: mv.rating, max: 10 })
FbRating({ score: mv.reviewAvg, max: 10 })
}
Blank()
// 同看的人(只出头像,空间紧)
Row({ space: 2 }) {
ForEach(mv.viewers.slice(0, 3), (v: FbKinChip) => {
FbPersonChip({ name: v.shown, color: v.avatarColor, withText: false })
.padding({ left: 0, right: 0 })
}, (v: FbKinChip): string => 'vw_' + mv.id.toString() + '_' + v.id.toString())
// 同看人数(只出数字,空间紧;点头像看详情是后续的事)
if (mv.attendeeCount > 0) {
Text(mv.attendeeCount.toString() + ' 人同看')
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
}
.width('100%')
@@ -232,5 +653,9 @@ export struct FbMoviesPage {
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
// 🔴 点卡片 → 打开详情浮层(用户 2026-10-12 反馈"点不进去")
.onClick((): void => {
this.openDetail(mv);
})
}
}
File diff suppressed because it is too large Load Diff
@@ -11,9 +11,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbResourceItem } from '../../model/Types';
import { FB_DEMO_RESOURCES, fbGroupResource } from '../../model/DemoData';
import { fbGroupResource } from '../../model/DemoData';
import { parseResources } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbResourcesPage {
@@ -27,88 +32,174 @@ export struct FbResourcesPage {
/** 模块主题色(资源 = 靛蓝) */
private accent: string = '#5566A8';
private all: FbResourceItem[] = FB_DEMO_RESOURCES;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbResourceItem[] = [];
/** 当前筛选类别('' = 全部) */
@Local kindFilter: string = '';
build() {
Scroll() {
Column() {
this.topBar()
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 项资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('证件 / 资料 / 附件,可共享给家庭成员')
private async load(): Promise<void> {
await loadModule<FbResourceItem[]>('resources', parseResources,
(rows: FbResourceItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 项资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('证件 / 资料 / 附件,可共享给家庭成员')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有资源',
hint: '家里的证件、资料和附件都可以收进来,需要时一键共享',
icon: $r('app.media.fb_ic_resources')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbResourceItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.items.length.toString() + ' 项') }) {
ForEach(g.items, (r: FbResourceItem) => {
ListItem() {
this.resourceCard(r)
}
}, (r: FbResourceItem): string => 'res_' + r.id.toString())
}
}, (g: FbGroup<FbResourceItem>): string => 'rsg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有资源',
hint: '家里的证件、资料和附件都可以收进来,需要时一键共享',
icon: $r('app.media.fb_ic_resources')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbResourceItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.items.length.toString() + ' 项') }) {
ForEach(g.items, (r: FbResourceItem) => {
ListItem() {
this.resourceCard(r)
}
}, (r: FbResourceItem): string => 'res_' + r.id.toString())
}
}, (g: FbGroup<FbResourceItem>): string => 'rsg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '资源设置',
items: FB_SET_RESOURCES,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一项资源卡 */
@@ -13,8 +13,21 @@
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleDef, FB_MODULES, FB_PRESET_COLORS } from '../../model/Modules';
import { FbAvatar, FbDot, FbTag } from '../../components/Widgets';
import { FbAvatar, FbDot, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FB_SERVER_STORE } from '../../data/FbServerStore';
import { FB_SYNC_ALL_PROGRESS, FbSyncAllResult, syncAll } from '../../data/FbSyncAll';
import { common } from '@kit.AbilityKit';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbApi } from '../../data/FbHttp';
import { FB_SESSION } from '../../data/FbSession';
import { FB_DEVICE_STORE, FB_APP_VERSION } from '../../data/FbDeviceStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSettings';
@ComponentV2
export struct FbSettingsPage {
@@ -31,6 +44,163 @@ export struct FbSettingsPage {
'resources', 'tasks', 'days', 'ledger', 'books', 'notes'];
/** 当前展开的配色分组('' = 都没展开) */
@Local colorOpenKey: string = '';
/** 令牌输入框的当前内容(「手动粘贴令牌」这个高级入口用) */
@Local tokenInput: string = '';
/** 账号区的状态说明(登录 / 同步 / 登出的结果) */
@Local tokenNote: string = '';
/** 「手动粘贴令牌」高级入口是否展开(v55:已不是主路径) */
@Local advancedOpen: boolean = false;
/** 设置页的主题色(与模块「个人设置」一致,取砖红) */
private accent: string = '#A84A28';
/**
* 页面出现时:不需要回填任何东西。
*
* ⚠️ v55 之前这里会把"已配置令牌"写进输入框 —— 现在登录是主路径,
* 令牌输入框已经收进「高级」,不再需要在进页面时提示一次。
*/
aboutToAppear(): void {
this.tokenNote = '';
}
/** 账号区状态文案 */
private tokenStateText(): string {
if (!FB_TOKEN_STORE.has()) {
return '未登录(只显示本地已有的数据)';
}
// ⚠️ 显示的是**会话里的姓名**,不是令牌 —— 令牌绝不出现在界面上
const who: string = this.app.session.me.name;
const role: string = this.app.session.me.role === 'admin' ? '管理员'
: (this.app.session.me.role === 'guest' ? '只读访客' : '家庭成员');
return '已登录:' + who + ' · ' + role;
}
/** 账号区状态颜色:已登录用绿、未登录用灰 */
private tokenStateColor(): Resource {
return FB_TOKEN_STORE.has() ? $r('app.color.green') : FbColors.muted;
}
/** 当前服务器(去掉协议省一行宽度;没配就明说) */
private serverHost(): string {
const base: string = FB_SERVER_STORE.get();
if (base === '') {
return '还没设置';
}
return base.replace(/^https?:\/\//i, '');
}
/**
* 手动同步一次。
*
* ⚠️ 登录后会自动全量同步,但用户可能"隔了几天再打开、想知道有没有新数据"
* —— 给他一个明确的、随时可按的入口。
*/
private async syncNow(): Promise<void> {
if (!FB_TOKEN_STORE.has()) {
this.tokenNote = '还没登录,无法同步';
return;
}
this.tokenNote = '正在同步…';
// 实时进度(定时器把当前步骤刷到界面上)。
// ⚠️ 本机 hilog 读不出内容 ⇒ 不给实时进度,用户会以为卡死。
const timer: number = setInterval((): void => {
this.tokenNote = '同步中…(' + FB_SYNC_ALL_PROGRESS + ')';
}, 700);
const res: FbSyncAllResult = await syncAll();
clearInterval(timer);
this.tokenNote = res.ok ? ('✓ ' + res.note) : ('部分未完成:' + res.note);
}
/**
* 退出登录:清令牌 + 回登录页(**地址保留**)。
*
* 🔴 为什么不顺手清本地数据:本地优先架构下"没登录"不等于"没数据"。
* 用户可能只是在别人手机上登出、或暂时断网 —— 把已同步的数据一起删掉,
* 等于让他下次进来面对一片空白(还得重新全量同步)。
*/
private async logout(): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
this.tokenNote = '拿不到应用上下文,无法退出';
return;
}
/* 🔴 v57(用户 2026-10-17):「先删本地令牌(这是主防线,捡到手机的人拿不到)
→ 再尽力通知服务端」。
⚠️ 顺序不能反:离线时通知发不出去,如果等它成功再清本地,
用户会看到"点了退出却没退"。本地那一刀是唯一可靠的。
⚠️ 服务端那一条**失败也不报错**:它只是让"这台设备"从设备列表里消失得
更及时,删不掉就等它 24h 后自然过期,或用户在网页端手动踢。 */
const token: string = FB_TOKEN_STORE.get();
await FB_TOKEN_STORE.clear(ctx);
await FB_SESSION.clear(ctx);
if (token !== '') {
fbApi('/api/v1/logout', { method: 'POST', token: token, authProbe: true })
.catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '通知服务端退出失败(已本地退出):%{public}s',
JSON.stringify(err));
});
}
this.tokenNote = '';
// 回登录页,从"输账号密码"开始(地址还留着)
this.app.lockReason = '你已退出登录';
this.app.loginStartStep = 2;
this.app.needLogin = true;
}
/**
* 更换服务器:回登录页第一步重填地址。
*
* 🔴 必须**同时清掉令牌**:令牌是那台服务器签发的,换了机器一定无效 ——
* 留着只会让用户在第二步莫名其妙地 401("密码明明是对的")。
* ⚠️ 地址本身**不清**:登录页第一步会预填它,用户改起来更方便。
*/
private async changeServer(): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx !== undefined) {
await FB_TOKEN_STORE.clear(ctx);
// ⚠️ 会话校验记录也要清:换了服务器,"上次校验成功"是旧服务器的事,
// 留着会让新服务器上的离线判定带着旧时间(v57)
await FB_SESSION.clear(ctx);
}
this.tokenNote = '';
this.app.lockReason = '换了服务器,需要重新登录';
this.app.loginStartStep = 1;
this.app.needLogin = true;
}
/**
* 保存手动粘贴的令牌并立刻同步一次(「高级」里的路)。
*
* ⚠️ 为什么还留着它:网页版「个人设置 → API 令牌」签发的令牌,
* 在"不方便输密码"或"只给某台设备一把只读令牌"时仍然有用。
* 但它**不再是主路径** —— 正常用户走登录。
*/
private async saveTokenAndSync(): Promise<void> {
const t: string = this.tokenInput.trim();
if (t === '') {
this.tokenNote = '请先粘贴令牌';
return;
}
// 🔴 没地址时令牌一定没用:请求拼出来是个相对地址,报的错还跟"令牌"无关
if (FB_SERVER_STORE.get() === '') {
this.tokenNote = '请先设置服务器地址';
return;
}
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
this.tokenNote = '拿不到应用上下文,无法保存';
return;
}
this.tokenNote = '正在保存…';
const ok: boolean = await FB_TOKEN_STORE.set(ctx, t);
if (!ok) {
this.tokenNote = '令牌保存失败';
return;
}
this.tokenInput = '';
await this.syncNow();
}
build() {
Scroll() {
@@ -75,6 +245,124 @@ export struct FbSettingsPage {
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 账号与服务器(登录 / 同步 / 登出)====================
// 🔴 v55(用户 2026-10-17「完整的登录功能」)——
// 这里从"手工粘贴令牌"变成了**登录态管理**:
// · 登录走登录页(地址 + 账号密码),这里只显示"当前是谁、连的哪台";
// · 「同步全部数据」保留(用户想立刻拉新数据时按一下);
// · 「退出登录 / 更换服务器」都回登录页(本应用没有路由栈,
// 靠 `app.needLogin` 这个状态位回去,见 Index)。
// ⚠️ 手工粘贴令牌降级成「高级」:它是"给某台设备一把只读令牌"这类
// 特殊场景的备用路,不该出现在普通人的视线里。
Column({ space: 0 }) {
this.rowTitle('账号与服务器')
// 登录状态(谁、什么角色)
Text(this.tokenStateText())
.fontSize(FbMetric.fontSmall)
.fontColor(this.tokenStateColor())
.width('100%')
.maxLines(2)
.padding({ top: 2, bottom: 8 })
// 当前服务器(可点「更换」)
Row({ space: 8 }) {
Text('服务器')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(this.serverHost())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('更换')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 8, top: 4, bottom: 4 })
.onClick((): void => { this.changeServer(); })
}
.width('100%')
.padding({ top: 2, bottom: 10 })
.alignItems(VerticalAlign.Center)
// 操作:同步 / 退出(未登录时只有同步,且点了会提示)
Row({ space: 8 }) {
Text('同步全部数据')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.height(36)
.layoutWeight(1)
.backgroundColor(this.accent)
.borderRadius(FbMetric.radiusS)
.onClick((): void => { this.syncNow(); })
if (FB_TOKEN_STORE.has()) {
Text('退出登录')
.fontSize(FbMetric.fontSmall)
// ⚠️ FbColors 里没有 danger(只有 ink/muted/faint/line…),
// 这里用字面色值。别写成 FbColors.danger —— 那会编译不过。
.fontColor('#A32D2D')
.textAlign(TextAlign.Center)
.height(36)
.width(88)
.border({ width: 1, color: FbColors.line })
.borderRadius(FbMetric.radiusS)
.onClick((): void => { this.logout(); })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 高级:手动粘贴 API 令牌 ----
Text(this.advancedOpen ? '收起「手动粘贴令牌」' : '高级:手动粘贴 API 令牌')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 12, bottom: 6 })
.onClick((): void => { this.advancedOpen = !this.advancedOpen; })
if (this.advancedOpen) {
TextInput({ placeholder: '粘贴 API 令牌(fb_ 开头)', text: this.tokenInput })
.type(InputType.Normal)
.fontSize(FbMetric.fontSmall)
.maxLength(128)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.tokenInput = v;
})
FbPrimaryButton({
label: '保存并同步',
bg: this.accent,
icon: $r('app.media.fb_ic_check'),
onTap: (): void => { this.saveTokenAndSync(); }
})
.margin({ top: 8 })
}
// 状态(登录 / 同步 / 登出的结果都落在这里)
if (this.tokenNote !== '') {
Text(this.tokenNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(3)
.margin({ top: 8 })
}
Text('退出登录不会删除已经同步到本机的数据,离线也能看;换个服务器则要重新登录。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
.margin({ top: 8 })
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 通知 ====================
Column({ space: 0 }) {
this.rowTitle('通知')
@@ -144,9 +432,13 @@ export struct FbSettingsPage {
// ==================== 关于 ====================
Column({ space: 0 }) {
this.rowTitle('关于')
this.plainRow('版本', '0.1.0(界面框架)')
this.plainRow('数据来源', '待接入 · 当前为演示数据')
this.plainRow('服务器', '未配置')
this.plainRow('版本', FB_APP_VERSION)
this.plainRow('服务器', this.serverHost())
this.plainRow('账号', FB_TOKEN_STORE.has() ? this.app.session.me.name : '未登录')
/* v57:这台设备在服务端叫什么。
用户 2026-10-17:「只有 uuid 用户无法确定是哪台设备。」
⇒ 网页端「设备管理」里显示的就是这个名字,用户要能对上号。 */
this.plainRow('这台设备', FB_DEVICE_STORE.getDeviceName())
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
@@ -162,6 +454,12 @@ export struct FbSettingsPage {
.backgroundColor(FbColors.paper)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
+161 -68
View File
@@ -12,9 +12,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types';
import { FB_DEMO_TASK_BOARDS, FB_DEMO_TASKS, fbGroupTask } from '../../model/DemoData';
import { fbGroupTask } from '../../model/DemoData';
import { parseTasks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbTasksPage {
@@ -26,93 +31,181 @@ export struct FbTasksPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private boards: string[] = FB_DEMO_TASK_BOARDS;
private boards: string[] = [];
/** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F';
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local tasks: FbTaskItem[] = [];
/** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbTaskItem[]>('tasks', parseTasks,
(rows: FbTaskItem[]): void => {
this.tasks = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.doingCount().toString() + ' 项在做')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 看板筛选条(横向滚动)----
Scroll() {
// ---- 页头 ----
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
Column({ space: 2 }) {
Text(this.doingCount().toString() + ' 项在做')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// ---- 按状态分组的列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里没有任务',
hint: '新建时会顺带指定它属于哪个看板、做到哪一步',
icon: $r('app.media.fb_ic_tasks')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbTaskItem>) => {
ListItemGroup({ header: this.groupHeader(g) }) {
ForEach(g.items, (t: FbTaskItem) => {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 看板筛选条(横向滚动)----
Scroll() {
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.padding({ bottom: 10 })
// ---- 按状态分组的列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里没有任务',
hint: '新建时会顺带指定它属于哪个看板、做到哪一步',
icon: $r('app.media.fb_ic_tasks')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbTaskItem>) => {
ListItemGroup({ header: this.groupHeader(g) }) {
ForEach(g.items, (t: FbTaskItem) => {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '任务设置',
items: FB_SET_TASKS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 看板筛选胶囊 */
+153 -48
View File
@@ -15,9 +15,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { FB_DEMO_TRIPS, FB_DEMO_TRIP_NODES, fbMoney } from '../../model/DemoData';
import { fbMoney } from '../../model/DemoData';
import { parseTrips } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbTripsPage {
@@ -29,71 +34,171 @@ export struct FbTripsPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private trips: FbTripItem[] = FB_DEMO_TRIPS;
private nodes: FbTripNode[] = FB_DEMO_TRIP_NODES;
/**
* 旅程列表(**来自接口** `/api/v1/trips`)。
*
* ⚠️ 初值给空数组而不是演示数据:先渲染假的、再被真数据替换,
* 用户会看到"闪一下"。
*/
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local trips: FbTripItem[] = [];
/**
* 行程节点。
*
* ⚠️ 接口 `/trips` 只给旅程**本身**(含 `node_count`),不给每一段。
* 所以这里保持空 —— 展开时才去拉 `/trips/<id>`(待实现)。
* 不假装有数据:宁可展开后显示"暂无行程段"。
*/
@Local nodes: FbTripNode[] = [];
/** 顶部提示(同步状态) */
@Local syncNote: string = '';
/** 模块主题色(旅程 = 绿) */
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
/**
* 页面出现:先缓存、再网络(与观影/名册同一套"本地优先"流程)。
*
* ⚠️ 本页**不自己打开本地库** —— 已在 `EntryAbility.onCreate` 统一打开了
* (见那里的说明)。页面只管用。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbTripItem[]>('trips', parseTrips,
(rows: FbTripItem[], fromCache: boolean): void => {
this.trips = rows;
this.syncNote = fromCache ? '' : '';
},
(note: string): void => {
this.syncNote = note;
});
}
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头操作条 ----
Row({ space: 8 }) {
Column({ space: 1 }) {
Text('共 ' + this.trips.length.toString() + ' 个旅程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('每个旅程里可以有若干段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
// ---- 页头操作条 ----
Row({ space: 8 }) {
Column({ space: 1 }) {
Text('共 ' + this.trips.length.toString() + ' 个旅程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('每个旅程里可以有若干段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.trips.length === 0) {
FbEmptyBox({
title: '还没有旅程',
hint: '去过的地方会按时间排在这里,点开能看到每一段行程',
icon: $r('app.media.fb_ic_trips')
})
} else {
List({ space: 12 }) {
ForEach(this.trips, (t: FbTripItem) => {
ListItem() {
this.tripCard(t)
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (t: FbTripItem): string => 'trip_' + t.id.toString())
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.trips.length === 0) {
FbEmptyBox({
title: '还没有旅程',
hint: '去过的地方会按时间排在这里,点开能看到每一段行程',
icon: $r('app.media.fb_ic_trips')
})
} else {
List({ space: 12 }) {
ForEach(this.trips, (t: FbTripItem) => {
ListItem() {
this.tripCard(t)
}
}, (t: FbTripItem): string => 'trip_' + t.id.toString())
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '旅程设置',
items: FB_SET_TRIPS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */