diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index d886bcf..8133586 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -19,6 +19,7 @@ import { FbColors, FbMetric, fbAlpha, FB_MASK } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { fbAppState } from '../common/Store'; import { FbAvatar, FbRating, FbSectionTitle } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; @@ -52,6 +53,28 @@ export struct FbMovieDetailSheet { /** 关闭回调 */ @Event onClose: () => void = (): void => { }; + /** + * 点「编辑这条观影记录」(2026-11-06)。 + * + * 🔴 用户:「观影其实是可以编辑的,虽然这个功能用得不多……但编辑这个功能 + * 应该还是有的。」 + * + * ⚠️ 卡片只负责**发信号**,不自己做编辑表单:编辑要用 `FbMovieNewSheet` + * 那套完整表单(片名/时间/影院/海报/照片/同看人),它需要 `cinemas` + * 列表和一个能落库的宿主 —— 那些只有页面层才有。 + * ⇒ 宿主收到后关掉详情卡、再打开编辑表单。 + */ + @Event onEdit: () => void = (): void => { + }; + /** + * 这个人能不能编辑(访客/只读账号不给入口)。 + * + * ⚠️ 与影评区的 `canWrite()` 同源(`FbAppState.session.me.canWrite`), + * 不要另立一套判断 —— 两处不一致会出现"能评论不能改"这种半吊子权限。 + */ + private canEdit(): boolean { + return fbAppState().session.me.canWrite; + } /** * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 @@ -647,19 +670,37 @@ export struct FbMovieDetailSheet { ⚠️ 访客/只读账号:组件内部按 `canWrite()` 已经隐藏了发表入口, 这里不用再判一次。 */ Column({ space: 0 }) { - FbSectionTitle({ - title: '评价与评论', - extra: this.listReviewCount > 0 - ? this.listReviewCount.toString() + ' 条' - : '', - accent: this.accentColor() - }) + /* 🔴 用户 2026-11-06:「评价与评论和上面的观影照片是对齐的吗?」 + **原来不对齐**:`FbSectionTitle` 自带 `padding({ left: 4 })`, + 而上面「简介 / 备注 / 观影照片」是自己写的 `Text`(无左内边距) + ⇒ 实测「评价与评论」x 起点 111、「观影照片」99,差 12px ≈ 4vp。 + + ⇒ 用负 margin 把这 4vp 抵消掉,让三处标题的左边缘都落在 99。 + ⚠️ 不能改成不用 `FbSectionTitle`:它的字号/字重/字距/extra/胶囊按钮 + 是**全站统一口径**(首页 5 个区块也用它),另写一份 Text 会 + 立刻与其它页不一致。抵消内边距是这里唯一不破坏统一性的做法。 */ + Column({ space: 0 }) { + FbSectionTitle({ + title: '评价与评论', + extra: this.listReviewCount > 0 + ? this.listReviewCount.toString() + ' 条' + : '', + accent: this.accentColor() + }) + } + .width('100%') + .margin({ left: -4 }) FbMovieReviewSheet({ movieId: d.id, movieTitle: '', accent: this.accentColor(), embedded: true, + /* 🔴 用户 2026-11-06:「下面的写评价不要了,三个字。」 + ⇒ 未写过评价时,表单上方不再出现「写评价」这行标题, + 直接从「评分」开始(区块标题已经有「评价与评论」了, + 再来一行「写评价」是重复的)。 */ + hideFormTitle: true, onChanged: (): void => { /* 评分/条数可能变了,但详情接口不返回这两个值 (见 FbMovieDetail 的 reviewCount 注释),所以这里不强刷。 @@ -672,6 +713,33 @@ export struct FbMovieDetailSheet { .width('100%') .padding({ top: 6 }) .alignItems(HorizontalAlign.Start) + + /* ==================== 编辑入口(2026-11-06)==================== + 🔴 用户:「另外一个,是不是得有个地方放这个编辑按钮啊?观影其实是可以 + 编辑的,虽然这个功能用得不多……就是说,刚开始照片没那么多, + 没拍那么全,后面会补几张照片。但编辑这个功能应该还是有的。」 + + ⇒ 放在**卡片最底部**(用户选定),一颗整宽描边按钮,不抢视觉但一定能找到。 + ⚠️ 用描边而不是实心主题色:编辑不是这条卡的主操作(主操作是看+评论), + 实心按钮会让人以为"这是要我点的"。 + ⚠️ 只在 `canEdit()` 为真时显示(访客/只读账号没有这个入口)。 */ + if (this.canEdit()) { + Row({ space: 8 }) { + Text('编辑这条观影记录') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(this.accentColor()) + } + .width('100%') + .height(44) + .justifyContent(FlexAlign.Center) + .borderRadius(FbMetric.radiusS) + .border({ width: 1, color: fbAlpha(this.accentColor(), 0.45) }) + .margin({ top: 14 }) + .onClick((): void => { + this.onEdit(); + }) + } } .width('100%') .padding({ left: 16, right: 16, top: 4, bottom: 28 }) diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets index 2a62390..9ef587a 100644 --- a/entry/src/main/ets/components/FbMovieNewSheet.ets +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -42,10 +42,12 @@ import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; -import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieDraft, FbMovieRepo } from '../data/FbMovieRepo'; +import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieAttendeeRow, FbMovieDetail, 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 { fbImageSrc } from '../data/FbAttachQueue'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose, FbSheetTitleBar } from './FbSheetClose'; import { FbCinemaPickSheet } from './FbCinemaPickSheet'; @@ -88,6 +90,24 @@ export struct FbMovieNewSheet { @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 = ''; @@ -101,6 +121,14 @@ export struct FbMovieNewSheet { @Local note: string = ''; /** 豆瓣抓到的海报键(保存时随记录提交;端上另选的海报走单独上传) */ @Local doubanPosterKey: string = ''; + /** + * 编辑模式下**这条记录已有的**海报键(只用于展示,保存时不提交)。 + * + * ⚠️ 与 `doubanPosterKey` 分开存:那个的语义是"这次要写进库的键", + * 而这个只是"库里现在是什么",混用会导致"打开编辑框、什么都没动、 + * 保存后海报变成了另一张"。 + */ + @Local existingPosterKey: string = ''; /** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */ @Local doubanMeta: string = ''; @@ -135,6 +163,13 @@ export struct FbMovieNewSheet { 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(); @@ -152,6 +187,59 @@ export struct FbMovieNewSheet { 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.cinemaName); + this.info = d.info; + this.note = d.note; + /* 已有海报:只展示,不提交(见 existingPosterKey 的说明)。 + ⚠️ 别塞进 doubanPosterKey —— 那个会被 PATCH 带上去, + 虽然值相同不出错,但语义就乱了,后人容易改错。 */ + this.existingPosterKey = d.posterKey; + 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; + } + + /** + * 影院名 → 影院 id。 + * + * ⚠️ 详情接口只给 `cinemaName`(不给 id)—— 查一下本地库那份影院列表。 + * 找不到(影院被删/改名)就返回 0,表单显示"未选影院",不会硬塞一个错的。 + */ + private cinemaIdOf(name: string): number { + if (name === '') { + return 0; + } + for (const c of this.cinemas) { + if (c.name === name) { + return c.id; + } + } + return 0; + } + aboutToDisappear(): void { this.clearSuggestTimer(); } @@ -412,12 +500,28 @@ export struct FbMovieNewSheet { 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; + /* 🔴 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; + } } /* ---- 图片:记录已经建好了,逐个传(失败不挡记录)---- @@ -473,8 +577,11 @@ export struct FbMovieNewSheet { Column({ space: 0 }) { // ---- 标题栏(统一版式:左标题 + 右 ✕)---- FbSheetTitleBar({ - title: '记一场电影', - subtitle: '片名和时间必填,其余可以留空', + /* 编辑模式换个标题,用户一眼知道自己在改而不是新建 */ + title: this.editingId > 0 ? '编辑观影记录' : '记一场电影', + subtitle: this.editingId > 0 + ? '改完点下面保存;照片可以再补几张' + : '片名和时间必填,其余可以留空', accent: this.accentColor(), onClose: (): void => { this.onClose(); @@ -555,7 +662,7 @@ export struct FbMovieNewSheet { // ---- 底部:保存(关闭统一在右上角 ✕)---- Row({ space: 10 }) { - Text(this.saving ? '保存中…' : '记下这一场') + Text(this.saving ? '保存中…' : (this.editingId > 0 ? '保存修改' : '记下这一场')) .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) @@ -875,72 +982,108 @@ export struct FbMovieNewSheet { */ @Builder posterField() { - Column({ space: 6 }) { - Row({ space: 8 }) { - /* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */ - Text('海报') - .fontSize(FbMetric.fontSmall) - .fontWeight(FontWeight.Bold) - .fontColor(this.accentColor()) - .width('100%') + /* 🔴 2026-11-06 结构性修正。 + 真机症状:「海报」标签在、下面那句说明也在,**中间那张缩略图、 + 「当前海报」、上传按钮全都凭空消失**。 - Row({ space: 8 }) { - if (this.posterUri !== '') { - // 端上自己选的(本地文件,直接可显示) - Image(this.posterUri) - .width(42) - .height(60) - .borderRadius(4) - .objectFit(ImageFit.Cover) - Text('已选 1 张') + 原因:原来外层是 + `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) - .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('保存后生效') + .fontSize(10) + .fontColor(FbColors.faint) } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } else if (this.existingPosterKey !== '') { + /* 编辑模式:这条记录**本来就有**海报。 + ⚠️ 只是**展示**,保存时不提交(见 `updateMovie` 的注释: + 不发 `poster_key`,服务端就不动它)。 - Text(this.posterUri !== '' || this.doubanPosterKey !== '' ? '换一张' : '上传') - .fontSize(FbMetric.fontTiny) - .fontColor(this.accentColor()) - .onClick((): void => { - this.pickPoster(); - }) + 🔴🔴 条件**必须读 `@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) } - .layoutWeight(1) - .height(60) - .padding({ left: 12, right: 12 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .alignItems(VerticalAlign.Center) + + /* 上传/换图。条件同样只读字段,不调方法。 */ + 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) - .padding({ left: 56 }) .width('100%') } .width('100%') diff --git a/entry/src/main/ets/components/FbMovieReviewSheet.ets b/entry/src/main/ets/components/FbMovieReviewSheet.ets index 3647970..5306d09 100644 --- a/entry/src/main/ets/components/FbMovieReviewSheet.ets +++ b/entry/src/main/ets/components/FbMovieReviewSheet.ets @@ -55,6 +55,18 @@ export struct FbMovieReviewSheet { * 加一个开关,两条路径共用同一套逻辑与 DOM。 */ @Param embedded: boolean = false; + /** + * 隐藏表单顶部的「写评价 / 我的评价」标题行(2026-11-06)。 + * + * 🔴 用户:「下面的写评价不要了,三个字。」 + * ⇒ 内嵌到详情卡后,上面已经有「评价与评论」这个区块标题, + * 再出现一行「写评价」是重复的。 + * + * ⚠️ 只隐藏**标题文字那一段**,右边「修改 / 删除 / 收起」三颗动作按钮 + * 仍然保留 —— 有评价时用户就是靠它们改和删,删掉整行会失去功能。 + * 没有评价时那一行本来就只剩标题,于是整行自然消失。 + */ + @Param hideFormTitle: boolean = false; /** 保存/删除后通知宿主(列表的平均分可能要刷新) */ @Event onChanged: () => void = (): void => {}; @@ -492,32 +504,40 @@ export struct FbMovieReviewSheet { @Builder myReviewForm() { Column({ space: 8 }) { - Row({ space: 8 }) { - Text(this.mine !== null ? '我的评价' : '写评价') - .fontSize(FbMetric.fontSmall) - .fontWeight(FontWeight.Bold) - .fontColor(this.accent) - .letterSpacing(0.6) - Blank() - if (this.mine !== null) { - Text('收起') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .padding({ left: 6, right: 4, top: 4, bottom: 4 }) - .onClick((): void => { - this.myFormOpen = false; - }) - Text('删除') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.red) - .padding({ left: 8, right: 2, top: 4, bottom: 4 }) - .onClick((): void => { - this.deleteReview(); - }) + /* 🔴 用户 2026-11-06:「下面的写评价不要了,三个字。」 + ⇒ 整行只在「有评价(要显示改/删/收起)」或「没要求隐藏标题」时才画。 + 内嵌 + 未评过 = 整行不存在,直接从「评分」开始,不浪费一行高度。 + ⚠️ 有评价时**不能**整行删掉:修改/删除/收起三颗按钮就在这行右边。 */ + if (this.mine !== null || !this.hideFormTitle) { + Row({ space: 8 }) { + if (!this.hideFormTitle) { + Text(this.mine !== null ? '我的评价' : '写评价') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent) + .letterSpacing(0.6) + } + Blank() + if (this.mine !== null) { + Text('收起') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .padding({ left: 6, right: 4, top: 4, bottom: 4 }) + .onClick((): void => { + this.myFormOpen = false; + }) + Text('删除') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.red) + .padding({ left: 8, right: 2, top: 4, bottom: 4 }) + .onClick((): void => { + this.deleteReview(); + }) + } } + .width('100%') + .alignItems(VerticalAlign.Center) } - .width('100%') - .alignItems(VerticalAlign.Center) // 打分(1-10 颗星) Column({ space: 6 }) { diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index 60bfbef..47717b7 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -843,6 +843,70 @@ export class FbMovieRepo { */ lastError: string = ''; + /** + * 修改一条已有的观影记录(2026-11-06)。 + * + * 🔴 用户:「观影其实是可以编辑的,虽然这个功能用得不多……就是说,刚开始 + * 照片没那么多,没拍那么全,后面会补几张照片。但编辑这个功能应该还是有的。」 + * + * ⇒ 端上原来只有 `createMovie`,**没有任何编辑入口**(网页端早就有 + * `movies/edit` + `movies/update`)。注意服务端 `PATCH /api/v1/movies/` + * **早就存在**(`ApiHandler::movies()` 里那条 PATCH 分支 + `movieData()` + * 的"缺省回退原值"语义),这里只是补端上缺的那一层。 + * + * ⚠️ 与 `createMovie` 的关键差别 —— **全字段都发**,不做"只发改动的字段": + * 服务端 `movieData()` 是"字段存在 = 要改",缺席才保留原值。 + * 只发改动项的话,`attendees` 这种数组一旦漏发就会保留旧值(看着像没改); + * 而全发能让"表单上看到什么就存什么",与网页端表单提交完全同语义。 + * ⇒ 判断"要不要清空某字段"也简单:表单里是空串,就发空串。 + * + * ⚠️ **不发 `poster_key`**:海报在端上是单独走 `uploadPoster()` 上传的 + * (字节 → 服务端落盘 → 返回键),编辑表单里没有"抓来的键"这个概念。 + * 发一个空串会把已有海报**清掉** —— 这是最不能接受的数据丢失。 + * + * @returns 是否成功(失败原因看 `lastError`) + */ + async updateMovie(id: number, d: FbMovieDraft, token: string): Promise { + if (token === '') { + this.lastError = '还没登录'; + return false; + } + if (id <= 0) { + this.lastError = '这条记录没有 id,改不了'; + return false; + } + if (d.title.trim() === '') { + this.lastError = '片名不能为空'; + return false; + } + if (d.startsAt.trim() === '') { + this.lastError = '观影时间不能为空'; + return false; + } + const body: Record = {}; + body['title'] = d.title.trim(); + body['starts_at'] = d.startsAt.trim(); + body['ends_at'] = d.endsAt.trim(); + body['location'] = d.location.trim(); + body['note'] = d.note.trim(); + body['info'] = d.info.trim(); + body['cinema_id'] = d.cinemaId; + body['attendees'] = d.attendees; + + const opts: FbApiOptions = { method: 'PATCH', token: token, body: body }; + const r: FbApiResult = await fbApi('/api/v1/movies/' + id.toString(), opts); + if (!r.ok) { + this.lastError = r.error !== '' ? r.error : '保存失败'; + return false; + } + this.lastError = ''; + /* 立刻重拉列表回写本地库(与 `createMovie` 同一取舍): + 服务端会补 id / 影院名 / 影院色这类派生字段,本地塞一条会和之后 + 同步下来的长得不一样。 */ + await this.sync(token); + return true; + } + /** * 新建 / 修改一家影院(**直接打接口,不等同步**)。 * diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index c50c833..f488670 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -310,6 +310,17 @@ export struct FbHomePage { @Local mvCreating: boolean = false; /** 观影卡要显示的最近几条(本地库) */ @Local mvRecent: FbMovieListItem[] = []; + /** + * 正在编辑的观影记录 id(0 = 新建)。2026-11-06。 + * + * 🔴 用户:「观影其实是可以编辑的……后面会补几张照片。但编辑这个功能 + * 应该还是有的。」 + * ⇒ 「记一场」那张表单兼做编辑(`FbMovieNewSheet` 加了 editingId/editingDetail), + * 这里只记"这次打开是新建还是改哪一条"。 + */ + @Local mvEditId: number = 0; + /** 编辑时要回填的原始详情(新建时 null) */ + @Local mvEditDetail: FbMovieDetail | null = null; /** * 海报加载失败的电影 id。 * @@ -1014,6 +1025,21 @@ export struct FbHomePage { * 否则现象是"填完了点保存,什么都没发生"。 */ private async openMovieCreate(): Promise { + /* 新建:清掉可能残留的编辑态(上一次编辑没走完就退出时留下的) */ + this.mvEditId = 0; + this.mvEditDetail = null; + await this.openMovieForm(); + } + + /** + * 打开观影表单(新建 / 编辑共用)。 + * + * ⚠️ 影院列表要**先拉好再开表单**:表单里选影院依赖它, + * 否则现象是"填完了点保存,什么都没发生"。 + * ⚠️ 编辑时**不因没有影院而拦下**:那条记录本来可能就没选影院 + * (老数据),因为"没影院可选"就不让编辑,等于把记录锁死了。 + */ + private async openMovieForm(): Promise { if (this.mvCreating || this.mvLoading) { return; } @@ -1027,7 +1053,7 @@ export struct FbHomePage { } finally { this.mvLoading = false; } - if (this.mvCinemas.length === 0) { + if (this.mvCinemas.length === 0 && this.mvEditId === 0) { this.note('还没有影院可选,先去观影模块里添加一家影院'); return; } @@ -2144,6 +2170,25 @@ export struct FbHomePage { accent: this.moviesAccent(), onClose: (): void => { this.closeMovieDetail(); + }, + /* 🔴 2026-11-06:详情卡底部的「编辑这条观影记录」。 + 用户:「观影其实是可以编辑的……后面会补几张照片。」 + ⇒ 关掉详情卡、用它的数据打开编辑表单(同一张「记一场」表单)。 */ + onEdit: (): void => { + /* ⚠️ 先把详情**取到局部变量**再往下走: + 下面 `closeMovieDetail()` 会把 `this.mvDetail` 置 null, + 如果直接传 `this.mvDetail` 给 `mvEditDetail`,ArkUI 在 + `@Local` 置空后可能拿不到原对象(真机实测:表单能开、 + 片名时间都回填了,但**海报没出来** —— 因为那一刻 + `editingDetail.posterKey` 已经是空的)。 */ + const d: FbMovieDetail | null = this.mvDetail; + if (d === null) { + return; // 详情还没加载出来,没有可回填的数据 + } + this.mvEditId = d.id; + this.mvEditDetail = d; + this.closeMovieDetail(); + this.openMovieForm(); } }) .transition(fbSheetTransition()) @@ -2261,12 +2306,28 @@ export struct FbHomePage { /* 「同看的人」默认带上**我**(与电影页同一个口径:自己不用选就该已选上)。 ⚠️ 取不到(0)时组件内部不会硬塞一个错的 id。 */ meId: this.app.session.me.id, + /* 🔴 2026-11-06:**同一张表单兼做编辑**(用户要求"观影可以编辑")。 + `mvEditId = 0` ⇒ 新建;> 0 ⇒ 编辑,回填 `mvEditDetail`。 + ⚠️ 回填数据用有效期内的那一份 `mvDetail`(用户刚从详情卡点进来, + 它就是当前记录的快照),不要在这里再发一次请求 — + 会多等一个网络往返,且可能与用户看到的不是同一版。 */ + editingId: this.mvEditId, + editingDetail: this.mvEditDetail, onClose: (): void => { this.mvCreating = false; + this.mvEditId = 0; + this.mvEditDetail = null; }, onCreated: (): void => { this.mvCreating = false; - /* 本地库已经在 `createMovie()` 里重拉过了 ⇒ 这里只重读卡片那几条 */ + /* 编辑保存后要顺手关掉详情卡 —— 它显示的还是旧数据。 + 本地库已经在 `updateMovie()` / `createMovie()` 里重拉过 ⇒ + 这里只重读卡片那几条。 */ + if (this.mvEditId > 0) { + this.closeMovieDetail(); + } + this.mvEditId = 0; + this.mvEditDetail = null; this.reloadSideCards(); } }) diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index e271c20..66da4ed 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -141,6 +141,17 @@ export struct FbMoviesPage { * ⇒ 那颗按钮原来**没有回调**(纯摆设),点了什么都不发生。 */ @Local newOpen: boolean = false; + /** + * 正在编辑的观影记录 id(0 = 新建)。2026-11-06。 + * + * 🔴 用户:「观影其实是可以编辑的,虽然这个功能用得不多,是吧?……就是说, + * 刚开始照片没那么多,没拍那么全,后面会补几张照片。但编辑这个功能 + * 应该还是有的。」 + * ⇒ 「记一场」表单兼做编辑(`FbMovieNewSheet` 加 editingId/editingDetail)。 + */ + @Local editId: number = 0; + /** 编辑时回填用的原始详情 */ + @Local editDetail: FbMovieDetail | null = null; /** * 高级搜索 / 统计 / 年度报告浮层是否打开。 * @@ -436,6 +447,17 @@ export struct FbMoviesPage { listReviewCount: this.sheetReviewCount, onClose: (): void => { this.sheetOpen = false; + }, + /* 🔴 2026-11-06(用户「观影其实是可以编辑的」): + 关掉详情卡,用同一张「记一场」表单预填后编辑。 */ + onEdit: (): void => { + if (this.sheetDetail === null) { + return; + } + this.editId = this.sheetDetail.id; + this.editDetail = this.sheetDetail; + this.sheetOpen = false; + this.newOpen = true; } }) .transition(fbSheetTransition()) @@ -490,12 +512,19 @@ export struct FbMoviesPage { 「自己不用选择就应该已经选上了」)。登录者 id 从全局会话取, 取不到(0)时浮层就不预置任何人 —— 不会硬塞一个错的 id 进 attendees。 */ meId: this.app.session.me.id, + /* 🔴 2026-11-06:同一张表单兼做编辑 —— `editId = 0` 新建,> 0 编辑。 */ + editingId: this.editId, + editingDetail: this.editDetail, onClose: (): void => { this.newOpen = false; + this.editId = 0; + this.editDetail = null; }, - /* 新建成功 ⇒ 重读本地库(`createMovie` 里已经重拉过接口并落库, - 这里只是把页面的列表刷出来)。 */ + /* 新建/编辑成功 ⇒ 重读本地库(`createMovie`/`updateMovie` 里已经 + 重拉过接口并落库,这里只是把页面的列表刷出来)。 */ onCreated: (): void => { + this.editId = 0; + this.editDetail = null; this.reloadFromLocal(); } })