观影照片支持单张删除:编辑表单加 ✕ (#86)
用户:编辑观影记录的时候,那个图片应该可以被删除吧?现在没有删除按钮。
服务端早就有 Movie::deletePhoto()(连存储对象一起删,含视频 thumb_key),
网页端 movies/photo_del 也在用 —— 缺的只是 API 那一层暴露。
服务端:
- photos 子路由从"只 POST"扩为"POST 追加 + DELETE 删除";
- photo_id 走 query(dispatch 只解析到 parts[2],parts[3] 会被丢),
与 trips/<id>/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,无版本控制/无回收站/设备无缓存,无法恢复。
教训已写进记忆:验证删除功能绝不拿真实数据当被测对象。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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,21 +1388,27 @@ 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 用 `TopEnd` 是为了把 ✕ 顶到右上角;
|
||||
媒体自身(含视频的播放角标)放在内层 Stack 里居中,
|
||||
两者对齐方式不同,不能共用一个 Stack。 */
|
||||
Stack({ alignContent: Alignment.TopEnd }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
if (m.kind === 'video') {
|
||||
if (m.thumb !== '') {
|
||||
@@ -1387,8 +1450,26 @@ export struct FbMovieNewSheet {
|
||||
}
|
||||
.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%')
|
||||
}
|
||||
|
||||
@@ -1200,6 +1200,38 @@ export class FbMovieRepo {
|
||||
return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉一张**已有**的观影照片 / 视频(`DELETE /api/v1/movies/<id>/photos?photo_id=N`)。
|
||||
*
|
||||
* 🔴 用户 2026-11-06:「编辑观影记录的时候,那个图片应该可以被删除吧?
|
||||
* 现在没有删除按钮。」
|
||||
*
|
||||
* ⚠️ 与网页端完全同一口径:`movies/photo_del` → `Movie::deletePhoto()`。
|
||||
* 那个方法连存储对象一起删(含视频的 `thumb_key`),**不会留孤儿文件**。
|
||||
* ⚠️ 删除是**立即生效**的,不随「保存修改」一起提交:
|
||||
* 与网页端一致(网页也是删完立刻 redirect 回编辑页)。
|
||||
* 这样才对得住"删了就是删了"的直觉 —— 若做成"先标记、保存时才提交",
|
||||
* 用户中途关掉表单会以为自己删掉了、其实还在。
|
||||
* ⚠️ 照片 id 走 **query**:`Api::dispatch()` 只解析到路径第三段
|
||||
* (`movies/<id>/photos`),再往后的 `parts[3]` 会被丢掉。
|
||||
* 项目里同类做法见 `deleteNodeAttach()`(node_id/attachment_id 走 query)。
|
||||
*
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
async deletePhoto(movieId: number, photoId: number, token: string): Promise<string> {
|
||||
if (token === '') {
|
||||
return '还没登录';
|
||||
}
|
||||
if (photoId <= 0) {
|
||||
return '这张照片没有 id,删不掉';
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
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/<id>/reviews`) */
|
||||
async reviews(id: number, token: string): Promise<FbMovieReviews> {
|
||||
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/<id>/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' 一致。 */
|
||||
|
||||
Reference in New Issue
Block a user