电影影评+评论:详情浮层接「评价与评论」真功能 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
@@ -0,0 +1,610 @@
/**
* 家书 FamilyBook · 观影影评 + 评论浮层(鸿蒙端,工单 #27)
*
* 对应网页版 `templates/movies/show.php` 的「评价与评论」区块。
*
* 用户 2026-10-28 原话:「鸿蒙端对齐网页端」。观影详情浮层原来只能看
* 一个平均分(`listRating`),不能写影评、不能评论。这个浮层把
* 影评(打分 + 观后感受 + 正文)与评论/回复补上。
*
* ⚠️ 每人对每部电影**只有一条影评**(再次提交即修改,服务端 `MovieReview::save`
* 的 isUpdate 语义);评论是**不限条数**的,可在任一条影评下回复。
*
* ⚠️ 只有「能写的人」(访客/只读账号)隐藏发表入口 —— 与 Web 端同口径。
*/
import { FbColors, FbMetric, FB_MASK, fbAlpha } from '../common/Theme';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { FbAvatar } from './Widgets';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbApiResult } from '../data/FbHttp';
import {
FbMovieComment, FbMovieReview, FbMovieReviews,
FbMovieRepo,
} from '../data/FbMovieRepo';
import { FbAppState, fbAppState } from '../common/Store';
/** 打分的一颗星 */
function starOn(i: number, cur: number): boolean {
return i <= cur;
}
@ComponentV2
export struct FbMovieReviewSheet {
/** 观影记录 id */
@Param movieId: number = 0;
/** 电影名(显示在标题旁) */
@Param movieTitle: string = '';
/** 观影模块色 */
@Param accent: string = '#7A4E8C';
/** 保存/删除后通知宿主(列表的平均分可能要刷新) */
@Event onChanged: () => void = (): void => {};
@Event onClose: () => void = (): void => {};
/** 我的影评(null = 还没写) */
@Local mine: FbMovieReview | null = null;
/** 全影评 + 评论 */
@Local reviews: FbMovieReview[] = [];
@Local count: number = 0;
@Local avg: number = 0;
/** 感受目录:key -> emoji + ' ' + label */
@Local catalog: Record<string, string> = {};
/** 写影评表单态 */
@Local rating: number = 0;
@Local pickedFeelings: string[] = [];
@Local draft: string = '';
@Local ratingPickOpen: boolean = false;
@Local saving: boolean = false;
/** 评论输入(针对某条影评) */
@Local commentTarget: number = 0;
@Local commentDraft: string = '';
@Local replyingTo: number = 0; // 回复某条评论的 id;0 = 直接评论影评
@Local busy: boolean = false;
@Local note: string = '';
@Local loading: boolean = true;
/** 我是不是访客(只读)—— 拿 AppState 判断 */
private app: FbAppState = fbAppState();
aboutToAppear(): void {
this.load();
}
private canWrite(): boolean {
return this.app.session.me.canWrite;
}
private async load(): Promise<void> {
this.loading = true;
const r: FbMovieReviews = await FbMovieRepo.inst().reviews(this.movieId, FB_TOKEN_STORE.get());
this.loading = false;
this.reviews = r.reviews;
this.mine = r.mine;
this.count = r.count;
this.avg = r.avg;
this.catalog = r.catalog;
if (this.mine !== null) {
this.rating = this.mine.rating;
this.pickedFeelings = this.mine.feelings.slice();
this.draft = this.mine.content;
}
}
private catalogKeys(): string[] {
return Object.keys(this.catalog);
}
private featText(key: string): string {
const v: string | undefined = this.catalog[key];
return v !== undefined ? v : key;
}
/** 打星(1-10,点第 n 颗 = 选 n 分) */
private pickRating(n: number): void {
if (this.rating === n) {
this.rating = 0;
} else {
this.rating = n;
}
}
/** 点一个感受 chip(多选,最多 6 个,服务端 FEELING_MAX) */
private toggleFeeling(key: string): void {
const i: number = this.pickedFeelings.indexOf(key);
if (i >= 0) {
const next: string[] = [];
for (const f of this.pickedFeelings) {
if (f !== key) {
next.push(f);
}
}
this.pickedFeelings = next;
} else if (this.pickedFeelings.length < 6) {
this.pickedFeelings = this.pickedFeelings.concat([key]);
}
}
private async saveReview(): Promise<void> {
if (this.saving) {
return;
}
if (this.rating < 1 || this.rating > 10) {
this.note = '请先打一个 1-10 的评分。';
return;
}
if (this.draft.trim() === '') {
this.note = '评价内容不能为空。';
return;
}
this.saving = true;
this.note = '';
const res: FbApiResult = await FbMovieRepo.inst().saveReview(
this.movieId, FB_TOKEN_STORE.get(), this.rating, this.draft.trim(), this.pickedFeelings);
this.saving = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没发出去,请稍后再试。';
return;
}
await this.load();
this.onChanged();
}
private async deleteReview(): Promise<void> {
if (this.busy) {
return;
}
this.busy = true;
this.note = '';
const res: FbApiResult = await FbMovieRepo.inst().deleteReview(this.movieId, FB_TOKEN_STORE.get());
this.busy = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没删掉,请稍后再试。';
return;
}
await this.load();
this.onChanged();
}
/** 发一条评论(或回复) */
private async submitComment(): Promise<void> {
if (this.busy) {
return;
}
const txt: string = this.commentDraft.trim();
if (txt === '') {
this.note = '评论内容不能为空。';
return;
}
this.busy = true;
this.note = '';
const res: FbApiResult = await FbMovieRepo.inst().addReviewComment(
this.movieId, FB_TOKEN_STORE.get(), this.commentTarget, txt, this.replyingTo);
this.busy = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没发出去,请稍后再试。';
return;
}
this.commentTarget = 0;
this.commentDraft = '';
this.replyingTo = 0;
await this.load();
this.onChanged();
}
/** 删评论 */
private async deleteComment(cid: number): Promise<void> {
if (this.busy) {
return;
}
this.busy = true;
const res: FbApiResult = await FbMovieRepo.inst().deleteReviewComment(
this.movieId, FB_TOKEN_STORE.get(), cid);
this.busy = false;
if (!res.ok) {
this.note = res.error !== '' ? res.error : '没删掉,请稍后再试。';
return;
}
await this.load();
}
/** 打开「写影评」表单(已有影评时就是编辑) */
private openReviewForm(): void {
this.note = '';
// 已有影评 → 回填;没有 → 空表单
if (this.mine !== null) {
this.rating = this.mine.rating;
this.pickedFeelings = this.mine.feelings.slice();
this.draft = this.mine.content;
} else {
this.rating = 0;
this.pickedFeelings = [];
this.draft = '';
}
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accent }) {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('评价与评论' + (this.movieTitle !== '' ? ' · ' + this.movieTitle : ''))
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
Text(this.loading
? '正在加载…'
: (this.count > 0
? this.count.toString() + ' 条评价 · 均分 ' + this.avg.toFixed(1)
: '还没有评价,等你来评一个'))
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.padding({ bottom: 8 })
.alignItems(VerticalAlign.Center)
if (this.note !== '') {
Text(this.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.width('100%')
.padding({ top: 6, bottom: 6 })
.maxLines(3)
}
Scroll() {
Column({ space: 12 }) {
// ---- 我的影评(写 / 改 / 删)----
if (this.canWrite()) {
this.myReviewBlock()
}
// ---- 全部影评 + 评论 ----
if (this.reviews.length === 0 && !this.loading) {
Text('还没有评价')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
} else {
ForEach(this.reviews, (rv: FbMovieReview) => {
this.reviewBlock(rv)
}, (rv: FbMovieReview): string => 'mvr_' + rv.id.toString())
}
}
.width('100%')
.padding({ top: 4, bottom: 20 })
.alignItems(HorizontalAlign.Start)
}
.constraintSize({ maxHeight: 520 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
/** 我的影评区块:未写 = 写表单;已写 = 摘要 + 改/删 */
@Builder
myReviewBlock() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(this.mine !== null ? '我的评价' : '写评价')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
if (this.mine !== null) {
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)
// 打分(1-10 颗星)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('评分')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 2 }) {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (i: number) => {
Text(starOn(i, this.rating) ? '★' : '☆')
.fontSize(18)
.fontColor(starOn(i, this.rating) ? '#C08A2E' : FbColors.line2)
.onClick((): void => {
this.pickRating(i);
})
}, (i: number): string => 'star_' + i.toString())
}
Text(this.rating > 0 ? this.rating.toString() + ' 分' : '未评分')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 感受多选
if (this.catalogKeys().length > 0) {
Column({ space: 6 }) {
Text('观后感受(可多选)')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.catalogKeys(), (k: string) => {
Text(this.featText(k))
.fontSize(FbMetric.fontTiny)
.fontColor(this.pickedFeelings.indexOf(k) >= 0 ? '#FFFFFF' : FbColors.ink)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.pickedFeelings.indexOf(k) >= 0
? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 6, bottom: 6 })
.onClick((): void => {
this.toggleFeeling(k);
})
}, (k: string): string => 'feel_' + k)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// 正文
TextArea({ text: this.draft, placeholder: '写下你对这部电影的评价…' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(90)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.draft = v;
})
Text(this.mine !== null ? '保存修改' : '发表评价')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(this.saving ? FbColors.faint : '#FFFFFF')
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(fbAlpha(this.accent, this.saving ? 0.4 : 0.96))
.onClick((): void => {
this.saveReview();
})
}
.width('100%')
.padding({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
/** 一条影评(含评论树) */
@Builder
reviewBlock(rv: FbMovieReview) {
Column({ space: 8 }) {
// 影评头
Row({ space: 8 }) {
FbAvatar({ name: rv.name, color: rv.color, dia: 30 })
Column({ space: 2 }) {
Text(rv.name)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Row({ space: 6 }) {
Text('★ ' + rv.rating.toFixed(0))
.fontSize(FbMetric.fontTiny)
.fontColor('#C08A2E')
if (rv.feelings.length > 0) {
Text(rv.feelings.map((k: string): string => this.featText(k).split(' ')[0]).join(''))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
}
.alignItems(VerticalAlign.Center)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (rv.mine) {
Text('我写的')
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
Text(rv.content)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.width('100%')
// 评论列表
if (rv.comments.length > 0) {
Column({ space: 6 }) {
ForEach(rv.comments, (c: FbMovieComment) => {
this.commentRow(rv.id, c)
}, (c: FbMovieComment): string => 'c_' + c.id.toString())
}
.width('100%')
.padding({ left: 8 })
}
// 评论入口
if (this.canWrite()) {
Row({ space: 8 }) {
Text('发表评论')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusPill)
.backgroundColor(fbAlpha(this.accent, 0.08))
.onClick((): void => {
this.commentTarget = rv.id;
this.replyingTo = 0;
this.commentDraft = '';
})
}
.width('100%')
}
// 评论输入框(针对这条影评展开时)
if (this.commentTarget === rv.id) {
Column({ space: 6 }) {
TextArea({ text: this.commentDraft, placeholder: this.replyingTo > 0 ? '写下回复…' : '写下评论…' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(60)
.width('100%')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.onChange((v: string): void => {
this.commentDraft = v;
})
Row({ space: 8 }) {
Text('取消')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.onClick((): void => {
this.commentTarget = 0;
this.commentDraft = '';
this.replyingTo = 0;
})
Blank()
Text(this.busy ? '发不出…' : '发送')
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.backgroundColor(this.accent)
.onClick((): void => {
this.submitComment();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: 8 })
}
Divider()
.color(FbColors.line)
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 一条评论(含回复) */
@Builder
commentRow(reviewId: number, c: FbMovieComment) {
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(c.name)
.fontSize(FbMetric.fontTiny)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.muted)
if (c.replyTo !== '') {
Text('回复 ' + c.replyTo)
.fontSize(10)
.fontColor(FbColors.faint)
}
Blank()
if (c.mine) {
Text('删')
.fontSize(10)
.fontColor(FbColors.red)
.padding({ left: 4, right: 2 })
.onClick((): void => {
this.deleteComment(c.id);
})
}
if (this.canWrite()) {
Text('回复')
.fontSize(10)
.fontColor(this.accent)
.padding({ left: 4, right: 2 })
.onClick((): void => {
this.commentTarget = reviewId;
this.replyingTo = c.id;
this.commentDraft = '';
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(c.content)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.width('100%')
// 回复
if (c.replies.length > 0) {
Column({ space: 4 }) {
ForEach(c.replies, (r: FbMovieComment) => {
this.commentRow(reviewId, r)
}, (r: FbMovieComment): string => 'cr_' + r.id.toString())
}
.width('100%')
.padding({ left: 10 })
}
}
.width('100%')
}
}