v95 服务卡片四项:日程卡「今」按钮放大、观影/任务 2x4 列表可滑动、观影 4x4/6x4 补列间距且 6x4 改两列

#1

用户提四项(2026-10-26):
  「日程的右上角那个今天的今,特别不好点,一不小心就到日程里面去了……
    是不是可以把按钮弄的大一点。」
  「观影的卡片,2x4,右侧观影的列表,不能上下滑动,第三个被截断,很难看,
    如果显示多个,就要能滑动,如果不能滑动,就显示两个。」
  「任务的 2x4 卡片,也是一样的。」
  「观影的 4x4 卡片,和 4x6 卡片,两列图片挤在一起,中间没有空隙,应该缩小一点,
    这个大小,我感觉应该根据屏幕的大小来。4x6,宽度都是一样的,为啥要弄成三列?」

① 日程卡「今」按钮 22x22 → 32x32
   病根:22 的圆比最小建议点击区小一半,手指偏一点就落到卡片本体的
   `.onClick(openApp)` 上 ⇒ 「想回今天」变成「进了日程详情」。
   面积 484 → 1024(2.1 倍);同一份 sideBtn() 里的「+」一并放大,
   翻页键 ‹ › 本来 32,现在四个按钮手感一致。
   为什么是 32 不是 40:4x4 卡高度紧,顶栏与内容区竖向堆叠
   (内容区 layoutWeight(1)),顶栏长高多少内容区就少多少。
   必须改 width/height 本身 —— 卡片里这是 Button,可点区就是自己的宽高。

②③ 观影/任务 2x4 右半 Column → List(可滚动)
   病根不在条数而在容器:Column 根本不滚动 + 硬截 3 条,
   3x(38+10=48) + 2x6 = 156vp 而可用仅 ≈130vp ⇒ 第三条被压扁/裁掉。
   条数上限 3 → MR_2X4_MAX=12 / TK_2X4_MAX=30;滚动条 BarState.Auto
   (条目少时不露空滚动条)。数据侧 FbCardMediaData 两处 `rows.length < 4`
   同步放到 30,否则卡上拿到的还是 4 条,改了容器也滑不出东西。

④ 观影 4x4 / 6x4 补列间距 + 6x4 三列改两列
   病根:`List({ space: 8 })` 的 space 在 lanes 多列模式下只管行间距,
   列与列之间不会自动留缝;海报格 .width('100%') + .aspectRatio(0.68)
   又贴着 lane 边排 ⇒ 两列严丝合缝贴死。
   改法:.lanes(2) → .lanes(2, 8),第二个位置参数才是列间距 gutter。
   ⚠️ gutter 是位置参数(SDK list.d.ts: lanes(value, gutter?: Dimension),
      Dimension = PX|VP|FP|LPX|Percentage|Resource)⇒ 传数字,
      写成 { gutter: 8 } 是类型错误(中途踩到,编译期才炸)。
   6x4 同时从 .lanes(3) 改成两列,与 4x4 一致;文件头那句
   「这块卡更宽、一排放三张」已过时,已注明别再照着它改回去。

同批带上 v92 未提交的观影海报墙实现(此前只在工作树里,从未提交过):
  - 新增 data/FbCardImages.ets(下载 + desiredSize 缩图 + fd 管理)
  - FbCardMediaData / FbFormAbility / FbCardForms 里的 formImages 管道
  - string.json 卡片名带尺寸(「家书 · 日程 4×4」/「家书 · 观影 4×4」)
  ⚠️ FbCardImages.ets 已被 FbCardMediaData import,不一起提交就编译不过。

验证:
  - 静态守卫 _fb_arkts_guard.js:1303 通过 / 0 失败
    (新增 groupV95() 共 28 条,钉住按钮尺寸、List 容器、条数上限、
      两列 + gutter>0、且不许写成 { gutter: N } 对象)
  - 反向验证 _fb_v59d_falsify.py:289 通过 / 0 失败
    (新增 9 条注入,逐条证明上述断言真的会红)
  - 真编译 hvigorw assembleHap:BUILD SUCCESSFUL,HAP 产物已生成

