v90 新增观影卡与任务卡(2x2/2x4):卡片按类型分流推数据,key 带 version 前缀

This commit is contained in:
2026-10-24 23:44:46 +08:00
parent 7ebe18b2d1
commit 6711197a94
9 changed files with 1196 additions and 42 deletions
+68 -10
View File
@@ -53,7 +53,7 @@ import { preferences } from '@kit.ArkData';
import { formBindingData, formProvider } from '@kit.FormKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbCardData, fbCardBuild } from './FbCardData';
import { fbCardDataByKind } from './FbCardMediaData';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCardForms';
@@ -61,6 +61,10 @@ const TAG: string = 'FbCardForms';
const STORE_NAME: string = 'fb_card_forms';
const K_IDS: string = 'form_ids';
const K_OFF: string = 'day_offsets';
/** 🔴 v90:formId → 卡片类型(`calendar` / `movies` / `tasks`)。
* ⚠️ 旧卡(v90 之前登记的日程卡)没有这一项 ⇒ 读不到一律按 `calendar` 处理,
* 绝不能让升级把桌面上已有的日程卡弄瞎。 */
const K_KIND: string = 'form_kinds';
/** 翻页最多到前后一年(防外部输入把日期算成 Invalid Date) */
const MAX_OFFSET: number = 366;
@@ -181,6 +185,52 @@ class FbCardFormsImpl {
}
}
/* ---- 卡片类型(v90:formId → calendar / movies / tasks)----
结构与偏移量同构(键值对都落在一份字符串里),值是类型名而非数字。 */
private parseKinds(raw: string): Map<string, string> {
const map: Map<string, string> = new Map<string, string>();
for (const item of raw.split(SEP_ITEM)) {
if (item === '') {
continue;
}
const at: number = item.indexOf(SEP_KV);
if (at <= 0) {
continue;
}
map.set(item.substring(0, at), item.substring(at + 1));
}
return map;
}
private joinKinds(map: Map<string, string>): string {
const parts: string[] = [];
map.forEach((kind: string, id: string): void => {
parts.push(id + SEP_KV + kind);
});
return parts.join(SEP_ITEM);
}
/** 读某张卡的类型(读不到按 `calendar` —— 兼容 v90 之前登记的旧日程卡) */
async kindOf(ctx: common.Context, formId: string): Promise<string> {
const map: Map<string, string> = this.parseKinds(await this.readStr(ctx, K_KIND));
const v: string | undefined = map.get(formId);
return v === undefined || v === '' ? 'calendar' : v;
}
private async setKind(ctx: common.Context, formId: string, kind: string): Promise<void> {
const map: Map<string, string> = this.parseKinds(await this.readStr(ctx, K_KIND));
map.set(formId, kind);
await this.writeStr(ctx, K_KIND, this.joinKinds(map));
}
private async clearKind(ctx: common.Context, formId: string): Promise<void> {
const map: Map<string, string> = this.parseKinds(await this.readStr(ctx, K_KIND));
if (map.delete(formId)) {
await this.writeStr(ctx, K_KIND, this.joinKinds(map));
}
}
/* ---- 卡片注册表 ---- */
private async ids(ctx: common.Context): Promise<string[]> {
@@ -198,7 +248,7 @@ class FbCardFormsImpl {
await this.writeStr(ctx, K_IDS, list.join(SEP_ITEM));
}
async register(ctx: common.Context, formId: string): Promise<void> {
async register(ctx: common.Context, formId: string, kind: string): Promise<void> {
if (formId === '') {
return;
}
@@ -207,17 +257,21 @@ class FbCardFormsImpl {
list.push(formId);
await this.saveIds(ctx, list);
}
// 类型只增不改:同一张卡不会换类型(卡片类型由 form_config 决定,随 formId 固定)
await this.setKind(ctx, formId, kind);
}
async unregister(ctx: common.Context, formId: string): Promise<void> {
const list: string[] = (await this.ids(ctx)).filter((id: string): boolean => id !== formId);
await this.saveIds(ctx, list);
await this.clearKind(ctx, formId);
}
/**
* 应用内日程变化后刷新**所有**已添加的卡。
* 应用内数据变化后刷新**所有**已添加的卡。
*
* ⚠️ 按偏移量分组构建:两张卡可能翻在不同日期,逐张构建会重复打同一个接口。
* ⚠️ 按类型 + 偏移量分组构建:两张卡可能类型不同 / 翻在不同日期,
* 逐张构建会重复打同一个接口。
* ⚠️ 推失败的 formId 直接**注销**(卡片多半已被用户删掉),否则每刷一次都白推一遍。
*/
async pushAll(ctx: common.Context): Promise<void> {
@@ -233,16 +287,20 @@ class FbCardFormsImpl {
hilog.warn(DOMAIN, TAG, 'pushAll:注册表为空(桌面没有卡片,或跨进程没读到)');
return;
}
const cache: Map<number, formBindingData.FormBindingData> =
new Map<number, formBindingData.FormBindingData>();
const cache: Map<string, formBindingData.FormBindingData> =
new Map<string, formBindingData.FormBindingData>();
const stale: string[] = [];
for (const formId of list) {
/* 🔴 v90:每张卡按自己的类型构建(日程卡才吃偏移量;
观影/任务卡没有"看哪一天"的概念,偏移量传 0 即可)。 */
const kind: string = await this.kindOf(ctx, formId);
const off: number = await this.getOffset(ctx, formId);
let binding: formBindingData.FormBindingData | undefined = cache.get(off);
const ck: string = kind + '#' + off.toString();
let binding: formBindingData.FormBindingData | undefined = cache.get(ck);
if (binding === undefined) {
const data: FbCardData = await fbCardBuild(ctx, off);
const data: Object = await fbCardDataByKind(ctx, kind, off);
binding = formBindingData.createFormBindingData(data);
cache.set(off, binding);
cache.set(ck, binding);
}
try {
await formProvider.updateForm(formId, binding);
@@ -252,7 +310,7 @@ class FbCardFormsImpl {
stale.push(formId);
}
}
hilog.info(DOMAIN, TAG, 'pushAll:已推 %{public}d 张卡(%{public}d 种偏移)',
hilog.info(DOMAIN, TAG, 'pushAll:已推 %{public}d 张卡(%{public}d 种组合)',
list.length, cache.size);
if (stale.length > 0) {
const keep: string[] = list.filter((id: string): boolean => stale.indexOf(id) < 0);
+366
View File
@@ -0,0 +1,366 @@
/**
* 家书 FamilyBook · **观影 / 任务**桌面卡片数据(鸿蒙端)—— v90
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 照日程卡片(`FbCardData`)的既有范式:
* · FormExtensionAbility 回调只活约 10 秒 ⇒ 永远不抛错、立刻给壳;
* · `FormBindingData` 只认标量 ⇒ 列表一律 `JSON.stringify`;
* · 卡片是后台刷新 ⇒ `authProbe: true` 且显式带 token(401 不踢登录);
* · 🔴 v89 教训:卡片 ForEach 的 key **必须**带 `version`(`Date.now()`)前缀,
* 否则下次推送数据换了一批、key 却一样 ⇒ ArkUI 直接复用旧组件不重绘。
*
* 与日程卡的一个差异:这两类卡**没有翻页**(观影/任务没有"看哪一天"的概念),
* `dayOffset` 对它们无意义 ⇒ 构建时不吃偏移,`FbFormAbility` 按类型分流即可。
*/
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FB_SERVER_STORE } from './FbServerStore';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
import { fbCardBuild, FbCardData } from './FbCardData';
import { parseTaskList } from './FbParse';
import { FbTaskItem } from '../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCardMedia';
/** 两位补零(与 FbCardData 同口径,卡片时间戳用) */
function pad2(v: number): string {
return v < 10 ? '0' + v.toString() : v.toString();
}
/** 当前时刻 `HH:mm` */
function clockText(): string {
const d: Date = new Date();
return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
/** `str | null | undefined` → `str`(服务端行里 null 很常见) */
function strOf(v: string | null | undefined): string {
return typeof v === 'string' ? v : '';
}
/** `number | null | undefined` → `number` */
function numOf(v: number | null | undefined): number {
return typeof v === 'number' && !isNaN(v) ? v : 0;
}
/** 卡片环境预加载 + 取令牌(三个动作一起做,失败给空串) */
async function cardToken(ctx: common.Context): Promise<string> {
try {
await FB_SERVER_STORE.init(ctx);
await FB_TOKEN_STORE.init(ctx);
} catch (err) {
hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err));
}
return FB_TOKEN_STORE.get();
}
/* ══════════════════════════════════════════════════════════════
观影卡
══════════════════════════════════════════════════════════════ */
/** 观影卡上的一行(最近观影) */
export class FbMovieCardRow {
key: string = '';
title: string = '';
/** `10月24日`(解析不出就空着,卡片不画日期) */
dateText: string = '';
cinema: string = '';
/** 家庭评分 `★ 4.5`(没评分为空串) */
rating: string = '';
}
/** 观影卡整体数据(字段名 = 卡片页 `@LocalStorageProp` 键名,两侧改名必须同步) */
export class FbMovieCardData {
/** `ok` / `login` / `noserver` / `offline` / `loading` */
state: string = 'loading';
hint: string = '';
/** `15:40 更新` */
updatedAt: string = '';
/** 今年看过的场数(2×2 的大数字) */
yearCount: number = 0;
/** 本次拉到的总场数(服务端分页最多 200 条,够用) */
totalCount: number = 0;
/** 最近观影(按日期倒序,最多 4 条) */
recentJson: string = '[]';
/** 🔴 v89 教训:ForEach key 前缀源 */
version: string = '';
}
/** 服务端观影列表的原始行(只取卡片要用的几个字段) */
interface FbRawMovie {
id?: number | null;
title?: string | null;
starts_at?: string | null;
cinema_name?: string | null;
review_count?: number | null;
review_avg?: number | null;
}
/** `starts_at` → `10月24日`(解析不出给空串) */
function movieDateText(startsAt: string): string {
// `YYYY-MM-DD HH:MM:SS`
if (startsAt.length < 10) {
return '';
}
const mm: string = startsAt.substring(5, 7);
const dd: string = startsAt.substring(8, 10);
const m: number = parseInt(mm, 10);
const d: number = parseInt(dd, 10);
if (isNaN(m) || isNaN(d) || m < 1 || m > 12 || d < 1 || d > 31) {
return '';
}
return m.toString() + '月' + d.toString() + '日';
}
/** 一条原始行 → 卡片行 */
function movieRowOf(m: FbRawMovie, idx: number): FbMovieCardRow {
const r: FbMovieCardRow = new FbMovieCardRow();
const id: number = numOf(m.id);
r.key = 'm' + idx.toString() + '_' + id.toString();
r.title = strOf(m.title) !== '' ? strOf(m.title) : '未命名观影';
r.dateText = movieDateText(strOf(m.starts_at));
r.cinema = strOf(m.cinema_name);
const avg: number = numOf(m.review_avg);
r.rating = avg > 0 ? '★ ' + avg.toFixed(1) : '';
return r;
}
/**
* 组装观影卡数据(异步,**永远不抛错**)。
*
* ⚠️ 直接拉 `/api/v1/movies`(与同步同一条接口,per_page=200 一次拿全):
* 卡片进程不能也不该走应用里的本地库(FbDb 是应用进程的单例)。
*/
export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCardData> {
const out: FbMovieCardData = new FbMovieCardData();
out.updatedAt = clockText();
out.version = Date.now().toString();
const token: string = await cardToken(ctx);
if (FB_SERVER_STORE.get() === '') {
out.state = 'noserver';
out.hint = '先在「家书」里填写服务器地址';
return out;
}
if (token === '') {
out.state = 'login';
out.hint = '先在「家书」里登录';
return out;
}
const query: Record<string, string> = {};
query['per_page'] = '200';
query['page'] = '1';
const opts: FbApiOptions = { method: 'GET', query: query, token: token, authProbe: true };
const r: FbApiResult = await fbApi('/api/v1/movies', opts);
if (!r.ok) {
out.state = 'offline';
out.hint = r.status === 401 ? '登录已失效,请重新登录'
: (r.error !== '' ? r.error : '连不上服务器');
return out;
}
// 解析(解析失败按"暂时没数据"处理,不抛)
let arr: FbRawMovie[] = [];
try {
arr = JSON.parse(r.data) as FbRawMovie[];
} catch (err) {
hilog.warn(DOMAIN, TAG, '观影卡数据解析失败');
}
if (!Array.isArray(arr)) {
arr = [];
}
// 按观看日期倒序(服务端顺序不保证,卡片要"最近几部"就必须自己排;
// ISO 日期字符串可直接按字典序比较)
arr.sort((a: FbRawMovie, b: FbRawMovie): number => {
const sa: string = strOf(a.starts_at);
const sb: string = strOf(b.starts_at);
if (sa === sb) {
return 0;
}
return sa > sb ? -1 : 1;
});
const nowYear: string = (new Date()).getFullYear().toString();
let yearCount: number = 0;
for (const m of arr) {
if (strOf(m.starts_at).substring(0, 4) === nowYear) {
yearCount++;
}
}
out.yearCount = yearCount;
out.totalCount = arr.length;
const rows: FbMovieCardRow[] = [];
for (let i = 0; i < arr.length && rows.length < 4; i++) {
rows.push(movieRowOf(arr[i], i));
}
out.recentJson = JSON.stringify(rows);
out.state = 'ok';
out.hint = '';
return out;
}
/* ══════════════════════════════════════════════════════════════
任务卡
══════════════════════════════════════════════════════════════ */
/** 任务卡上的一行 */
export class FbTaskCardRow {
key: string = '';
title: string = '';
/** 截止时间文案(服务端给的显示文本,空 = 没有期限) */
dueText: string = '';
/** `todo` / `doing`(done 的不进卡片) */
status: string = '';
/** 看板主题色(左侧色条) */
boardColor: string = '#A8574F';
overdue: boolean = false;
}
/** 任务卡整体数据 */
export class FbTaskCardData {
state: string = 'loading';
hint: string = '';
updatedAt: string = '';
/** 未完成任务数(todo + doing) */
openCount: number = 0;
/** 其中逾期数(标题行给红色提示) */
overdueCount: number = 0;
/** 优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */
rowsJson: string = '[]';
version: string = '';
}
/** 卡片展示优先级:逾期 0 / 进行中 1 / 普通 2 */
function taskRank(t: FbTaskItem): number {
if (t.overdue) {
return 0;
}
return t.status === 'doing' ? 1 : 2;
}
/** 一条任务 → 卡片行 */
function taskRowOf(t: FbTaskItem): FbTaskCardRow {
const r: FbTaskCardRow = new FbTaskCardRow();
r.key = 't' + t.id.toString() + '_' + t.status;
r.title = t.title !== '' ? t.title : '未命名任务';
r.dueText = t.dueText;
r.status = t.status;
r.boardColor = t.boardColor !== '' ? t.boardColor : '#A8574F';
r.overdue = t.overdue;
return r;
}
/**
* 组装任务卡数据(异步,**永远不抛错**)。
*
* ⚠️ `/api/v1/tasks` 默认视图就是"未完成任务"(已完成走 `?view=done`);
* 这里仍按 status 过滤一道,服务端口径将来变了也不会把 done 显示成待办。
*/
export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardData> {
const out: FbTaskCardData = new FbTaskCardData();
out.updatedAt = clockText();
out.version = Date.now().toString();
const token: string = await cardToken(ctx);
if (FB_SERVER_STORE.get() === '') {
out.state = 'noserver';
out.hint = '先在「家书」里填写服务器地址';
return out;
}
if (token === '') {
out.state = 'login';
out.hint = '先在「家书」里登录';
return out;
}
const opts: FbApiOptions = { method: 'GET', token: token, authProbe: true };
const r: FbApiResult = await fbApi('/api/v1/tasks', opts);
if (!r.ok) {
out.state = 'offline';
out.hint = r.status === 401 ? '登录已失效,请重新登录'
: (r.error !== '' ? r.error : '连不上服务器');
return out;
}
let items: FbTaskItem[] = [];
try {
items = parseTaskList(r.data);
} catch (err) {
hilog.warn(DOMAIN, TAG, '任务卡数据解析失败');
}
const open: FbTaskItem[] = [];
for (const t of items) {
if (t.status !== 'done') {
open.push(t);
}
}
out.openCount = open.length;
let overdueCount: number = 0;
for (const t of open) {
if (t.overdue) {
overdueCount++;
}
}
out.overdueCount = overdueCount;
// 逾期优先,其次进行中,再普通;同组按截止时间升序(没有期限的排最后)
open.sort((a: FbTaskItem, b: FbTaskItem): number => {
const ra: number = taskRank(a);
const rb: number = taskRank(b);
if (ra !== rb) {
return ra - rb;
}
if (a.dueAt === b.dueAt) {
return 0;
}
if (a.dueAt === '') {
return 1;
}
if (b.dueAt === '') {
return -1;
}
return a.dueAt < b.dueAt ? -1 : 1;
});
const rows: FbTaskCardRow[] = [];
for (let i = 0; i < open.length && rows.length < 4; i++) {
rows.push(taskRowOf(open[i]));
}
out.rowsJson = JSON.stringify(rows);
out.state = 'ok';
out.hint = '';
return out;
}
/* ══════════════════════════════════════════════════════════════
按卡片类型分流
══════════════════════════════════════════════════════════════ */
/**
* 按卡片类型构建数据 —— `FbFormAbility` 与 `FbCardForms.pushAll` 共用这一个入口。
*
* @param kind `calendar` / `movies` / `tasks`(认不出按日程处理 ——
* 兼容升级前已登记的旧卡片,它们的 kind 没落盘,只能默认日程)
* @param dayOffset 仅日程卡用(翻页);其余类型忽略
*/
export async function fbCardDataByKind(ctx: common.Context, kind: string,
dayOffset: number): Promise<Object> {
if (kind === 'movies') {
const m: FbMovieCardData = await fbMovieCardBuild(ctx);
return m;
}
if (kind === 'tasks') {
const t: FbTaskCardData = await fbTaskCardBuild(ctx);
return t;
}
const d: FbCardData = await fbCardBuild(ctx, dayOffset);
return d;
}