F3: 表单校验反馈修复——点名缺失字段+自动滚动+@Builder按引用传参

用户真机反馈"还有 2 项未完成,点击不了提交并生成报告",排查出两层问题:

1. 表层(可见性):汇总条只报数量不报字段名,缺失字段在视野外。
   - 汇总条改为点名缺失字段:"请完成:核心诉求、心理风格"
   - 校验失败自动滚动到第一个缺失字段(componentUtils.getRectangleById
     取 windowOffset 相对 form_root 的内容坐标,Scroller.scrollTo 动画滚动)
   - 汇总条本身可点击,点击重新跳到第一个缺失字段
   - 必填项标签加红色 * 标记

2. 根因(@Builder 陷阱):字段级红字从未渲染过。FieldLabel 按值传参
   (label/err/required),ArkUI 规定 @Builder 按值传参不随 @State 变化
   刷新——err 停在初始空串。改为 $$ 按引用传参(FieldLabelParams),
   真机 dumpLayout 验证红字已随校验结果出现。

真机验证(uitest 注入 + dumpLayout + 截图):
- 空表单提交:红字四项齐现、汇总条点名、自动滚到首个错误
- 完整表单提交:进 40920 对话框(本期已生成),链路正常
This commit is contained in:
xsl
2026-08-15 13:35:06 +08:00
parent c24924fa43
commit 98a134c7a2
@@ -11,7 +11,7 @@
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 幂等重试
*/
import { router } from '@kit.ArkUI'
import { router, componentUtils } from '@kit.ArkUI'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { AuthStore } from '../core/AuthStore'
@@ -24,6 +24,12 @@ const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
interface FieldLabelParams {
label: string
err: string
required: boolean
}
@Entry
@Component
struct ReportCreatePage {
@@ -51,6 +57,7 @@ struct ReportCreatePage {
private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度
private taskId: string = ''
private reportId: string = ''
private scroller: Scroller = new Scroller()
aboutToAppear(): void {
const cached = AppStorage.get<string>(LAST_REPORT_STORAGE_KEY)
@@ -111,15 +118,61 @@ struct ReportCreatePage {
this.formSummary = ''
if (!this.validateForm()) {
// 字段级红字可能滚在视野外(真机实测的"点了没反应"就是这种情况):
// 顶部再放一条汇总,让失败永远可见。
const errs: string[] = [this.genderErr, this.heightErr, this.weightErr, this.goalsErr, this.styleErr]
const n = errs.filter((e: string): boolean => e !== '').length
this.formSummary = `还有 ${n} 项未完成,请检查标红的字段(性别/身高/体重/核心诉求/心理风格)`
// 汇总条点名缺哪几项,并自动滚到第一个缺失字段。
const labels: string[] = this.missingLabels()
this.formSummary = labels.length > 0 ? `请完成:${labels.join('、')}` : '请检查标红的字段'
this.scrollToFirstError()
return
}
this.doSubmit()
}
private missingLabels(): string[] {
const labels: string[] = []
if (this.genderErr !== '') {
labels.push('性别')
}
if (this.heightErr !== '') {
labels.push('身高')
}
if (this.weightErr !== '') {
labels.push('体重')
}
if (this.goalsErr !== '') {
labels.push('核心诉求')
}
if (this.styleErr !== '') {
labels.push('心理风格')
}
return labels
}
private scrollToFirstError(): void {
let firstId = ''
if (this.genderErr !== '') {
firstId = 'field_gender'
} else if (this.heightErr !== '' || this.weightErr !== '') {
firstId = 'field_height'
} else if (this.goalsErr !== '') {
firstId = 'field_goals'
} else if (this.styleErr !== '') {
firstId = 'field_style'
}
if (firstId === '') {
return
}
try {
// windowOffset 随滚动同步移动,两处相减即为内容坐标差,
// 与当前滚动位置无关
const root = componentUtils.getRectangleById('form_root')
const target = componentUtils.getRectangleById(firstId)
const dy = target.windowOffset.y - root.windowOffset.y - 8
this.scroller.scrollTo({ xOffset: 0, yOffset: Math.max(0, dy), animation: { duration: 250, curve: Curve.EaseOut } })
} catch (e) {
console.error(`scrollTo ${firstId} failed: ${String(e).substring(0, 80)}`)
}
}
private async doSubmit(): Promise<void> {
this.submitting = true
this.submitError = ''
@@ -226,14 +279,21 @@ struct ReportCreatePage {
.padding({ left: 12, right: 16, top: 10, bottom: 6 })
}
// 按引用传递($$):@Builder 按值传参时 @State 变化不会触发刷新,
// 字段级红字将永远停在初始空值(真机实测复现)
@Builder
FieldLabel(label: string, err: string) {
FieldLabel($$: FieldLabelParams) {
Row({ space: 6 }) {
Text(label)
if ($$.required) {
Text('*')
.fontSize(14)
.fontColor('#E5484D')
}
Text($$.label)
.fontSize(13)
.fontColor('#666666')
if (err !== '') {
Text(err)
if ($$.err !== '') {
Text($$.err)
.fontSize(11)
.fontColor('#E5484D')
.layoutWeight(1)
@@ -245,7 +305,7 @@ struct ReportCreatePage {
@Builder
FormBody() {
Scroll() {
Scroll(this.scroller) {
Column({ space: 12 }) {
Column({ space: 4 }) {
Text('生成你的形象诊断报告')
@@ -266,7 +326,7 @@ struct ReportCreatePage {
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column({ space: 8 }) {
this.FieldLabel('性别', this.genderErr)
this.FieldLabel({ label: '性别', err: this.genderErr, required: true })
Row({ space: 8 }) {
ForEach(GENDER_OPTIONS, (g: string) => {
Text(g)
@@ -284,10 +344,11 @@ struct ReportCreatePage {
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.id('field_gender')
Row({ space: 12 }) {
Column({ space: 8 }) {
this.FieldLabel('身高 (cm)', this.heightErr)
this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true })
TextInput({ placeholder: '如 165', text: this.heightInput })
.type(InputType.Number)
.height(44)
@@ -301,9 +362,10 @@ struct ReportCreatePage {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.id('field_height')
Column({ space: 8 }) {
this.FieldLabel('体重 (kg)', this.weightErr)
this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true })
TextInput({ placeholder: '如 52', text: this.weightInput })
.type(InputType.Number)
.height(44)
@@ -321,7 +383,7 @@ struct ReportCreatePage {
.width('100%')
Column({ space: 8 }) {
this.FieldLabel('生日', '')
this.FieldLabel({ label: '生日', err: '', required: false })
Row({ space: 12 }) {
Text(`当前选择:${formatDate(this.birthday)}`)
.fontSize(13)
@@ -363,7 +425,7 @@ struct ReportCreatePage {
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Column({ space: 8 }) {
this.FieldLabel('核心诉求(多选)', this.goalsErr)
this.FieldLabel({ label: '核心诉求(多选)', err: this.goalsErr, required: true })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(GOAL_OPTIONS, (g: string) => {
Text(g)
@@ -382,9 +444,10 @@ struct ReportCreatePage {
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.id('field_goals')
Column({ space: 8 }) {
this.FieldLabel('心理风格(九型·单选)', this.styleErr)
this.FieldLabel({ label: '心理风格(九型·单选)', err: this.styleErr, required: true })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(STYLE_OPTIONS, (s: string) => {
Text(s)
@@ -404,6 +467,7 @@ struct ReportCreatePage {
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.id('field_style')
}
.width('100%')
.padding(16)
@@ -445,6 +509,9 @@ struct ReportCreatePage {
.backgroundColor('#FDECEC')
.borderRadius(8)
.textAlign(TextAlign.Start)
.onClick(() => {
this.scrollToFirstError()
})
}
if (this.submitError !== '') {
Text(this.submitError)
@@ -466,6 +533,7 @@ struct ReportCreatePage {
})
}
.padding({ left: 16, right: 16 })
.id('form_root')
}
.layoutWeight(1)
.width('100%')