diff --git a/entry/src/main/ets/components/FbDateTimeField.ets b/entry/src/main/ets/components/FbDateTimeField.ets index d84f371..680ee37 100644 --- a/entry/src/main/ets/components/FbDateTimeField.ets +++ b/entry/src/main/ets/components/FbDateTimeField.ets @@ -171,38 +171,50 @@ export struct FbDateTimeRow { * 点击**穿透**到底下的观影列表(表现为"点日期行,列表反而响应了")。 * 根 `Row`(`.width('100%')`)与 `FbCalOptionRow` 同一形态,热区完整。 */ - Row({ space: 10 }) { + /* GLOBAL-FORM-SPEC(用户 2026-11-05):标签独占第一排,控件第二排整宽。 + 原来是 `Text(label).width(28)` 硬凑 —— 28 只够「开始」两字, + 标签一长(「全天」还行,再长就挤)。两排后与字数无关。 */ + Column({ space: 6 }) { + /* ① 第一排:标签(主题色 / Bold / 大一号) */ Text(this.label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(28) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent !== '' ? this.accent : FbColors.ink) + .width('100%') - // 日期与时间在**同一个控件**里,中间不再分两块 - Row() { - Text(fbCnDate(this.date)) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - if (!this.allDay) { - Text(' ' + this.time) + /* ② 第二排:日期时间框 + 右侧箭头 */ + Row({ space: 6 }) { + // 日期与时间在**同一个控件**里,中间不再分两块 + Row() { + Text(fbCnDate(this.date)) .fontSize(FbMetric.fontSmall) - .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) + if (!this.allDay) { + Text(' ' + this.time) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + } } - } - .layoutWeight(1) - .height(42) - .padding({ left: 12, right: 12 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .alignItems(VerticalAlign.Center) + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) - // 右侧小箭头:暗示"点开会选" - Text('›') - .fontSize(FbMetric.fontBody) - .fontColor(FbColors.faint) + // 右侧小箭头:暗示"点开会选" + Text('›') + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.faint) + } + .width('100%') + .alignItems(VerticalAlign.Center) } .width('100%') - .alignItems(VerticalAlign.Center) + .alignItems(HorizontalAlign.Start) + /* ⚠️ onClick 必须挂在**最外层**:原注释记录过挂内层会被表单的两列容器 + 盖住、点击穿透到下面的列表。改结构后这个坑依然成立。 */ .onClick((): void => { this.onTap(); }) diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index 34d8f76..374d910 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -316,18 +316,38 @@ export struct FbEventEditSheet { // ============================================================ @Builder - field(label: string, value: string, hint: string, onChange: (v: string) => void) { - Column({ space: 4 }) { + /** + * 一个输入行(**两排版式**,GLOBAL-FORM-SPEC —— 用户 2026-11-05)。 + * + * 「点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。 + * 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。」 + * 「为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示。」 + * + * | 项 | 规则 | + * |---|---| + * | 标签颜色 | **主题色** `this.accent`(必填红 `#C0533F`) | + * | 标签字重 | **Bold** | + * | 标签字号 | `fontSmall`(比正文大一号) | + * | 版式 | 标签独占第一排,输入框第二排整宽 | + * | 必填 | 红色,**无星号** | + */ + @Builder + field(label: string, value: string, hint: string, onChange: (v: string) => void, + req: boolean = false) { + Column({ space: 6 }) { Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(req ? '#C0533F' : this.accent) + .width('100%') TextInput({ text: value, placeholder: hint }) .placeholderColor(FbColors.faint) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) - .height(40) + .height(42) + .width('100%') .padding({ left: 12, right: 12 }) .onChange((v: string): void => { onChange(v); diff --git a/entry/src/main/ets/components/FbFormField.ets b/entry/src/main/ets/components/FbFormField.ets new file mode 100644 index 0000000..89b6378 --- /dev/null +++ b/entry/src/main/ets/components/FbFormField.ets @@ -0,0 +1,149 @@ +/** + * 全局表单字段组件(GLOBAL-FORM-SPEC)。 + * + * 🔴 用户 2026-11-05 定的**全局规范**(原话): + * + * 「你记住,以后我们所有的功能全部都这样: + * 1. 点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。 + * 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。 + * 2. 为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示: + * 第一排显示片名,因为是必选必填,所以用红色;下一行开始是片名的输入框, + * 以及开始时间、结束时间的输入框,全部放在下一行,这样一列下来就行了。」 + * + * ## 规范 + * + * | 项 | 规则 | + * |---|---| + * | 标签颜色 | **主题色**(传入的 `accent`) | + * | 标签字重 | **Bold** | + * | 标签字号 | `fontSmall`(比正文大一号) | + * | 必填标记 | **红色** `#C0533F`,**不加星号** | + * | 版式 | **标签独占第一排,控件第二排整宽** | + * + * ## 为什么做成组件而不是各表单各写一份 `@Builder` + * + * 之前 5 个表单各有一份 `field()` / `labelRow()`,改一处就要改五处, + * 而且各文件的**外层结构不一致**(有的 `Row` 有的 `Column`), + * 批量脚本改坏过两次(`@Builder` 重复、括号错乱)。 + * + * ⇒ 收敛到一个组件,**表单只调用,不自己实现**: + * · 规范改一次,全站生效 + * · 结构统一,不会再出现"这个表单两排、那个单排" + * + * ## 为什么两排能解决左右对齐 + * + * 原来是 `标签 | 输入框` 同行,标签宽度由**字数**决定: + * + * 姓名 [__________] ← 2 字 + * 我对他的称呼 [__________] ← 6 字,输入框起点更靠右 + * + * 有人用 `标签.width(48)` 硬凑,但字数一变就得改数字, + * 而且旁边多个按钮(如「自动获取」)又会挤歪。 + * + * 标签独占一行后,所有控件都从**同一左边缘**开始,与字数、有无侧边按钮**无关**。 + */ +import { FbColors, FbMetric } from '../common/Theme'; + +/** + * 一行表单字段(标签 + 输入框,两排)。 + * + * @example + * FbFormField({ + * label: '片名', + * value: this.title, + * placeholder: '看了哪一部?', + * accent: this.accentColor(), + * required: true, + * onChange: (v: string): void => { this.title = v; } + * }) + */ +@ComponentV2 +export struct FbFormField { + /** 标签文字 */ + @Param label: string = ''; + /** 当前值 */ + @Param value: string = ''; + /** 占位提示(输入框为空时显示) */ + @Param placeholder: string = ''; + /** 主题色 —— 标签用这个颜色(`required` 时被红色覆盖) */ + @Param accent: string = '#3F7D5A'; + /** 是否必填 —— 必填时标签变红,**不加星号**(用户明确要求) */ + @Param required: boolean = false; + /** 是否多行(简介、备注这类长文本) */ + @Param multiline: boolean = false; + /** 输入框高度(多行时用) */ + @Param boxHeight: number = 42; + /** 值变化回调 */ + @Event onChange: (v: string) => void = (_v: string): void => { + }; + + /** 标签颜色:必填红,否则主题色 */ + private labelColor(): string { + return this.required ? '#C0533F' : this.accent; + } + + build() { + Column({ space: 6 }) { + /* ① 第一排:标签(主题色/必填红,Bold,大一号,无星号) */ + Text(this.label) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.labelColor()) + .width('100%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + /* ② 第二排:输入框(整宽,与标签左边缘对齐) */ + if (this.multiline) { + TextArea({ text: this.value, placeholder: this.placeholder }) + .placeholderColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .width('100%') + .height(this.boxHeight) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .onChange((v: string): void => { + this.onChange(v); + }) + } else { + TextInput({ text: this.value, placeholder: this.placeholder }) + .placeholderColor(FbColors.faint) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .width('100%') + .height(this.boxHeight) + .padding({ left: 12, right: 12 }) + .onChange((v: string): void => { + this.onChange(v); + }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } +} + +/** + * 只读的「标签 + 说明」两排块(用于"参与的人""照片"这类 + * 内容由别处渲染的字段,只需要一个统一的标签样式)。 + */ +@ComponentV2 +export struct FbFormLabel { + @Param label: string = ''; + @Param accent: string = '#3F7D5A'; + @Param required: boolean = false; + + build() { + Text(this.label) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.required ? '#C0533F' : this.accent) + .width('100%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets index 8e32b08..2a62390 100644 --- a/entry/src/main/ets/components/FbMovieNewSheet.ets +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -657,18 +657,16 @@ export struct FbMovieNewSheet { @Builder titleField() { Column({ space: 6 }) { - Row({ space: 8 }) { - Row({ space: 2 }) { - Text('片名') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - Text('*') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.red) - } - .width(48) - .alignItems(VerticalAlign.Center) + /* GLOBAL-FORM-SPEC(用户 2026-11-05):标签独占第一排,输入框第二排。 + 原来是 `Row` 里 `标签.width(48) + 输入框`,写死 48 只是"凑"对齐, + 字数一变就错开。片名必填 ⇒ 红色,**无星号**(用户明确要求去掉)。 */ + Text('片名') + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor('#C0533F') + .width('100%') + Row({ space: 8 }) { TextInput({ text: this.title, placeholder: '看了哪一部?' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) @@ -781,10 +779,12 @@ export struct FbMovieNewSheet { @Builder cinemaField() { Row({ space: 8 }) { + /* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */ Text('影院') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(48) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accentColor()) + .width('100%') Row({ space: 8 }) { if (this.cinemaColor() !== '') { @@ -828,10 +828,12 @@ export struct FbMovieNewSheet { @Builder personField() { Row({ space: 8 }) { + /* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */ Text('同看的人') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(48) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accentColor()) + .width('100%') Row({ space: 8 }) { Text(this.picked.length > 0 @@ -875,10 +877,12 @@ export struct FbMovieNewSheet { posterField() { Column({ space: 6 }) { Row({ space: 8 }) { + /* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */ Text('海报') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(48) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accentColor()) + .width('100%') Row({ space: 8 }) { if (this.posterUri !== '') { @@ -953,10 +957,12 @@ export struct FbMovieNewSheet { photoField() { Column({ space: 6 }) { Row({ space: 8 }) { + /* GLOBAL-FORM-SPEC:标签独占第一排(主题色 Bold,必填红无星号) */ Text('观影照片') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(48) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accentColor()) + .width('100%') Row({ space: 8 }) { Text(this.photoUris.length > 0 @@ -1022,11 +1028,13 @@ export struct FbMovieNewSheet { /** 一行多行文本输入(简介 / 备注) */ @Builder areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { - Row({ space: 8 }) { + /* GLOBAL-FORM-SPEC:标签独占第一排(原来是 .width(48) 硬凑) */ + Column({ space: 6 }) { Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(48) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Bold) + .fontColor(this.accentColor()) + .width('100%') TextArea({ text: value, placeholder: placeholder }) .placeholderColor(FbColors.faint) @@ -1036,13 +1044,13 @@ export struct FbMovieNewSheet { .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(72) - .layoutWeight(1) + .width('100%') .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .onChange((v: string): void => { onChange(v); }) } .width('100%') - .alignItems(VerticalAlign.Top) + .alignItems(HorizontalAlign.Start) } } \ No newline at end of file diff --git a/entry/src/main/ets/components/FbPersonEditSheet.ets b/entry/src/main/ets/components/FbPersonEditSheet.ets index 269580d..6b68f92 100644 --- a/entry/src/main/ets/components/FbPersonEditSheet.ets +++ b/entry/src/main/ets/components/FbPersonEditSheet.ets @@ -799,9 +799,10 @@ export struct FbPersonEditSheet { Scroll() { Column({ space: 12 }) { this.section('基本') + /* GLOBAL-FORM-SPEC:姓名必填 ⇒ 标签红色(无星号) */ this.field('姓名', this.fName, '', (v: string): void => { this.fName = v; - }) + }, true) // 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开 this.field('我对他的称呼', this.fNickname, '只影响你看到的叫法,留空 = 用姓名', (v: string): void => { diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 64db2fa..c0274b7 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -182,6 +182,26 @@ class FbPendingShot { * ⚠️ 与行程详情页的 `fbPersonName()` 同一口径 —— 名册里既有本名也有昵称, * 家人之间看昵称更自然(用户习惯叫「红围脖」而不是全名)。 */ +/** + * 这个附件是不是视频(#56)。 + * + * 🔴 照片墙原来对所有附件都用 `Image` 渲染 —— 视频是 .mp4, + * `Image` 加载不出来 ⇒ 空白块 ⇒ 用户以为"视频没带进来"。 + * + * ⚠️ 判据用 `mime`(服务端 `attachments.mime`,如 `video/mp4`)。 + * 不要用扩展名:上传时服务端按内容嗅探 MIME,比文件名可靠。 + * ⚠️ `mime` 为空时退回 `isImage` 判断:`is_image=0` 且没有 mime, + * 大概率是视频或文档 —— 这里按"非图片"处理,画 ▶ 比画空白好。 + */ +function isVideoAttach(ph: FbTripNodePhoto): boolean { + const mime: string = ph.mime.toLowerCase(); + if (mime !== '') { + return mime.indexOf('video/') === 0; + } + /* 没有 mime 时的兜底:is_image=0 视为非图片(视频/文档) */ + return !ph.isImage; +} + function fbPersonLabel(p: FbRosterPerson): string { const nk: string = fbSafeStr(p.myNickname); if (nk !== '') { @@ -1015,11 +1035,34 @@ export struct FbTripNodeSheet { Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.photos, (ph: FbTripNodePhoto) => { Stack({ alignContent: Alignment.TopEnd }) { - Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get())) + /* 🔴🔴 #56(用户:「编辑的时候是视频都没带进来」) + 根因:**所有附件都走 `Image`**。视频是 .mp4, + `Image` 加载不出任何东西 ⇒ 一个空白块 ⇒ + 用户看着像"视频没带进来"。 + + `FbTripNodePhoto` 本来就有 `mime` 字段(attachsOf 解析过), + 按它分流: + · 视频 → 深色底 + ▶(与详情页视频卡一致,不取首帧, + 因为端上解视频首帧很卡,打开编辑会卡顿) + · 图片 → Image(现状) */ + if (isVideoAttach(ph)) { + Column() { + Text('▶') + .fontSize(22) + .fontColor('#FFFFFFCC') + } .width(72) .height(72) - .objectFit(ImageFit.Cover) + .justifyContent(FlexAlign.Center) + .backgroundColor('#2A2320') .borderRadius(6) + } else { + Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get())) + .width(72) + .height(72) + .objectFit(ImageFit.Cover) + .borderRadius(6) + } Text('✕') .fontSize(10) .fontColor('#FFFFFF')