Files
FamilyBook_harmonyos/entry/src/main/ets/pages/Index.ets
T

696 lines
32 KiB
Plaintext
Raw Normal View History

/**
* 家书 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 { 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';
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' || this.app.activeModule === 'kin') {
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 })
} 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)
}
}
}