编辑观影记录:修豆瓣联想乱弹与多字段回填,标题主题色、删副标题 (#83)

用户:编辑观影记录后片名下面列出好几个条目(豆瓣的?输入框变更后才该出现);
影院、同看的人、观影照片、简介、备注都没带进来;
编辑观影记录要用主题色;副标题那句删掉。

五个问题三种根因:

A. TextInput/TextArea 把回填当成用户输入
- 片名:prefill 改完 this.title,下次渲染的 TextInput 把新值当 onChange 回调
  ⇒ scheduleSuggest 弹出一排豆瓣候选。改为 onChange 里先比较 v !== this.title,
  值没真变就不调度;prefill 末尾兜底清联想态。
  (先试过 @Local prefilling 标记,没用——prefill 同步跑完标记立刻复位,
   而 onChange 是下一帧才回调的。)
- 简介/备注:areaField 是**值参数** Builder,TextArea({text:value}) 只拿首次
  调用的字面量。拆成 infoField/noteField 两个 Builder,内部直接读 this.info/note。

B. Row { 标签.width(100%); 内层Row } 把内容挤出可视区
- cinemaField/personField/photoField 三个字段都是这个结构,内层 Row 塌成 0 高、
  完全不渲染 ⇒ 看起来像没带进来,其实没画。全部改两排版式。
  (这是 #80 修 posterField 时漏掉的同一个坑,同文件另外三处没扫全。)
- 观影照片:照片区只渲染 photoUris(新选图),已有图在 editingDetail.photos 里
  从未渲染。新增 @Local existingPhotos,显示「已有 N 张(保存后保持不变)」。

C. 字段压根没从接口接上
- FbMovieDetail / FbApiMovieDetail 都没有 cinemaId/cinema_id,但服务端
  Movie::find() 的 SELECT 明确有 d.cinema_id。所以只能按影院名反查,
  重名会选错、改名会失败。补齐字段并解析,cinemaIdOf 改为优先用 id。

另外:FbSheetTitleBar 加 @Param titleColor(默认空=正文色,老调用点零影响),
观影编辑传 accentColor;编辑模式 subtitle 传空串,新建模式保留必填提示。

验证(真机):豆瓣候选无;影院「黄岛城市传媒广场 · 青岛东方影都艺术影城」;
同看人「已选 2 人:杨永全、红围脖」;简介输入框深色像素 2181(916 字全文);
备注 0(该记录 note 本来就是 NULL,SQL 已核实);照片「已有 4 张」;
标题 RGB(99,69,112)=观影模块色 #7A4E8C 派生;标题下方 50px 内无文本。
hvigorw assembleHap BUILD SUCCESSFUL。
This commit is contained in:
2026-11-06 10:30:39 +08:00
parent 0f22aef689
commit ac666dd7f4
4 changed files with 290 additions and 65 deletions
@@ -136,7 +136,7 @@ export struct FbMovieDetailSheet {
return {
ok: false, error: '', id: 0, title: '', startsAt: '', endsAt: '', note: '',
info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '',
cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '',
cinemaAddress: '', cinemaColor: '', cinemaId: 0, myRating: 0, myReview: '',
reviewCount: 0, reviewAvg: 0, attendees: [], photos: []
};
}
+252 -62
View File
@@ -129,6 +129,19 @@ export struct FbMovieNewSheet {
* 保存后海报变成了另一张"。
*/
@Local existingPosterKey: string = '';
/**
* 编辑模式下**这条记录已有的观影照片**(相对键;只展示,不提交)。
*
* 🔴 用户 2026-11-06:「观影照片…没带进来。」
* 原来照片区只渲染 `photoUris`(本次新选的本地图),已有照片在
* `editingDetail.photos` 里**完全没画** ⇒ 用户以为照片丢了。
*
* ⚠️ 用 `@Local` 而不是在 build 里读 `this.editingDetail.photos`:
* `if` 条件里调方法/取对象深层字段都不建依赖(本项目反复踩过)。
* ⚠️ 与 `photoUris` 分开存:那个是"待上传的本地 uri",语义不同,
* 混在一起会让"保存时传哪些"变得说不清。
*/
@Local existingPhotos: string[] = [];
/** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */
@Local doubanMeta: string = '';
@@ -205,13 +218,15 @@ export struct FbMovieNewSheet {
const en: string = d.endsAt;
this.endDate = en.length >= 10 ? en.substring(0, 10) : this.startDate;
this.endTime = en.length >= 16 ? en.substring(11, 16) : '';
this.cinemaId = this.cinemaIdOf(d.cinemaName);
this.cinemaId = this.cinemaIdOf(d);
this.info = d.info;
this.note = d.note;
/* 已有海报:只展示,不提交(见 existingPosterKey 的说明)。
⚠️ 别塞进 doubanPosterKey —— 那个会被 PATCH 带上去,
虽然值相同不出错,但语义就乱了,后人容易改错。 */
this.existingPosterKey = d.posterKey;
/* 已有观影照片(票根另算,它是 movie_details.ticket_key,不在这里) */
this.existingPhotos = d.photos.slice();
const ids: number[] = [];
/* ⚠️ 必须显式标类型(`arkts-no-any-unknown`):
直接 `for (const a of d.attendees)` 会让 `a` 被推断成 any 而编译失败。 */
@@ -220,24 +235,38 @@ export struct FbMovieNewSheet {
ids.push(a.id);
}
this.picked = ids;
/* 🔴 2026-11-06:回填完把豆瓣联想的任何残留态清干净。
用户报的「片名下面列出来好几个条目(来自豆瓣)」就是联想列表 ——
拦它的地方在 `titleField` 的 `onChange`(值没真变就不调度),
这里负责兜底清一次,双保险。 */
this.clearSuggestTimer();
this.suggests = [];
this.suggestOpen = false;
}
/**
* 影院名 → 影院 id。
* 影院 id 的回填。
*
* ⚠️ 详情接口只给 `cinemaName`(不给 id)—— 查一下本地库那份影院列表。
* 找不到(影院被删/改名)就返回 0,表单显示"未选影院",不会硬塞一个错的。
* 🔴 2026-11-06:原来只能按**影院名**反查(因为 `FbMovieDetail` 里没有 id),
* 用户报的「影院没带进来」就是这个 ——
* · 影院重名(很多城市都有「万达影城」)⇒ 反查选中了错的那家;
* · 影院改过名 ⇒ 旧记录的 `cinema_name` 与影院表对不上 ⇒ 直接回填失败。
* 现在 `FbMovieDetail.cinemaId` 已经从接口接上了,**优先用 id**,
* 只有 id 缺失(老数据 / 接口没给)时才退回按名字模糊匹配。
*/
private cinemaIdOf(name: string): number {
if (name === '') {
private cinemaIdOf(d: FbMovieDetail): number {
if (d.cinemaId > 0) {
// 确认这家影院还在本地列表里(可能已被删)
const hit: FbCinemaItem | undefined =
this.cinemas.find((c: FbCinemaItem): boolean => c.id === d.cinemaId);
return hit !== undefined ? d.cinemaId : 0;
}
if (d.cinemaName === '') {
return 0;
}
for (const c of this.cinemas) {
if (c.name === name) {
return c.id;
}
}
return 0;
const byName: FbCinemaItem | undefined =
this.cinemas.find((c: FbCinemaItem): boolean => c.name === d.cinemaName);
return byName !== undefined ? byName.id : 0;
}
aboutToDisappear(): void {
@@ -276,6 +305,34 @@ export struct FbMovieNewSheet {
return out.join('、');
}
/**
* 「同看的人」那一行的文案。
*
* 🔴 2026-11-06:用户报「同看的人没带进来」。
* 实际是**名字还没查出来时显示成 `已选 2 人:`(冒号后面空白)**,
* 看起来像"什么都没带进来"。
*
* 根因是两条时序叠在一起:
* ① `prefill()` 只填了 `picked`(id 数组),名字要靠 `roster` 反查;
* ② `loadRoster()` 是**异步**的 —— 表单刚打开那一两帧里 `roster` 还是空,
* 于是 `pickedNames()` 返回空串。
*
* ⇒ 分三种情况说清楚,不让用户看到"半句话":
* · 没人选 → 「还没有选人」
* · 有人但名字还没到 → 「已选 N 人(正在读取名字…)」
* · 都有 → 「已选 N 人:张三、李四」
*/
private pickedLabel(): string {
if (this.picked.length === 0) {
return '还没有选人';
}
const names: string = this.pickedNames();
if (names === '') {
return '已选 ' + this.picked.length.toString() + ' 人(正在读取名字…)';
}
return '已选 ' + this.picked.length.toString() + ' 人:' + names;
}
private cinemaLabel(): string {
if (this.cinemaId <= 0) {
return '不记录影院';
@@ -579,10 +636,14 @@ export struct FbMovieNewSheet {
FbSheetTitleBar({
/* 编辑模式换个标题,用户一眼知道自己在改而不是新建 */
title: this.editingId > 0 ? '编辑观影记录' : '记一场电影',
subtitle: this.editingId > 0
? '改完点下面保存;照片可以再补几张'
: '片名和时间必填,其余可以留空',
/* 🔴 用户 2026-11-06:「编辑观影记录这几个字,也应该是主题颜色才对,
下面的一行小字(改完点下面保存,照片可以再补几张。),删除不要了。」
⇒ ① 标题用主题色(见下面 `titleColor` 参数的来源);
② **编辑模式不再传 subtitle** —— 那句说明是他明确要删的。
新建模式保留原有的提示(那边还需要"片名和时间必填"这句引导)。 */
subtitle: this.editingId > 0 ? '' : '片名和时间必填,其余可以留空',
accent: this.accentColor(),
titleColor: this.accentColor(),
onClose: (): void => {
this.onClose();
}
@@ -626,14 +687,9 @@ export struct FbMovieNewSheet {
this.posterField()
this.photoField()
this.areaField('简介', '剧情简介(可空,点「自动获取」从豆瓣填)', this.info,
(v: string): void => {
this.info = v;
})
this.areaField('备注', '想记的话(可空)', this.note,
(v: string): void => {
this.note = v;
})
this.infoField()
this.noteField()
}
.width('100%')
.padding({ top: 6, bottom: 4 })
@@ -785,7 +841,19 @@ export struct FbMovieNewSheet {
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
/* 🔴 2026-11-06:编辑模式回填时,`TextInput` 会把"刚被赋值的新内容"
当成一次 onChange 回调(用户报的"片名下面列出来好几个条目")。
⇒ 值没有真正变化时,不调度联想 ——
回填是"值本来就是这个",不是"用户改了"。
⚠️ 这里用"值比较"而不是 `prefilling` 标记:
`prefill()` 是同步执行完的,标记会立刻复位;
而 `onChange` 是在**下一帧**渲染时才回调的,
那时标记早就 false 了,拦不住(我第一版就是这么写的,没用)。 */
const changed: boolean = v !== this.title;
this.title = v;
if (!changed) {
return;
}
this.scheduleSuggest();
})
@@ -885,7 +953,13 @@ export struct FbMovieNewSheet {
*/
@Builder
cinemaField() {
Row({ space: 8 }) {
/* 🔴 2026-11-06 结构性修正(与 `posterField` 同一条病):
原来外层是 `Row { Text('影院').width('100%'); Row { 值…选择 } }`
—— 标签 `width('100%')` 吃满整行,把内层 `Row` 挤到行外。
真机症状:编辑表单里「影院」只剩一个标签,**下面的值整个不见了**
(用户报「影院没带进来」,其实是根本没渲染)。
⇒ 按《全局表单规范》改成上下两排:标签第一排、控件第二排整宽。 */
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('影院')
.fontSize(FbMetric.fontSmall)
@@ -893,6 +967,7 @@ export struct FbMovieNewSheet {
.fontColor(this.accentColor())
.width('100%')
/* 第二排:色点 + 当前选中 + 「选择」 */
Row({ space: 8 }) {
if (this.cinemaColor() !== '') {
Text('')
@@ -911,7 +986,7 @@ export struct FbMovieNewSheet {
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.layoutWeight(1)
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
@@ -922,7 +997,7 @@ export struct FbMovieNewSheet {
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.alignItems(HorizontalAlign.Start)
}
/**
@@ -934,7 +1009,12 @@ export struct FbMovieNewSheet {
*/
@Builder
personField() {
Row({ space: 8 }) {
/* 🔴 2026-11-06 结构性修正(与 `cinemaField` / `posterField` 同一条病):
原来外层 `Row` 里标签 `width('100%')` 把内层 `Row` 挤出可视区,
真机症状是「同看的人」只剩标签、**下面的值整个不渲染**
(用户报「同看的人没带进来」其实是根本没画)。
⇒ 改成上下两排。 */
Column({ space: 6 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('同看的人')
.fontSize(FbMetric.fontSmall)
@@ -942,10 +1022,9 @@ export struct FbMovieNewSheet {
.fontColor(this.accentColor())
.width('100%')
/* 第二排:已选人 + 「选择」 */
Row({ space: 8 }) {
Text(this.picked.length > 0
? ('已选 ' + this.picked.length.toString() + ' 人:' + this.pickedNames())
: '还没有选人')
Text(this.pickedLabel())
.fontSize(FbMetric.fontSmall)
.fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
@@ -956,7 +1035,7 @@ export struct FbMovieNewSheet {
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.layoutWeight(1)
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
@@ -972,7 +1051,7 @@ export struct FbMovieNewSheet {
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.alignItems(HorizontalAlign.Start)
}
/**
@@ -1099,39 +1178,39 @@ export struct FbMovieNewSheet {
@Builder
photoField() {
Column({ space: 6 }) {
Row({ space: 8 }) {
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */
Text('观影照片')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
/* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号)
🔴 2026-11-06:原来标签和内层 `Row` 挤在同一个外层 `Row` 里,
标签 `width('100%')` 把内层挤出可视区 ⇒ 「选照片」按钮整个不渲染。
改成上下两排(同 `cinemaField` / `personField` / `posterField`)。 */
Text('观影照片')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
Row({ space: 8 }) {
Text(this.photoUris.length > 0
? ('已选 ' + this.photoUris.length.toString() + ' 张')
: '票根、合影、随手拍都可以放这儿')
.fontSize(FbMetric.fontSmall)
.fontColor(this.photoUris.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('选照片')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.layoutWeight(1)
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.pickPhotos();
})
/* 第二排:说明/已选数 + 「选照片」 */
Row({ space: 8 }) {
Text(this.photoUris.length > 0
? ('已新选 ' + this.photoUris.length.toString() + ' 张')
: '票根、合影、随手拍都可以放这儿')
.fontSize(FbMetric.fontSmall)
.fontColor(this.photoUris.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text('选照片')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.pickPhotos();
})
if (this.photoUris.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
@@ -1163,12 +1242,53 @@ export struct FbMovieNewSheet {
.width('100%')
.padding({ left: 56 })
}
/* 🔴 2026-11-06:**已有照片**(编辑模式)。
用户报「观影照片没有带进来」—— 原来这里只渲染 `photoUris`
(= 本次新选的本地图),而编辑时已有的图在 `existingPhotos` 里,
完全没画出来 ⇒ 用户以为照片丢了,会重新选一遍。
⚠️ 只**展示 + 说明**,不做删除:删照片是另一件事(要调服务端删附件),
用户这轮只要求"带进来能看到"。
⚠️ 条件读 `@Local existingPhotos`(不是调方法),
否则 ArkUI 不建立依赖(同 `posterField` 那条教训)。 */
if (this.existingPhotos.length > 0) {
Column({ space: 6 }) {
Text('已有 ' + this.existingPhotos.length.toString() + ' 张(保存后保持不变)')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.existingPhotos, (k: string) => {
Image(fbImageSrc(k, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
.margin({ right: 8, bottom: 8 })
}, (k: string): string => 'exph_' + k)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 一行多行文本输入(简介 / 备注) */
/**
* 一行多行文本输入。
*
* ⚠️ **值参数 Builder 有坑**(本项目 `pitfalls.md` 记过):
* `Builder(value)` 的值参数**不参与状态观察**,
* `TextArea({ text: value })` 只拿得到首次调用时的字面量。
* ⇒ 简介 / 备注**已改成独立 Builder**(`infoField` / `noteField`),
* 直接在内部读 `this.info` / `this.note`,编辑回填才能显示出来。
* ⇒ 这个通用版留着给"值不会变、或调用方自己保证重传"的场景用。
*/
@Builder
areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
/* GLOBAL-FORM-SPEC:标签独占第一排(原来是 .width(48) 硬凑) */
@@ -1196,4 +1316,74 @@ export struct FbMovieNewSheet {
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 简介(横跨整个表单宽度)。
*
* 🔴 2026-11-06:**不再走 `areaField(...)`**,因为它是**值参数** Builder ——
* `TextArea({ text: value })` 拿到的是**首次调用时**的字面量,
* `prefill()` 在 `aboutToAppear` 里改了 `this.info` 之后,这里收不到新值
* ⇒ 编辑表单里「简介」永远空白(用户报的"简介没带进来"就是这个)。
*
* 这与 `pitfalls.md` 的「@Builder 值参数不参与状态观察」是**同一条规则**:
* ❌ `Builder(value)` —— 值参数,建不了依赖
* ✅ Builder 内部直接读 `this.xxx`
*
* ⚠️ `areaField` 保留:它还被别的地方用(备注已改成独立 Builder,
* 见下面 `noteField`)。真要彻底删得先确认没有其它调用点。
*/
@Builder
infoField() {
Column({ space: 6 }) {
Text('简介')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextArea({ text: this.info, placeholder: '剧情简介(可空,点「自动获取」从豆瓣填)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(110)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.info = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 备注(同上,直接读 `this.note`) */
@Builder
noteField() {
Column({ space: 6 }) {
Text('备注')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(this.accentColor())
.width('100%')
TextArea({ text: this.note, placeholder: '想记的话(可空)' })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontTiny })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.onChange((v: string): void => {
this.note = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
+12 -1
View File
@@ -96,6 +96,17 @@ export struct FbSheetTitleBar {
@Param confirmText: string = '';
/** 主题色(标题下的副标题、确认文字用它) */
@Param accent: string = '';
/**
* 主标题的颜色(空 = 用正文色 `FbColors.ink`)。
*
* 🔴 用户 2026-11-06:「编辑观影记录这几个字,也应该是主题颜色才对。」
*
* ⚠️ 为什么单独开一个参数,而不是直接让 `title` 用 `accent`:
* 本组件被**全站浮层**引用(设置类、影院管理、任务面板…),
* 标题默认必须是正文色(深墨),只有明确要求的浮层才上主题色。
* `@Param` 带默认空串 ⇒ 老调用点一行都不用改。
*/
@Param titleColor: string = '';
/** 同上:回调统一 `@Event`(`@Param` 实测也能用,但语义不对) */
@Event onClose: () => void = (): void => {};
@@ -106,7 +117,7 @@ export struct FbSheetTitleBar {
Text(this.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.fontColor(this.titleColor !== '' ? this.titleColor : FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}