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

512 lines
23 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 { 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
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;
}
/**
* 量屏幕断点(同步,首帧就能用)。
*
* ⚠️ 这里**只测宽窄屏**。状态栏/手势条高度**不再自己算** ——
* 沉浸式改用「系统栏透明 + 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` 是设置页「退出登录 / 更换服务器」置的位 ——
// 本应用没有路由栈,登录页只能靠这个状态位回到前台。
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%')
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
// 🔴 **浮层开着时也不显示**(用户 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 {
// 兜底:模块还没做页面时给一句提示,而不是白屏
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)
}
}
}