Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbMovieNewSheet.ets
T

1592 lines
64 KiB
Plaintext
Raw Normal View History

/**
* 家书 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)
}
}