Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbSyncAll.ets
T
laoyang 6e7cdaf5fc v74 端上删掉家谱 + 名册页头改「醒目搜索 + 添加」
用户 2026-10-21:「算了,你画不好。这个功能直接从鸿蒙端去掉吧,不要了。
这样名册这个功能,进来以后,上面就是搜索和添加按钮,搜索,做的醒目一点,
现在看不清。家谱和名册的标签,都不要了。家谱的相关功能,都删除。」

家谱链路整条删除(不是隐藏):
- 删 common/KinLayout.ets、components/FbKinGraph.ets 两个源文件
- 删 Types 的 FbKinPerson / FbKinGraph / FbKinRelation、FbParse 的 parseKin
- 删 FbModuleLoad 路由、FbModuleRepo 模块名、FbSyncAll 预同步清单里的 kin
- 删 DemoData 的 FB_DEMO_KIN_RELATIONS、Theme/Settings 的家谱图标映射
- 删死资源 fb_ic_kin.png
- Index 的 kin 分支去掉(不再有「名册/家谱」两个模块共用一个页面)
- FbPeoplePage:删 subView 分段切换、kinHeader/kinView、loadKin、segBtn;
  openDetail 不再借家谱字段兜底,改成纯 id 占位 + 异步补全
  (保留"本地没缓存也要立刻开浮层",那是 v73 修过的"点了没反应")

名册页头:
- 第一行就是搜索 + 添加,上面不再有任何东西
- 搜索框从「看不清」改成醒目:白底 #FFFFFF(原来 inputBg 与页面米色几乎同色
  ⇒ 没有边界)、1.5 主题色描边、高度 36→44、图标 15→18 并染主题色、
  字号 fontSmall→fontBody、提示词收短成「搜索姓名 / 首字母 / 手机」

验证:
- ArkTS 静态守卫 917/0(新增 6 条:家谱五条链路全清 + 搜索框三个具体数值)
- 反向验证 154/0(新增 7 条注入全部有效;另修好两条因代码演进失效的旧锚点)
- 编译 BUILD SUCCESSFUL + 装机 install bundle successfully
- 真机 v74 探针 22/0(高度实测 44.2vp、白底、占屏 66%、同排对齐、
  家谱在页面与抽屉里都不存在、搜索输入与清空真的生效)

顺带修掉探针三处假红(都不是应用问题,是量错了节点):
- 搜索框在界面树里是两层,TextInput(75px) 只是内部输入区,
  白底容器 Row(138px) 才是盒子;只按"有文字/可点"收集会把它漏掉
- uitest uiInput inputText 是 (x,y,text) 三参数,只给文本会静默不输入
- 空态文案是「换个关键词试试…」,按"没有/找到"判永远为假
2026-10-21 23:28:19 +08:00

