Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbMovieNewSheet.ets
T
laoyang f45f4506b0 票根并入观影照片:端上不再「票根另算」(#87)
用户:现在不是已经没有票根的概念了吗?全部都换成观影照片了。
现在只分两类:一类是海报,一类是观影照片。

数据层已迁移(服务端 #87):220 条 ticket_key 搬进 event_photos,
旧字段置 NULL,Movie::find 也不再返回它。

端上跟着收:
- allPhotos() 不再把 ticketKey 拼进列表,直接 return d.photos.slice();
  原来那段还特意给它 id=0("删不掉"的标记),现在没这个特殊项了;
- FbMovieRepo 不再写/解析 ticket_key(本地列留着不改 schema,
  但恒为空串,字段标 @deprecated);
- FbSyncAll 收附件时 SQL 去掉 ticket_key(票根的 key 会在
  module_cache.payload 递归扫描里被收进来,不丢附件)。

⚠️ 特别注意 allPhotos 里**不留** ticketKey 兜底分支:留着就等于承认
   还有第三类,而那正是用户要消灭的东西。若线上还有残留(迁移漏跑),
   症状是"某些老记录照片变少",应该去补跑迁移,不是在这里兜。

hvigorw assembleHap BUILD SUCCESSFUL。真机验证待做(当时手机锁屏)。
2026-11-06 11:25:56 +08:00

1592 lines
64 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 「记一场」新建观影浮层(鸿蒙端)
*
* 🔴 用户 2026-10-19 这一轮的四条(本文件是主要落点):
*
* ① 「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。
* 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」
* ⇒ 片名输入框**边打边出候选**(320ms 防抖,与网页端同款),点候选即抓;
* 旁边一颗「自动获取」按钮按当前片名抓。抓到的东西与网页端**同一份**:
* · 简介 = 服务端拼好的 `summary`(含导演/主演/年份/类型/片长/国家/评分 + 剧情);
* · 海报 = 服务端已经落盘的 `poster_key`(走 poster 槽位管线裁成 2:3)。
* ⚠️ 只填空白的(用户已经写过的不覆盖),这是网页端的既定语义。
*
* ② 「开始时间和结束时间,应该都是**时间选择框**才对,跟那个日程里面的是一样的。」
* ⇒ 两个纯文本输入框 → `FbDateTimeRow`(**与日程共用同一个组件**)。
*
* ③ 「**影院都应该是弹框选择**,同看的人也应该是弹框选择,这种选择方式太难找了。
* 而且这个弹框选择要跟网页端的一样,在上面有个筛选框,就是当输入人的拼音的
* 首字母的时候,可以把这个人给筛选出来。」
* ⇒ 影院:`FbCinemaPickSheet`(顶部搜索框,匹配地方+名称);
* 同看人:`FbPersonPickSheet`(顶部搜索框,**拼音首字母子序列**匹配,
* 多选"暂存 + 确定"两段式)。
* ⚠️ 原来那两排横向胶囊/头像全部删掉 —— 用户点名的就是"太难找了"。
*
* ④ 「**允许上传多张图片,包括海报。**」
* ⇒ 海报 1 张 + 观影照片**多张**,用系统图片选择器(可多选),
* 在浮层里出缩略图预览、可逐张删。
* ⚠️ 图片是**记录建完之后**逐个上传的(先建记录再传图):
* 建记录失败不会留下孤儿图;某张图失败也**不挡记录**
* (与网页端"图片上传失败不挡保存"同一取舍)。
*
* ⑤ 「添加电影这里,**地点不需要**,因为影院里面就有。」
* ⇒ 「地点」这个输入框**删掉**(`location` 字段仍然存在于服务端,
* 网页端与历史数据都还在用,只是端上不再提供这个输入口)。
*
* ⚠️ 片名与时间是**必填**(服务端 `movies` 分支就校验这两条),
* 前端先拦一道,免得用户填完一堆才被服务端打回。
*
* ⚠️ 走 `FbMovieRepo.createMovie()`(`POST /api/v1/movies`)——
* 写完**立刻重拉列表回写本地库**,否则刚记的这场要等下次同步才出现。
*/
import { FbColors, FbMetric, fbAlpha } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieAttendeeRow, FbMovieDetail, FbMovieDraft,
FbMovieMedia, FbMovieRepo } from '../data/FbMovieRepo';
import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { fbApiBase, fbImageUrl } from '../data/FbHttp';
import { fbImageSrc } from '../data/FbAttachQueue';
/* 🔴 2026-11-06:同看的人改成「头像 + 名字」胶囊,需要 FbAvatar */
import { FbAvatar } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose, FbSheetTitleBar } from './FbSheetClose';
import { FbCinemaPickSheet } from './FbCinemaPickSheet';
import { FbPersonPickSheet } from './FbPersonPickSheet';
import {
FbDateTimeRow,
FbDateTimePicker,
fbPad2,
fbToday,
fbJoinDateTime
} from './FbDateTimeField';
import { picker } from '@kit.CoreFileKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMovieNew';
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 片名联想的防抖(毫秒)—— **与网页端同值**(`templates/movies/form.php` 里是 320)。
*
* ⚠️ 为什么要防抖:中文输入法下每敲一个字母都会触发 `onChange`,
* 不防抖就是对豆瓣发十几次请求(还可能被限流)。
* ⚠️ 与网页端同值是有意的:两边的手感应当一致,将来改也要一起改。
*/
const FB_SUGGEST_DEBOUNCE_MS: number = 320;
@ComponentV2
export struct FbMovieNewSheet {
/** 影院列表(本地库;给"选影院"用) */
@Param cinemas: FbCinemaItem[] = [];
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
/** 新建成功后通知页面刷新列表 */
@Event onCreated: () => void = (): void => {
};
/** 环色(hex,'' = 观影模块色) */
@Param accent: string = '';
/** 登录者 id(**默认把"我"选进同看人**,与网页端 v38 同口径;0 = 不知道) */
@Param meId: number = 0;
/**
* 编辑模式:要修改的记录 id(0 = 新建)。
*
* 🔴 用户 2026-11-06:「观影其实是可以编辑的……后面会补几张照片。
* 但编辑这个功能应该还是有的。」
*
* ⇒ 表单改成双用途:新建(`editingId = 0`)与编辑(传具体 id)。
* 同一套字段、同一套 UI,只是**保存时走不同的接口**:
* · 新建 → `POST /api/v1/movies`(`createMovie`)
* · 编辑 → `PATCH /api/v1/movies/<id>`(`updateMovie`)
*
* ⚠️ 为什么不另写一个 `FbMovieEditSheet`:这张表单有 300+ 行
* (豆瓣联想、地址联想、影院/人选择器、海报与照片多选、日期时间控件),
* 抄一份必然"改一处漏一处"(本项目已多次踩过)。
*/
@Param editingId: number = 0;
/** 编辑时用来回填的原始详情(`FbMovieDetail`;新建时为 null) */
@Param editingDetail: FbMovieDetail | null = null;
// ---- 表单字段 ----
@Local title: string = '';
/** 开始时间(`YYYY-MM-DD` + `HH:mm` 分开存,便于给日期时间控件用) */
@Local startDate: string = '';
@Local startTime: string = '';
@Local endDate: string = '';
@Local endTime: string = '';
@Local cinemaId: number = 0;
@Local info: string = '';
@Local note: string = '';
/** 豆瓣抓到的海报键(保存时随记录提交;端上另选的海报走单独上传) */
@Local doubanPosterKey: string = '';
/**
* 编辑模式下**这条记录已有的**海报键(只用于展示,保存时不提交)。
*
* ⚠️ 与 `doubanPosterKey` 分开存:那个的语义是"这次要写进库的键",
* 而这个只是"库里现在是什么",混用会导致"打开编辑框、什么都没动、
* 保存后海报变成了另一张"。
*/
@Local existingPosterKey: string = '';
/**
* 编辑模式下**这条记录已有的观影照片**(相对键;只展示,不提交)。
*
* 🔴 用户 2026-11-06:「观影照片…没带进来。」
* 原来照片区只渲染 `photoUris`(本次新选的本地图),已有照片在
* `editingDetail.photos` 里**完全没画** ⇒ 用户以为照片丢了。
*
* ⚠️ 用 `@Local` 而不是在 build 里读 `this.editingDetail.photos`:
* `if` 条件里调方法/取对象深层字段都不建依赖(本项目反复踩过)。
* ⚠️ 与 `photoUris` 分开存:那个是"待上传的本地 uri",语义不同,
* 混在一起会让"保存时传哪些"变得说不清。
*/
@Local existingPhotos: FbMovieMedia[] = [];
/**
* 正在删除的那张照片 id(0 = 空闲)。
*
* 防连点:删除是网络请求,用户手快会点两次,第二次因为记录已删会 404,
* 弹出一个"找不到这张照片"的报错 —— 看着像功能坏了,其实是重复提交。
*/
@Local deletingPhotoId: number = 0;
/** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */
@Local doubanMeta: string = '';
/** 名册(可选同看人;懒加载,进浮层才读本地库) */
@Local roster: FbRosterPerson[] = [];
@Local picked: number[] = [];
// ---- 豆瓣联想 ----
@Local suggests: FbDoubanSuggest[] = [];
@Local suggestOpen: boolean = false;
@Local fetching: boolean = false;
private suggestTimer: number = -1;
// ---- 媒体(端上选的图)----
/** 海报:端上选的本地 URI('' = 没选) */
@Local posterUri: string = '';
/** 观影照片:本地 URI 列表(可多张) */
@Local photoUris: string[] = [];
// ---- 子浮层 ----
@Local cinemaPickOpen: boolean = false;
@Local personPickOpen: boolean = false;
@Local pickOpen: boolean = false;
/** 正在选的是"结束"(false = 开始) */
@Local pickIsEnd: boolean = false;
// ---- 状态 ----
@Local saving: boolean = false;
@Local note2: string = '';
@Local bad: boolean = false;
private repo: FbMovieRepo = FbMovieRepo.inst();
aboutToAppear(): void {
/* 🔴 编辑模式:先用原始详情把字段填满,**不要**再走下面那套"今天+下个整点"
的默认值 —— 那会把用户原来填的时间冲掉,看起来像"打开就变了个日期"。 */
if (this.editingId > 0 && this.editingDetail !== null) {
this.prefill(this.editingDetail);
this.loadRoster();
return;
}
// 默认给"今天 + 下一个整点":记一场电影通常就是"刚刚看的",
// 给个空的让用户从头选日期是最烦的。
const now: Date = new Date();
const d: string = fbToday();
this.startDate = d;
this.endDate = d;
const nh: number = now.getHours();
this.startTime = fbPad2(nh) + ':' + fbPad2(now.getMinutes() < 30 ? 0 : 30);
// 结束 = 开始 + 2 小时(一部电影的常见时长),封顶 23:59
this.endTime = fbPad2(Math.min(nh + 2, 23)) + ':' + fbPad2(now.getMinutes() < 30 ? 0 : 30);
// 同看人默认带上"我"(与网页端 v38 一致:自己不用选就该在)
if (this.meId > 0) {
this.picked = [this.meId];
}
this.loadRoster();
}
/**
* 用已有详情回填表单(编辑模式)。
*
* ⚠️ `startsAt` / `endsAt` 是 `YYYY-MM-DD HH:MM:SS`(服务端格式),
* 而表单存的是"日期 + 时分"两段 ⇒ 必须拆开,不能整串塞给日期控件。
* ⚠️ 同看人 `attendees` 是对象数组,这里只要 id。
* ⚠️ 海报:`editingDetail.posterKey` 是**已有**的海报,不要把键塞进
* `doubanPosterKey`(那个字段的语义是"这次从豆瓣抓来、随保存一起提交")。
* 已有海报在服务端本来就在,不传 `poster_key` 就不会动它。
*/
private prefill(d: FbMovieDetail): void {
this.title = d.title;
const st: string = d.startsAt;
this.startDate = st.length >= 10 ? st.substring(0, 10) : st;
this.startTime = st.length >= 16 ? st.substring(11, 16) : '';
const en: string = d.endsAt;
this.endDate = en.length >= 10 ? en.substring(0, 10) : this.startDate;
this.endTime = en.length >= 16 ? en.substring(11, 16) : '';
this.cinemaId = this.cinemaIdOf(d);
this.info = d.info;
this.note = d.note;
/* 已有海报:只展示,不提交(见 existingPosterKey 的说明)。
⚠️ 别塞进 doubanPosterKey —— 那个会被 PATCH 带上去,
虽然值相同不出错,但语义就乱了,后人容易改错。 */
this.existingPosterKey = d.posterKey;
/* 已有观影照片。2026-11-06 起**票根也在这里**了 —— 它已经迁进
`event_photos`,与现场照片同一类,可看可删。 */
this.existingPhotos = d.photos.slice();
const ids: number[] = [];
/* ⚠️ 必须显式标类型(`arkts-no-any-unknown`):
直接 `for (const a of d.attendees)` 会让 `a` 被推断成 any 而编译失败。 */
const arr: FbMovieAttendeeRow[] = d.attendees;
for (const a of arr) {
ids.push(a.id);
}
this.picked = ids;
/* 🔴 2026-11-06:回填完把豆瓣联想的任何残留态清干净。
用户报的「片名下面列出来好几个条目(来自豆瓣)」就是联想列表 ——
拦它的地方在 `titleField` 的 `onChange`(值没真变就不调度),
这里负责兜底清一次,双保险。 */
this.clearSuggestTimer();
this.suggests = [];
this.suggestOpen = false;
}
/**
* 影院 id 的回填。
*
* 🔴 2026-11-06:原来只能按**影院名**反查(因为 `FbMovieDetail` 里没有 id),
* 用户报的「影院没带进来」就是这个 ——
* · 影院重名(很多城市都有「万达影城」)⇒ 反查选中了错的那家;
* · 影院改过名 ⇒ 旧记录的 `cinema_name` 与影院表对不上 ⇒ 直接回填失败。
* 现在 `FbMovieDetail.cinemaId` 已经从接口接上了,**优先用 id**,
* 只有 id 缺失(老数据 / 接口没给)时才退回按名字模糊匹配。
*/
private cinemaIdOf(d: FbMovieDetail): number {
if (d.cinemaId > 0) {
// 确认这家影院还在本地列表里(可能已被删)
const hit: FbCinemaItem | undefined =
this.cinemas.find((c: FbCinemaItem): boolean => c.id === d.cinemaId);
return hit !== undefined ? d.cinemaId : 0;
}
if (d.cinemaName === '') {
return 0;
}
const byName: FbCinemaItem | undefined =
this.cinemas.find((c: FbCinemaItem): boolean => c.name === d.cinemaName);
return byName !== undefined ? byName.id : 0;
}
aboutToDisappear(): void {
this.clearSuggestTimer();
}
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('movies');
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand;
}
private token(): string {
return FB_TOKEN_STORE.get();
}
private async loadRoster(): Promise<void> {
try {
this.roster = await FbPersonRepo.inst().list();
} catch (err) {
hilog.warn(DOMAIN, TAG, '读名册失败(同看人不可选):%{public}s', JSON.stringify(err));
}
}
private pickedNames(): string {
const out: string[] = [];
for (const id of this.picked) {
const p: FbRosterPerson | undefined =
this.roster.find((x: FbRosterPerson): boolean => x.id === id);
if (p !== undefined) {
out.push(p.shown !== '' ? p.shown : p.name);
}
}
return out.join('、');
}
/**
* 「同看的人」那一行的文案。
*
* 🔴 2026-11-06:用户报「同看的人没带进来」。
* 实际是**名字还没查出来时显示成 `已选 2 人:`(冒号后面空白)**,
* 看起来像"什么都没带进来"。
*
* 根因是两条时序叠在一起:
* ① `prefill()` 只填了 `picked`(id 数组),名字要靠 `roster` 反查;
* ② `loadRoster()` 是**异步**的 —— 表单刚打开那一两帧里 `roster` 还是空,
* 于是 `pickedNames()` 返回空串。
*
* ⇒ 分三种情况说清楚,不让用户看到"半句话":
* · 没人选 → 「还没有选人」
* · 有人但名字还没到 → 「已选 N 人(正在读取名字…)」
* · 都有 → 「已选 N 人:张三、李四」
*/
private pickedLabel(): string {
if (this.picked.length === 0) {
return '还没有选人';
}
const names: string = this.pickedNames();
if (names === '') {
return '已选 ' + this.picked.length.toString() + ' 人(正在读取名字…)';
}
return '已选 ' + this.picked.length.toString() + ' 人:' + names;
}
private cinemaLabel(): string {
if (this.cinemaId <= 0) {
return '不记录影院';
}
const c: FbCinemaItem | undefined =
this.cinemas.find((x: FbCinemaItem): boolean => x.id === this.cinemaId);
if (c === undefined) {
return '不记录影院';
}
return c.place !== '' ? (c.place + ' · ' + c.name) : c.name;
}
private cinemaColor(): string {
const c: FbCinemaItem | undefined =
this.cinemas.find((x: FbCinemaItem): boolean => x.id === this.cinemaId);
return c !== undefined && c.color !== '' ? c.color : '';
}
// ============================================================
// 豆瓣:联想 + 抓取
// ============================================================
private clearSuggestTimer(): void {
if (this.suggestTimer >= 0) {
clearTimeout(this.suggestTimer);
this.suggestTimer = -1;
}
}
/**
* 片名变了 → 重排一次联想(防抖)。
*
* ⚠️ 必须 `clearTimeout` 上一次:中文输入法下连续输入会排出一串定时器,
* 不清理就是对豆瓣发一串请求。
*/
private scheduleSuggest(): void {
this.clearSuggestTimer();
const q: string = this.title.trim();
if (q === '') {
this.suggests = [];
this.suggestOpen = false;
return;
}
this.suggestTimer = setTimeout((): void => {
this.suggestTimer = -1;
this.doSuggest(q);
}, FB_SUGGEST_DEBOUNCE_MS);
}
private async doSuggest(q: string): Promise<void> {
const list: FbDoubanSuggest[] = await this.repo.doubanSuggest(q, this.token());
// ⚠️ 期间用户可能又改了片名 ⇒ 只有"当前片名 == 这次请求的片名"才渲染
// (否则会把过期的候选画到新片名下面,看着像联想错了)
if (this.title.trim() !== q) {
return;
}
this.suggests = list;
this.suggestOpen = list.length > 0;
}
/** 点候选 → 按豆瓣 id 抓(与网页端 `fetchById` 同一条路) */
private async pickSuggest(it: FbDoubanSuggest): Promise<void> {
this.suggestOpen = false;
await this.fetchDouban(it.id !== undefined ? it.id : '', this.title);
}
/** 「自动获取」按钮 → 按当前片名抓(与网页端 `fetchBtn` 同一条路) */
private async fetchByTitle(): Promise<void> {
const q: string = this.title.trim();
if (q === '') {
this.bad = true;
this.note2 = '先写个片名,再点「自动获取」';
return;
}
this.suggestOpen = false;
await this.fetchDouban('', q);
}
/**
* 抓详情 + 海报。
*
* ⚠️ **只填空白的**(与网页端 `apply()` 完全同一条语义):
* 用户已经写过的简介**不能被豆瓣覆盖** —— 那是他自己记的东西。
* 但要说清楚"抓到了、没覆盖",否则用户会以为抓取坏了。
*/
private async fetchDouban(doubanId: string, q: string): Promise<void> {
if (this.fetching) {
return;
}
this.fetching = true;
this.bad = false;
this.note2 = '正在从豆瓣获取…';
const res: FbDoubanFetch | null = await this.repo.doubanFetch(this.token(), doubanId, q);
this.fetching = false;
if (res === null) {
this.bad = true;
this.note2 = this.repo.lastError !== '' ? this.repo.lastError : '抓取失败,请稍后再试';
return;
}
// ---- 片名:豆瓣的正规名回填(用户打的可能是简称)----
if (res.title !== '') {
this.title = res.title;
}
// ---- 简介:只填空白的 ----
let infoFilled: boolean = false;
const summary: string = res.summary.trim();
if (summary !== '' && this.info.trim() === '') {
this.info = summary;
infoFilled = true;
}
// ---- 海报 ----
if (res.posterKey !== '') {
this.doubanPosterKey = res.posterKey;
// 豆瓣海报**优先于**端上选的图?不 —— 用户自己选的更权威。
// 但如果他还没选,就把抓来的海报当预览(保存时用键,不重传图)。
}
if (res.meta !== '') {
this.doubanMeta = res.meta;
}
// ---- 结果提示(把"抓到了什么、有没有覆盖"说清楚)----
if (summary === '' && res.posterKey === '') {
this.bad = false;
this.note2 = '豆瓣上没有这部电影的简介和海报';
} else if (summary !== '' && !infoFilled) {
this.bad = false;
this.note2 = '已获取。简介里已有你写的内容,没有覆盖它';
} else {
this.bad = false;
this.note2 = res.posterKey !== '' ? '已获取片名、简介与海报' : '已获取片名与简介(豆瓣没有海报)';
}
}
// ============================================================
// 图片选择(海报 1 张 / 观影照片多张)
// ============================================================
/** 选一张海报(系统图片选择器,单选) */
private async pickPoster(): Promise<void> {
try {
const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
opts.maxSelectNumber = 1;
const p: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await p.select(opts);
if (sel.photoUris.length > 0) {
this.posterUri = sel.photoUris[0];
// ⚠️ 端上选了图 ⇒ 豆瓣那张键就**不再用了**(否则保存时两个来源打架:
// 服务端会先被 poster_key 设置、再被上传的海报覆盖,白跑一趟)
this.doubanPosterKey = '';
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '选海报取消/失败:%{public}s', JSON.stringify(err));
}
}
/**
* 选观影照片(**可多选** —— 用户明确要求"允许上传多张图片")。
*
* ⚠️ `maxSelectNumber` 给 9:单次最多 9 张是系统选择器的常见上限,
* 也给用户一个"一次别传太多"的软约束;想传更多可以再点一次
* (这里是**追加**,不是替换)。
*/
private async pickPhotos(): Promise<void> {
try {
const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
/* 🔴 2026-11-06:从 `IMAGE_TYPE` 改成 `IMAGE_VIDEO_TYPE` ——
用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改,
要求能够上传视频,视频需要转码,参考旅程中的行程做法。」
旅程那边就是 `IMAGE_VIDEO_TYPE`(见 `FbTripNodeSheet.pickPhotos`),
这里对齐。转码在服务端做(`Video::toH264`),端上只负责把文件传上去。 */
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE;
opts.maxSelectNumber = 9;
const p: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await p.select(opts);
if (sel.photoUris.length > 0) {
this.photoUris = this.photoUris.concat(sel.photoUris);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '选照片取消/失败:%{public}s', JSON.stringify(err));
}
}
/** 删一张已经选好的观影照片 */
private removePhoto(i: number): void {
const next: string[] = this.photoUris.slice();
next.splice(i, 1);
this.photoUris = next;
}
/**
* 删一张**已有**照片 / 视频(编辑模式)。
*
* 🔴 用户 2026-11-06:「编辑观影记录的时候,那个图片应该可以被删除吧?
* 现在没有删除按钮。」
*
* ⚠️ **立即提交**,不等「保存修改」:与网页端完全一致
* (`movies/photo_del` 删完就 redirect 回编辑页)。
* 做成"先标记、保存时才提交"的话,用户中途关掉表单会以为自己删了、
* 其实还在 —— 那种"看起来删了但没删"的假象更难排查。
* ⚠️ 删完把服务端返回的最新列表整体换掉,而不是本地 splice:
* 服务端是唯一真相(它还要删存储对象),本地猜会和实际不一致。
* ⚠️ 左滑/点 ✕ 都走这里,所以用 `deletingPhotoId` 防止连点重复发请求
* (第二次会 404,用户看到莫名其妙的报错)。
*/
private async deleteExistingPhoto(m: FbMovieMedia): Promise<void> {
if (this.deletingPhotoId !== 0) {
return;
}
const token: string = this.token();
if (token === '') {
this.bad = true;
this.note2 = '还没登录';
return;
}
if (this.editingId <= 0) {
this.bad = true;
this.note2 = '这条记录还没有 id,删不掉照片';
return;
}
this.deletingPhotoId = m.id;
const err: string = await FbMovieRepo.inst().deletePhoto(this.editingId, m.id, token);
this.deletingPhotoId = 0;
if (err !== '') {
this.bad = true;
this.note2 = err;
return;
}
// 本地同步移除(服务端已删成功,这里只是让界面立刻对上)
const next: FbMovieMedia[] = [];
for (const x of this.existingPhotos) {
if (x.id !== m.id) {
next.push(x);
}
}
this.existingPhotos = next;
this.bad = false;
this.note2 = '已删除 1 张';
}
// ============================================================
// 保存
// ============================================================
private async submit(): Promise<void> {
if (this.saving) {
return;
}
if (this.title.trim() === '') {
this.bad = true;
this.note2 = '片名不能为空';
return;
}
const token: string = this.token();
if (token === '') {
this.bad = true;
this.note2 = '还没登录';
return;
}
// ---- 时间:日期 + 时分拼成服务端认的格式 ----
const startsAt: string = fbJoinDateTime(this.startDate, this.startTime);
const endsAt: string = fbJoinDateTime(this.endDate, this.endTime);
const d: FbMovieDraft = {
title: this.title,
startsAt: startsAt,
endsAt: endsAt,
// 🔴 用户:「地点不需要,因为影院里面就有」⇒ 端上不再提供这个入口,
// 但仍把字段带上(服务端 `location` 可空,网页端与历史数据还在用)。
location: '',
note: this.note,
info: this.info,
cinemaId: this.cinemaId,
attendees: this.picked,
posterKey: this.doubanPosterKey
};
this.saving = true;
this.bad = false;
this.note2 = '正在保存…';
/* 🔴 2026-11-06:新建与编辑走**不同的接口**。
· 新建 → `createMovie`(POST),返回新 id,后面传图要用它;
· 编辑 → `updateMovie`(PATCH),id 就是 `editingId`。
⚠️ 两者都不发 `poster_key`(编辑时发空串会清掉已有海报,
新建时的键由 `doubanPosterKey` 那条老路单独处理)。 */
let id: number = this.editingId;
if (this.editingId > 0) {
const ok: boolean = await this.repo.updateMovie(this.editingId, d, token);
if (!ok) {
this.saving = false;
this.bad = true;
this.note2 = this.repo.lastError !== '' ? this.repo.lastError : '保存失败';
return;
}
} else {
id = await this.repo.createMovie(d, token);
if (id <= 0) {
this.saving = false;
this.bad = true;
this.note2 = this.repo.lastError !== '' ? this.repo.lastError : '保存失败';
return;
}
}
/* ---- 图片:记录已经建好了,逐个传(失败不挡记录)----
⚠️ 这一步的顺序很重要:**先建记录再传图**。
反过来(先传图再建记录)的话,建记录失败就会留下一堆
"没人认领的图"(服务端 `/movies/<id>/…` 需要 id,孤儿图连删都找不到)。
⚠️ 失败**不回滚记录**:一条记录因为一张 12MB 的原图没传上去,
不该让用户重新填一遍表单(与网页端"图片上传失败不挡保存"同一取舍)。 */
const warns: string[] = [];
if (this.posterUri !== '') {
this.note2 = '正在上传海报…';
const e: string = await this.repo.uploadPoster(id, this.posterUri, token);
if (e !== '') {
warns.push('海报:' + e);
}
}
let done: number = 0;
for (const uri of this.photoUris) {
done++;
this.note2 = '正在上传照片 ' + done.toString() + ' / ' + this.photoUris.length.toString() + '…';
const e: string = await this.repo.uploadPhoto(id, uri, token);
if (e !== '') {
warns.push('第 ' + done.toString() + ' 张照片:' + e);
}
}
this.saving = false;
if (warns.length > 0) {
// ⚠️ 记录**已经存下了**,所以这里用"已记下这一场,但有 N 项没成功"
// 而不是"保存失败" —— 后者会让用户以为白填了、再填一遍(重复记录)。
this.bad = true;
this.note2 = '已记下这一场,但' + warns.join(';');
this.onCreated();
return;
}
this.note2 = '已记下这一场';
this.onCreated();
this.onClose();
}
// ============================================================
// 视图
// ============================================================
build() {
// 🔴 外层必须是 `Stack`:本组件要同时显示**四层**
// (编辑卡片 + 影院选择 + 人员选择 + 日期时间选择),build() 只能有一个根节点。
Stack({ alignContent: Alignment.Center }) {
// ---- 第 1 层:主卡片 ----
Column() {
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题栏(统一版式:左标题 + 右 ✕)----
FbSheetTitleBar({
/* 编辑模式换个标题,用户一眼知道自己在改而不是新建 */
title: this.editingId > 0 ? '编辑观影记录' : '记一场电影',
/* 🔴 用户 2026-11-06:「编辑观影记录这几个字,也应该是主题颜色才对,
下面的一行小字(改完点下面保存,照片可以再补几张。),删除不要了。」
⇒ ① 标题用主题色(见下面 `titleColor` 参数的来源);
② **编辑模式不再传 subtitle** —— 那句说明是他明确要删的。
新建模式保留原有的提示(那边还需要"片名和时间必填"这句引导)。 */
subtitle: this.editingId > 0 ? '' : '片名和时间必填,其余可以留空',
accent: this.accentColor(),
titleColor: this.accentColor(),
onClose: (): void => {
this.onClose();
}
})
Divider()
.color(FbColors.line)
.margin({ top: 10, bottom: 4 })
// ---- 表单 ----
Scroll() {
Column({ space: 10 }) {
this.titleField()
if (this.suggestOpen) {
this.suggestList()
}
FbDateTimeRow({
label: '开始',
date: this.startDate,
time: this.startTime,
accent: this.accentColor(),
onTap: (): void => {
this.pickIsEnd = false;
this.pickOpen = true;
}
})
FbDateTimeRow({
label: '结束',
date: this.endDate,
time: this.endTime,
accent: this.accentColor(),
onTap: (): void => {
this.pickIsEnd = true;
this.pickOpen = true;
}
})
this.cinemaField()
this.personField()
this.posterField()
this.photoField()
this.infoField()
this.noteField()
}
.width('100%')
.padding({ top: 6, bottom: 4 })
}
/* 🔴 #55(用户:「"记一场电影"这种输入,输入框怎么上下划不动?」)
根因:`Scroll` 在 `Column` 里只写了 `flexShrink(1)` ——
那只是"允许被压缩",**不等于"占满剩余高度"**。
没有 `layoutWeight(1)` 时,Scroll 的高度 = 内容高度
(超出父级后被裁切)⇒ 自身没有可滚动空间 ⇒ **划不动**。
⇒ 必须给它 `layoutWeight(1)` 才能拿到父级剩下的高度。 */
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
.edgeEffect(EdgeEffect.Spring)
// ---- 结果提示 ----
if (this.note2 !== '') {
Text(this.note2)
.fontSize(FbMetric.fontTiny)
.fontColor(this.bad ? FbColors.red : FbColors.muted)
.width('100%')
.maxLines(3)
.padding({ top: 8 })
}
// ---- 底部:保存(关闭统一在右上角 ✕)----
Row({ space: 10 }) {
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下这一场'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(44)
.backgroundColor(this.saving
? fbAlpha(this.accentColor(), 0.5) : this.accentColor())
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.submit();
})
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.constraintSize({ maxHeight: '86%' })
.padding({ left: 18, right: 18, top: 18, bottom: 16 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
// ---- 第 2 层:影院选择(用户:「影院都应该是弹框选择」)----
if (this.cinemaPickOpen) {
FbCinemaPickSheet({
cinemas: this.cinemas,
selected: this.cinemaId,
accent: this.accentColor(),
onPick: (id: number): void => {
this.cinemaId = id;
this.cinemaPickOpen = false;
},
onClose: (): void => {
this.cinemaPickOpen = false;
}
})
}
// ---- 第 3 层:同看的人(拼音首字母筛选 + 暂存/确定)----
if (this.personPickOpen) {
FbPersonPickSheet({
people: this.roster,
selected: this.picked,
title: '选择观影人',
accent: this.accentColor(),
onConfirm: (ids: number[]): void => {
this.picked = ids;
this.personPickOpen = false;
},
onClose: (): void => {
this.personPickOpen = false;
}
})
}
// ---- 第 4 层:日期 + 时间(**与日程同一个组件**)----
if (this.pickOpen) {
FbDateTimePicker({
title: this.pickIsEnd ? '选择结束时间' : '选择开始时间',
date: this.pickIsEnd ? this.endDate : this.startDate,
time: this.pickIsEnd ? this.endTime : this.startTime,
accent: this.accentColor(),
onConfirm: (d: string, t: string): void => {
if (this.pickIsEnd) {
this.endDate = d;
this.endTime = t;
} else {
this.startDate = d;
this.startTime = t;
}
this.pickOpen = false;
},
onCancel: (): void => {
this.pickOpen = false;
}
})
}
}
.width('100%')
.height('100%')
}
// ============================================================
// 各字段
// ============================================================
/**
* 片名(带豆瓣联想 + 「自动获取」)。
*
* ⚠️ 这是整个表单**唯一**一个"带网络行为"的输入框:
* 边打边联想(防抖 320ms)+ 一键抓简介与海报。
*/
@Builder
titleField() {
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC(用户 2026-11-05):标签独占第一排,输入框第二排。
原来是 `Row` 里 `标签.width(48) + 输入框`,写死 48 只是"凑"对齐,
字数一变就错开。片名必填 ⇒ 红色,**无星号**(用户明确要求去掉)。 */
Text('片名')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor('#C0533F')
.width('100%')
Row({ space: 8 }) {
TextInput({ text: this.title, placeholder: '看了哪一部?' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
/* 🔴 2026-11-06:编辑模式回填时,`TextInput` 会把"刚被赋值的新内容"
当成一次 onChange 回调(用户报的"片名下面列出来好几个条目")。
⇒ 值没有真正变化时,不调度联想 ——
回填是"值本来就是这个",不是"用户改了"。
⚠️ 这里用"值比较"而不是 `prefilling` 标记:
`prefill()` 是同步执行完的,标记会立刻复位;
而 `onChange` 是在**下一帧**渲染时才回调的,
那时标记早就 false 了,拦不住(我第一版就是这么写的,没用)。 */
const changed: boolean = v !== this.title;
this.title = v;
if (!changed) {
return;
}
this.scheduleSuggest();
})
// 「自动获取」:一次把片名/简介/海报都抓回来(与网页端那颗按钮同名同义)
Text(this.fetching ? '获取中' : '自动获取')
.fontSize(FbMetric.fontTiny)
.fontColor(this.fetching ? FbColors.faint : Color.White)
.textAlign(TextAlign.Center)
.height(40)
.padding({ left: 10, right: 10 })
.backgroundColor(this.fetching ? FbColors.inputBg : this.accentColor())
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.fetchByTitle();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 联想候选下拉(片名输入框下面那一段)。
*
* ⚠️ 每条都要点得到(真机上小文字热区很低)⇒ 行高给 48、整行可点。
* ⚠️ 缩略图地址已经是 **API 代理形式**(`/api/v1/movies/douban?action=thumb&…`)——
* 豆瓣有防盗链,客户端直连一律 403;这里补上域名与令牌才能加载。
*/
@Builder
suggestList() {
Column({ space: 2 }) {
ForEach(this.suggests, (it: FbDoubanSuggest) => {
Row({ space: 10 }) {
if (it.thumb !== undefined && it.thumb !== '') {
Image(fbApiBase() + it.thumb + '&token=' + encodeURIComponent(this.token()))
.width(30)
.height(42)
.borderRadius(3)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
}
Column({ space: 2 }) {
Text(it.title !== undefined && it.title !== '' ? it.title : '(无片名)')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.suggestMeta(it))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.height(48)
.padding({ left: 8, right: 8 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.pickSuggest(it);
})
}, (it: FbDoubanSuggest): string => 'ds_' + (it.id !== undefined ? it.id : '0'))
}
.width('100%')
.constraintSize({ maxHeight: 200 })
.padding({ left: 56 })
}
/** 候选的第二行:副标题 · 年份 · 类型(与网页端拼法一致,只有有值的才出现) */
private suggestMeta(it: FbDoubanSuggest): string {
const bits: string[] = [];
const sub: string = it.sub_title !== undefined ? it.sub_title : '';
const title: string = it.title !== undefined ? it.title : '';
if (sub !== '' && sub !== title) {
bits.push(sub);
}
if (it.year !== undefined && it.year !== '') {
bits.push(it.year);
}
if (it.kind !== undefined && it.kind !== '') {
bits.push(it.kind);
}
return bits.join(' · ');
}
/**
* 影院(**点开弹框选**)。
*
* 🔴 用户:「影院都应该是弹框选择,这种选择方式太难找了。」
* ⇒ 原来那排"横向胶囊"删掉,改成一行可点的框(显示当前选中)+ 弹出列表。
*/
@Builder
cinemaField() {
/* 🔴 2026-11-06 结构性修正(与 `posterField` 同一条病):
原来外层是 `Row { Text('影院').width('100%'); Row { 值…选择 } }`
—— 标签 `width('100%')` 吃满整行,把内层 `Row` 挤到行外。
真机症状:编辑表单里「影院」只剩一个标签,**下面的值整个不见了**
(用户报「影院没带进来」,其实是根本没渲染)。
⇒ 按《全局表单规范》改成上下两排:标签第一排、控件第二排整宽。 */
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('影院')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
/* 第二排:色点 + 当前选中 + 「选择」 */
Row({ space: 8 }) {
if (this.cinemaColor() !== '') {
Text('')
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor(this.cinemaColor())
}
Text(this.cinemaLabel())
.fontSize(FbMetric.fontSmall)
.fontColor(this.cinemaId > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.cinemaPickOpen = true;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 同看的人(**点开弹框选**,带拼音首字母筛选)。
*
* 🔴 用户:「同看的人也应该是弹框选择,这种选择方式太难找了。」
* ⇒ 原来那排"只列前 40 个的横向头像"删掉 —— 名册上千人,
* 要找的人排在后面就永远找不到(这正是用户说的"太难找")。
*/
@Builder
personField() {
/* 🔴 2026-11-06 结构性修正(与 `cinemaField` / `posterField` 同一条病):
原来外层 `Row` 里标签 `width('100%')` 把内层 `Row` 挤出可视区,
真机症状是「同看的人」只剩标签、**下面的值整个不渲染**
(用户报「同看的人没带进来」其实是根本没画)。
⇒ 改成上下两排。 */
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('同看的人')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
/* 第二排:已选的人 —— **头像 + 名字的胶囊标签**,流式换行 */
Column({ space: 8 }) {
if (this.picked.length === 0) {
/* 没人选:还是一行可点的空框(点它去选) */
Row({ space: 8 }) {
Text('还没有选人')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
Text('选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.openPersonPick();
})
} else {
/* 🔴 用户 2026-11-06:「同看的人,按照头像加名字组成标签的形式进行展示。」
⇒ 每人一颗胶囊:头像(有图显示图、没图回落色块字)+ 名字 + 小叉(删掉)。
用 `Flex(wrap)` 而不是 `Row`:人多时 `Row` 不换行会横向溢出(本项目 v76 的坑)。 */
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.pickedPeople(), (p: FbRosterPerson) => {
Row({ space: 6 }) {
FbAvatar({
name: p.shown !== '' ? p.shown : p.name,
color: p.avatarColor !== '' ? p.avatarColor : this.accentColor(),
dia: 24,
/* ⚠️ 必须传 imageUrl:不传的话有头像的人也退化成色块字
(首页观影卡踩过同一个坑,见 FbMovieDetailSheet 的注释) */
imageUrl: p.avatar !== '' ? fbImageSrc(p.avatar, this.token()) : ''
})
Text(p.shown !== '' ? p.shown : p.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
/* 小叉:从已选里移除(不影响名册,只是这次不放他) */
Text('✕')
.fontSize(10)
.fontColor(FbColors.faint)
.padding({ left: 2, right: 2 })
.onClick((): void => {
this.picked = this.picked.filter((x: number): boolean => x !== p.id);
})
}
.padding({ left: 4, right: 8, top: 3, bottom: 3 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (p: FbRosterPerson): string => 'pk_' + p.id.toString())
}
.width('100%')
/* 底部「+ 选择」:加人 */
Row({ space: 4 }) {
Text('+ 选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusPill)
.border({ width: 1, color: fbAlpha(this.accentColor(), 0.4) })
.onClick((): void => {
this.openPersonPick();
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 已选的同看人(按 `picked` 的顺序映射成完整人物对象)。
*
* ⚠️ 返回数组而不是在模板里内联 find:模板里做复杂查询会让
* `ForEach` 的依赖追踪变模糊(本项目反复踩过),这里一次算清。
* ⚠️ 名单里找不到的 id(人被删了)直接跳过,不占位。
*/
private pickedPeople(): FbRosterPerson[] {
const out: FbRosterPerson[] = [];
for (const id of this.picked) {
const p: FbRosterPerson | undefined =
this.roster.find((x: FbRosterPerson): boolean => x.id === id);
if (p !== undefined) {
out.push(p);
}
}
return out;
}
/** 打开「选同看人」弹框(名册没同步时给提示,不硬开一个空列表) */
private openPersonPick(): void {
if (this.roster.length === 0) {
this.bad = true;
this.note2 = '名册还没同步,同看人稍后可以在网页端补';
return;
}
this.personPickOpen = true;
}
/**
* 海报(1 张;可从豆瓣抓、也可自己选)。
*
* 🔴 用户:「允许上传多张图片,**包括海报**。」
*/
@Builder
posterField() {
/* 🔴 2026-11-06 结构性修正。
真机症状:「海报」标签在、下面那句说明也在,**中间那张缩略图、
「当前海报」、上传按钮全都凭空消失**。
原因:原来外层是
`Row { Text('海报').width('100%'); Row { 预览 … 按钮 } }`
—— 标签 `width('100%')` 吃满整行,把内层 `Row` 挤到行外;
外层 `Row` 又没给高度 ⇒ 内层内容塌成 0 高、根本不渲染。
⇒ 按《全局表单规范》改成**上下两排**:标签独占第一排、控件第二排整宽。
其它字段(片名/时间/影院/同看的人)早就是这个版式,此处是漏改。 */
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('海报')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
/* 第二排:预览图 + 说明 + 上传按钮(整宽,固定 60vp 高) */
Row({ space: 8 }) {
if (this.posterUri !== '') {
// 端上自己选的(本地文件,直接可显示)
Image(this.posterUri)
.width(42)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
Text('新选了 1 张封面')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.layoutWeight(1)
} else if (this.doubanPosterKey !== '') {
// 豆瓣抓来的(走 media.php 代理,补域名 + 令牌)
Image(fbImageUrl(this.doubanPosterKey, this.token()))
.width(42)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
Column({ space: 2 }) {
Text('豆瓣海报')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
Text('保存后生效')
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
} else if (this.existingPosterKey !== '') {
/* 编辑模式:这条记录**本来就有**海报。
⚠️ 只是**展示**,保存时不提交(见 `updateMovie` 的注释:
不发 `poster_key`,服务端就不动它)。
🔴🔴 条件**必须读 `@Local existingPosterKey`,不能写方法调用**:
ArkUI V2 的 `if/else if` 只对字段/参数建立依赖,条件里调方法
不会被观察 —— 首次 build 读到空串就永久走 `else`,之后有值也不重算。
真机症状:探针打出 `posters/963d`(正确),界面却一直说"还没有海报"。
本项目同一个坑:`ForEach(this.typeOptions(), ...)` 追踪不到
(见 `pitfalls.md`「ArkUI 三个反复踩的坑」第 2 条)。 */
Image(fbImageSrc(this.existingPosterKey, this.token()))
.width(42)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
Column({ space: 2 }) {
Text('当前海报')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
Text('不选新图就保持不变')
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
} else {
Text('还没有海报')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
}
/* 上传/换图。条件同样只读字段,不调方法。 */
Text((this.posterUri !== '' || this.doubanPosterKey !== '' || this.existingPosterKey !== '')
? '换一张' : '上传')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
.onClick((): void => {
this.pickPoster();
})
}
.width('100%')
.height(60)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
Text('海报可以点「自动获取」由豆瓣带回来,也可以自己传一张(竖版效果最好)')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 观影照片(**多张**)。
*
* 🔴 用户:「允许上传多张图片,包括海报。」
* ⇒ 可一次选多张、也可以多次追加;已选的以缩略图网格列出,可逐张删。
*/
@Builder
photoField() {
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号)
🔴 2026-11-06:原来标签和内层 `Row` 挤在同一个外层 `Row` 里,
标签 `width('100%')` 把内层挤出可视区 ⇒ 「选照片」按钮整个不渲染。
改成上下两排(同 `cinemaField` / `personField` / `posterField`)。 */
Text('观影照片')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
/* 第二排:说明/已选数 + 「选照片」 */
Row({ space: 8 }) {
Text(this.photoUris.length > 0
? ('已新选 ' + this.photoUris.length.toString() + ' 张')
: '合影、票根、随手拍都可以放这儿')
.fontSize(FbMetric.fontSmall)
.fontColor(this.photoUris.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('选照片')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.pickPhotos();
})
if (this.photoUris.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.photoUris, (uri: string, i: number) => {
Stack({ alignContent: Alignment.TopEnd }) {
Image(uri)
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
Text('✕')
.fontSize(10)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width(18)
.height(18)
.backgroundColor('#B0000000')
.borderRadius(9)
.margin({ top: 2, right: 2 })
.onClick((): void => {
this.removePhoto(i);
})
}
.width(60)
.height(60)
.margin({ right: 8, bottom: 8 })
}, (uri: string, i: number): string => 'ph_' + i.toString() + '_' + uri)
}
.width('100%')
.padding({ left: 56 })
}
/* 🔴 2026-11-06:**已有照片**(编辑模式)。
用户报「观影照片没有带进来」—— 原来这里只渲染 `photoUris`
(= 本次新选的本地图),而编辑时已有的图在 `existingPhotos` 里,
完全没画出来 ⇒ 用户以为照片丢了,会重新选一遍。
🔴 同一天第二轮,用户又说:「编辑观影记录的时候,那个图片应该可以
被删除吧?现在没有删除按钮。」⇒ 每张右上角加 ✕。
⚠️ 删除**立即提交**(与网页端 `movies/photo_del` 同口径),
不是"标记后等保存"——那种做法用户中途关表单会以为删了、其实还在。
⚠️ 不加二次确认:与旅程 `FbTripNodeSheet.removePhoto` 和网页端一致,
单张照片删除不值得拦一道;整条记录删除才需要确认。
⚠️ 条件读 `@Local existingPhotos`(不是调方法),
否则 ArkUI 不建立依赖(同 `posterField` 那条教训)。 */
if (this.existingPhotos.length > 0) {
Column({ space: 6 }) {
Text('已有 ' + this.existingPhotos.length.toString() + ' 张(点 ✕ 可删)')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.existingPhotos, (m: FbMovieMedia) => {
/* ⚠️ 外层 Stack 用 `TopEnd` 是为了把 ✕ 顶到右上角;
媒体自身(含视频的播放角标)放在内层 Stack 里居中,
两者对齐方式不同,不能共用一个 Stack。 */
Stack({ alignContent: Alignment.TopEnd }) {
Stack({ alignContent: Alignment.Center }) {
if (m.kind === 'video') {
if (m.thumb !== '') {
Image(fbImageSrc(m.thumb, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
} else {
Column()
.width(60)
.height(60)
.borderRadius(4)
.backgroundColor(FbColors.inputBg)
Image($r('app.media.fb_ic_movies'))
.width(22)
.height(22)
}
Column() {
Text('▶')
.fontSize(11)
.fontColor(Color.White)
}
.width(22)
.height(22)
.borderRadius(11)
.backgroundColor('#99000000')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
} else {
Image(fbImageSrc(m.key, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
}
}
.width(60)
.height(60)
/* 删除角标:与「新选照片」那颗同一尺寸/配色,视觉语言统一。
⚠️ 正在删这张时显示「…」,点不动 —— 免得连点发第二次请求。 */
Text(this.deletingPhotoId === m.id ? '…' : '✕')
.fontSize(10)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width(18)
.height(18)
.backgroundColor('#B0000000')
.borderRadius(9)
.margin({ top: 2, right: 2 })
.onClick((): void => {
this.deleteExistingPhoto(m);
})
}
.width(60)
.height(60)
.margin({ right: 8, bottom: 8 })
}, (m: FbMovieMedia): string => 'exph_' + m.id.toString() + '_' + m.key)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 一行多行文本输入。
*
* ⚠️ **值参数 Builder 有坑**(本项目 `pitfalls.md` 记过):
* `Builder(value)` 的值参数**不参与状态观察**,
* `TextArea({ text: value })` 只拿得到首次调用时的字面量。
* ⇒ 简介 / 备注**已改成独立 Builder**(`infoField` / `noteField`),
* 直接在内部读 `this.info` / `this.note`,编辑回填才能显示出来。
* ⇒ 这个通用版留着给"值不会变、或调用方自己保证重传"的场景用。
*/
@Builder
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
/* GLOBAL-FORM-SPEC:标签独占第一排(原来是 .width(48) 硬凑) */
Column({ space: 6 }) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextArea({ text: value, placeholder: placeholder })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 简介(横跨整个表单宽度)。
*
* 🔴 2026-11-06:**不再走 `areaField(...)`**,因为它是**值参数** Builder ——
* `TextArea({ text: value })` 拿到的是**首次调用时**的字面量,
* `prefill()` 在 `aboutToAppear` 里改了 `this.info` 之后,这里收不到新值
* ⇒ 编辑表单里「简介」永远空白(用户报的"简介没带进来"就是这个)。
*
* 这与 `pitfalls.md` 的「@Builder 值参数不参与状态观察」是**同一条规则**:
* ❌ `Builder(value)` —— 值参数,建不了依赖
* ✅ Builder 内部直接读 `this.xxx`
*
* ⚠️ `areaField` 保留:它还被别的地方用(备注已改成独立 Builder,
* 见下面 `noteField`)。真要彻底删得先确认没有其它调用点。
*/
@Builder
infoField() {
Column({ space: 6 }) {
Text('简介')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextArea({ text: this.info, placeholder: '剧情简介(可空,点「自动获取」从豆瓣填)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(110)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.info = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 备注(同上,直接读 `this.note`) */
@Builder
noteField() {
Column({ space: 6 }) {
Text('备注')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextArea({ text: this.note, placeholder: '想记的话(可空)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.note = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}