/** * 家书 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'; /** * 内嵌模式(2026-11-06)。 * * 🔴 用户原话:「电影弹出框,评价与评论放在最下面,直接显示出来, * 可以直接评论,不用再打开了。」 * ⇒ 本组件现在有两种形态: * · `embedded = false`(默认,老形态):自带遮罩 + 圆角卡 + 标题栏, * 作为独立浮层弹出(`bindSheet` 之类); * · `embedded = true`:**只出内容**(我的评价 + 全部影评 + 评论), * 不画遮罩、不画圆角卡、不画标题栏 —— 由 `FbMovieDetailSheet` * 在它自己的 Scroll 里直接渲染。 * * ⚠️ 为什么不另写一份「内嵌版」:影评/评论的表单、打分、感受多选、 * 回复树有 300+ 行,抄一份必然出现"改一处漏一处"(本项目已多次踩过)。 * 加一个开关,两条路径共用同一套逻辑与 DOM。 */ @Param embedded: boolean = false; /** * 隐藏表单顶部的「写评价 / 我的评价」标题行(2026-11-06)。 * * 🔴 用户:「下面的写评价不要了,三个字。」 * ⇒ 内嵌到详情卡后,上面已经有「评价与评论」这个区块标题, * 再出现一行「写评价」是重复的。 * * ⚠️ 只隐藏**标题文字那一段**,右边「修改 / 删除 / 收起」三颗动作按钮 * 仍然保留 —— 有评价时用户就是靠它们改和删,删掉整行会失去功能。 * 没有评价时那一行本来就只剩标题,于是整行自然消失。 */ @Param hideFormTitle: boolean = false; /** 保存/删除后通知宿主(列表的平均分可能要刷新) */ @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 = {}; /** 写影评表单态 */ @Local rating: number = 0; @Local pickedFeelings: string[] = []; @Local draft: string = ''; @Local ratingPickOpen: boolean = false; @Local saving: boolean = false; /** * 我的评价表单是否展开(2026-11-06)。 * * 🔴 用户选择:「已评过=收起,没评过=展开」。 * · 已经写过的人:只看到评分摘要,想改再点「修改」展开 —— * 否则每次打开详情卡都被一大块表单占住,评论被挤到很下面; * · 还没写的人:直接看到表单,进来就能评。 * * ⚠️ 初始值给 `false`,由 `load()` 拿到 `mine` 之后再定 * (数据还没回来时不知道写没写过,不能提前猜)。 * ⚠️ 是 `@Local` 而不是从这里反推 `this.mine !== null`: * 用户点「修改」要能把它展开,而 `mine` 在**保存成功后**才会变, * 两者不是同一个状态。 */ @Local myFormOpen: 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 { 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; } /* 「已评过=收起,没评过=展开」(用户 2026-11-06 选定)。 ⚠️ 只在**首次加载**时决定,不能每次 load 都重置: 保存成功后 `load()` 会再跑一遍,若无条件按 `mine !== null` 赋 false, 用户刚写完评价、表单立刻收起来 —— 看着像"没保存成功"。 ⇒ 用 `loading` 之前那个「首轮」标记控制(见 myFormTouched)。 */ if (!this.myFormTouched) { this.myFormOpen = this.mine === null; this.myFormTouched = true; } } /** * 表单开合是否已被"用户意图"或"首轮加载"定过。 * * 为什么要这个标记:`load()` 在保存/删除后都会重跑, * 若无条件按 `mine` 重置 `myFormOpen`,刚写完评价表单就自己收起, * 用户会以为没保存成功。⇒ 只有第一轮才按"写没写过"决定。 */ @Local myFormTouched: boolean = false; 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 { 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; } /* 保存成功 ⇒ 收起表单(用户刚写完,接着就是想看结果/评论, 而不是继续盯着输入框)。⚠️ 必须在 `load()` 之后置,否则会被 load 里"首轮"判断覆盖。 */ this.myFormOpen = false; await this.load(); this.onChanged(); } private async deleteReview(): Promise { 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; } /* 删掉之后「我的评价」不存在了 ⇒ 表单重新展开,让用户能再写一个 (否则会停在一个空的摘要块上)。清空草稿与打分,别把旧内容带回来。 */ this.rating = 0; this.pickedFeelings = []; this.draft = ''; this.myFormOpen = true; await this.load(); this.onChanged(); } /** 发一条评论(或回复) */ private async submitComment(): Promise { 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 { 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() { /* 🔴 用户 2026-11-06:「评价与评论放在最下面,直接显示出来,可以直接评论, 不用再打开了。」 ⇒ 内嵌模式只出内容(发表遮罩/圆角卡/标题栏),由详情卡在自己的 Scroll 里直接渲染。内容与独立模式**同一份** `reviewContent()`, 不抄第二份 —— 影评/评论那 300 行抄一份必然改一处漏一处。 */ if (this.embedded) { this.reviewContent(true) } else { 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.summaryText()) .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() { this.reviewContent(false) } .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) } } /** 标题下方那句统计(独立模式用;内嵌模式由详情卡的区块标题承担) */ private summaryText(): string { if (this.loading) { return '正在加载…'; } if (this.count > 0) { return this.count.toString() + ' 条评价 · 均分 ' + this.avg.toFixed(1); } return '还没有评价,等你来评一个'; } /** * 评价与评论的正文(两种形态共用)。 * * @param inner 是否为内嵌模式 —— 内嵌时不加额外上下留白 * (宿主已经在卡片里排好了间距,再补会多一截空)。 */ @Builder reviewContent(inner: boolean) { 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: inner ? 0 : 4, bottom: inner ? 0 : 20 }) .alignItems(HorizontalAlign.Start) } /** * 我的影评区块。 * * 两个态(用户 2026-11-06 选定「已评过=收起,没评过=展开」): * · `myFormOpen === false` 且已写过 → **摘要态**:评分 + 感受 + 正文摘要 * +「修改 / 删除」,只占几行,把空间让给下面的评论; * · `myFormOpen === true` 或无评价 → **表单态**:打分 + 感受 + 正文 + 发表。 */ @Builder myReviewBlock() { if (this.mine !== null && !this.myFormOpen) { this.myReviewSummary() } else { this.myReviewForm() } } /** 收起态:我写过的评价摘要 + 改/删 */ @Builder myReviewSummary() { Column({ space: 6 }) { Row({ space: 8 }) { Text('我的评价') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Bold) .fontColor(this.accent) .letterSpacing(0.6) Blank() Text('修改') .fontSize(FbMetric.fontTiny) .fontColor(this.accent) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .onClick((): void => { this.myFormOpen = true; }) Text('删除') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .padding({ left: 6, right: 2, top: 4, bottom: 4 }) .onClick((): void => { this.deleteReview(); }) } .width('100%') .alignItems(VerticalAlign.Center) if (this.mine !== null) { Row({ space: 8 }) { Text('★ ' + this.mine.rating.toFixed(0)) .fontSize(FbMetric.fontTiny) .fontColor('#C08A2E') if (this.mine.feelings.length > 0) { Text(this.mine.feelings.map((k: string): string => this.featText(k)).join(' · ')) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) } } .width('100%') .alignItems(VerticalAlign.Center) if (this.mine.content !== '') { Text(this.mine.content) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .maxLines(3) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } } } .width('100%') .padding(12) .backgroundColor(fbAlpha(this.accent, 0.06)) .borderRadius(FbMetric.radiusS) .alignItems(HorizontalAlign.Start) } /** 展开态:写 / 改评价的表单 */ @Builder myReviewForm() { Column({ space: 8 }) { /* 🔴 用户 2026-11-06:「下面的写评价不要了,三个字。」 ⇒ 整行只在「有评价(要显示改/删/收起)」或「没要求隐藏标题」时才画。 内嵌 + 未评过 = 整行不存在,直接从「评分」开始,不浪费一行高度。 ⚠️ 有评价时**不能**整行删掉:修改/删除/收起三颗按钮就在这行右边。 */ if (this.mine !== null || !this.hideFormTitle) { Row({ space: 8 }) { if (!this.hideFormTitle) { Text(this.mine !== null ? '我的评价' : '写评价') .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Bold) .fontColor(this.accent) .letterSpacing(0.6) } Blank() if (this.mine !== null) { Text('收起') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .padding({ left: 6, right: 4, top: 4, bottom: 4 }) .onClick((): void => { this.myFormOpen = false; }) 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%') } }