电影影评+评论:详情浮层接「评价与评论」真功能 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:
@@ -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 }) {
|
||||
|
||||
@@ -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%')
|
||||
}
|
||||
}
|
||||
@@ -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