⚠️ 真机验收未做:hdc list targets 为空(设备未连/未解锁)。
   这四项全是观感与手感,静态断言证明不了「点得着、滑得动、看着舒服」,
   待设备连上后补真机探针(重点:真的点「今」、真的滑列表、量两列实际间距)。
This commit is contained in:
2026-10-26 07:28:06 +08:00
parent 7ae34e6e23
commit b84ea06b73
11 changed files with 732 additions and 169 deletions
+10 -1
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 { fbCardDataByKind } from './FbCardMediaData';
import { fbCardDataByKind, fbCloseImagesOf } from './FbCardMediaData';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCardForms';
@@ -289,6 +289,10 @@ class FbCardFormsImpl {
}
const cache: Map<string, formBindingData.FormBindingData> =
new Map<string, formBindingData.FormBindingData>();
/* 🔴 v92:本次推送构建出来的数据对象(图片 fd 挂在里面)。
⚠️ 必须**全部推完**再统一关闭:同一个组合的 binding 会复用给多张卡,
推一张就关 fd 会让后面那几张卡取不到图。 */
const built: Object[] = [];
const stale: string[] = [];
for (const formId of list) {
/* 🔴 v90:每张卡按自己的类型构建(日程卡才吃偏移量;
@@ -301,6 +305,7 @@ class FbCardFormsImpl {
const data: Object = await fbCardDataByKind(ctx, kind, off);
binding = formBindingData.createFormBindingData(data);
cache.set(ck, binding);
built.push(data);
}
try {
await formProvider.updateForm(formId, binding);
@@ -310,6 +315,10 @@ class FbCardFormsImpl {
stale.push(formId);
}
}
// 图片 fd 用完即关(应用进程是长活进程,不关会一路攒着)
for (const d of built) {
fbCloseImagesOf(d);
}
hilog.info(DOMAIN, TAG, 'pushAll:已推 %{public}d 张卡(%{public}d 种组合)',
list.length, cache.size);
if (stale.length > 0) {
+270
View File
@@ -0,0 +1,270 @@
/**
* 家书 FamilyBook · **卡片图片**(鸿蒙端)—— v92
*
* 用户 2026-10-25:「观影应该显示图片,左侧应该显示图片。实际上应该用大图的形式显示,
* 就做成和观影模式一样,不行吗?」
*
* 🔴🔴 为什么不能直接在卡片里写 `Image('https://…')`:
* `Image` 组件的说明里写着「**ArkTS 卡片上不支持 http:// 等网络相关路径前缀
* 和 file:// 路径前缀的字符串**」,`PixelMap` 也不支持直传。
* ⇒ 卡片要显示网络图,**只能**由 FormExtensionAbility 把图取下来,
* 以 `formImages`(键 → fd)交给卡片,卡片再用 `Image('memory://<键>')` 取。
* 这是官方示例与多份文档一致的口径,别想着绕。
*
* 🔴 为什么还要在本机做一次「缩小 + 转 jpg」,而不是直接把原文件 fd 塞进去:
* 线上海报实测 **110 KB ~ 1.1 MB**,解码成位图是 `宽×高×4` 字节
* (600×900 就是 2.1 MB/张)。一张卡放 6 张 ⇒ 十几 MB 常驻卡片进程,
* 卡片渲染进程被拖垮的表现是**整张卡白掉**,而且极难联想到图片。
* ⇒ 只解码一次、缩到 240×360(约 0.33 MB/张)、存成小 jpg,
* 以后每次刷新只开 fd、不再解码也不再联网。
*
* ⚠️ 三条铁律(与卡片其它数据侧同口径):
* ① **永不抛错**:卡片进程被回收最多"这次没图",绝不能让卡片白掉;
* ② 缓存目录自己管:文件名 = 海报键 basename,超过 24 个删最旧的
* (不清理会无限长,用户的手机上这是实打实的垃圾);
* ③ fd 必须**用完就关**:`formProvider.updateForm` 完成后由调用方
* `fbCloseCardImageFds()` 统一关闭(与官方 FAQ 示例一致)。
*/
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCardImg';
/** 缓存目录(`filesDir/cardimg`):跟着应用数据走,卸载即清 */
const DIR_NAME: string = 'cardimg';
/** 缩略图尺寸:卡片上最大也就 ~150vp 宽(≈ 470px@3.125x),240×360 足够 */
const THUMB_W: number = 240;
const THUMB_H: number = 360;
/** 目录里最多留多少个文件(每部电影一张,24 部够翻一大阵子) */
const MAX_FILES: number = 24;
function dirOf(ctx: common.Context): string {
return ctx.filesDir + '/' + DIR_NAME;
}
/** 海报键 `posters/xxx_suffix.webp` → 缓存文件名 `xxx_suffix.jpg` */
function cacheName(posterKey: string): string {
const slash: number = posterKey.lastIndexOf('/');
let base: string = slash >= 0 ? posterKey.substring(slash + 1) : posterKey;
const dot: number = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
// 只留安全字符:文件名直接来自服务端,不做过滤的话 "/"、".." 会很麻烦
let out: string = '';
for (let i = 0; i < base.length; i++) {
const c: string = base.charAt(i);
const ok: boolean = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || c === '_' || c === '-';
out += ok ? c : '_';
}
return out === '' ? '' : out + '.jpg';
}
/** 确保目录存在 */
function ensureDir(dir: string): void {
try {
if (!fileIo.accessSync(dir)) {
fileIo.mkdirSync(dir);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '建卡片图片目录失败:%{public}s', JSON.stringify(err));
}
}
/** 文件存在且非空 */
function hasFile(path: string): boolean {
try {
if (!fileIo.accessSync(path)) {
return false;
}
return fileIo.statSync(path).size > 0;
} catch (err) {
return false;
}
}
/** 目录里超过 MAX_FILES 就删最旧的几个(按 mtime) */
function pruneDir(dir: string): void {
try {
const names: string[] = fileIo.listFileSync(dir);
if (names.length <= MAX_FILES) {
return;
}
const rows: string[][] = [];
for (const n of names) {
const p: string = dir + '/' + n;
let mt: number = 0;
try {
mt = fileIo.statSync(p).mtime * 1000;
} catch (err) {
mt = 0;
}
rows.push([p, mt.toString()]);
}
rows.sort((a: string[], b: string[]): number => {
return parseInt(a[1], 10) - parseInt(b[1], 10);
});
const drop: number = names.length - MAX_FILES;
for (let i = 0; i < drop && i < rows.length; i++) {
try {
fileIo.unlinkSync(rows[i][0]);
} catch (err) {
// 单个删不掉就算了
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '清理卡片图片目录失败:%{public}s', JSON.stringify(err));
}
}
/** 下载一张图(失败返回 null) */
async function download(url: string): Promise<ArrayBuffer | null> {
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
connectTimeout: 6000,
readTimeout: 10000
});
if (resp.responseCode !== 200) {
return null;
}
const body: string | Object | ArrayBuffer = resp.result;
if (body instanceof ArrayBuffer) {
return body as ArrayBuffer;
}
return null;
} catch (err) {
hilog.warn(DOMAIN, TAG, '下载海报失败:%{public}s', JSON.stringify(err));
return null;
} finally {
// ⚠️ 必须销毁:每次 createHttp 都占一个连接对象
req.destroy();
}
}
/**
* 把原图缩成小 jpg 落盘(只做一次,之后命中缓存直接返回)。
*
* @return 落盘后的本地路径;失败返回空串(卡片就只显示文字,不会白)
*/
async function makeThumb(buf: ArrayBuffer, outPath: string): Promise<string> {
const tmp: string = outPath + '.tmp';
let src: image.ImageSource | null = null;
let pm: image.PixelMap | null = null;
try {
src = image.createImageSource(buf);
/* ⚠️ 必须给 desiredSize:不给就是把原图整张解出来(600×900×4 ≈ 2.1 MB),
卡片进程同时解 6 张直接十几 MB —— 表现是卡片白掉。 */
pm = await src.createPixelMap({
desiredSize: { width: THUMB_W, height: THUMB_H }
});
const fdObj: fileIo.File = fileIo.openSync(tmp,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
try {
const packer: image.ImagePacker = image.createImagePacker();
await packer.packToFile(pm, fdObj.fd, { format: 'image/jpeg', quality: 80 });
} finally {
fileIo.closeSync(fdObj);
}
// 先写 .tmp 再改名:中途被杀不会留下半张图(半张图 = 卡片上永远一个灰块)
fileIo.renameSync(tmp, outPath);
return outPath;
} catch (err) {
hilog.warn(DOMAIN, TAG, '缩图失败:%{public}s', JSON.stringify(err));
try {
if (fileIo.accessSync(tmp)) {
fileIo.unlinkSync(tmp);
}
} catch (e) { /* ignore */ }
return '';
} finally {
if (pm !== null) {
try {
await pm.release();
} catch (err) { /* ignore */ }
}
if (src !== null) {
try {
await src.release();
} catch (err) { /* ignore */ }
}
}
}
/**
* 取一张海报的**本地小图**(没有就下载并缩一次)。
*
* @param posterKey 服务端海报键(`posters/xxx.webp`)
* @param url 带令牌的可下载地址;空串表示不下载(只查缓存)
* @return 本地路径,失败空串
*/
export async function fbCardImagePath(ctx: common.Context, posterKey: string,
url: string): Promise<string> {
if (posterKey === '') {
return '';
}
const name: string = cacheName(posterKey);
if (name === '') {
return '';
}
const dir: string = dirOf(ctx);
const path: string = dir + '/' + name;
if (hasFile(path)) {
return path;
}
if (url === '') {
return '';
}
ensureDir(dir);
const buf: ArrayBuffer | null = await download(url);
if (buf === null) {
return '';
}
const made: string = await makeThumb(buf, path);
if (made !== '') {
pruneDir(dir);
}
return made;
}
/** 打开本地小图并返回 fd(失败 -1) */
export function fbCardImageFd(path: string): number {
if (path === '') {
return -1;
}
try {
return fileIo.openSync(path, fileIo.OpenMode.READ_ONLY).fd;
} catch (err) {
hilog.warn(DOMAIN, TAG, '打开卡片图片失败:%{public}s', JSON.stringify(err));
return -1;
}
}
/**
* 关闭本次推送用的所有 fd —— `updateForm` 之后由调用方调用。
*
* ⚠️ 卡片渲染侧会自己 dup 这些 fd(官方 FAQ 示例同样是"推完就 close"),
* 不关的话:`pushAll` 可能在**应用主进程**里跑,fd 会一路攒着。
*/
export function fbCloseCardImageFds(fds: number[]): void {
for (const fd of fds) {
if (fd < 0) {
continue;
}
try {
fileIo.closeSync(fd);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '关闭卡片图片 fd 失败:%{public}s', e.message);
}
}
}
+131 -15
View File
@@ -17,7 +17,8 @@ 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 { fbApi, fbImageUrl, FbApiResult, FbApiOptions } from './FbHttp';
import { fbCardImagePath, fbCardImageFd, fbCloseCardImageFds } from './FbCardImages';
import { fbCardBuild, FbCardData } from './FbCardData';
import { parseTasks } from './FbParse';
import { setTaskStatus } from './FbTaskRepo';
@@ -71,6 +72,11 @@ export class FbMovieCardRow {
cinema: string = '';
/** 家庭评分 `★ 4.5`(没评分为空串) */
rating: string = '';
/** 服务端海报键(`posters/xxx.webp`;没有海报是空串) */
poster: string = '';
/** 🔴 v92:卡片取图用的**内存键**(卡片写 `Image('memory://' + img)`);
* 空串 = 没有可显示的图,卡片画占位。 */
img: string = '';
}
/** 观影卡整体数据(字段名 = 卡片页 `@LocalStorageProp` 键名,两侧改名必须同步) */
@@ -88,6 +94,16 @@ export class FbMovieCardData {
recentJson: string = '[]';
/** 🔴 v91:4×4/6×4 的滚动列表(按日期倒序,最多 30 条)—— 4×2 的卡不吃这个字段 */
listJson: string = '[]';
/**
* 🔴 v92:本次推送的图片表(内存键 → 文件 fd)。
*
* ⚠️ 字段名 `formImages` **是 FormKit 的固定约定,不能改名**:
* 卡片侧 `Image('memory://p0')` 就是靠它去共享内存里找图。
* ⚠️ 卡片里**不能**直接写 http 地址(官方限制),必须走这条 fd 通道。
*/
formImages: Record<string, number> = {};
/** 本次打开的 fd(推完由调用方统一 `fbCloseCardImageFds` 关闭) */
imgFds: number[] = [];
/** 🔴 v89 教训:ForEach key 前缀源 */
version: string = '';
}
@@ -100,6 +116,8 @@ interface FbRawMovie {
cinema_name?: string | null;
review_count?: number | null;
review_avg?: number | null;
/** `posters/xxx.jpg`(v92 起卡片要显示海报) */
poster_key?: string | null;
}
/** `starts_at` → `10月24日`(解析不出给空串) */
@@ -128,6 +146,7 @@ function movieRowOf(m: FbRawMovie, idx: number): FbMovieCardRow {
r.cinema = strOf(m.cinema_name);
const avg: number = numOf(m.review_avg);
r.rating = avg > 0 ? '★ ' + avg.toFixed(1) : '';
r.poster = strOf(m.poster_key);
return r;
}
@@ -198,18 +217,34 @@ export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCard
out.yearCount = yearCount;
out.totalCount = arr.length;
/* 🔴🔴 v95(用户 2026-10-26):2×4 卡的右半改成**可滚动**之后,条数不能再只给 4 条 ——
给 4 条的话,List 最多只能滑到第 4 条,用户滑两下就到底,
"能滑动"形同虚设(数据侧已经先砍掉了,卡片再怎么改也变不出来)。
⇒ 与 4×4/6×4 的滚动列表**合并成同一份**(它们本来就是"倒序全量"的同一个东西:
v91 为 4×4/6×4 建的 `list` 就是 30 条倒序,与原来给 2×4 的 `rows` 前几条完全一致)。
合成一份之后 `recentJson` 与 `listJson` 只差一个 `slice` 的名字,
不会多一次请求、也不会多一张图(配图只给前 6 条,见下面 fbAttachPosters 的说明)。
⚠️ 别把上限调成 `arr.length`:真有人存 200 条时,FormBindingData 序列化会明显变大。
⚠️ 也别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。 */
const rows: FbMovieCardRow[] = [];
for (let i = 0; i < arr.length && rows.length < 4; i++) {
for (let i = 0; i < arr.length && rows.length < 30; i++) {
rows.push(movieRowOf(arr[i], i));
}
out.recentJson = JSON.stringify(rows);
/* 同一个数组,两个名字:`rows` 给 2×2/2×4(滑动列表),`list` 给 4×4/6×4(网格)。
不要各自再 for 一遍 —— 那是同一个循环写两次,改上限时极易只改一处。 */
const list: FbMovieCardRow[] = rows;
/* v91:4×4/6×4 的滚动列表 —— 全量倒序(最多 30 条,够滑一阵子;
200 条全塞会让 FormBindingData 序列化白白变大,卡片也展示不了那么多)。 */
const list: FbMovieCardRow[] = [];
for (let i = 0; i < arr.length && list.length < 30; i++) {
list.push(movieRowOf(arr[i], i));
}
/* 🔴 v92:给「最近 3 条 + 列表前 6 条」配缩略图。
⚠️ 顺序必须是 **先建行 → 配图 → 再 JSON.stringify**:
反过来的话 `img` 字段压根不会进 `recentJson`/`listJson`,
卡片拿到的每个 `img` 都是空的 —— 图全不显示,而数据看着"正常"。 */
const bag: FbImageBag = await fbAttachPosters(ctx, token, rows, list);
out.formImages = bag.formImages;
out.imgFds = bag.imgFds;
out.recentJson = JSON.stringify(rows);
out.listJson = JSON.stringify(list);
out.state = 'ok';
@@ -217,6 +252,84 @@ export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCard
return out;
}
/** 一次推送带的图片(`formImages` 交给卡片取图,`imgFds` 交给调用方关闭) */
class FbImageBag {
formImages: Record<string, number> = {};
imgFds: number[] = [];
}
/** 一张卡最多配几张图(再多也是浪费:卡片一屏放不下,还会拖慢推送) */
const CARD_IMG_MAX: number = 6;
/**
* 给卡片行挂上缩略图(`img` = formImages 的键),并返回 `formImages` / `imgFds`。
*
* ⚠️ 按**海报键去重**:最近 3 条和列表前 6 条大量重叠,不去重会重复下载同一张。
* ⚠️ 单张失败只让那一行 `img` 留空(卡片画占位),绝不中断整体推送。
*/
async function fbAttachPosters(ctx: common.Context, token: string,
recent: FbMovieCardRow[], list: FbMovieCardRow[]): Promise<FbImageBag> {
const bag: FbImageBag = new FbImageBag();
// 收候选:先最近(2×2/2×4 用),再列表(4×4/6×4 用)
const keys: string[] = [];
for (const r of recent) {
if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) {
keys.push(r.poster);
}
}
for (const r of list) {
if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) {
keys.push(r.poster);
}
}
const map: Map<string, string> = new Map<string, string>();
let seq: number = 0;
for (const pk of keys) {
const path: string = await fbCardImagePath(ctx, pk, fbImageUrl(pk, token));
const fd: number = fbCardImageFd(path);
if (fd >= 0) {
const memKey: string = 'p' + seq.toString();
seq++;
map.set(pk, memKey);
bag.formImages[memKey] = fd;
bag.imgFds.push(fd);
}
}
if (map.size > 0) {
for (const r of recent) {
const k: string | undefined = map.get(r.poster);
if (k !== undefined) {
r.img = k;
}
}
for (const r of list) {
const k: string | undefined = map.get(r.poster);
if (k !== undefined) {
r.img = k;
}
}
}
return bag;
}
/**
* 关闭某份卡片数据里带的图片 fd(`updateForm` 之后调用)。
*
* ⚠️ 必须容错:日程卡/任务卡的载荷里根本没有 `imgFds` 这个字段,
* 这里不能因为"取到 undefined"就抛错(会把整次推送带崩)。
*/
export function fbCloseImagesOf(data: Object): void {
const d: FbMovieCardData = data as FbMovieCardData;
if (d === null || d === undefined) {
return;
}
const fds: number[] | undefined = d.imgFds;
if (fds !== undefined && Array.isArray(fds)) {
fbCloseCardImageFds(fds);
}
}
/* ══════════════════════════════════════════════════════════════
任务卡
══════════════════════════════════════════════════════════════ */
@@ -351,17 +464,20 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
return a.dueAt < b.dueAt ? -1 : 1;
});
/* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的」
—— 2×4 的右半改成可滚动后,条数不能只给 4 条(否则 List 滑两下就到底)。
⇒ 与 4×4/6×4 的 `all` 合并成同一份(同一个排序、同一个上限 30)。
⚠️ 别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。
⚠️ 上限别写 `open.length`:任务多的家庭可能上百条,FormBindingData 会明显变大。 */
const rows: FbTaskCardRow[] = [];
for (let i = 0; i < open.length && rows.length < 4; i++) {
for (let i = 0; i < open.length && rows.length < 30; i++) {
rows.push(taskRowOf(open[i]));
}
out.rowsJson = JSON.stringify(rows);
/* v91:4×4/6×4 的滚动列表 —— 同一排序全量(最多 30 条) */
const all: FbTaskCardRow[] = [];
for (let i = 0; i < open.length && all.length < 30; i++) {
all.push(taskRowOf(open[i]));
}
/* 同一个数组,两个名字:`rows` 给 2×4(滑动列表),`all` 给 4×4/6×4(网格)。
不要各自再 for 一遍 —— 同一个循环写两次,改上限时极易只改一处。 */
const all: FbTaskCardRow[] = rows;
out.allJson = JSON.stringify(all);
out.state = 'ok';