From f1f279f9730cae43af8a7e5b60e3cc349a80eba6 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 22:29:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E5=BD=B1=E5=BD=B1=E8=AF=84+=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=EF=BC=9A=E8=AF=A6=E6=83=85=E6=B5=AE=E5=B1=82=E6=8E=A5?= =?UTF-8?q?=E3=80=8C=E8=AF=84=E4=BB=B7=E4=B8=8E=E8=AF=84=E8=AE=BA=E3=80=8D?= =?UTF-8?q?=E7=9C=9F=E5=8A=9F=E8=83=BD=20laoyang/FamilyBook#27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(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 + 反证命中。 --- .../ets/components/FbMovieDetailSheet.ets | 34 +- .../ets/components/FbMovieReviewSheet.ets | 610 ++++++++++++++++++ entry/src/main/ets/data/FbMovieRepo.ets | 228 +++++++ 3 files changed, 871 insertions(+), 1 deletion(-) create mode 100644 entry/src/main/ets/components/FbMovieReviewSheet.ets diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index fd37ce6..bbd05af 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -15,13 +15,14 @@ * 加载中 / 成功 / 失败。别只画成功态 —— 断网时用户会看到"空白浮层"。 */ -import { FbColors, FbMetric } from '../common/Theme'; +import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbAvatar, FbRating } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; +import { FbMovieReviewSheet } from './FbMovieReviewSheet'; import { fbImageSrc } from '../data/FbAttachQueue'; /** @@ -45,6 +46,8 @@ export struct FbMovieDetailSheet { /** 列表项带过来的评分(详情接口不给,见 FbMovieRepo.detail 的说明) */ @Param listRating: number = 0; @Param listReviewCount: number = 0; + /** 影评/评论浮层是否打开(工单 #27) */ + @Local reviewsOpen: boolean = false; /** 关闭回调 */ @Event onClose: () => void = (): void => { }; @@ -168,6 +171,21 @@ export struct FbMovieDetailSheet { if (this.viewerOpen) { this.viewer() } + + // ---- 影评 + 评论浮层(工单 #27)---- + if (this.reviewsOpen && this.detail !== null) { + FbMovieReviewSheet({ + movieId: this.detail.id, + movieTitle: this.detail.title, + accent: this.accentColor(), + onClose: (): void => { + this.reviewsOpen = false; + }, + onChanged: (): void => { + // 评分/条数可能变了,但详情接口不返回,不在这里强刷 + } + }) + } } .width('100%') // ⚠️ 高度随内容自适应(浮层是"底部抽屉"形态)。 @@ -417,6 +435,20 @@ export struct FbMovieDetailSheet { .alignItems(VerticalAlign.Center) } + // ---- 评价与评论入口(工单 #27)---- + Row() { + Text('评价与评论' + (this.listReviewCount > 0 ? '(' + this.listReviewCount.toString() + ')' : '')) + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .padding({ left: 12, right: 12, top: 7, bottom: 7 }) + .borderRadius(FbMetric.radiusPill) + .backgroundColor(fbAlpha(this.accentColor(), 0.1)) + .onClick((): void => { + this.reviewsOpen = true; + }) + } + .width('100%') + // ---- 影院 ---- if (d.cinemaName !== '') { Column({ space: 4 }) { diff --git a/entry/src/main/ets/components/FbMovieReviewSheet.ets b/entry/src/main/ets/components/FbMovieReviewSheet.ets new file mode 100644 index 0000000..a34f3a8 --- /dev/null +++ b/entry/src/main/ets/components/FbMovieReviewSheet.ets @@ -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 = {}; + + /** 写影评表单态 */ + @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 { + 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 { + 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 { + 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 { + 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() { + 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%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index e36ab84..60bfbef 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -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//reviews`) */ +export interface FbMovieReviews { + reviews: FbMovieReview[]; + /** 我的影评(null = 没写) */ + mine: FbMovieReview | null; + /** 条数 / 均分 */ + count: number; + avg: number; + /** 感受目录:key -> {emoji,label} */ + catalog: Record; +} + /** * 观影仓库。 * @@ -1066,6 +1116,52 @@ export class FbMovieRepo { return fbUploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token); } + /** 影评 + 评论列表(工单 #27;`GET /movies//reviews`) */ + async reviews(id: number, token: string): Promise { + 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//review`) */ + async saveReview(id: number, token: string, rating: number, content: string, + feelings: string[]): Promise { + const body: Record = {}; + 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//review`) */ + async deleteReview(id: number, token: string): Promise { + const opts: FbApiOptions = { method: 'DELETE', token: token }; + return await fbApi('/api/v1/movies/' + id.toString() + '/review', opts); + } + + /** 评论 / 回复(工单 #27;`POST /movies//comments`) */ + async addReviewComment(id: number, token: string, reviewId: number, content: string, + parentId: number): Promise { + const body: Record = {}; + 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//comments?comment_id=N`) */ + async deleteReviewComment(id: number, token: string, commentId: number): Promise { + const q: Record = {}; + 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 { 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//reviews` 的载荷 → 端上形态 */ +function parseReviews(json: string): FbMovieReviews { + const meId: number = fbAppState().session.me.id; + try { + const o: Record = JSON.parse(json) as Record; + 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; + count = typeof so['count'] === 'number' ? so['count'] : 0; + avg = typeof so['avg'] === 'number' ? so['avg'] : 0; + } + const catalog: Record = {}; + const cat: Object | undefined = o['catalog']; + if (cat !== undefined && cat !== null && typeof cat === 'object') { + const co = cat as Record; + 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; + 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: {} }; + } +}