1041 lines
37 KiB
Plaintext
1041 lines
37 KiB
Plaintext
/**
|
||||
|
|
* 家书 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, FbMovieDraft, FbMovieRepo } from '../data/FbMovieRepo';
|
|||
|
|
import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo';
|
|||
|
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
|||
|
|
import { fbApiBase, fbImageUrl } from '../data/FbHttp';
|
|||
|
|
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;
|
|||
|
|
|
|||
|
|
// ---- 表单字段 ----
|
|||
|
|
@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 = '';
|
|||
|
|
/** 豆瓣抓到的元数据 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 {
|
|||
|
|
// 默认给"今天 + 下一个整点":记一场电影通常就是"刚刚看的",
|
|||
|
|
// 给个空的让用户从头选日期是最烦的。
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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('、');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 = '正在保存…';
|
|||
|
|
|
|||
|
|
const id: number = 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: '记一场电影',
|
|||
|
|
subtitle: '片名和时间必填,其余可以留空',
|
|||
|
|
accent: 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.areaField('简介', '剧情简介(可空,点「自动获取」从豆瓣填)', this.info,
|
|||
|
|
(v: string): void => {
|
|||
|
|
this.info = v;
|
|||
|
|
})
|
|||
|
|
this.areaField('备注', '想记的话(可空)', this.note,
|
|||
|
|
(v: string): void => {
|
|||
|
|
this.note = v;
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.padding({ top: 6, bottom: 4 })
|
|||
|
|
}
|
|||
|
|
.flexShrink(1)
|
|||
|
|
.width('100%')
|
|||
|
|
.scrollBar(BarState.Off)
|
|||
|
|
.align(Alignment.TopStart)
|
|||
|
|
|
|||
|
|
// ---- 结果提示 ----
|
|||
|
|
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 ? '保存中…' : '记下这一场')
|
|||
|
|
.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 }) {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Row({ space: 2 }) {
|
|||
|
|
Text('片名')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
Text('*')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.red)
|
|||
|
|
}
|
|||
|
|
.width(48)
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
|
|||
|
|
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 => {
|
|||
|
|
this.title = v;
|
|||
|
|
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() {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text('影院')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.width(48)
|
|||
|
|
|
|||
|
|
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())
|
|||
|
|
}
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
.height(42)
|
|||
|
|
.padding({ left: 12, right: 12 })
|
|||
|
|
.backgroundColor(FbColors.inputBg)
|
|||
|
|
.borderRadius(FbMetric.radiusS)
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
.onClick((): void => {
|
|||
|
|
this.cinemaPickOpen = true;
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 同看的人(**点开弹框选**,带拼音首字母筛选)。
|
|||
|
|
*
|
|||
|
|
* 🔴 用户:「同看的人也应该是弹框选择,这种选择方式太难找了。」
|
|||
|
|
* ⇒ 原来那排"只列前 40 个的横向头像"删掉 —— 名册上千人,
|
|||
|
|
* 要找的人排在后面就永远找不到(这正是用户说的"太难找")。
|
|||
|
|
*/
|
|||
|
|
@Builder
|
|||
|
|
personField() {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text('同看的人')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.width(48)
|
|||
|
|
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text(this.picked.length > 0
|
|||
|
|
? ('已选 ' + this.picked.length.toString() + ' 人:' + this.pickedNames())
|
|||
|
|
: '还没有选人')
|
|||
|
|
.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())
|
|||
|
|
}
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
.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%')
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 海报(1 张;可从豆瓣抓、也可自己选)。
|
|||
|
|
*
|
|||
|
|
* 🔴 用户:「允许上传多张图片,**包括海报**。」
|
|||
|
|
*/
|
|||
|
|
@Builder
|
|||
|
|
posterField() {
|
|||
|
|
Column({ space: 6 }) {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text('海报')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.width(48)
|
|||
|
|
|
|||
|
|
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 {
|
|||
|
|
Text('还没有海报')
|
|||
|
|
.fontSize(FbMetric.fontSmall)
|
|||
|
|
.fontColor(FbColors.muted)
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Text(this.posterUri !== '' || this.doubanPosterKey !== '' ? '换一张' : '上传')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(this.accentColor())
|
|||
|
|
.onClick((): void => {
|
|||
|
|
this.pickPoster();
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
.height(60)
|
|||
|
|
.padding({ left: 12, right: 12 })
|
|||
|
|
.backgroundColor(FbColors.inputBg)
|
|||
|
|
.borderRadius(FbMetric.radiusS)
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
|
|||
|
|
Text('海报可以点「自动获取」由豆瓣带回来,也可以自己传一张(竖版效果最好)')
|
|||
|
|
.fontSize(10)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.padding({ left: 56 })
|
|||
|
|
.width('100%')
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(HorizontalAlign.Start)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 观影照片(**多张**)。
|
|||
|
|
*
|
|||
|
|
* 🔴 用户:「允许上传多张图片,包括海报。」
|
|||
|
|
* ⇒ 可一次选多张、也可以多次追加;已选的以缩略图网格列出,可逐张删。
|
|||
|
|
*/
|
|||
|
|
@Builder
|
|||
|
|
photoField() {
|
|||
|
|
Column({ space: 6 }) {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text('观影照片')
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.width(48)
|
|||
|
|
|
|||
|
|
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())
|
|||
|
|
}
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
.height(42)
|
|||
|
|
.padding({ left: 12, right: 12 })
|
|||
|
|
.backgroundColor(FbColors.inputBg)
|
|||
|
|
.borderRadius(FbMetric.radiusS)
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
.onClick((): void => {
|
|||
|
|
this.pickPhotos();
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(VerticalAlign.Center)
|
|||
|
|
|
|||
|
|
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 })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(HorizontalAlign.Start)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 一行多行文本输入(简介 / 备注) */
|
|||
|
|
@Builder
|
|||
|
|
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
|||
|
|
Row({ space: 8 }) {
|
|||
|
|
Text(label)
|
|||
|
|
.fontSize(FbMetric.fontTiny)
|
|||
|
|
.fontColor(FbColors.faint)
|
|||
|
|
.width(48)
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
.layoutWeight(1)
|
|||
|
|
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
|||
|
|
.onChange((v: string): void => {
|
|||
|
|
onChange(v);
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
.width('100%')
|
|||
|
|
.alignItems(VerticalAlign.Top)
|
|||
|
|
}
|
|||
|
|
}
|