电影影评+评论:详情浮层接「评价与评论」真功能 laoyang/FamilyBook#27

用户原话(2026-10-28):「鸿蒙端对齐网页端」。观影详情原来只能看平均分。
现在:

1) FbMovieRepo 加影评/评论类型 + reviews/saveReview/deleteReview/
   addReviewComment/deleteReviewComment 五个 API + 解析(含感受目录、评论树、mine 判断)
2) 新组件 FbMovieReviewSheet:我的影评(打分 1-10 星 + 观后感受多选 + 正文 +
   改/删)、全部影评列表(含评论树、回复、逐条删除)
3) FbMovieDetailSheet 加「评价与评论」入口 + 内嵌影评浮层

⚠️ 影评数/均分仍以列表项为准(详情接口不返回,与既有注释一致)。

编译 BUILD SUCCESSFUL;服务端冒烟 _i27_movie_review_api.py 22/0 + 反证命中。
This commit is contained in:
2026-10-28 22:30:09 +08:00
parent 59a9d3df39
commit f1f279f973
3 changed files with 871 additions and 1 deletions
+228
View File
@@ -25,6 +25,7 @@ import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { fbUploadOne } from './FbUpload';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbAppState } from '../common/Store';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMovieRepo';
@@ -297,6 +298,55 @@ export interface FbMovieDetail {
photos: string[];
}
// ============================================================
// 影评与评论(工单 #27)
// ============================================================
/** 一条评论(服务端 movie_review_comments) */
export interface FbMovieComment {
id: number;
/** 评论人 */
name: string;
color: string;
content: string;
/** 回复的人(空 = 顶层评论) */
replyTo: string;
/** 时间 */
createdAt: string;
/** 是不是我发的(决定能否删) */
mine: boolean;
/** 回复列表 */
replies: FbMovieComment[];
}
/** 一条影评(服务端 movie_reviews + 评论树) */
export interface FbMovieReview {
id: number;
/** 评价人 */
name: string;
color: string;
rating: number;
content: string;
/** 感受(key 数组,端上查 catalog 显示 emoji + 文案) */
feelings: string[];
createdAt: string;
/** 是不是我写的(决定能否删/改) */
mine: boolean;
comments: FbMovieComment[];
}
/** 影评区的完整载荷(`GET /movies/<id>/reviews`) */
export interface FbMovieReviews {
reviews: FbMovieReview[];
/** 我的影评(null = 没写) */
mine: FbMovieReview | null;
/** 条数 / 均分 */
count: number;
avg: number;
/** 感受目录:key -> {emoji,label} */
catalog: Record<string, string>;
}
/**
* 观影仓库。
*
@@ -1066,6 +1116,52 @@ export class FbMovieRepo {
return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
}
/** 影评 + 评论列表(工单 #27;`GET /movies/<id>/reviews`) */
async reviews(id: number, token: string): Promise<FbMovieReviews> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/movies/' + id.toString() + '/reviews', opts);
if (!res.ok) {
return { reviews: [], mine: null, count: 0, avg: 0, catalog: {} };
}
return parseReviews(res.data);
}
/** 写 / 改我的影评(工单 #27;`POST /movies/<id>/review`) */
async saveReview(id: number, token: string, rating: number, content: string,
feelings: string[]): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['rating'] = rating;
body['content'] = content;
body['feelings'] = feelings;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/movies/' + id.toString() + '/review', opts);
}
/** 删我的影评(工单 #27;`DELETE /movies/<id>/review`) */
async deleteReview(id: number, token: string): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/movies/' + id.toString() + '/review', opts);
}
/** 评论 / 回复(工单 #27;`POST /movies/<id>/comments`) */
async addReviewComment(id: number, token: string, reviewId: number, content: string,
parentId: number): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['review_id'] = reviewId;
body['content'] = content;
body['parent_id'] = parentId;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/movies/' + id.toString() + '/comments', opts);
}
/** 删评论(工单 #27;`DELETE /movies/<id>/comments?comment_id=N`) */
async deleteReviewComment(id: number, token: string, commentId: number): Promise<FbApiResult> {
const q: Record<string, string> = {};
q['comment_id'] = commentId.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/movies/' + id.toString() + '/comments', opts);
}
async watermark(table: string): Promise<string> {
const row: FbRow | null = await this.db.queryOne(
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
@@ -1429,3 +1525,135 @@ function parseDetail(id: number, json: string): FbMovieDetail {
photos: photos
};
}
// ============================================================
// 影评与评论解析(工单 #27)
// ============================================================
/** 服务端影评行(`MovieReview::forEvent`,含 comments 树) */
interface RawMovieReview {
id?: number; person_id?: number; person_name?: string | null;
avatar_color?: string | null; rating?: number; content?: string | null;
feelings?: string[] | null; created_at?: string | null;
comments?: RawMovieCommentTree[];
}
/** 服务端评论(两级平铺:顶层 + 内嵌 replies) */
interface RawMovieCommentTree {
comment?: RawMovieComment;
/** 回复是**平铺的评论行**(不再嵌套,服务端 `attachComments` 里就是 `replies` 数组) */
replies?: RawMovieComment[];
}
interface RawMovieComment {
id?: number; person_id?: number; person_name?: string | null;
avatar_color?: string | null; content?: string | null;
reply_to_name?: string | null; created_at?: string | null;
}
/** 一条平铺评论行 → 端上形态 */
function parseOneComment(c: RawMovieComment, meId: number): FbMovieComment {
return {
id: c.id !== undefined ? c.id : 0,
name: c.person_name !== undefined && c.person_name !== null ? c.person_name : '',
color: c.avatar_color !== undefined && c.avatar_color !== null ? c.avatar_color : '#9AA0A6',
content: c.content !== undefined && c.content !== null ? c.content : '',
replyTo: c.reply_to_name !== undefined && c.reply_to_name !== null ? c.reply_to_name : '',
createdAt: c.created_at !== undefined && c.created_at !== null ? c.created_at : '',
mine: c.person_id !== undefined ? c.person_id === meId : false,
replies: [],
};
}
/** 评论树 → 端上形态(顶层一条 = 评论 + 平铺 replies) */
function parseCommentTree(nodes: RawMovieCommentTree[] | undefined, meId: number): FbMovieComment[] {
const out: FbMovieComment[] = [];
if (nodes === undefined || nodes === null) {
return out;
}
for (const node of nodes) {
const c: RawMovieComment = node.comment !== undefined ? node.comment : {};
const top: FbMovieComment = parseOneComment(c, meId);
if (node.replies !== undefined && node.replies !== null) {
const reps: FbMovieComment[] = [];
for (const r of node.replies) {
reps.push(parseOneComment(r, meId));
}
top.replies = reps;
}
out.push(top);
}
return out;
}
/** 影评行 → 端上形态 */
function parseOneReview(rw: RawMovieReview, meId: number): FbMovieReview {
const feelings: string[] = [];
if (rw.feelings !== undefined && rw.feelings !== null) {
for (const f of rw.feelings) {
if (f !== '') {
feelings.push(f);
}
}
}
const mine: boolean = rw.person_id !== undefined ? rw.person_id === meId : false;
return {
id: rw.id !== undefined ? rw.id : 0,
name: rw.person_name !== undefined && rw.person_name !== null ? rw.person_name : '',
color: rw.avatar_color !== undefined && rw.avatar_color !== null ? rw.avatar_color : '#9AA0A6',
rating: rw.rating !== undefined ? rw.rating : 0,
content: rw.content !== undefined && rw.content !== null ? rw.content : '',
feelings: feelings,
createdAt: rw.created_at !== undefined && rw.created_at !== null ? rw.created_at : '',
mine: mine,
comments: parseCommentTree(rw.comments, meId),
};
}
/** `GET /movies/<id>/reviews` 的载荷 → 端上形态 */
function parseReviews(json: string): FbMovieReviews {
const meId: number = fbAppState().session.me.id;
try {
const o: Record<string, Object> = JSON.parse(json) as Record<string, Object>;
const rows: RawMovieReview[] = (o['items'] !== undefined && o['items'] !== null)
? (o['items'] as RawMovieReview[]) : [];
const reviews: FbMovieReview[] = [];
for (const rw of rows) {
reviews.push(parseOneReview(rw, meId));
}
// mine:服务端 `MovieReview::mine`(单个对象或 null)
let mine: FbMovieReview | null = null;
const mv: Object | undefined = o['mine'];
if (mv !== undefined && mv !== null && typeof mv === 'object') {
mine = parseOneReview(mv as RawMovieReview, meId);
}
// stats + catalog
const st: Object | undefined = o['stats'];
let count: number = 0;
let avg: number = 0;
if (st !== undefined && st !== null && typeof st === 'object') {
const so = st as Record<string, Object>;
count = typeof so['count'] === 'number' ? so['count'] : 0;
avg = typeof so['avg'] === 'number' ? so['avg'] : 0;
}
const catalog: Record<string, string> = {};
const cat: Object | undefined = o['catalog'];
if (cat !== undefined && cat !== null && typeof cat === 'object') {
const co = cat as Record<string, Object>;
const keys: string[] = Object.keys(co);
for (const k of keys) {
const v: Object | undefined = co[k];
if (v !== undefined && v !== null && typeof v === 'object') {
const vo = v as Record<string, Object>;
const emoji: Object | undefined = vo['emoji'];
const label: Object | undefined = vo['label'];
catalog[k] = (typeof emoji === 'string' ? emoji : '') +
(typeof label === 'string' ? ' ' + label : '');
}
}
}
return { reviews: reviews, mine: mine, count: count, avg: avg, catalog: catalog };
} catch (err) {
return { reviews: [], mine: null, count: 0, avg: 0, catalog: {} };
}
}