电影影评+评论:详情浮层接「评价与评论」真功能 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:
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user