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