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

1048 lines
38 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, 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 })
}
/* 🔴 #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 ? '保存中…' : '记下这一场')
.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)
}
}