Files
FamilyBook_harmonyos/entry/src/main/ets/pages/Index.ets
T
laoyang 3e2904cc3d v75 旅程重构:列表页 + 详情整页,卡片改「图在上、信息在下」
用户 2026-10-21:
「每一个旅程里面的行程才是重头戏……点击一个旅程,应该进入到一个新的界面,
  在里面列出所有的行程,就跟网页版的一样。
  这个旅程的图片是比较重要的……图片在正上方,就是充满整个的这个列表的宽度。
  然后呢把这个标题还有其他的一些信息放在下面。如果是图片放左边,信息放右边的话,
  它总是被挤占,显示不全。……想看其他的这个旅程的话,直接往下滑就可以了。」

- 新建 `pages/trips/FbTripDetailPage.ets`(照 `trips/show.php`):题图 16:9 占满整宽、
  五项统计、行程按 计划/实际/未成行 三组(空组不显示)、页头两行动作区
  (+计划=蓝 / +实际=绿 / 编辑=黄 / 删除=红 / 设置)
- `FbTripsPage` 846→573 行:删掉就地展开全套,`tripCard` 改「题图在上、信息全在下面」
  卡尾「看行程」自己带 onClick,整卡也可点
- `Types.ets` `FbTripNode` 8→18 字段 + 四个新类型;`FbTripRepo` 新增
  `fetchTripDetail` / `fetchTripSummary` 与四个解析辅助
- 修真 bug:`parseNodes` 读 `expense_cent`,服务端写的是 `spent_cents`
  ⇒ 每段「💰 这一段花了」从来没显示过
- `Store.ets` 新增 `@Trace tripDetailId` 与 `leaveTo(parentKey)`:
  删完整趟回列表要走它(内部 `applyModule` 会 `resetScroll`),
  直接赋 `activeModule` 会让回列表后轻轻一滑就收起悬浮栏
- 接线:`Index` 分发 / `TopBar` 标题「旅程」/ `Modules.fbModuleColor` 归到 trips 色
- `FbTripNodeSheet` 新增 `@Param startStage` 预选阶段(选择器保留,点错还能改)

守卫 931/0,反向验证 164/0,编译 BUILD SUCCESSFUL,真机待设备连上后跑 _fb_v75_verify.py
2026-10-22 00:07:16 +08:00

