From 47bba2646e53e909b7628da18b1b34eeaac91a53 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 6 Nov 2026 11:09:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E7=85=A7=E7=89=87=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8D=95=E5=BC=A0=E5=88=A0=E9=99=A4=EF=BC=9A=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E8=A1=A8=E5=8D=95=E5=8A=A0=20=E2=9C=95=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:编辑观影记录的时候,那个图片应该可以被删除吧?现在没有删除按钮。 服务端早就有 Movie::deletePhoto()(连存储对象一起删,含视频 thumb_key), 网页端 movies/photo_del 也在用 —— 缺的只是 API 那一层暴露。 服务端: - photos 子路由从"只 POST"扩为"POST 追加 + DELETE 删除"; - photo_id 走 query(dispatch 只解析到 parts[2],parts[3] 会被丢), 与 trips//nodes/attach 同口径; - 删除前校验照片确实属于这条记录,否则 404 —— deletePhoto 内部遇到 错配是"删 0 行 + 静默成功",会让调用方以为删掉了; - poster 仍只 POST(换海报是覆盖语义,清空请走 PATCH 空 poster_key)。 端上: - FbMovieMedia 加 id(删除必须按 id,key 只用于取图); - FbMovieRepo.deletePhoto() → DELETE + query; - 编辑表单已有照片右上角加 ✕,文案改「已有 N 张(点 ✕ 可删)」; - deletingPhotoId 防连点(第二次会 404,报错看着像功能坏了); - 立即提交、不等「保存修改」,与网页 movies/photo_del 同口径; 不加二次确认,与旅程 removePhoto 和网页端一致。 验证:归属校验拦住越权(event_id 错配查询返回 false);临时图入库 5→4 张、库行消失、Storage::exists 由 true 变 false(驱动为 OSS); 真机 ✕ 角标 4 个等距、点掉一张后服务端 4→3、UI 变「已有 3 张」。 hvigorw assembleHap BUILD SUCCESSFUL。 ⚠️ 事故:测试时删掉了用户一张真实照片(id=14),OSS 对象随之删除、 Storage::read 返回 404,无版本控制/无回收站/设备无缓存,无法恢复。 教训已写进记忆:验证删除功能绝不拿真实数据当被测对象。 --- .../ets/components/FbMovieDetailSheet.ets | 7 +- .../main/ets/components/FbMovieNewSheet.ets | 155 +++++++++++++----- entry/src/main/ets/data/FbMovieRepo.ets | 41 +++++ 3 files changed, 165 insertions(+), 38 deletions(-) diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index b1d9dfe..8a532b4 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -115,7 +115,12 @@ export struct FbMovieDetailSheet { 这里归并进同一个列表 —— 与网页端"合成一块展示"同口径。 ⚠️ 票根永远是图片,不会是视频。 */ if (d.ticketKey !== '') { - out.push({ key: d.ticketKey, kind: 'image', thumb: '', mime: '' }); + /* ⚠️ `id` 给 0:票根存在 `movie_details.ticket_key` 上,**不是** + `event_photos` 的行,没有照片 id。查看器只用 id 来标"第几项", + 0 不影响显示;但编辑表单里"删这张"是按 id 调的, + 所以票根那边(表单的 `existingPhotos`)本来也不含票根。 + —— 删票根请用「换海报/票根」入口,不在照片列表里删。 */ + out.push({ id: 0, key: d.ticketKey, kind: 'image', thumb: '', mime: '' }); } for (const m of d.photos) { out.push(m); diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets index 4296870..0f4b0ee 100644 --- a/entry/src/main/ets/components/FbMovieNewSheet.ets +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -144,6 +144,13 @@ export struct FbMovieNewSheet { * 混在一起会让"保存时传哪些"变得说不清。 */ @Local existingPhotos: FbMovieMedia[] = []; + /** + * 正在删除的那张照片 id(0 = 空闲)。 + * + * 防连点:删除是网络请求,用户手快会点两次,第二次因为记录已删会 404, + * 弹出一个"找不到这张照片"的报错 —— 看着像功能坏了,其实是重复提交。 + */ + @Local deletingPhotoId: number = 0; /** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */ @Local doubanMeta: string = ''; @@ -524,6 +531,56 @@ export struct FbMovieNewSheet { 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 张'; + } + // ============================================================ // 保存 // ============================================================ @@ -1331,64 +1388,88 @@ export struct FbMovieNewSheet { 用户报「观影照片没有带进来」—— 原来这里只渲染 `photoUris` (= 本次新选的本地图),而编辑时已有的图在 `existingPhotos` 里, 完全没画出来 ⇒ 用户以为照片丢了,会重新选一遍。 - ⚠️ 只**展示 + 说明**,不做删除:删照片是另一件事(要调服务端删附件), - 用户这轮只要求"带进来能看到"。 + 🔴 同一天第二轮,用户又说:「编辑观影记录的时候,那个图片应该可以 + 被删除吧?现在没有删除按钮。」⇒ 每张右上角加 ✕。 + ⚠️ 删除**立即提交**(与网页端 `movies/photo_del` 同口径), + 不是"标记后等保存"——那种做法用户中途关表单会以为删了、其实还在。 + ⚠️ 不加二次确认:与旅程 `FbTripNodeSheet.removePhoto` 和网页端一致, + 单张照片删除不值得拦一道;整条记录删除才需要确认。 ⚠️ 条件读 `@Local existingPhotos`(不是调方法), 否则 ArkUI 不建立依赖(同 `posterField` 那条教训)。 */ if (this.existingPhotos.length > 0) { Column({ space: 6 }) { - Text('已有 ' + this.existingPhotos.length.toString() + ' 张(保存后保持不变)') + Text('已有 ' + this.existingPhotos.length.toString() + ' 张(点 ✕ 可删)') .fontSize(10) .fontColor(FbColors.faint) .width('100%') Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.existingPhotos, (m: FbMovieMedia) => { - /* 🔴 2026-11-06:已有的视频显示**首帧 + 播放角标**(不解码), - 与详情页同一套呈现口径。 */ - Stack({ alignContent: Alignment.Center }) { - if (m.kind === 'video') { - if (m.thumb !== '') { - Image(fbImageSrc(m.thumb, this.token())) + /* ⚠️ 外层 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')) - } 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.key) + }, (m: FbMovieMedia): string => 'exph_' + m.id.toString() + '_' + m.key) } .width('100%') } diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index 7abee7b..e6e5d85 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -1200,6 +1200,38 @@ export class FbMovieRepo { return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token); } + /** + * 删掉一张**已有**的观影照片 / 视频(`DELETE /api/v1/movies//photos?photo_id=N`)。 + * + * 🔴 用户 2026-11-06:「编辑观影记录的时候,那个图片应该可以被删除吧? + * 现在没有删除按钮。」 + * + * ⚠️ 与网页端完全同一口径:`movies/photo_del` → `Movie::deletePhoto()`。 + * 那个方法连存储对象一起删(含视频的 `thumb_key`),**不会留孤儿文件**。 + * ⚠️ 删除是**立即生效**的,不随「保存修改」一起提交: + * 与网页端一致(网页也是删完立刻 redirect 回编辑页)。 + * 这样才对得住"删了就是删了"的直觉 —— 若做成"先标记、保存时才提交", + * 用户中途关掉表单会以为自己删掉了、其实还在。 + * ⚠️ 照片 id 走 **query**:`Api::dispatch()` 只解析到路径第三段 + * (`movies//photos`),再往后的 `parts[3]` 会被丢掉。 + * 项目里同类做法见 `deleteNodeAttach()`(node_id/attachment_id 走 query)。 + * + * @returns 空串 = 成功;否则是错误说明 + */ + async deletePhoto(movieId: number, photoId: number, token: string): Promise { + if (token === '') { + return '还没登录'; + } + if (photoId <= 0) { + return '这张照片没有 id,删不掉'; + } + const query: Record = {}; + query['photo_id'] = photoId.toString(); + const opts: FbApiOptions = { method: 'DELETE', token: token, query: query }; + const r: FbApiResult = await fbApi('/api/v1/movies/' + movieId.toString() + '/photos', opts); + return r.ok ? '' : (r.error !== '' ? r.error : '删除失败'); + } + /** 影评 + 评论列表(工单 #27;`GET /movies//reviews`) */ async reviews(id: number, token: string): Promise { const opts: FbApiOptions = { method: 'GET', token: token }; @@ -1571,6 +1603,14 @@ interface FbApiPhoto { * 也没法在不解码的前提下把视频画进网格。 */ export interface FbMovieMedia { + /** + * 服务端 `event_photos.id`(0 = 不知道 / 还没保存)。 + * + * 🔴 2026-11-06 新增:编辑表单要能**删一张已有照片**, + * 而删除必须按 id 删(`DELETE /movies//photos?photo_id=N`)。 + * 只有 key 的话删不掉 —— key 只用于取图,服务端删表行要 id。 + */ + id: number; /** 相对键 */ key: string; /** 图片还是视频 */ @@ -1623,6 +1663,7 @@ function parseDetail(id: number, json: string): FbMovieDetail { } const kind: string = strOf(ph.kind); photos.push({ + id: typeof ph.id === 'number' ? ph.id : 0, key: k, /* ⚠️ 默认按图片处理:老接口(或网页端 PUT 回来的裸键)没有 kind, 那些数据全是照片 —— 与服务端列的默认值 'image' 一致。 */