v0.8.0 本地加密 / 被踢清数据 / 增量同步 / 附件队列 / 顶栏同步进度
用户 2026-10-18 两条: 「直接做 L3 锁界面 + 本地加密(HUKS,踢设备即吊销密钥):数据在但读不出, 重登后恢复密钥即可用。」 「右上角头像和提醒前面加同步进度…首次全量、之后增量;进来先展示本地数据, 一边同步一边更新;附件后台队列同步(名册头像 / 电影海报 / 观影照片 / 资源附件)。」 ① 整库加密(FbSchema v4→v5,新增 module_sync / attach_cache 两张表) RDB encrypt=true。加密属性一经创建不可修改 ⇒ openWithEncryption 三段式: 直接开 → 失败删库重建 → 再失败降级明文(degraded 字段,设置页如实显示, 不静默降级)。 ② 两层密钥(FbKeyStore) HUKS 里 KEK(fb_kek_v1,不可导出)包裹 32 字节 DEK;revoke() 删 KEK ⇒ 已落盘附件密文在数学上解不开,这就是"踢设备即吊销密钥"。 重新登录重新生成 DEK 并重下附件(不做服务端托管,为了省一次重下 把能解你本地数据的密钥放到服务端不值)。 会话属性里 PURPOSE 只出现一次:原来把含 ENCRYPT|DECRYPT 的生成属性直接 拿去开会话再 push 一个 PURPOSE,同一 tag 出现两次在 HUKS 里行为未定义 ⇒ 拆成 kekBaseProps / kekGenProps / kekSessionProps。 ③ AES-256-GCM(FbCrypto) 落盘 iv(12)|ct|tag(16);变换串必须带 padding 段(AES256|GCM|PKCS7, 缺了抛 801);解密从尾部切 tag。 ④ 附件后台队列(FbAttachQueue) attach_cache 台账(pending/done/fail,MAX_TRIES=3);密文放 filesDir、 明文临时放 cacheDir;扩展名白名单筛 key。 图片地址收成统一入口 fbImageSrc(key,token):本地有就用 file://, 没有才回网络 —— 收成一个函数才漏不掉(5 个页面全部改过来)。 ⑤ 被踢 / 退出清数据 lockOut → 清令牌 → FB_ATTACH.wipe(第一步就是吊销 KEK)→ FbDb.wipe (先关句柄再 deleteRdbStore,并发去重,open 会等它)→ 同步状态复位。 重新登录后重建密钥 + 库 + 附件索引,否则之后只读网络不写缓存且不报错。 ⑥ 增量同步(FbSyncAll 重写) 先问 /api/v1/changes;指纹相同就跳过。本地没水位必须拉(否则老用户升级后 一个模块都不同步);24h 陈旧上限兜"改无 updated_at 表的已有行"; /changes 拿不到时判 ping status>0 —— 断网直接收工,不退回全量 (否则 12 个模块各等 15 秒)。 家谱也进了预同步列表(原来只在打开时拉,离线是一片空白); 总步数由 CACHE_MODULES.length 算出,不写死。 ⑦ 删除传播 服务端是物理删除、没有墓碑 ⇒ 观影同步改成"先清后插" (persons 跨模块共用,不清)。 ⑧ 顶栏同步进度 头像与铃铛之间加三态按钮(转圈 / 红感叹号 / 灰对勾)+ bindSheet 面板: 进度条、「N 个模块没有变化」、附件行、失败清单、立即同步(防连点)。 状态中心 FbSyncState(@ObservedV2 + @Trace,AppStorageV2 单例); 界面直接读 @Trace 字段,不提供派生 getter。 验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 290/0(本批新增 59 条); _fb_v58_falsify.py 31 项注入 38/0;b57 反向验证 12/0(锚点跟上本批 logout 改动); 本地 e2e 1519/0;全量流水线 PUSH_RC=0。
This commit is contained in:
@@ -17,9 +17,9 @@
|
||||
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
|
||||
import { fbImageUrl } from '../data/FbHttp';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { FbAvatar, FbRating } from './Widgets';
|
||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||
|
||||
/**
|
||||
* 照片缩略图的宽高比。
|
||||
@@ -250,7 +250,7 @@ export struct FbMovieDetailSheet {
|
||||
// ② 大图
|
||||
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
|
||||
// 裁掉边缘就失去意义(缩略图才用 Cover)。
|
||||
Image(fbImageUrl(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
|
||||
Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
|
||||
.width('100%')
|
||||
.height('76%')
|
||||
.objectFit(ImageFit.Contain)
|
||||
@@ -323,7 +323,7 @@ export struct FbMovieDetailSheet {
|
||||
Column({ space: 10 }) {
|
||||
// ---- 海报(大图,2:3)----
|
||||
if (d.posterKey !== '') {
|
||||
Image(fbImageUrl(d.posterKey, FB_TOKEN_STORE.get()))
|
||||
Image(fbImageSrc(d.posterKey, FB_TOKEN_STORE.get()))
|
||||
.width(140)
|
||||
.aspectRatio(0.75)
|
||||
.objectFit(ImageFit.Cover)
|
||||
@@ -462,7 +462,7 @@ export struct FbMovieDetailSheet {
|
||||
// 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半,
|
||||
// "看起来格式不对"。用户 2026-10-12 反馈的就是这个。
|
||||
Column() {
|
||||
Image(fbImageUrl(k, FB_TOKEN_STORE.get()))
|
||||
Image(fbImageSrc(k, FB_TOKEN_STORE.get()))
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.objectFit(ImageFit.Cover)
|
||||
|
||||
@@ -25,7 +25,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { FbApiOptions, FbApiResult, fbApi, fbApiBase, fbImageUrl } from '../data/FbHttp';
|
||||
import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from '../data/FbHttp';
|
||||
import { FbAvatar } from './Widgets';
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
@@ -33,6 +33,7 @@ import { picker } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FbTagPickChip } from './FbTagPickChip';
|
||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||
|
||||
/**
|
||||
* 贴底卡片的手势区缓冲(vp)。
|
||||
@@ -1057,7 +1058,7 @@ export struct FbPersonEditSheet {
|
||||
// 🔴 v55:**不再自己拼**「基址 + /media.php?...」——
|
||||
// 服务器地址已经是运行期可配置的(见 FbServerStore),
|
||||
// 自己拼的人漏改一处就会对着旧服务器要图片(表现为"换了服务器头像全裂")。
|
||||
return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get());
|
||||
return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get());
|
||||
}
|
||||
|
||||
private idCardHint(): string {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
|
||||
import { FbAvatar, FbTag } from './Widgets';
|
||||
import { fbImageUrl } from '../data/FbHttp';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||
|
||||
/**
|
||||
* 贴底卡片的手势区缓冲(vp)。
|
||||
@@ -171,14 +171,14 @@ export struct FbPersonSheet {
|
||||
* `FbAvatar` 的 `imageUrl` 缺省是空串,于是**所有人在详情里都退化成色块字**
|
||||
* (有头像的人也显示不出来)。这是纯粹的漏传参数,不是数据问题。
|
||||
* ⚠️ `avatar_url`(服务端下发的那个)是**相对路径**,`Image()` 直接用会
|
||||
* 打到 App 自己的包名上 ⇒ 必须用 `fbImageUrl(key, token)` 拼成
|
||||
* 打到 App 自己的包名上 ⇒ 必须用 `fbImageSrc(key, token)` 拼成
|
||||
* 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。
|
||||
*/
|
||||
private avatarUrl(): string {
|
||||
if (this.person === null) {
|
||||
return '';
|
||||
}
|
||||
return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get());
|
||||
return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get());
|
||||
}
|
||||
|
||||
/** 拼多行联系方式的显示串(`label: value`,label 空就只显示 value) */
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
|
||||
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
|
||||
import { FbAppState, fbAppState } from '../common/Store';
|
||||
import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore';
|
||||
import { fbModuleOf } from '../model/Modules';
|
||||
import { FbAvatar } from './Widgets';
|
||||
import { syncAll } from '../data/FbSyncAll';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbTopBar {
|
||||
@@ -27,6 +30,17 @@ export struct FbTopBar {
|
||||
/** 右侧额外操作(各模块页自己往里塞「新建」「搜索」等按钮) */
|
||||
@BuilderParam actions: () => void;
|
||||
|
||||
/**
|
||||
* 同步状态(顶栏图标 + 面板共用)。
|
||||
*
|
||||
* ⚠️ 用 `@Local` 持有单例引用(而不是每处都调 `fbSyncState()`):
|
||||
* `AppStorageV2.connect` 每次调用都会做一次连接查找,在 build 里反复调用很浪费;
|
||||
* 而且拿到的本来就是同一个实例。
|
||||
*/
|
||||
@Local sync: FbSyncState = fbSyncState();
|
||||
/** 同步面板是否打开 */
|
||||
@Local syncSheet: boolean = false;
|
||||
|
||||
build() {
|
||||
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条):
|
||||
// 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。
|
||||
@@ -75,6 +89,52 @@ export struct FbTopBar {
|
||||
// ---- 右:各模块自己的操作 ----
|
||||
this.actions()
|
||||
|
||||
/* 🔴 v59(用户 2026-10-17):「应该在那个右上角的那个头像和提醒前面加一个
|
||||
同步的进度的这样一个按钮或者这样一个图示」。
|
||||
⇒ 就放在这里:模块操作之后、铃铛之前。三态一眼可辨:
|
||||
· 同步中 → 转圈
|
||||
· 有失败 → 红色感叹号
|
||||
· 其余 → 灰色对勾("已是最新"或"还没同步",点开面板看详情)
|
||||
⚠️ 进度/状态**直接读 `this.sync` 的 @Trace 字段**,不要写成
|
||||
`this.syncTitle()` 这种方法调用 —— 方法里读 @Trace 不建立观察关系,
|
||||
图标会一直停在初始态(本项目在 @Builder 多值参数上栽过 6 次)。 */
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
if (this.sync.phase === SYNC_RUNNING) {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.color(FbColors.brand)
|
||||
} else if (this.sync.phase === SYNC_FAIL) {
|
||||
Text('!')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(FbColors.red)
|
||||
} else {
|
||||
Image(FbIcon.ui('check'))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(FbColors.muted)
|
||||
}
|
||||
}
|
||||
.width(36)
|
||||
.height(36)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.stateEffect(true)
|
||||
.onClick(() => {
|
||||
this.syncSheet = true;
|
||||
})
|
||||
.bindSheet(this.syncSheet, this.syncPanel(), {
|
||||
height: SheetSize.FIT_CONTENT,
|
||||
dragBar: true,
|
||||
showClose: false,
|
||||
backgroundColor: FbColors.surface,
|
||||
onDisappear: (): void => {
|
||||
// 点遮罩/下滑关闭时复位,否则下次点不开
|
||||
this.syncSheet = false;
|
||||
}
|
||||
})
|
||||
|
||||
// 通知铃铛(未读角标)
|
||||
Stack({ alignContent: Alignment.TopEnd }) {
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
@@ -147,4 +207,189 @@ export struct FbTopBar {
|
||||
const m = fbModuleOf(this.app.activeModule);
|
||||
return m !== undefined ? m.name : '家书';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步(面板里的按钮)。
|
||||
*
|
||||
* ⚠️ 已经在跑就直接返回:`syncAll` 自己不做并发保护,
|
||||
* 连点两下会并发拉同一批数据(浪费流量、还可能把水位写乱)。
|
||||
*/
|
||||
private runSync(): void {
|
||||
if (this.sync.isRunning()) {
|
||||
return;
|
||||
}
|
||||
syncAll().catch((err: Object): void => {
|
||||
hilog.warn(0x0000, 'FamilyBook', '手动同步失败:%{public}s', JSON.stringify(err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步面板(点顶栏图标弹出)。
|
||||
*
|
||||
* 🔴 面板要回答用户三个问题:**现在在干什么 / 上次什么时候 / 有没有出问题**。
|
||||
* 以及一个他一定会问的:**为什么这次这么快就同步完了** ——
|
||||
* 所以"跳过 N 个没有变化的模块"必须显式写出来,
|
||||
* 否则增量同步会被误认为"没同步"。
|
||||
*
|
||||
* ⚠️ 全部用表达式直接读 `this.sync.xxx`,不调用方法(见文件里那两处说明)。
|
||||
*/
|
||||
@Builder
|
||||
syncPanel() {
|
||||
Column({ space: 12 }) {
|
||||
// ---- 标题 + 状态徽标 ----
|
||||
Row({ space: 8 }) {
|
||||
Text('同步')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(FbColors.ink)
|
||||
Blank()
|
||||
if (this.sync.phase === SYNC_RUNNING) {
|
||||
Text('同步中')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.surface)
|
||||
.backgroundColor(FbColors.brand)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
} else if (this.sync.phase === SYNC_FAIL) {
|
||||
Text('有失败')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.surface)
|
||||
.backgroundColor(FbColors.red)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
} else if (this.sync.phase === SYNC_DONE) {
|
||||
Text('已是最新')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.green)
|
||||
.backgroundColor(FbColors.surfaceAlt)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
} else {
|
||||
Text('还没同步')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.backgroundColor(FbColors.surfaceAlt)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// ---- 进度条(只在同步中显示)----
|
||||
if (this.sync.phase === SYNC_RUNNING) {
|
||||
Progress({
|
||||
value: this.sync.done,
|
||||
total: this.sync.total > 0 ? this.sync.total : 1,
|
||||
type: ProgressType.Linear
|
||||
})
|
||||
.width('100%')
|
||||
.height(4)
|
||||
.color(FbColors.brand)
|
||||
Text('正在同步:' + (this.sync.current !== '' ? this.sync.current : '准备中')
|
||||
+ '(' + this.sync.done.toString() + ' / ' + this.sync.total.toString() + ')')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ---- 人话说明(上次结果 / 失败原因)----
|
||||
if (this.sync.lastNote !== '') {
|
||||
Text(this.sync.lastNote)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(this.sync.phase === SYNC_FAIL ? FbColors.red : FbColors.ink)
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ---- 统计 ----
|
||||
Row({ space: 6 }) {
|
||||
Text('上次同步')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
Text(this.sync.lastAt > 0 ? fbFmtTime(this.sync.lastAt) : '还没同步过')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.ink)
|
||||
Blank()
|
||||
// 🔴 增量生效的证据:这次有多少模块"因为没变"而没拉
|
||||
Text(this.sync.skipped > 0
|
||||
? (this.sync.skipped.toString() + ' 个模块没有变化')
|
||||
: '全部检查过')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.sync.attachTotal > 0 || this.sync.attachRunning) {
|
||||
Row({ space: 6 }) {
|
||||
Text('附件')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
Text(this.sync.attachDone.toString() + ' / ' + this.sync.attachTotal.toString()
|
||||
+ (this.sync.attachRunning ? ',正在下载…' : ',已下载'))
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.ink)
|
||||
Blank()
|
||||
Text(fbFmtBytes(this.sync.attachBytes))
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ---- 失败清单 ----
|
||||
if (this.sync.failed.length > 0) {
|
||||
Column({ space: 4 }) {
|
||||
ForEach(this.sync.failed, (name: string) => {
|
||||
Text('· ' + name + ' 没同步成功')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.red)
|
||||
.width('100%')
|
||||
}, (name: string) => name)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ---- 按钮 ----
|
||||
Button(this.sync.phase === SYNC_RUNNING ? '正在同步…' : '立即同步')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.surface)
|
||||
.backgroundColor(this.sync.phase === SYNC_RUNNING ? FbColors.faint : FbColors.brand)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.enabled(this.sync.phase !== SYNC_RUNNING)
|
||||
.onClick(() => {
|
||||
this.runSync();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 4, bottom: 20 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 毫秒时间戳 → `MM-DD HH:mm`。
|
||||
*
|
||||
* ⚠️ 为什么不显示年份:面板上只有"上次同步"这一处,
|
||||
* 一眼要看的是"多久以前",不是"哪一年"。
|
||||
*/
|
||||
function fbFmtTime(ms: number): string {
|
||||
const d: Date = new Date(ms);
|
||||
const two = (n: number): string => (n < 10 ? '0' + n.toString() : n.toString());
|
||||
return two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' '
|
||||
+ two(d.getHours()) + ':' + two(d.getMinutes());
|
||||
}
|
||||
|
||||
/** 字节 → 人话(`12.3 MB` / `860 KB` / `0 B`) */
|
||||
function fbFmtBytes(n: number): string {
|
||||
if (n <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
if (n < 1024) {
|
||||
return n.toString() + ' B';
|
||||
}
|
||||
if (n < 1024 * 1024) {
|
||||
return (n / 1024).toFixed(0) + ' KB';
|
||||
}
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user