704 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 主界面(鸿蒙端)
*
* 骨架:**左侧抽屉 + 右侧内容**,两套形态由窗口短边决定(见 FbAppState.wideLayout):
* · 宽屏(平板 / 折叠屏展开 / 横屏手机):抽屉常驻,内容区并排;
* · 窄屏:内容铺满,抽屉从左侧滑出(点遮罩或选中模块即收起)。
*
* 内容区 = 顶栏 + 当前模块页。此处只做**模块分发**,各模块的版面在各自文件里。
*
* ⚠️ 当前全部为**演示数据 / 静态界面**:还没有接口,所有「新建 / 编辑 / 删除」
* 只做入口与样式,不落任何数据。接口到位后只要替换各页面里的数据源即可。
*/
import { display, window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
import { fbModuleColor } from '../model/Modules';
import { FbDrawerShell } from '../components/Drawer';
import { FbTopBar } from '../components/TopBar';
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';
/* 🔴 v75:旅程详情是**整页**(用户:「点击一个旅程,应该进入到一个新的界面」) */
import { FbTripDetailPage } from './trips/FbTripDetailPage';
import { FbCalendarPage } from './calendar/FbCalendarPage';
import { FbTasksPage } from './tasks/FbTasksPage';
import { FbPeoplePage } from './people/FbPeoplePage';
import { FbResourcesPage } from './resources/FbResourcesPage';
import { FbDaysPage } from './days/FbDaysPage';
import { FbLedgerPage } from './ledger/FbLedgerPage';
import { FbBooksPage } from './books/FbBooksPage';
import { FbNotesPage, FbNotificationsPage } from './misc/FbMiscPages';
import { FbSettingsPage } from './settings/FbSettingsPage';
import { FbAccountPage } from './account/FbAccountPage';
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, FbMeIdentityResult, SESSION_NEED_LOGIN, SESSION_OFFLINE } from '../data/FbSession';
import { FbAccount, FbSession } from '../model/Types';
import { fbOnUnauthorized } from '../data/FbHttp';
import { FbDb } from '../data/FbDb';
import { FB_ATTACH } from '../data/FbAttachQueue';
import { FB_KEY_STORE } from '../data/FbKeyStore';
import { fbSyncState } from '../common/FbSyncStore';
import { FB_BACK } from '../common/FbBack';
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
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();
}
/**
* 🔴🔴 v59e(用户 2026-10-18):接管**系统返回手势**。
*
* 用户原话:
* 「加入全局返回按键手势,现在在界面中使用系统的返回手势,是返回到桌面了,
* 这样不好,如果有弹框,返回应该是关闭弹窗,或者返回上个页面,
* 如果要退出 app,用户可以选择返回桌面的手势。在应用中给,
* 应该是返回上一层才对。」
*
* 为什么以前会"直接回桌面":本应用**没有实现 `onBackPress`** ⇒
* 系统认为这一页没有消费返回 ⇒ 按默认行为**把应用退到后台**。
* 而本应用是"单页 + 模块分发",系统并不知道"用户其实还在应用里"。
*
* 处理顺序(从**最上面一层**往下):
* ① 抽屉开着 ⇒ 收抽屉(它是最外层的浮层);
* ② 页面内浮层(详情 / 编辑 / 简要卡 / 设置 …)⇒ 由页面自己关,
* 见 `common/FbBack.ets` 的登记表;
* ③ 不在首页 ⇒ 回上一个模块(没有历史就回首页);
* ④ 首页且没有任何浮层 ⇒ **返回 false**,交还给系统
* (用户想退到桌面时,退回桌面仍是他的手势说了算)。
*
* ⚠️ 返回值语义:`true` = **已消费**(不再往下传);`false` = 交给系统处理。
* 这里必须显式写 `: boolean`,返回 `void` 会被当成 false ⇒ 等于没接管。
*/
onBackPress(): boolean {
// ① 抽屉
if (this.app.drawerOpen) {
this.app.drawerOpen = false;
return true;
}
// ② 页面内浮层(登记表里最后登记的先问 —— 见 common/FbBack.ets)
if (FB_BACK.back()) {
return true;
}
// ③ 回上一个模块 / 回首页
if (this.entered && !this.app.needLogin && this.app.backToPrevModule()) {
return true;
}
// ④ 真的在最外层了:让系统退到桌面
return false;
}
/**
* 读状态栏高度(由 `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) {
/* 🔴 v58:有令牌却校验失败(被网页端踢掉 / 令牌过期 / 账号停用)
⇒ 走 `lockOut`,它会**把本地数据一起清掉**。
⚠️ 不能只设 needLogin 就完事:那样沙箱里的库和附件还在,
与用户要的"所有内容都不允许看"差一大截。 */
this.lockOut(r.reason);
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;
/* 🔴 v59:把 `/me` 带回来的**身份**写进全局状态(姓名 / 真头像 /
家庭名 / 标语)。用户 2026-10-18 两条要求都靠它:
· 「右上角的头像应该是真实的」 → `me.avatarUrl`
· 抽屉左上角要显示标语 → `session.slogan`
⚠️ 必须判 `has`:断网启动时 `check()` 仍会回 SESSION_OK(离线可用),
但**没有**身份数据。不判就会把已有的头像/标语冲成空 —— 那是
"一次断网,头像就没了"这种最难解释的 bug。 */
this.applyIdentity(r.identity);
}
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/v58:令牌失效(任何请求 401 / 启动校验发现被踢)→ 锁定回登录页
* **并把本地数据一起清掉**。
*
* 用户原话:「运行期间任何一个请求返回 401,立刻锁定跳登录页 ——
* 这是真正的兜底,因为同步/写入本来就在不断发请求。」
* v58 又加了一句:「退到登录页面之后,这里面所有的资源、所有的内容
* 都是不允许看的。目的就是为了保证数据的安全性。」
* ⇒ 只锁界面挡不住"拷走沙箱"。所以四处一起做:
* 令牌 → 数据密钥 → 本地库 → 附件缓存。缺任何一处,那条路都还通。
*
* ⚠️ 由 `FbHttp` 的网络层统一回调(不是每个页面自己判),所以**漏不掉**;
* 登录接口自己传了 `authProbe` 不会触发。
*
* @param reason 给用户看的原因('' = 用默认文案)
*/
private lockOut(reason?: string): 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.wipeLocalData(ctx);
}
this.app.lockReason = reason !== undefined && reason !== ''
? reason : '这台设备已经被退出登录,请重新登录';
this.app.loginStartStep = 2;
this.app.needLogin = true;
this.app.offlineLocked = false;
this.entered = false;
}
/**
* 清空本地数据(与设置页的「退出登录」走**同一套**动作)。
*
* ⚠️ 为什么不在 `FbSession` 里做:会话层只管"能不能用"这个判定,
* 清数据要碰库、碰文件、碰 UI 状态,把它塞进会话层会让那层不再纯粹。
* 而 `FbSession` 的两个返回值(need_login / offline)最终都汇到 `Index`,
* 在这里统一编排刚好只写一份。
*/
private wipeLocalData(ctx: common.Context): void {
this.doWipe(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '清本地数据异常:%{public}s', JSON.stringify(err));
});
}
/** 实际动作(拆出来是为了能 await 又能统一兜异常) */
private async doWipe(ctx: common.Context): Promise<void> {
// ① 附件(内部第一步就是**吊销数据密钥** → 已落盘的密文永久解不开)
try {
await FB_ATTACH.wipe(ctx);
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '清附件失败:%{public}s', JSON.stringify(err));
}
// ② 本地库
try {
await FbDb.inst().wipe(ctx);
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '清库失败:%{public}s', JSON.stringify(err));
}
// ③ 同步进度(别把上一账号的状态带到下一个)
fbSyncState().reset();
}
/** 登录成功:进主界面(会话信息已由登录页写进 `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;
// 🔴 v58:重新登录后**必须把本地库重新打开**。
// 被踢/退出时 `doWipe()` 把库删了(store=null),如果不在这里重开,
// 整个应用之后都只能"读网络、不写缓存"—— 界面能用,但一断网就全空,
// 而且没有任何报错(`isReady()` 只是返回 false)。这是最难查的那类问题。
this.reopenLocal();
}
/**
* 🔴 v59:把 `/me` 带回来的身份写进全局状态。
*
* 处理的是"**启动时校验会话**"这条路径(登录那一刻的身份由登录页自己写,
* 见 `FbLoginPage.applySession`);两条路径都必须能更新这些字段,
* 否则会出现"登录后标语是对的,重启一次又变回旧的"。
*
* ⚠️ 逐项判空后再写:`check()` 在**断网**时也会回 SESSION_OK(离线可用),
* 但那时没有身份数据。整块替换会把头像/标语清成空
* (症状:"坐一趟飞机回来,头像和标语都没了")。
* ⚠️ `slogan` 只在服务端给了非空值时覆盖 —— 兜底那句话由端上的
* `FB_DEFAULT_SLOGAN` 负责,服务端不给就保持原值。
*/
private applyIdentity(id: FbMeIdentityResult | undefined): void {
if (id === undefined || !id.has) {
return;
}
const old: FbSession = this.app.session;
// 姓名 / 头像 / 角色 变了才整体换(V2 对 interface 的深层属性观察不可靠,
// 换引用才能保证读它的页面都刷新 —— 与登录页 applySession 同一理由)
const me: FbAccount = {
id: old.me.id,
name: id.name !== '' ? id.name : old.me.name,
kinship: old.me.kinship,
avatarColor: old.me.avatarColor,
avatarUrl: id.avatarUrl !== '' ? id.avatarUrl : old.me.avatarUrl,
role: old.me.role,
isGuest: old.me.isGuest,
canWrite: old.me.canWrite,
};
const next: FbSession = {
loggedIn: true,
familyId: old.familyId,
familyTitle: id.familyName !== '' ? id.familyName : old.familyTitle,
slogan: id.slogan !== '' ? id.slogan : old.slogan,
me: me,
};
this.app.session = next;
}
/**
* 重新建立本地库 + 附件索引(登录成功后调)。
*
* ⚠️ 不 await:开库是异步的,挡住"进入首页"会让登录后多转圈半秒;
* 各页面在库没好时会自动退化成"只走网络",不会出错。
* ⚠️ 顺序:先开库,再让附件队列重建索引(它要读 `attach_cache`)。
*/
private reopenLocal(): void {
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx === undefined) {
return;
}
this.doReopen(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '重建本地环境异常:%{public}s', JSON.stringify(err));
});
}
/**
* 三段:① 数据密钥(wipe 时被吊销了)② 本地库(wipe 时被删了)③ 附件索引(要读库)。
*
* ⚠️ 每段**各自兜异常**:一个环节失败不该让后面全都不做
* (比如 HUKS 不可用时,库和附件索引仍然要能建起来)。
*/
private async doReopen(ctx: common.Context): Promise<void> {
try {
await FB_KEY_STORE.init(ctx);
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '重建数据密钥失败:%{public}s', JSON.stringify(err));
}
try {
await FbDb.inst().open(ctx);
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '重开本地库失败:%{public}s', JSON.stringify(err));
}
try {
await FB_ATTACH.init(ctx);
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '重建附件索引失败:%{public}s', JSON.stringify(err));
}
}
/**
* 量屏幕断点(同步,首帧就能用)。
*
* ⚠️ 这里**只测宽窄屏**。状态栏/手势条高度**不再自己算** ——
* 沉浸式改用「系统栏透明 + expandSafeArea」方案后,
* 窗口布局不动、系统已把内容排在安全区内,自己再算一遍必然错(踩过)。
*/
private measureScreen(): void {
try {
const d: display.Display = display.getDefaultDisplaySync();
const density: number = d.densityPixels;
if (density <= 0) {
return;
}
// 宽窄屏:按窗口短边(短边旋转时不变,故比例接近 1:1 的设备横竖屏布局天然一致)
const shortSide: number = Math.min(d.width, d.height) / density;
this.app.wideLayout = shortSide >= FB_WIDE_BREAKPOINT;
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', 'measureScreen failed: %{public}s', JSON.stringify(err));
}
}
build() {
// 🔴 用 Stack 而不是 Column:底部快捷栏要**浮在内容之上**(用户要求"不要固定在下边,
// 要浮着")。写在 Column 里它就是"占一行的固定底栏"(老样式)。
Stack({ alignContent: Alignment.Bottom }) {
// ---- 启动检查还没完:先给一块底色,别闪 ----
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` 是设置页「退出登录 / 更换服务器」置的位 ——
// 本应用没有路由栈,登录页只能靠这个状态位回到前台。
//
// 🔴🔴 v59(用户 2026-10-18):**「先不登录 / 离线使用」已彻底移除**。
// 用户原话:「登录页面,把那些不登录、看离线数据之类的提示全部去掉。
// 以后就不再拥有这样的功能了,把这些功能也全部删除。」
// ⇒ 没有 `allowSkip`、没有 `onSkip`,**登录页就是唯一下车口**。
// ⚠️ 这是产品决定,别再"顺手"加回一个跳过按钮 —— 它本来就是个
// 安全缺口(被踢之后不该能看任何内容),v58 上了整库加密之后更要堵死。
FbLoginPage({
// 传 app 只为让它拿到**状态栏高度**(全屏布局下登录页也要自己让位)
app: this.app,
// 从第几步开始(1=连地址一起换,2=只重登,0=自动)
startStep: this.app.loginStartStep,
onLoggedIn: (): void => {
this.onLoggedIn();
}
})
} else {
// ---- 主界面 ----
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
}
}
.width('100%')
.height('100%')
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
// 🔴 **浮层开着时也不显示**(用户 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%')
.backgroundColor(FbColors.paper)
// 🔴 这里**不再用 `expandSafeArea`**(用户 2026-10-13 改"全沉浸"后)。
//
// 原因:安全区扩展只在**窗口仍然避让系统栏**的前提下才有意义
// (它做的事是"让背景多画进系统栏那条,但子组件不动")。
// 现在窗口已经开了 `setWindowLayoutFullScreen(true)`——
// 内容本来就铺满整屏、系统栏也全透明,再叠加 `expandSafeArea` 是多余的;
// 而且两者叠加会出现"背景扩一次、内容又扩一次"的重复计算风险。
// ⇒ 全屏这条路线下,**让位交给顶栏自己的 padding.top**(见 FbTopBar),
// 边界一清二楚。
}
/** 内容区:当前模块页(含顶栏 —— 顶栏由页面放进自己的滚动流,见 topBarSlot) */
@Builder
mainArea() {
Column() {
this.moduleContent()
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/**
* 顶栏内容(交给各模块页放进**自己的滚动流最前面** ⇒ 跟着内容一起滚走)。
*
* 🔴 用户要求:「向上滑动的时候,标题栏也跟着向上滑动,不需要固定在当前位置」
* ⇒ 顶栏不能再像以前那样摆在滚动容器**外面**(那样它是固定的)。
* 交给页面承载、页面把它放在 Scroll/List 的内容最前面,才会跟着滚。
*
* ⚠️ 状态栏由系统直接涂成白色(EntryAbility 里 `statusBarColor = surface`,
* 与顶栏同色)。所以顶栏滚走之后,通知栏区域仍由系统保持白色,
* 不会露出页面底色 —— 不需要在页面上再铺"白色压条"。
*
* ⚠️ v55 顺手清掉了一段残留:这上面原本还有一个**孤立的 `@Builder`**
* (是早期"临时诊断条"删剩的壳)。它让编译器报
* "Duplicate '@Builder' decorators for method are not allowed" ——
* 行为上没坏(生效的是下面这个),但每轮编译都刷一条假错误,
* 真出问题时很容易被它盖住。
*/
@Builder
topBarSlot() {
FbTopBar({ app: this.app }) {
}
}
/** 按当前模块渲染对应页面(用 if/else 分发:ArkUI 的 build 里不允许 switch) */
@Builder
moduleContent() {
if (this.app.activeModule === 'home') {
FbHomePage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'people') {
FbPeoplePage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'movies') {
FbMoviesPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'trips') {
FbTripsPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'calendar') {
FbCalendarPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'tasks') {
FbTasksPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'resources') {
FbResourcesPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'days') {
FbDaysPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'ledger') {
FbLedgerPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'books') {
FbBooksPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === 'notes') {
FbNotesPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === '__notifications') {
FbNotificationsPage({ app: this.app, topBar: this.topBarSlot })
/* 🔴 v75(用户 2026-10-21):旅程详情整页。
⚠️ 必须传 `accent`(旅程模块绿),否则详情页的按钮/圆点会回落砖红,
与列表页颜色不一致。 */
} else if (this.app.activeModule === '__trip_detail') {
FbTripDetailPage({ app: this.app, topBar: this.topBarSlot, accent: fbModuleColor('trips') })
} else if (this.app.activeModule === '__settings') {
FbSettingsPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === '__account') {
/* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、修改密码、
系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」
⇒ 点右上角头像进这一页(抽屉底部那四行已删)。 */
FbAccountPage({ app: this.app, topBar: this.topBarSlot })
} else {
// 兜底:模块还没做页面时给一句提示,而不是白屏
Column({ space: 8 }) {
Text('这个模块还没有页面')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.muted)
Text('当前模块:' + this.app.activeModule)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
}