观影详情卡:评价评论内嵌到底部可直评;简介/观影照片按全局规范改样式 (#79)

用户:电影弹出框,评价与评论放在最下面,直接显示出来,可以直接评论,不用再打开了。
简介、观影照片,注意按照我们的约定进行颜色和字体的修改。

① 评价与评论内嵌
- 删掉 FbMovieDetailSheet 里的入口胶囊与 reviewsOpen 死状态;
- 在 body() 末尾内嵌 FbMovieReviewSheet({embedded:true}),顺序与网页端一致:
  海报/片名/时间/评分/影院/同看的人/简介/备注/观影照片/**评价与评论**;
- FbMovieReviewSheet 加 embedded 开关,两种形态共用 reviewContent(),
  不抄第二份 300 行(抄必然改一处漏一处)。

② 评论框默认隐藏(用户明确):点「发表评论/回复」才展开,发送成功或取消即收起
  (submitComment 成功路径本来就会把 commentTarget 归零)。

③ 我的评价两态(用户选定):已评过=摘要(★+感受+正文3行+修改/删除),
  没评过=展开表单。保存成功自动收起;删除成功自动展开并清空草稿。
  加 myFormTouched 防止 load() 重跑时把刚写完的表单又收起来。

④ 简介/备注/观影照片标签按《全局表单规范》:
  主题色 accent + Bold + fontSmall + letterSpacing 0.6,标签独占一行、内容第二行整宽。
  改前是 fontTiny + faint 灰,与正文挤在一起。「N 张」保持 fontTiny+faint(同 extra 口径)。

验证(真机 5NC0225B27004718):
- 逐段抓 UI 文本,顺序命中 简介→观影照片→评价与评论,评价在最下面;
- 标签取最深5%像素均值:简介(98,67,110)、评价与评论(98,68,110)、写评价(97,69,109),
  均为主题紫色相;改前的 faint 灰是 (150,150,150) 量级;
- 底部文本含 写评价/评分/观后感受/发表评价,未评过故表单默认展开;
- hvigorw assembleHap BUILD SUCCESSFUL。
This commit is contained in:
2026-11-06 07:58:45 +08:00
parent 723d028679
commit cd60aa7788
2 changed files with 344 additions and 118 deletions
@@ -19,7 +19,7 @@ import { FbColors, FbMetric, fbAlpha, FB_MASK } 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 { FbAvatar, FbRating, FbSectionTitle } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose';
import { FbMovieReviewSheet } from './FbMovieReviewSheet';
@@ -46,8 +46,9 @@ export struct FbMovieDetailSheet {
/** 列表项带过来的评分(详情接口不给,见 FbMovieRepo.detail 的说明) */
@Param listRating: number = 0;
@Param listReviewCount: number = 0;
/** 影评/评论浮层是否打开(工单 #27) */
@Local reviewsOpen: boolean = false;
/* 2026-11-06:`reviewsOpen` 已废弃 —— 评价与评论改为在 body 末尾内嵌,
不再有"点开浮层"这一步(见 body() 末尾那段说明)。
留着这个字段只会让后来的人以为还有入口可点。 */
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
@@ -197,19 +198,12 @@ export struct FbMovieDetailSheet {
}
// ---- 影评 + 评论浮层(工单 #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 => {
// 评分/条数可能变了,但详情接口不返回,不在这里强刷
}
})
}
/* 🔴 2026-11-06:**这里整个删掉了**。
用户:「评价与评论放在最下面,直接显示出来,可以直接评论,不用再打开了。」
⇒ 不再作为独立浮层弹出,改为在 `body()` 末尾内嵌
(`FbMovieReviewSheet({ embedded: true })`)。
⚠️ `reviewsOpen` 这个 `@Local` 连同它的开合逻辑一起废弃,
别再留着"看着还能用、其实点了没东西"的死状态。 */
}
.width('100%')
// ⚠️ 高度随内容自适应(浮层是"底部抽屉"形态)。
@@ -463,18 +457,11 @@ export struct FbMovieDetailSheet {
}
// ---- 评价与评论入口(工单 #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%')
/* 🔴 用户 2026-11-06:「评价与评论放在最下面,直接显示出来,可以直接评论,
不用再打开了。」
⇒ 这颗「点开浮层」的入口按钮**删掉**,改为在卡片最下面直接内嵌渲染
(见本 builder 末尾的 `FbMovieReviewSheet({ embedded: true })`)。
区块标题由那里自己的 `FbSectionTitle` 承担,这里不再留占位。 */
// ---- 影院 ----
if (d.cinemaName !== '') {
@@ -543,16 +530,28 @@ export struct FbMovieDetailSheet {
}
// ---- 剧情简介 ----
/* 🔴 用户 2026-11-06:「简介、观影照片,注意按照我们的约定进行颜色和字体的修改。」
「约定」= `pitfalls.md` 的《全局表单规范》(用户 2026-11-05 定为通用):
标签用 **主题色 accent**、**Bold**、**fontSmall**、
与控件**分两排**(标签独占第一行,内容从第二行整宽开始)。
改前:标签是 `fontTiny` + `faint` 灰,与正文挤在一起,
既不分层也不带主题色 —— 和别的模块的表单不一致。 */
if (d.info !== '') {
Column({ space: 6 }) {
Text('简介')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.letterSpacing(0.6)
.width('100%')
Text(d.info)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
// ⚠️ 不设 maxLines:简介本来就长,让用户滚着看全文
.textAlign(TextAlign.Start)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
@@ -562,11 +561,15 @@ export struct FbMovieDetailSheet {
if (d.note !== '') {
Column({ space: 6 }) {
Text('备注')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.letterSpacing(0.6)
.width('100%')
Text(d.note)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
@@ -575,14 +578,20 @@ export struct FbMovieDetailSheet {
// ---- 照片(票根 + 现场照片合成一块,与 Web 端一致)----
if (this.allPhotos(d).length > 0) {
Column({ space: 6 }) {
Row({ space: 6 }) {
/* 同样按《全局表单规范》:主题色 + Bold + fontSmall。
「N 张」是数量提示、不是标签,保持 fontTiny + faint
(与 `FbSectionTitle` 的 `extra` 同口径)。 */
Row({ space: 8 }) {
Text('观影照片')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.letterSpacing(0.6)
Text(this.allPhotos(d).length.toString() + ' 张')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Grid() {
@@ -621,6 +630,48 @@ export struct FbMovieDetailSheet {
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/* ==================== 评价与评论(内嵌,2026-11-06)====================
🔴 用户:「评价与评论放在最下面,直接显示出来,可以直接评论,
不用再打开了。」
⇒ 放在**最后一块**(海报→片名→时间→评分→影院→同看的人→简介→备注→
照片→**评价与评论**),与网页端 `templates/movies/show.php` 的五大块
顺序一致(那里也是「观影照片」之后才是「评价与评论」)。
⚠️ `embedded: true` 只出内容,不画遮罩/圆角卡/标题栏 ——
它已经在这张卡的 Scroll 里了,再套一层就是"卡里还有一张卡"。
⚠️ 这块**自己滚不动**(外层 Scroll 已经占了滚动手势),
所以评论多了会让整张详情卡变长、一起滚 —— 这正是用户要的
"直接显示出来",不是内嵌一个小滚动区(那会变成两层滚动打架)。
⚠️ 访客/只读账号:组件内部按 `canWrite()` 已经隐藏了发表入口,
这里不用再判一次。 */
Column({ space: 0 }) {
FbSectionTitle({
title: '评价与评论',
extra: this.listReviewCount > 0
? this.listReviewCount.toString() + ' 条'
: '',
accent: this.accentColor()
})
FbMovieReviewSheet({
movieId: d.id,
movieTitle: '',
accent: this.accentColor(),
embedded: true,
onChanged: (): void => {
/* 评分/条数可能变了,但详情接口不返回这两个值
(见 FbMovieDetail 的 reviewCount 注释),所以这里不强刷。
下次打开详情卡时列表侧会带新的进来。 */
},
onClose: (): void => {
}
})
}
.width('100%')
.padding({ top: 6 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 28 })
@@ -38,6 +38,23 @@ export struct FbMovieReviewSheet {
@Param movieTitle: string = '';
/** 观影模块色 */
@Param accent: string = '#7A4E8C';
/**
* 内嵌模式(2026-11-06)。
*
* 🔴 用户原话:「电影弹出框,评价与评论放在最下面,直接显示出来,
* 可以直接评论,不用再打开了。」
* ⇒ 本组件现在有两种形态:
* · `embedded = false`(默认,老形态):自带遮罩 + 圆角卡 + 标题栏,
* 作为独立浮层弹出(`bindSheet` 之类);
* · `embedded = true`:**只出内容**(我的评价 + 全部影评 + 评论),
* 不画遮罩、不画圆角卡、不画标题栏 —— 由 `FbMovieDetailSheet`
* 在它自己的 Scroll 里直接渲染。
*
* ⚠️ 为什么不另写一份「内嵌版」:影评/评论的表单、打分、感受多选、
* 回复树有 300+ 行,抄一份必然出现"改一处漏一处"(本项目已多次踩过)。
* 加一个开关,两条路径共用同一套逻辑与 DOM。
*/
@Param embedded: boolean = false;
/** 保存/删除后通知宿主(列表的平均分可能要刷新) */
@Event onChanged: () => void = (): void => {};
@@ -58,6 +75,21 @@ export struct FbMovieReviewSheet {
@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;
@@ -93,8 +125,26 @@ export struct FbMovieReviewSheet {
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);
}
@@ -150,6 +200,10 @@ export struct FbMovieReviewSheet {
this.note = res.error !== '' ? res.error : '没发出去,请稍后再试。';
return;
}
/* 保存成功 ⇒ 收起表单(用户刚写完,接着就是想看结果/评论,
而不是继续盯着输入框)。⚠️ 必须在 `load()` 之后置,否则会被
load 里"首轮"判断覆盖。 */
this.myFormOpen = false;
await this.load();
this.onChanged();
}
@@ -166,6 +220,12 @@ export struct FbMovieReviewSheet {
this.note = res.error !== '' ? res.error : '没删掉,请稍后再试。';
return;
}
/* 删掉之后「我的评价」不存在了 ⇒ 表单重新展开,让用户能再写一个
(否则会停在一个空的摘要块上)。清空草稿与打分,别把旧内容带回来。 */
this.rating = 0;
this.pickedFeelings = [];
this.draft = '';
this.myFormOpen = true;
await this.load();
this.onChanged();
}
@@ -228,110 +288,225 @@ export struct FbMovieReviewSheet {
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.onClick((): void => {
this.onClose();
})
/* 🔴 用户 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.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()
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)
// ---- 全部影评 + 评论 ----
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())
}
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accent
})
}
.width('100%')
.padding({ top: 4, bottom: 20 })
.alignItems(HorizontalAlign.Start)
.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)
}
.constraintSize({ maxHeight: 520 })
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 18 })
.alignItems(HorizontalAlign.Start)
}
.width('92%')
}
.width('92%')
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
}
.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 }) {
Row({ space: 8 }) {
Text(this.mine !== null ? '我的评价' : '写评价')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.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)