diff --git a/entry/src/main/ets/data/FbCardForms.ets b/entry/src/main/ets/data/FbCardForms.ets index 4079635..7c8155b 100644 --- a/entry/src/main/ets/data/FbCardForms.ets +++ b/entry/src/main/ets/data/FbCardForms.ets @@ -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 = new Map(); + /* 🔴 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) { diff --git a/entry/src/main/ets/data/FbCardImages.ets b/entry/src/main/ets/data/FbCardImages.ets new file mode 100644 index 0000000..dc20a95 --- /dev/null +++ b/entry/src/main/ets/data/FbCardImages.ets @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/entry/src/main/ets/data/FbCardMediaData.ets b/entry/src/main/ets/data/FbCardMediaData.ets index b6b9522..d36e235 100644 --- a/entry/src/main/ets/data/FbCardMediaData.ets +++ b/entry/src/main/ets/data/FbCardMediaData.ets @@ -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 = {}; + /** 本次打开的 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 = {}; + 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 { + 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 = new Map(); + 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 { 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); diff --git a/entry/src/main/ets/pages/card/FbCard4x4.ets b/entry/src/main/ets/pages/card/FbCard4x4.ets index c5ce61a..7710ff5 100644 --- a/entry/src/main/ets/pages/card/FbCard4x4.ets +++ b/entry/src/main/ets/pages/card/FbCard4x4.ets @@ -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')) diff --git a/entry/src/main/ets/pages/card/FbCard6x4.ets b/entry/src/main/ets/pages/card/FbCard6x4.ets index f9cb4bd..f0ccaa7 100644 --- a/entry/src/main/ets/pages/card/FbCard6x4.ets +++ b/entry/src/main/ets/pages/card/FbCard6x4.ets @@ -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')) diff --git a/entry/src/main/ets/pages/card/FbMovieCard2x4.ets b/entry/src/main/ets/pages/card/FbMovieCard2x4.ets index 5b3aceb..3ca2e88 100644 --- a/entry/src/main/ets/pages/card/FbMovieCard2x4.ets +++ b/entry/src/main/ets/pages/card/FbMovieCard2x4.ets @@ -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) diff --git a/entry/src/main/ets/pages/card/FbMovieCard4x4.ets b/entry/src/main/ets/pages/card/FbMovieCard4x4.ets index 278a778..3373ab7 100644 --- a/entry/src/main/ets/pages/card/FbMovieCard4x4.ets +++ b/entry/src/main/ets/pages/card/FbMovieCard4x4.ets @@ -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) diff --git a/entry/src/main/ets/pages/card/FbMovieCard6x4.ets b/entry/src/main/ets/pages/card/FbMovieCard6x4.ets index 56e9304..d6791c4 100644 --- a/entry/src/main/ets/pages/card/FbMovieCard6x4.ets +++ b/entry/src/main/ets/pages/card/FbMovieCard6x4.ets @@ -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()) } } diff --git a/entry/src/main/ets/pages/card/FbTaskCard2x4.ets b/entry/src/main/ets/pages/card/FbTaskCard2x4.ets index 86113e9..37273b2 100644 --- a/entry/src/main/ets/pages/card/FbTaskCard2x4.ets +++ b/entry/src/main/ets/pages/card/FbTaskCard2x4.ets @@ -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) diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 8c6799f..1463632 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -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" } ] }