474 lines
17 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 · 全量 / 增量同步(鸿蒙端)
*
* 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块,
* 就自动同步一个模块"):
* 之前每个页面**自己同步自己**,结果是"用户必须把每个页面都点一遍才拿到全量数据",
* 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,用户没法理解。
* ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。
*
* 🔴 v59(用户 2026-10-17)把它升级成**首次全量 / 之后增量**:
* 「登录后立即进入主页面,变同步变更新应用中的数据,后续增量同步也一样」。
* 做法见下面 `plan()`:
* ① 先打 `GET /api/v1/changes`,服务端给每个模块一个**变更指纹**
* (行数 + 最大 id + 最大 updated_at,见服务端 `changes()` 的说明);
* ② 指纹与本地 `module_sync` 里记的一样 ⇒ **这个模块根本没变,不拉**;
* ③ 不一样 / 本地没记录 ⇒ 拉。
* ⚠️ 为什么端上要"没记录就拉":老用户从 v58 之前的版本升上来时 `module_sync` 是空的,
* 第一条记录必须靠全量建立 —— 否则会"一个模块都不同步"。
*
* 🔴 **陈旧上限(`FORCE_FULL_AFTER_MS`)不是补丁,是这套方案的一部分**:
* 服务端有一半的表没有 `updated_at`(`movie_details` 等,见服务端注释),
* "改一张没有 updated_at 的表里已有的一行"**指纹察觉不到**。
* 所以"上次真正拉取"超过 24 小时就无条件全量一次。
* ⇒ 最坏情况:改了某个字段,最迟 24 小时后端上能看到。
* 对"家里看日程/看电影"这类数据,这个延迟是可以接受的;
* 而换来的是"日常打开 App 只拉变了的那一两个模块"。
*
* ⚠️ 它与"页面各自同步"**不冲突**,两者都要有:
* · 页面各自同步:保证"刚进这一页就有最新数据";
* · 全量同步:保证"不用点遍每一页"。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbMovieRepo, FbSyncResult } from './FbMovieRepo';
import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo';
import { FbDb, FbRow } from './FbDb';
import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo';
import { FB_MODULE_PATH } from './FbModuleLoad';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { FB_ATTACH } from './FbAttachQueue';
import { FbSyncState, fbSyncState } from '../common/FbSyncStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSyncAll';
/**
* 陈旧上限:距上次**真正拉取**超过这么久,就无条件全量一次。
* 理由见文件头"陈旧上限不是补丁"那段。
*/
const FORCE_FULL_AFTER_MS: number = 24 * 3600 * 1000;
/** 全量同步结果 */
export interface FbSyncAllResult {
ok: boolean;
/** 成功同步的模块数(**含**因"没变化"而跳过的) */
done: number;
/** 总模块数 */
total: number;
/** 失败模块名(用户看得到的人话) */
failed: string[];
/** 当前进度文案(同步中实时更新用) */
note: string;
/** 本次因"服务端说没变"而跳过的模块数(增量生效的证据) */
skipped: number;
}
/**
* 进度回调(可选)。
*
* 🔴 为什么还要保留这个参数(v59 已经有 `FbSyncState` 了):
* 登录页是"用户正盯着屏幕等首次同步"的场景,它需要一个**从函数里推出来**的回调;
* 而 `FbSyncState` 是给顶栏/面板这种"订阅制"的地方用的。两者不冲突。
*/
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
/** 模块的中文名(进度提示用) */
const MODULE_CN: Record<string, string> = {
'movies': '观影',
'roster': '名册',
'calendar': '日程',
'tasks': '任务',
'days': '纪念日',
'trips': '旅程',
'ledger': '账本',
'resources': '资源',
'books': '藏书',
'notes': '笔记',
'notifications': '通知',
'home': '首页',
};
/**
* 走通用缓存的模块(观影 / 名册有实体表,单独走)。
*
* ⚠️ v74:`kin`(家谱)已从端上删除,这里一并移除 —— 否则每次全量同步
* 都会去拉一个端上已经没有任何页面消费的接口。
*/
const CACHE_MODULES: string[] = [
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
'notifications',
];
/** `GET /api/v1/changes` 的响应 */
interface FbChangesData {
server_time?: string;
/** 模块名 → 指纹(null = 服务端判断不了) */
modules?: Record<string, string | null>;
all?: string;
}
interface FbChangesEnvelope {
ok?: boolean;
error?: string;
code?: string;
data?: FbChangesData;
}
/** 本地某个模块已同步到的状态 */
interface FbModMark {
fp: string;
fullAt: number;
}
export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
const token: string = FB_TOKEN_STORE.get();
const st: FbSyncState = fbSyncState();
if (token === '') {
st.failAll('未登录,无法同步');
const r: FbSyncAllResult = {
ok: false, done: 0, total: 1 + 1 + CACHE_MODULES.length + 1,
failed: [], note: '未登录,无法同步', skipped: 0,
};
return r;
}
// 总步数**由模块表算出来**(观影 + 名册 + 缓存模块 + 首页;跳过也占一步,百分比才准)。
// 🔴 别写死数字:v59 加 `kin` 时写死的 12 就会让进度条最后停在 11/12
// —— 写死常量的进度和真实的循环次数是两套账,迟早对不上。
const total: number = 1 + 1 + CACHE_MODULES.length + 1;
st.begin(total);
let done: number = 0;
const failed: string[] = [];
/** 跑一个模块并记账 */
const step = async (label: string, work: () => Promise<boolean>): Promise<void> => {
st.step(label, done, total);
if (onStep !== undefined) {
onStep(label, done, total);
}
try {
const ok: boolean = await work();
if (ok) {
done += 1;
} else {
failed.push(label);
done += 1;
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err));
failed.push(label);
done += 1;
}
};
/** 记一步"服务端说没变,跳过" */
const skip = (label: string): void => {
st.step(label + '(没变化)', done, total);
if (onStep !== undefined) {
onStep(label, done, total);
}
st.skipOne();
done += 1;
};
// ---- ① 问一句"哪些模块变了" ----
const marks: Map<string, FbModMark> = await loadMarks();
const fpResult: FbChangesData | null = await fetchChanges(token);
if (fpResult === null) {
/* `/changes` 没拿到:两种可能,处理方式**必须不同**。
① 真断网 → 立刻收工。不能退回全量:12 个模块各超时 15 秒 = 3 分钟白等,
用户会以为 App 卡死(这正是之前"同步很久"的观感来源)。
② 服务端是老版本(没这个接口,回 404)→ 退回"全部拉",功能不能因此断掉。
判据:再打一次 `/ping`(它不需要令牌、开销极小)。
连 `/ping` 都到不了 ⇒ 断网;`/ping` 有 HTTP 状态码 ⇒ 服务端在,只是没这接口。 */
if (!(await pingReachable())) {
const msg: string = '连不上服务器,等会儿自动重试';
st.failAll(msg);
const r: FbSyncAllResult = {
ok: false, done: 0, total: total, failed: [], note: msg, skipped: 0,
};
return r;
}
hilog.warn(DOMAIN, TAG, '服务端不支持变更指纹,退回全量同步');
}
/** 该查什么:true = 需要拉 */
const plan = (name: string): boolean => {
if (fpResult === null) {
return true; // 拿不到指纹 ⇒ 只能全量
}
const mods: Record<string, string | null> = fpResult.modules !== undefined ? fpResult.modules : {};
const serverFp: string | null = mods[name] !== undefined ? mods[name] : null;
if (serverFp === null || serverFp === '') {
return true; // 服务端判断不了这个模块 ⇒ 老实拉
}
const mark: FbModMark | undefined = marks.get(name);
if (mark === undefined) {
return true; // 本地没记录(新装/刚升级)⇒ 首次全量
}
if (Date.now() - mark.fullAt > FORCE_FULL_AFTER_MS) {
return true; // 陈旧上限到了(见文件头)
}
return mark.fp !== serverFp;
};
// ---- ② 观影 ----
let anyFetched: boolean = false; // 有任何模块真拉了 ⇒ 首页可能变
if (plan('movies')) {
anyFetched = true;
await step(MODULE_CN['movies'], async (): Promise<boolean> => {
const r: FbSyncResult = await FbMovieRepo.inst().sync(token);
if (r.ok) {
await saveMark('movies', fpOf(fpResult, 'movies'));
}
return r.ok;
});
} else {
skip(MODULE_CN['movies']);
}
// ---- ③ 名册 ----
if (plan('roster')) {
anyFetched = true;
await step(MODULE_CN['roster'], async (): Promise<boolean> => {
const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token);
if (r.ok) {
await saveMark('roster', fpOf(fpResult, 'roster'));
}
return r.ok;
});
} else {
skip(MODULE_CN['roster']);
}
// ---- ④ 其余 9 个模块(走通用缓存)----
const repo: FbModuleRepo = FbModuleRepo.inst();
for (const name of CACHE_MODULES) {
const label: string = MODULE_CN[name] !== undefined ? MODULE_CN[name] : name;
const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : '';
if (path === '') {
skip(label);
continue;
}
if (!plan(name)) {
skip(label);
continue;
}
anyFetched = true;
await step(label, async (): Promise<boolean> => {
const ok: boolean = await loadOne(repo, name, path);
if (ok) {
await saveMark(name, fpOf(fpResult, name));
}
return ok;
});
}
// ---- ⑤ 首页(派生页)----
/* home 不在服务端的指纹里(它由上面这些模块拼出来)。
规则:只要有任何一个模块真拉了,首页就可能变 ⇒ 跟着拉;
全都没变 ⇒ 首页也不会变 ⇒ 跳过。这样日常打开 App 首页也不会白拉一次。 */
if (anyFetched) {
const homePath: string = FB_MODULE_PATH['home'] !== undefined ? FB_MODULE_PATH['home'] : '';
if (homePath === '') {
skip(MODULE_CN['home']);
} else {
await step(MODULE_CN['home'], async (): Promise<boolean> => {
return await loadOne(repo, 'home', homePath);
});
}
} else {
skip(MODULE_CN['home']);
}
// ---- ⑥ 收尾:附件后台队列(不阻塞同步完成)----
collectAttachKeys().then((): void => {
// ⚠️ 不 await:附件可能几百个,主界面不该等它
FB_ATTACH.run();
}).catch((err: Object): void => {
hilog.warn(DOMAIN, TAG, '附件键收集失败:%{public}s', JSON.stringify(err));
});
const skipped: number = st.skipped;
const note: string = failed.length === 0
? (skipped > 0
? ('已是最新:' + done.toString() + ' 个模块,其中 ' + skipped.toString() + ' 个没有变化')
: ('全部 ' + done.toString() + ' 个模块同步完成'))
: (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、'));
st.failed = failed;
st.finish(note);
const result: FbSyncAllResult = {
ok: failed.length === 0,
done: done,
total: total,
failed: failed,
note: note,
skipped: skipped,
};
return result;
}
/* ═══════════════════════════════════════════════════════════════
内部工具
═══════════════════════════════════════════════════════════════ */
/** 拉一个模块的通用缓存(等网络那次回来才算结果) */
function loadOne(repo: FbModuleRepo, name: string, path: string): Promise<boolean> {
return new Promise<boolean>((resolve: (v: boolean) => void): void => {
let settled: boolean = false;
repo.load(name, path, (r: FbModuleLoad): void => {
if (r.fromCache || settled) {
return; // 缓存那次不算结果
}
settled = true;
resolve(r.error === '');
});
// 兜底超时(网络卡住时不能让全量同步永远挂着)
setTimeout((): void => {
if (!settled) {
settled = true;
resolve(false);
}
}, 20000);
});
}
/** 读本地 `module_sync` 记录 */
async function loadMarks(): Promise<Map<string, FbModMark>> {
const out: Map<string, FbModMark> = new Map<string, FbModMark>();
const db: FbDb = FbDb.inst();
if (!db.isReady()) {
return out;
}
try {
const rows: FbRow[] = await db.queryRows('SELECT name, fp, full_at FROM module_sync');
for (const r of rows) {
const m: FbModMark = { fp: r.strOr('fp', ''), fullAt: r.num('full_at', 0) };
out.set(r.strOr('name', ''), m);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '读同步水位失败(当作首次全量):%{public}s', JSON.stringify(err));
}
return out;
}
/** 写一条模块水位 */
async function saveMark(name: string, fp: string): Promise<void> {
const db: FbDb = FbDb.inst();
if (!db.isReady()) {
return;
}
try {
await db.exec(
'INSERT OR REPLACE INTO module_sync (name, fp, synced_at, full_at) VALUES (?,?,?,?)',
[name, fp, Date.now(), Date.now()]);
} catch (err) {
hilog.warn(DOMAIN, TAG, '写同步水位失败:%{public}s', JSON.stringify(err));
}
}
/** 从指纹结果里取某模块的指纹(拿不到返回空串 = 下次还会拉) */
function fpOf(data: FbChangesData | null, name: string): string {
if (data === null || data.modules === undefined) {
return '';
}
const v: string | null | undefined = data.modules[name];
return v !== undefined && v !== null ? v : '';
}
/**
* 打 `/changes`。
*
* @returns 数据(可能是 null:网络不通 / 接口不可用 —— 都由调用方退化为全量)
*/
async function fetchChanges(token: string): Promise<FbChangesData | null> {
const opts: FbApiOptions = { method: 'GET', token: token, authProbe: true };
const r: FbApiResult = await fbApi('/api/v1/changes', opts);
if (!r.ok) {
hilog.info(DOMAIN, TAG, '取变更指纹失败(%{public}s),本轮退化为全量', r.error);
return null;
}
try {
const env: FbChangesEnvelope = JSON.parse(r.data) as FbChangesEnvelope;
return env.data !== undefined ? env.data : null;
} catch (err) {
return null;
}
}
/**
* 服务端到底通不通(只看"能不能打到一个不需要令牌的接口")。
*
* ⚠️ 判据是 **HTTP 状态码是否大于 0**,不是"返回 ok":
* 老服务端打 `/changes` 会回 404(有状态码、没这个接口),那属于"服务端在,只是不支持";
* 真断网时 `fbApi` 给的 status 是 0(它在 catch 里返回 `status: 0`)。
* 用 `ok` 判会把"老服务端"误判成"断网",直接不同步了。
*/
async function pingReachable(): Promise<boolean> {
const opts: FbApiOptions = { method: 'GET', authProbe: true };
const r: FbApiResult = await fbApi('/api/v1/ping', opts);
return r.status > 0;
}
/**
* 把"已经落地的数据"里出现的附件键登记进队列。
*
* 来源三处(都在本地,不额外发请求):
* · `module_cache.payload` —— 9 个模块的完整 JSON(递归扫,见 `FbAttachQueue.collectFromJson`);
* · `movie_details.poster_key` / `ticket_key` —— 海报与票根;
* · `persons.avatar` —— 名册头像。
*/
async function collectAttachKeys(): Promise<void> {
const db: FbDb = FbDb.inst();
if (!db.isReady()) {
return;
}
const keys: string[] = [];
try {
const caches: FbRow[] = await db.queryRows('SELECT payload FROM module_cache');
for (const r of caches) {
FB_ATTACH.collectFromJson(r.strOr('payload', ''), keys);
}
const posters: FbRow[] = await db.queryRows(
"SELECT poster_key, ticket_key FROM movie_details "
+ "WHERE (poster_key IS NOT NULL AND poster_key <> '') "
+ " OR (ticket_key IS NOT NULL AND ticket_key <> '')");
for (const r of posters) {
const p: string = r.strOr('poster_key', '');
const t: string = r.strOr('ticket_key', '');
if (p !== '') {
keys.push(p);
}
if (t !== '') {
keys.push(t);
}
}
const avatars: FbRow[] = await db.queryRows(
"SELECT avatar FROM persons WHERE avatar IS NOT NULL AND avatar <> ''");
for (const r of avatars) {
const a: string = r.strOr('avatar', '');
if (a !== '') {
keys.push(a);
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '收集附件键失败:%{public}s', JSON.stringify(err));
}
// 去重后登记
const uniq: string[] = [];
const seen: Set<string> = new Set<string>();
for (const k of keys) {
if (!seen.has(k)) {
seen.add(k);
uniq.push(k);
}
}
const n: number = await FB_ATTACH.collectKeys(uniq);
hilog.info(DOMAIN, TAG, '发现附件 %{public}d 个(新登记 %{public}d)', uniq.length, n);
}