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
@@ -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)
}