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';
@@ -24,7 +24,7 @@ import { FormExtensionAbility, formBindingData, formProvider, formInfo } from '@
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fbCardDataByKind, fbTaskCardComplete, FbTaskCardData } from '../data/FbCardMediaData';
import { fbCardDataByKind, fbTaskCardComplete, fbCloseImagesOf, FbTaskCardData } from '../data/FbCardMediaData';
import { FB_CARD_FORMS } from '../data/FbCardForms';
import { FB_BG_SYNC } from '../common/FbBackgroundSync';
@@ -57,9 +57,12 @@ export default class FbFormAbility extends FormExtensionAbility {
formProvider.updateForm(formId, binding).then((): void => {
hilog.info(DOMAIN, TAG, '卡片 %{public}s(%{public}s) 已更新(偏移 %{public}d)',
formId, kind, dayOffset);
// 🔴 v92:图片 fd 推完就关(卡片渲染侧会自己 dup,关早了也不会丢图)
fbCloseImagesOf(data);
}).catch((err: BusinessError): void => {
// 卡片可能已经被用户删了 —— 打一条日志就够,不要重试
hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message);
fbCloseImagesOf(data);
});
}).catch((err: BusinessError): void => {
hilog.error(DOMAIN, TAG, '卡片数据构建失败:%{public}s', err.message);
+30 -13
View File
@@ -166,32 +166,49 @@ struct FbCard4x4 {
private pageBtn(label: string, act: string) {
Button() {
Text(label)
.fontSize(14)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#5A4A3D')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#F3ECE2')
/* ⚠️ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开应用 */
.onClick((): void => this.page(act))
}
/** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天) */
/** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天)
*
* 🔴🔴 v95(用户 2026-10-26):「日程的右上角那个今天的今,特别不好点,
* 一不小心就到日程里面去了,其实我只不过想看看今天的日程有哪些,
* 是不是可以把按钮弄的大一点。」
*
* ⚠️ 原来这里是 `22x22` —— 直径 22 的圆,比系统建议的最小点击区(40vp 左右)
* 小了一半,手指稍微偏一点就落到**卡片本体**的 `.onClick(openApp)` 上,
* 于是"想回今天"变成"进了日程详情",正是用户描述的现象。
*
* ⚠️ 为什么是 32 而不是 40:4×4 卡高度很紧,而顶栏和内容区是**竖向堆叠**
* (内容区 `layoutWeight(1)`)。顶栏长高多少,内容区就少多少 ——
* 做成 40 会把时间轴挤掉一截。32 是"热区显著变大、又不吃掉内容"的折中:
* 面积从 22×22=484 涨到 32×32=1024(**2.1 倍**),
* 且与翻页键同尺寸,三个按钮的点击手感一致。
* ⚠️ 别只放大 `hitTestBehavior` 之类而不动尺寸:卡片是 `Button`,
* 它的可点区域就是自己的宽高,不改宽高等于没改。
*/
@Builder
private sideBtn() {
if (this.isToday) {
Button() {
Text('+')
.fontSize(14)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#A84A28')
.onClick((): void => {
@@ -204,13 +221,13 @@ struct FbCard4x4 {
} else {
Button() {
Text('今')
.fontSize(11)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#A84A28')
.onClick((): void => this.page('today'))
+21 -13
View File
@@ -161,32 +161,40 @@ struct FbCard6x4 {
private pageBtn(label: string, act: string) {
Button() {
Text(label)
.fontSize(14)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#5A4A3D')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#F3ECE2')
/* ⚠️ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开应用 */
.onClick((): void => this.page(act))
}
/** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天) */
/** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天)
*
* 🔴🔴 v95(用户 2026-10-26):「日程的右上角那个今天的今,特别不好点,
* 一不小心就到日程里面去了……是不是可以把按钮弄的大一点。」
* 原来 22×22,比最小建议点击区小一半,手指偏一点就落到卡片本体的
* `.onClick(openApp)` 上 ⇒ "想回今天"变成"进了日程详情"。
* ⇒ 放大到 32×32(面积 484→1024,2.1 倍),与 4×4 卡同一尺寸、同一手感。
* ⚠️ 必须改 width/height 本身:卡片里这是 `Button`,可点区就是自己的宽高,
* 光改 padding / 字号不会扩大热区。 */
@Builder
private sideBtn() {
if (this.isToday) {
Button() {
Text('+')
.fontSize(14)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#A84A28')
.onClick((): void => {
@@ -199,13 +207,13 @@ struct FbCard6x4 {
} else {
Button() {
Text('今')
.fontSize(11)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(22)
.height(22)
.borderRadius(11)
.width(32)
.height(32)
.borderRadius(16)
.padding(0)
.backgroundColor('#A84A28')
.onClick((): void => this.page('today'))
@@ -18,6 +18,27 @@ class FbMovieRow4 {
rating: string = '';
}
/**
* 🔴🔴 v95(用户 2026-10-26):「观影的卡片,2x4,右侧观影的列表,不能上下滑动,
* 第三个被截断,很难看,如果显示多个,就要能滑动,如果不能滑动,就显示两个。」
*
* ⚠️ 病根不在"条数写错了",而在**容器用错了**:
* 原来右半是 `Column({ space: 6 })` + `justifyContent(FlexAlign.Center)`,
* `Column` **根本不滚动**,而 `recent()` 又硬截到 3 条 ——
* 3 × (38 色条 + 上下 padding 5+5 = 48) + 2 × 6 间隔 = **156vp**,
* 而 2×4 卡右半可用高度只有 ≈130vp ⇒ 第三条被父容器直接压扁/裁掉,
* 就是用户看到的"第三个被截断,很难看"。
*
* ⇒ 改成 `List`(可滚动)+ 把条数上限提到 `MR_2X4_MAX`。
* 这样:条目多于可见高度时**能上下滑**(用户要的"显示多个就能滑动"),
* 同时可见高度天然只容得下完整的行(List 不会把行压扁,行高由内容定)。
*
* ⚠️ 不要退回 `Column` + `slice(0,2)` 的写法:那是"不能滑就显示两个",
* 用户明确更倾向"能滑"(他先说的是"如果显示多个,就要能滑动")。
* 真要兜底也只能是 list 空出来,不能靠截条数硬凑。
*/
const MR_2X4_MAX = 12;
@Entry(storageMv4)
@Component
struct FbMovieCard2x4 {
@@ -30,11 +51,11 @@ struct FbMovieCard2x4 {
/* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */
@LocalStorageProp('version') version: string = '';
/** 右半最多三条(数据侧已裁到 4,这里再兜底截断) */
/** 右半最多 MR_2X4_MAX 条(数据侧会裁;这里只兜底防御异常大数据) */
private recent(): FbMovieRow4[] {
try {
const all: FbMovieRow4[] = JSON.parse(this.recentJson) as FbMovieRow4[];
return all.slice(0, 3);
return all.slice(0, MR_2X4_MAX);
} catch (err) {
return [];
}
@@ -166,19 +187,28 @@ struct FbMovieCard2x4 {
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 右半:最近观影(不滚动)=====
// ===== 右半:最近观影(🔴 v95 改成可滚动,见 MR_2X4_MAX 的注释)=====
Column() {
if (this.recent().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
List({ space: 6 }) {
ForEach(this.recent(), (item: FbMovieRow4) => {
this.recentRow(item)
ListItem() {
this.recentRow(item)
}
.width('100%')
}, (item: FbMovieRow4) => `mv_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
/* ⚠️ 必须 `layoutWeight(1)` + 固定 `BarState.Auto`:
前者让它撑满右半高度、才有"可滚"的视口;
后者让滚动条只在真能滚时出现(条目少的时候不露一条空滚动条)。
⚠️ 不能加 `justifyContent(Center)`:List 上那是内容对齐,
内容比视口高时会变成"上边也被推到看不见"的地方,首行反而被裁。 */
.scrollBar(BarState.Auto)
.cachedCount(4)
}
}
.layoutWeight(1)
+100 -50
View File
@@ -1,28 +1,35 @@
/**
* 家书 FamilyBook · 4×4 服务卡片(观影 · 上概述/新建,下滚动列表)
* 家书 FamilyBook · 4×4 服务卡片(观影 · 上概述,下**海报墙**)—— v92
*
* 用户 2026-10-25:「观影卡片,加4x4,和4x6,上面概述,加新建按钮,
* 下面瀑布流,就跟观影模块类似,可以滑下去看全部电影。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「记一场」);
* 下半:`List`(卡片**不支持 Scroll、只支持 List**,见 FbCard4x4 的注释)
* 装全部观影(数据侧倒序裁到 30 条),整卡可点进模块。
* 用户 2026-10-25:「观影应该显示图片,左侧应该显示图片。实际上应该用大图的形式显示,
* 就做成和观影模式一样,不行吗?」
* ⇒ 下半从"文字行列表"改成**和观影模块一致的海报墙**:两列大图(2:3 海报),
* 片名压在图上(黑色半透明条),整卡可点进模块。
*
* ⚠️ 卡片不能自己发网络请求:只画 `fbMovieCardBuild()` 推过来的字段。
* ⚠️ ForEach key 带 version 前缀(v89 教训,不重绘就是它的锅)。
* 🔴🔴 卡片里的图**不能写 http 地址**(ArkTS 卡片不支持网络路径前缀和 file://):
* 图由 `FbCardMediaData` 预先下载/缩图、以 `formImages` 的 fd 交给这里,
* 本页只写 `Image('memory://' + item.img)`。看不到节点名就看不到图的坑,
* 全在 `data/FbCardImages.ets` 的文件头里。
*
* ⚠️ 卡片不支持 `Scroll`,只支持 `List` —— 海报墙用 `List().lanes(2)` 做两列,
* 既保住了滚动,也保住了"大图"(`Grid` 在卡片里的支持不可靠,别换)。
* ⚠️ ForEach key 带 version 前缀(v89 教训:key 撞车 = 静默不重绘)。
*/
let storageMvB = new LocalStorage();
let storageMvP4 = new LocalStorage();
/** 列表一行 */
class FbMovieRow9a {
/** 海报墙一格 */
class FbMovieCell9a {
key: string = '';
title: string = '';
dateText: string = '';
cinema: string = '';
rating: string = '';
/** `memory://` 的内存键;空串 = 没图,画占位 */
img: string = '';
}
@Entry(storageMvB)
@Entry(storageMvP4)
@Component
struct FbMovieCard4x4 {
@LocalStorageProp('state') state: string = 'loading';
@@ -33,9 +40,9 @@ struct FbMovieCard4x4 {
@LocalStorageProp('listJson') listJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbMovieRow9a[] {
private rows(): FbMovieCell9a[] {
try {
const all: FbMovieRow9a[] = JSON.parse(this.listJson) as FbMovieRow9a[];
const all: FbMovieCell9a[] = JSON.parse(this.listJson) as FbMovieCell9a[];
return all;
} catch (err) {
return [];
@@ -59,43 +66,70 @@ struct FbMovieCard4x4 {
});
}
/** 列表单行:日期 + 片名/影院(+评分) */
/** 图下那行小字:`10月24日 · 万象城`(都空就不画) */
private subText(item: FbMovieCell9a): string {
let s: string = item.dateText;
if (item.cinema !== '') {
s = s !== '' ? s + ' · ' + item.cinema : item.cinema;
}
return s;
}
/** 一格格:大图 + 底部黑条上的片名/日期 */
@Builder
listRow(item: FbMovieRow9a) {
Row({ space: 8 }) {
Text(item.dateText !== '' ? item.dateText : '—')
.fontSize(9)
.fontColor('#A8988A')
.width(46)
.maxLines(1)
Column({ space: 2 }) {
posterCell(item: FbMovieCell9a) {
Stack({ alignContent: Alignment.Bottom }) {
if (item.img !== '') {
// ⚠️ 只能 memory://(见文件头):写 http 地址在卡片里是一张空白
Image('memory://' + item.img)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.borderRadius(10)
} else {
// 没海报 / 缩图失败:淡紫底 + 片名,别留白块
Column() {
Text(item.title)
.fontSize(10)
.fontColor('#7A4E8C')
.maxLines(3)
.textAlign(TextAlign.Center)
.padding({ left: 4, right: 4 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#EDE6F1')
.borderRadius(10)
}
// 底部信息条(黑 40%),压在图上,省掉图上图下两份高度
Column({ space: 1 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.cinema !== '') {
Text(item.cinema)
.fontSize(9)
.fontColor('#A8988A')
.width('100%')
if (this.subText(item) !== '') {
Text(this.subText(item))
.fontSize(8)
.fontColor('#FFFFFF')
.opacity(0.8)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (item.rating !== '') {
Text(item.rating)
.fontSize(10)
.fontColor('#C08A3E')
.maxLines(1)
}
.width('100%')
.padding({ left: 6, right: 6, top: 3, bottom: 3 })
.backgroundColor('#66000000')
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F6F2F7')
// 2:3 竖版海报(和观影模块同一个比例)
.aspectRatio(0.68)
.borderRadius(10)
.clip(true)
}
/** 空态 */
@@ -155,21 +189,37 @@ struct FbMovieCard4x4 {
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部观影(可滚动)=====
// ===== 海报墙(两列,可滚动)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbMovieRow9a) => {
List({ space: 8 }) {
ForEach(this.rows(), (item: FbMovieCell9a) => {
ListItem() {
this.listRow(item)
this.posterCell(item)
}
}, (item: FbMovieRow9a): string => `ml_${this.version}_${item.key}`)
}, (item: FbMovieCell9a): string => `mp_${this.version}_${item.key}`)
}
/* 🔴🔴 v95(用户 2026-10-26):「观影的 4x4 卡片……两列图片挤在一起,
中间没有空隙,应该缩小一点。」
⚠️ 病根:`List({ space: 8 })` 的 `space` 在 **lanes 多列模式**下只管
**行间距**,列与列之间**不会**自动留缝。而海报格是
`.width('100%')` + `.aspectRatio(0.68)` —— 宽度由 lane 决定、
lane 又贴着排 ⇒ 两列严丝合缝贴死,正是用户看到的样子。
⇒ 必须用 `lanes(count, gutter)` 显式给**列间距**。
`gutter` 才是 lanes 模式下的列缝;
这里与行间距同取 8,横竖一致,看着才是"网格"而不是"拼图"。
⚠️⚠️ `gutter` 是**位置参数**(SDK: `lanes(value, gutter?: Dimension)`,
`Dimension = PX|VP|FP|LPX|Percentage|Resource`),直接传数字 `8`。
**不能**写成 `{ gutter: 8 }` —— 那是别的框架的写法,ArkTS 里是类型错误。
⚠️ 别指望调 `space` 或给格子加 margin 来凑:
加 margin 会让 `width('100%')` 溢出 lane,右列被截掉一条。
⚠️ 也别改 `.aspectRatio` 来"缩小图片":比例一变海报就被拉扁,
用户要的是"中间有空隙",不是"图变小变形"。 */
.lanes(2, 8)
.alignListItem(ListItemAlign.Start)
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
@@ -1,28 +1,35 @@
/**
* 家书 FamilyBook · 6×4 服务卡片(观影 · 上概述/新建,下滚动列表)
* 家书 FamilyBook · 6×4 服务卡片(观影 · 上概述,下**两列海报墙**)—— v92 / v95
*
* 用户 2026-10-25:「观影卡片,加4x4,和4x6,上面概述,加新建按钮,
* 下面瀑布流,就跟观影模块类似,可以滑下去看全部电影。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「记一场」);
* 下半:`List`(卡片**不支持 Scroll、只支持 List**,见 FbCard4x4 的注释)
* 装全部观影(数据侧倒序裁到 30 条),整卡可点进模块。
* 用户 2026-10-25:「观影应该显示图片……实际上应该用大图的形式显示,
* 就做成和观影模式一样,不行吗?」
* ⇒ 与 4×4 同一套海报墙。
*
* ⚠️ 卡片不能自己发网络请求:只画 `fbMovieCardBuild()` 推过来的字段。
* ⚠️ ForEach key 带 version 前缀(v89 教训,不重绘就是它的锅)。
* 🔴 v95(用户 2026-10-26):「4x6,宽度都是一样的,为啥要弄成三列?」
* ⇒ 列数从 3 改成 **2**(与 4×4 一致),并补上列间距 `gutter`。
* ⚠️ 文件头原来写的是"这块卡更宽、一排放三张"——那句已经**过时**,
* 别再照着它改回 `.lanes(3)`。
*
* 🔴 卡片里的图不能写 http 地址,只能 `Image('memory://' + img)`
* (图由 `FbCardMediaData` 下载+缩图、以 `formImages` 的 fd 交过来)。
* ⚠️ 卡片不支持 `Scroll`,只支持 `List` ⇒ 多列用 `List().lanes(n, gutter)`,别换成 Grid。
* ⚠️ ForEach key 带 version 前缀(v89 教训)。
*/
let storageMvB2 = new LocalStorage();
let storageMvP6 = new LocalStorage();
/** 列表一行 */
class FbMovieRow9b {
/** 海报墙一格 */
class FbMovieCell9b {
key: string = '';
title: string = '';
dateText: string = '';
cinema: string = '';
rating: string = '';
/** `memory://` 的内存键;空串 = 没图,画占位 */
img: string = '';
}
@Entry(storageMvB2)
@Entry(storageMvP6)
@Component
struct FbMovieCard6x4 {
@LocalStorageProp('state') state: string = 'loading';
@@ -33,9 +40,9 @@ struct FbMovieCard6x4 {
@LocalStorageProp('listJson') listJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbMovieRow9b[] {
private rows(): FbMovieCell9b[] {
try {
const all: FbMovieRow9b[] = JSON.parse(this.listJson) as FbMovieRow9b[];
const all: FbMovieCell9b[] = JSON.parse(this.listJson) as FbMovieCell9b[];
return all;
} catch (err) {
return [];
@@ -59,43 +66,67 @@ struct FbMovieCard6x4 {
});
}
/** 列表单行:日期 + 片名/影院(+评分) */
/** 图下那行小字:`10月24日 · 万象城`(都空就不画) */
private subText(item: FbMovieCell9b): string {
let s: string = item.dateText;
if (item.cinema !== '') {
s = s !== '' ? s + ' · ' + item.cinema : item.cinema;
}
return s;
}
/** 一格格:大图 + 底部黑条上的片名/日期 */
@Builder
listRow(item: FbMovieRow9b) {
Row({ space: 8 }) {
Text(item.dateText !== '' ? item.dateText : '—')
.fontSize(9)
.fontColor('#A8988A')
.width(46)
.maxLines(1)
Column({ space: 2 }) {
posterCell(item: FbMovieCell9b) {
Stack({ alignContent: Alignment.Bottom }) {
if (item.img !== '') {
// ⚠️ 只能 memory://(见文件头):写 http 地址在卡片里是一张空白
Image('memory://' + item.img)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.borderRadius(10)
} else {
Column() {
Text(item.title)
.fontSize(10)
.fontColor('#7A4E8C')
.maxLines(3)
.textAlign(TextAlign.Center)
.padding({ left: 4, right: 4 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#EDE6F1')
.borderRadius(10)
}
Column({ space: 1 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.cinema !== '') {
Text(item.cinema)
.fontSize(9)
.fontColor('#A8988A')
.width('100%')
if (this.subText(item) !== '') {
Text(this.subText(item))
.fontSize(8)
.fontColor('#FFFFFF')
.opacity(0.8)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (item.rating !== '') {
Text(item.rating)
.fontSize(10)
.fontColor('#C08A3E')
.maxLines(1)
}
.width('100%')
.padding({ left: 6, right: 6, top: 3, bottom: 3 })
.backgroundColor('#66000000')
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F6F2F7')
.aspectRatio(0.68)
.borderRadius(10)
.clip(true)
}
/** 空态 */
@@ -127,7 +158,6 @@ struct FbMovieCard6x4 {
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
/* ⭐ 新建按钮(子组件 onClick 优先于整卡,不会连带进 app) */
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
@@ -155,21 +185,33 @@ struct FbMovieCard6x4 {
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部观影(可滚动)=====
// ===== 海报墙(三列,可滚动)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbMovieRow9b) => {
List({ space: 8 }) {
ForEach(this.rows(), (item: FbMovieCell9b) => {
ListItem() {
this.listRow(item)
this.posterCell(item)
}
}, (item: FbMovieRow9b): string => `ml_${this.version}_${item.key}`)
}, (item: FbMovieCell9b): string => `mp_${this.version}_${item.key}`)
}
/* 🔴🔴 v95(用户 2026-10-26):「4x6,宽度都是一样的,为啥要弄成三列?」
—— 原来是 `.lanes(3)`(文件头那句"这块卡更宽、一排放三张")。
⇒ 改成**两列**,与 4×4 一致:
· 用户明确要求,且三列时每张海报明显更小、更挤,观感差;
· 两列 + 更高的卡高 ⇒ 一屏能看到的行数更多,反而比三列看得多。
⚠️ 同时补 `gutter`:`List({ space: 8 })` 的 `space` 在 lanes 多列
模式下只管**行间距**,列缝必须靠 `lanes(count, gutter)` 给,
否则两列又会贴死(与 4×4 同一个坑,见 FbMovieCard4x4 里的详细说明)。
`gutter` 是**位置参数**(`Dimension`),直接传数字,**不是** `{ gutter: 8 }`。
⚠️ 别再改回 `.lanes(3)`:列数一多,同一块卡里的海报就被压到
看不清片名,而用户要的恰恰是"大图"。 */
.lanes(2, 8)
.alignListItem(ListItemAlign.Start)
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
@@ -180,7 +222,6 @@ struct FbMovieCard6x4 {
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -21,6 +21,14 @@ class FbTaskRow4 {
overdue: boolean = false;
}
/** 右半可滚动列表的条数上限。
*
* 🔴 v95:配套 `rows` 改成 `List` 一起用。上限必须**和观影 2×4 同一个量级**,
* 否则"能滑"只是名义上能滑(数据侧先砍到 4 条,卡片就只滑得到 4 条)。
* 30 与 4×4/6×4 的网格列表一致,数据侧也只给这么多。
*/
const TK_2X4_MAX = 30;
@Entry(storageTk4)
@Component
struct FbTaskCard2x4 {
@@ -33,11 +41,16 @@ struct FbTaskCard2x4 {
/* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */
@LocalStorageProp('version') version: string = '';
/** 右半最多三条(数据侧已裁到 4,这里再兜底截断) */
/** 右半最多 TK_2X4_MAX 条(数据侧会裁;这里只兜底防御异常大数据)
*
* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的。」
* —— 与观影 2×4 完全同一个病:`Column` 不能滚 + 硬截 3 条 ⇒ 第三条被压扁截断。
* ⇒ 容器改 `List`(可滚),上限提到 TK_2X4_MAX。详见 FbMovieCard2x4 的同名注释。
*/
private rows(): FbTaskRow4[] {
try {
const all: FbTaskRow4[] = JSON.parse(this.rowsJson) as FbTaskRow4[];
return all.slice(0, 3);
return all.slice(0, TK_2X4_MAX);
} catch (err) {
return [];
}
@@ -169,19 +182,25 @@ struct FbTaskCard2x4 {
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 右半:待办(不滚动)=====
// ===== 右半:待办(🔴 v95 改成可滚动)=====
Column() {
if (this.rows().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
List({ space: 6 }) {
ForEach(this.rows(), (item: FbTaskRow4) => {
this.taskRow(item)
ListItem() {
this.taskRow(item)
}
.width('100%')
}, (item: FbTaskRow4) => `tk_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
/* ⚠️ 与观影 2×4 同一套:layoutWeight 才有视口、Auto 才只在能滚时出条,
且**不要**加 justifyContent(Center)(内容超高时首行会被推出去)。 */
.scrollBar(BarState.Auto)
.cachedCount(4)
}
}
.layoutWeight(1)
@@ -22,51 +22,51 @@
},
{
"name": "card_2x2_name",
"value": "家书 · 今天"
"value": "家书 · 今天 2×2"
},
{
"name": "card_2x4_name",
"value": "家书 · 今日日程"
"value": "家书 · 今日日程 2×4"
},
{
"name": "card_4x4_name",
"value": "家书 · 日程"
"value": "家书 · 日程 4×4"
},
{
"name": "card_6x4_name",
"value": "家书 · 日程(宽)"
"value": "家书 · 日程 6×4"
},
{
"name": "card_movie_2x2_name",
"value": "家书 · 观影"
"value": "家书 · 观影 2×2"
},
{
"name": "card_movie_2x4_name",
"value": "家书 · 最近观影"
"value": "家书 · 观影 2×4"
},
{
"name": "card_task_2x2_name",
"value": "家书 · 待办"
"value": "家书 · 待办 2×2"
},
{
"name": "card_task_2x4_name",
"value": "家书 · 待办任务"
"value": "家书 · 待办 2×4"
},
{
"name": "card_movie_4x4_name",
"value": "家书 · 观影(全部)"
"value": "家书 · 观影 4×4"
},
{
"name": "card_movie_6x4_name",
"value": "家书 · 观影(宽)"
"value": "家书 · 观影 6×4"
},
{
"name": "card_task_4x4_name",
"value": "家书 · 待办(全部)"
"value": "家书 · 待办 4×4"
},
{
"name": "card_task_6x4_name",
"value": "家书 · 待办(宽)"
"value": "家书 · 待办 6×4"
}
]
}