2026-10-20 07:35:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 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';
|
2026-11-06 08:44:36 +08:00
|
|
|
|
import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieAttendeeRow, FbMovieDetail, FbMovieDraft,
|
|
|
|
|
|
FbMovieRepo } from '../data/FbMovieRepo';
|
2026-10-20 07:35:37 +08:00
|
|
|
|
import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo';
|
|
|
|
|
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
|
|
|
|
|
import { fbApiBase, fbImageUrl } from '../data/FbHttp';
|
2026-11-06 08:44:36 +08:00
|
|
|
|
import { fbImageSrc } from '../data/FbAttachQueue';
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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;
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 编辑模式:要修改的记录 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;
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 表单字段 ----
|
|
|
|
|
|
@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 = '';
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 编辑模式下**这条记录已有的**海报键(只用于展示,保存时不提交)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 与 `doubanPosterKey` 分开存:那个的语义是"这次要写进库的键",
|
|
|
|
|
|
* 而这个只是"库里现在是什么",混用会导致"打开编辑框、什么都没动、
|
|
|
|
|
|
* 保存后海报变成了另一张"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local existingPosterKey: string = '';
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 编辑模式下**这条记录已有的观影照片**(相对键;只展示,不提交)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 用户 2026-11-06:「观影照片…没带进来。」
|
|
|
|
|
|
* 原来照片区只渲染 `photoUris`(本次新选的本地图),已有照片在
|
|
|
|
|
|
* `editingDetail.photos` 里**完全没画** ⇒ 用户以为照片丢了。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 用 `@Local` 而不是在 build 里读 `this.editingDetail.photos`:
|
|
|
|
|
|
* `if` 条件里调方法/取对象深层字段都不建依赖(本项目反复踩过)。
|
|
|
|
|
|
* ⚠️ 与 `photoUris` 分开存:那个是"待上传的本地 uri",语义不同,
|
|
|
|
|
|
* 混在一起会让"保存时传哪些"变得说不清。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local existingPhotos: string[] = [];
|
2026-10-20 07:35:37 +08:00
|
|
|
|
/** 豆瓣抓到的元数据 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 {
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/* 🔴 编辑模式:先用原始详情把字段填满,**不要**再走下面那套"今天+下个整点"
|
|
|
|
|
|
的默认值 —— 那会把用户原来填的时间冲掉,看起来像"打开就变了个日期"。 */
|
|
|
|
|
|
if (this.editingId > 0 && this.editingDetail !== null) {
|
|
|
|
|
|
this.prefill(this.editingDetail);
|
|
|
|
|
|
this.loadRoster();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
// 默认给"今天 + 下一个整点":记一场电影通常就是"刚刚看的",
|
|
|
|
|
|
// 给个空的让用户从头选日期是最烦的。
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 用已有详情回填表单(编辑模式)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ `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) : '';
|
2026-11-06 10:30:39 +08:00
|
|
|
|
this.cinemaId = this.cinemaIdOf(d);
|
2026-11-06 08:44:36 +08:00
|
|
|
|
this.info = d.info;
|
|
|
|
|
|
this.note = d.note;
|
|
|
|
|
|
/* 已有海报:只展示,不提交(见 existingPosterKey 的说明)。
|
|
|
|
|
|
⚠️ 别塞进 doubanPosterKey —— 那个会被 PATCH 带上去,
|
|
|
|
|
|
虽然值相同不出错,但语义就乱了,后人容易改错。 */
|
|
|
|
|
|
this.existingPosterKey = d.posterKey;
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 已有观影照片(票根另算,它是 movie_details.ticket_key,不在这里) */
|
|
|
|
|
|
this.existingPhotos = d.photos.slice();
|
2026-11-06 08:44:36 +08:00
|
|
|
|
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 10:30:39 +08:00
|
|
|
|
/* 🔴 2026-11-06:回填完把豆瓣联想的任何残留态清干净。
|
|
|
|
|
|
用户报的「片名下面列出来好几个条目(来自豆瓣)」就是联想列表 ——
|
|
|
|
|
|
拦它的地方在 `titleField` 的 `onChange`(值没真变就不调度),
|
|
|
|
|
|
这里负责兜底清一次,双保险。 */
|
|
|
|
|
|
this.clearSuggestTimer();
|
|
|
|
|
|
this.suggests = [];
|
|
|
|
|
|
this.suggestOpen = false;
|
2026-11-06 08:44:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-11-06 10:30:39 +08:00
|
|
|
|
* 影院 id 的回填。
|
2026-11-06 08:44:36 +08:00
|
|
|
|
*
|
2026-11-06 10:30:39 +08:00
|
|
|
|
* 🔴 2026-11-06:原来只能按**影院名**反查(因为 `FbMovieDetail` 里没有 id),
|
|
|
|
|
|
* 用户报的「影院没带进来」就是这个 ——
|
|
|
|
|
|
* · 影院重名(很多城市都有「万达影城」)⇒ 反查选中了错的那家;
|
|
|
|
|
|
* · 影院改过名 ⇒ 旧记录的 `cinema_name` 与影院表对不上 ⇒ 直接回填失败。
|
|
|
|
|
|
* 现在 `FbMovieDetail.cinemaId` 已经从接口接上了,**优先用 id**,
|
|
|
|
|
|
* 只有 id 缺失(老数据 / 接口没给)时才退回按名字模糊匹配。
|
2026-11-06 08:44:36 +08:00
|
|
|
|
*/
|
2026-11-06 10:30:39 +08:00
|
|
|
|
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 === '') {
|
2026-11-06 08:44:36 +08:00
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
2026-11-06 10:30:39 +08:00
|
|
|
|
const byName: FbCinemaItem | undefined =
|
|
|
|
|
|
this.cinemas.find((c: FbCinemaItem): boolean => c.name === d.cinemaName);
|
|
|
|
|
|
return byName !== undefined ? byName.id : 0;
|
2026-11-06 08:44:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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 10:30:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「同看的人」那一行的文案。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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();
|
|
|
|
|
|
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 保存
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
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 08:44:36 +08:00
|
|
|
|
/* 🔴 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;
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 图片:记录已经建好了,逐个传(失败不挡记录)----
|
|
|
|
|
|
⚠️ 这一步的顺序很重要:**先建记录再传图**。
|
|
|
|
|
|
反过来(先传图再建记录)的话,建记录失败就会留下一堆
|
|
|
|
|
|
"没人认领的图"(服务端 `/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({
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/* 编辑模式换个标题,用户一眼知道自己在改而不是新建 */
|
|
|
|
|
|
title: this.editingId > 0 ? '编辑观影记录' : '记一场电影',
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 🔴 用户 2026-11-06:「编辑观影记录这几个字,也应该是主题颜色才对,
|
|
|
|
|
|
下面的一行小字(改完点下面保存,照片可以再补几张。),删除不要了。」
|
|
|
|
|
|
⇒ ① 标题用主题色(见下面 `titleColor` 参数的来源);
|
|
|
|
|
|
② **编辑模式不再传 subtitle** —— 那句说明是他明确要删的。
|
|
|
|
|
|
新建模式保留原有的提示(那边还需要"片名和时间必填"这句引导)。 */
|
|
|
|
|
|
subtitle: this.editingId > 0 ? '' : '片名和时间必填,其余可以留空',
|
2026-10-20 07:35:37 +08:00
|
|
|
|
accent: this.accentColor(),
|
2026-11-06 10:30:39 +08:00
|
|
|
|
titleColor: this.accentColor(),
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-11-06 10:30:39 +08:00
|
|
|
|
this.infoField()
|
|
|
|
|
|
|
|
|
|
|
|
this.noteField()
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ top: 6, bottom: 4 })
|
|
|
|
|
|
}
|
2026-11-05 12:41:04 +08:00
|
|
|
|
/* 🔴 #55(用户:「"记一场电影"这种输入,输入框怎么上下划不动?」)
|
|
|
|
|
|
根因:`Scroll` 在 `Column` 里只写了 `flexShrink(1)` ——
|
|
|
|
|
|
那只是"允许被压缩",**不等于"占满剩余高度"**。
|
|
|
|
|
|
没有 `layoutWeight(1)` 时,Scroll 的高度 = 内容高度
|
|
|
|
|
|
(超出父级后被裁切)⇒ 自身没有可滚动空间 ⇒ **划不动**。
|
|
|
|
|
|
⇒ 必须给它 `layoutWeight(1)` 才能拿到父级剩下的高度。 */
|
|
|
|
|
|
.layoutWeight(1)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.width('100%')
|
|
|
|
|
|
.scrollBar(BarState.Off)
|
|
|
|
|
|
.align(Alignment.TopStart)
|
2026-11-05 12:41:04 +08:00
|
|
|
|
.edgeEffect(EdgeEffect.Spring)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 结果提示 ----
|
|
|
|
|
|
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 }) {
|
2026-11-06 08:44:36 +08:00
|
|
|
|
Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下这一场'))
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.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 }) {
|
2026-11-05 12:52:51 +08:00
|
|
|
|
/* GLOBAL-FORM-SPEC(用户 2026-11-05):标签独占第一排,输入框第二排。
|
|
|
|
|
|
原来是 `Row` 里 `标签.width(48) + 输入框`,写死 48 只是"凑"对齐,
|
|
|
|
|
|
字数一变就错开。片名必填 ⇒ 红色,**无星号**(用户明确要求去掉)。 */
|
|
|
|
|
|
Text('片名')
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor('#C0533F')
|
|
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-05 12:52:51 +08:00
|
|
|
|
Row({ space: 8 }) {
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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 10:30:39 +08:00
|
|
|
|
/* 🔴 2026-11-06:编辑模式回填时,`TextInput` 会把"刚被赋值的新内容"
|
|
|
|
|
|
当成一次 onChange 回调(用户报的"片名下面列出来好几个条目")。
|
|
|
|
|
|
⇒ 值没有真正变化时,不调度联想 ——
|
|
|
|
|
|
回填是"值本来就是这个",不是"用户改了"。
|
|
|
|
|
|
⚠️ 这里用"值比较"而不是 `prefilling` 标记:
|
|
|
|
|
|
`prefill()` 是同步执行完的,标记会立刻复位;
|
|
|
|
|
|
而 `onChange` 是在**下一帧**渲染时才回调的,
|
|
|
|
|
|
那时标记早就 false 了,拦不住(我第一版就是这么写的,没用)。 */
|
|
|
|
|
|
const changed: boolean = v !== this.title;
|
2026-10-20 07:35:37 +08:00
|
|
|
|
this.title = v;
|
2026-11-06 10:30:39 +08:00
|
|
|
|
if (!changed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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 10:30:39 +08:00
|
|
|
|
/* 🔴 2026-11-06 结构性修正(与 `posterField` 同一条病):
|
|
|
|
|
|
原来外层是 `Row { Text('影院').width('100%'); Row { 值…选择 } }`
|
|
|
|
|
|
—— 标签 `width('100%')` 吃满整行,把内层 `Row` 挤到行外。
|
|
|
|
|
|
真机症状:编辑表单里「影院」只剩一个标签,**下面的值整个不见了**
|
|
|
|
|
|
(用户报「影院没带进来」,其实是根本没渲染)。
|
|
|
|
|
|
⇒ 按《全局表单规范》改成上下两排:标签第一排、控件第二排整宽。 */
|
|
|
|
|
|
Column({ space: 6 }) {
|
2026-11-05 12:52:51 +08:00
|
|
|
|
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
|
2026-10-20 07:35:37 +08:00
|
|
|
|
Text('影院')
|
2026-11-05 12:52:51 +08:00
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor(this.accentColor())
|
|
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 第二排:色点 + 当前选中 + 「选择」 */
|
2026-10-20 07:35:37 +08:00
|
|
|
|
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())
|
|
|
|
|
|
}
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.height(42)
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.alignItems(VerticalAlign.Center)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.cinemaPickOpen = true;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 同看的人(**点开弹框选**,带拼音首字母筛选)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 用户:「同看的人也应该是弹框选择,这种选择方式太难找了。」
|
|
|
|
|
|
* ⇒ 原来那排"只列前 40 个的横向头像"删掉 —— 名册上千人,
|
|
|
|
|
|
* 要找的人排在后面就永远找不到(这正是用户说的"太难找")。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
personField() {
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 🔴 2026-11-06 结构性修正(与 `cinemaField` / `posterField` 同一条病):
|
|
|
|
|
|
原来外层 `Row` 里标签 `width('100%')` 把内层 `Row` 挤出可视区,
|
|
|
|
|
|
真机症状是「同看的人」只剩标签、**下面的值整个不渲染**
|
|
|
|
|
|
(用户报「同看的人没带进来」其实是根本没画)。
|
|
|
|
|
|
⇒ 改成上下两排。 */
|
|
|
|
|
|
Column({ space: 6 }) {
|
2026-11-05 12:52:51 +08:00
|
|
|
|
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
|
2026-10-20 07:35:37 +08:00
|
|
|
|
Text('同看的人')
|
2026-11-05 12:52:51 +08:00
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor(this.accentColor())
|
|
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 第二排:已选人 + 「选择」 */
|
2026-10-20 07:35:37 +08:00
|
|
|
|
Row({ space: 8 }) {
|
2026-11-06 10:30:39 +08:00
|
|
|
|
Text(this.pickedLabel())
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted)
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.flexShrink(1)
|
|
|
|
|
|
Text('选择')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(this.accentColor())
|
|
|
|
|
|
}
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.height(42)
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.alignItems(VerticalAlign.Center)
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
if (this.roster.length === 0) {
|
|
|
|
|
|
this.bad = true;
|
|
|
|
|
|
this.note2 = '名册还没同步,同看人稍后可以在网页端补';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.personPickOpen = true;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 海报(1 张;可从豆瓣抓、也可自己选)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 用户:「允许上传多张图片,**包括海报**。」
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
posterField() {
|
2026-11-06 08:44:36 +08:00
|
|
|
|
/* 🔴 2026-11-06 结构性修正。
|
|
|
|
|
|
真机症状:「海报」标签在、下面那句说明也在,**中间那张缩略图、
|
|
|
|
|
|
「当前海报」、上传按钮全都凭空消失**。
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-06 08:44:36 +08:00
|
|
|
|
原因:原来外层是
|
|
|
|
|
|
`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('豆瓣海报')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
2026-11-06 08:44:36 +08:00
|
|
|
|
Text('保存后生效')
|
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
2026-11-06 08:44:36 +08:00
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
} else if (this.existingPosterKey !== '') {
|
|
|
|
|
|
/* 编辑模式:这条记录**本来就有**海报。
|
|
|
|
|
|
⚠️ 只是**展示**,保存时不提交(见 `updateMovie` 的注释:
|
|
|
|
|
|
不发 `poster_key`,服务端就不动它)。
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-06 08:44:36 +08:00
|
|
|
|
🔴🔴 条件**必须读 `@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)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
2026-11-06 08:44:36 +08:00
|
|
|
|
|
|
|
|
|
|
/* 上传/换图。条件同样只读字段,不调方法。 */
|
|
|
|
|
|
Text((this.posterUri !== '' || this.doubanPosterKey !== '' || this.existingPosterKey !== '')
|
|
|
|
|
|
? '换一张' : '上传')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(this.accentColor())
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.pickPoster();
|
|
|
|
|
|
})
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-11-06 08:44:36 +08:00
|
|
|
|
.height(60)
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.alignItems(VerticalAlign.Center)
|
|
|
|
|
|
|
|
|
|
|
|
Text('海报可以点「自动获取」由豆瓣带回来,也可以自己传一张(竖版效果最好)')
|
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
|
.fontColor(FbColors.faint)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 观影照片(**多张**)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 用户:「允许上传多张图片,包括海报。」
|
|
|
|
|
|
* ⇒ 可一次选多张、也可以多次追加;已选的以缩略图网格列出,可逐张删。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
photoField() {
|
|
|
|
|
|
Column({ space: 6 }) {
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 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%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/* 第二排:说明/已选数 + 「选照片」 */
|
|
|
|
|
|
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())
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.height(42)
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.backgroundColor(FbColors.inputBg)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.alignItems(VerticalAlign.Center)
|
2026-11-06 10:30:39 +08:00
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.pickPhotos();
|
|
|
|
|
|
})
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
|
|
|
|
|
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 10:30:39 +08:00
|
|
|
|
|
|
|
|
|
|
/* 🔴 2026-11-06:**已有照片**(编辑模式)。
|
|
|
|
|
|
用户报「观影照片没有带进来」—— 原来这里只渲染 `photoUris`
|
|
|
|
|
|
(= 本次新选的本地图),而编辑时已有的图在 `existingPhotos` 里,
|
|
|
|
|
|
完全没画出来 ⇒ 用户以为照片丢了,会重新选一遍。
|
|
|
|
|
|
⚠️ 只**展示 + 说明**,不做删除:删照片是另一件事(要调服务端删附件),
|
|
|
|
|
|
用户这轮只要求"带进来能看到"。
|
|
|
|
|
|
⚠️ 条件读 `@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, (k: string) => {
|
|
|
|
|
|
Image(fbImageSrc(k, this.token()))
|
|
|
|
|
|
.width(60)
|
|
|
|
|
|
.height(60)
|
|
|
|
|
|
.borderRadius(4)
|
|
|
|
|
|
.objectFit(ImageFit.Cover)
|
|
|
|
|
|
.alt($r('app.media.fb_ic_movies'))
|
|
|
|
|
|
.margin({ right: 8, bottom: 8 })
|
|
|
|
|
|
}, (k: string): string => 'exph_' + k)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-06 10:30:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一行多行文本输入。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ **值参数 Builder 有坑**(本项目 `pitfalls.md` 记过):
|
|
|
|
|
|
* `Builder(value)` 的值参数**不参与状态观察**,
|
|
|
|
|
|
* `TextArea({ text: value })` 只拿得到首次调用时的字面量。
|
|
|
|
|
|
* ⇒ 简介 / 备注**已改成独立 Builder**(`infoField` / `noteField`),
|
|
|
|
|
|
* 直接在内部读 `this.info` / `this.note`,编辑回填才能显示出来。
|
|
|
|
|
|
* ⇒ 这个通用版留着给"值不会变、或调用方自己保证重传"的场景用。
|
|
|
|
|
|
*/
|
2026-10-20 07:35:37 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
2026-11-05 12:52:51 +08:00
|
|
|
|
/* GLOBAL-FORM-SPEC:标签独占第一排(原来是 .width(48) 硬凑) */
|
|
|
|
|
|
Column({ space: 6 }) {
|
2026-10-20 07:35:37 +08:00
|
|
|
|
Text(label)
|
2026-11-05 12:52:51 +08:00
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor(this.accentColor())
|
|
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-11-05 12:52:51 +08:00
|
|
|
|
.width('100%')
|
2026-10-20 07:35:37 +08:00
|
|
|
|
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
|
|
|
|
|
.onChange((v: string): void => {
|
|
|
|
|
|
onChange(v);
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-11-05 12:52:51 +08:00
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|
2026-11-06 10:30:39 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 简介(横跨整个表单宽度)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 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)
|
|
|
|
|
|
}
|
2026-10-20 07:35:37 +08:00
|
|
|
|
}
|