/** * ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。 * * 流程: * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验; * 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入 * 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg * 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放) * 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录), * 无记录则提示稍后在任务中心查看 * 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回) * 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 幂等重试 */ import { router, componentUtils } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' import { ReportCreateResp, buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes' const COLOR_PRIMARY: string = '#C2185B' const GENDER_OPTIONS: string[] = ['女', '男', '不便告知'] const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈'] const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年'] const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id' // 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致 const HEIGHT_MIN: number = 100 const HEIGHT_MAX: number = 250 const HEIGHT_DEFAULT: number = 165 const WEIGHT_MIN: number = 25 const WEIGHT_MAX: number = 300 const WEIGHT_DEFAULT: number = 52 function buildRangeOptions(min: number, max: number): string[] { const arr: string[] = [] for (let v = min; v <= max; v++) { arr.push(`${v}`) } return arr } const HEIGHT_OPTIONS: string[] = buildRangeOptions(HEIGHT_MIN, HEIGHT_MAX) const WEIGHT_OPTIONS: string[] = buildRangeOptions(WEIGHT_MIN, WEIGHT_MAX) interface FieldLabelParams { label: string err: string required: boolean } @Entry @Component struct ReportCreatePage { @State phase: string = 'form' // form | polling | failed @State gender: string = '' @State heightInput: string = '' @State weightInput: string = '' @State birthday: Date = new Date(2000, 0, 15) @State coreGoals: string[] = [] @State psychStyle: string = '' @State photoKeyInput: string = '' @State genderErr: string = '' @State heightErr: string = '' @State weightErr: string = '' @State goalsErr: string = '' @State styleErr: string = '' @State submitting: boolean = false @State showDatePicker: boolean = false @State formSummary: string = '' @State navError: string = '' @State submitError: string = '' @State failMsg: string = '' @State showDupDialog: boolean = false @State lastReportId: string = '' private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度 private taskId: string = '' private reportId: string = '' private scroller: Scroller = new Scroller() aboutToAppear(): void { const cached = AppStorage.get(LAST_REPORT_STORAGE_KEY) if (cached !== undefined && cached !== '') { this.lastReportId = cached } } private validateForm(): boolean { let ok = true this.genderErr = this.gender === '' ? '请选择性别' : '' if (this.genderErr !== '') { ok = false } // 选择器产出必然在合法区间,未选才是唯一错误态 this.heightErr = this.heightInput === '' ? '请选择身高' : '' if (this.heightErr !== '') { ok = false } this.weightErr = this.weightInput === '' ? '请选择体重' : '' if (this.weightErr !== '') { ok = false } this.goalsErr = this.coreGoals.length === 0 ? '至少选择 1 项' : '' if (this.goalsErr !== '') { ok = false } this.styleErr = this.psychStyle === '' ? '请选择心理风格' : '' if (this.styleErr !== '') { ok = false } return ok } private toggleGoal(g: string): void { const idx = this.coreGoals.indexOf(g) if (idx >= 0) { this.coreGoals.splice(idx, 1) } else { this.coreGoals.push(g) } this.goalsErr = '' } private isSelectedGoal(g: string): boolean { return this.coreGoals.indexOf(g) >= 0 } /** TextPickerResult.index 是联合类型(多列选择器为数组),单列取数字下标。 */ private pickIndex(v: TextPickerResult): number { return typeof v.index === 'number' ? v.index : -1 } /** 已有值从当前值滚起,未选过从常见默认值(165cm)滚起。 */ private openHeightPicker(): void { const cur = this.heightInput !== '' ? Number(this.heightInput) : HEIGHT_DEFAULT const clamped = Math.min(Math.max(cur - HEIGHT_MIN, 0), HEIGHT_OPTIONS.length - 1) this.getUIContext().showTextPickerDialog({ range: HEIGHT_OPTIONS, selected: clamped, canLoop: false, onAccept: (value: TextPickerResult) => { const idx = this.pickIndex(value) if (idx >= 0 && idx < HEIGHT_OPTIONS.length) { this.heightInput = HEIGHT_OPTIONS[idx] this.heightErr = '' } } }) } private openWeightPicker(): void { const cur = this.weightInput !== '' ? Number(this.weightInput) : WEIGHT_DEFAULT const clamped = Math.min(Math.max(cur - WEIGHT_MIN, 0), WEIGHT_OPTIONS.length - 1) this.getUIContext().showTextPickerDialog({ range: WEIGHT_OPTIONS, selected: clamped, canLoop: false, onAccept: (value: TextPickerResult) => { const idx = this.pickIndex(value) if (idx >= 0 && idx < WEIGHT_OPTIONS.length) { this.weightInput = WEIGHT_OPTIONS[idx] this.weightErr = '' } } }) } private onSubmitTap(): void { if (this.submitting) { return } this.formSummary = '' if (!this.validateForm()) { // 字段级红字可能滚在视野外(真机实测的"点了没反应"就是这种情况): // 汇总条点名缺哪几项,并自动滚到第一个缺失字段。 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 { this.submitting = true this.submitError = '' if (this.requestKey === '') { // 随机 hex;FAILED 后的重试复用同一 key(见 retrySubmit 注释) this.requestKey = randomHex(16) } const heightVal: number = Number(this.heightInput) const weightVal: number = Number(this.weightInput) const goalsObj: Object = this.coreGoals const questionnaire: Record = { 'gender': this.gender, 'height_cm': heightVal, 'weight_kg': weightVal, 'birthday': formatDate(this.birthday), 'core_goals': goalsObj, 'psych_style': this.psychStyle } const qObj: Object = questionnaire const photoInput = this.photoKeyInput.trim() const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous') const req: Record = { 'questionnaire': qObj, 'photo_object_key': photoKey, 'request_key': this.requestKey } try { const data = await ApiClient.post('/api/v1/reports', req) this.reportId = data.report_id ?? '' this.taskId = data.task_id ?? '' if (this.reportId !== '') { this.lastReportId = this.reportId AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, this.reportId) } if (this.taskId === '') { this.submitError = '服务端未返回任务 ID,请稍后重试' this.submitting = false return } this.submitting = false this.phase = 'polling' } catch (e) { this.submitting = false if (e instanceof ApiError) { if (e.code === 40920) { // 本期报告已生成:给“查看我的报告”入口(无记录则提示稍后在任务中心看) this.showDupDialog = true return } this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message return } this.submitError = '请求失败,请检查网络' } } private retrySubmit(): void { // 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理, // 不会重复生成报告或重复扣减额度 this.failMsg = '' this.doSubmit() } private onTaskSucceeded(): void { const params: Record = { 'reportId': this.reportId } // 显式 catch:replaceUrl 的 Promise 拒绝在无 catch 时完全静默(真机实测 // 卡在轮询页无任何提示),至少把失败暴露出来并由兜底按钮接管。 router.replaceUrl({ url: 'pages/ReportResultPage', params: params }) .catch((e: Object): void => { this.navError = `报告已生成,但打开结果页失败:${String(e).substring(0, 80)}` }) } private onTaskFailed(reason: string): void { this.phase = 'failed' this.failMsg = reason } private goLastReport(): void { this.showDupDialog = false if (this.lastReportId === '') { return } const params: Record = { 'reportId': this.lastReportId } router.pushUrl({ url: 'pages/ReportResultPage', params: params }) } @Builder TopBar() { Row({ space: 8 }) { Text('←') .fontSize(20) .fontColor('#333333') .padding({ left: 4, right: 4, top: 2, bottom: 2 }) .onClick(() => { router.back() }) Text('生成报告') .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor('#333333') } .width('100%') .padding({ left: 12, right: 16, top: 10, bottom: 6 }) } // 按引用传递($$):@Builder 按值传参时 @State 变化不会触发刷新, // 字段级红字将永远停在初始空值(真机实测复现) @Builder FieldLabel($$: FieldLabelParams) { Row({ space: 6 }) { if ($$.required) { Text('*') .fontSize(14) .fontColor('#E5484D') } Text($$.label) .fontSize(13) .fontColor('#666666') if ($$.err !== '') { Text($$.err) .fontSize(11) .fontColor('#E5484D') .layoutWeight(1) .maxLines(1) } } .width('100%') } @Builder FormBody() { Scroll(this.scroller) { Column({ space: 12 }) { Column({ space: 4 }) { Text('生成你的形象诊断报告') .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#333333') Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告') .fontSize(12) .fontColor('#999999') } .width('100%') .alignItems(HorizontalAlign.Start) .padding({ top: 4, bottom: 4 }) Column({ space: 14 }) { Text('基础信息') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#333333') Column({ space: 8 }) { this.FieldLabel({ label: '性别', err: this.genderErr, required: true }) Row({ space: 8 }) { ForEach(GENDER_OPTIONS, (g: string) => { Text(g) .fontSize(14) .fontColor(this.gender === g ? '#FFFFFF' : '#444444') .padding({ left: 14, right: 14, top: 7, bottom: 7 }) .backgroundColor(this.gender === g ? COLOR_PRIMARY : '#F3F4F6') .borderRadius(14) .onClick(() => { this.gender = g this.genderErr = '' }) }, (g: string) => g) } } .width('100%') .alignItems(HorizontalAlign.Start) .id('field_gender') Row({ space: 12 }) { Column({ space: 8 }) { this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true }) Row() { Text(this.heightInput === '' ? '请选择' : `${this.heightInput} cm`) .fontSize(14) .fontColor(this.heightInput === '' ? '#AAAAAA' : '#333333') .layoutWeight(1) Text('▼') .fontSize(10) .fontColor('#999999') } .height(44) .padding({ left: 12, right: 12 }) .backgroundColor('#F5F6F8') .borderRadius(10) .alignItems(VerticalAlign.Center) .onClick(() => { this.openHeightPicker() }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) .id('field_height') Column({ space: 8 }) { this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true }) Row() { Text(this.weightInput === '' ? '请选择' : `${this.weightInput} kg`) .fontSize(14) .fontColor(this.weightInput === '' ? '#AAAAAA' : '#333333') .layoutWeight(1) Text('▼') .fontSize(10) .fontColor('#999999') } .height(44) .padding({ left: 12, right: 12 }) .backgroundColor('#F5F6F8') .borderRadius(10) .alignItems(VerticalAlign.Center) .onClick(() => { this.openWeightPicker() }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width('100%') Column({ space: 8 }) { this.FieldLabel({ label: '生日', err: '', required: false }) Row({ space: 12 }) { Text(`当前选择:${formatDate(this.birthday)}`) .fontSize(13) .fontColor('#333333') .layoutWeight(1) Text(this.showDatePicker ? '收起 ▲' : '修改 ▼') .fontSize(13) .fontColor('#C2185B') .onClick(() => { this.showDatePicker = !this.showDatePicker }) } .width('100%') // DatePicker 默认收起:展开态高约半屏,会把心理风格/提交按钮挤出 // 视野并把滚动手势吃掉(真机实测:用户看不到必填项与错误提示, // 表现为"提交没反应")。需要改生日时才展开。 if (this.showDatePicker) { DatePicker({ start: new Date(1930, 0, 1), end: new Date(), selected: this.birthday }) .onDateChange((d: Date) => { this.birthday = d }) } } .width('100%') .alignItems(HorizontalAlign.Start) } .width('100%') .padding(16) .borderRadius(14) .backgroundColor('#FFFFFF') Column({ space: 14 }) { Text('风格偏好') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#333333') Column({ space: 8 }) { this.FieldLabel({ label: '核心诉求(多选)', err: this.goalsErr, required: true }) Flex({ wrap: FlexWrap.Wrap }) { ForEach(GOAL_OPTIONS, (g: string) => { Text(g) .fontSize(13) .fontColor(this.isSelectedGoal(g) ? '#FFFFFF' : '#444444') .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.isSelectedGoal(g) ? COLOR_PRIMARY : '#F3F4F6') .borderRadius(13) .margin({ right: 8, bottom: 8 }) .onClick(() => { this.toggleGoal(g) }) }, (g: string) => g) } .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) .id('field_goals') Column({ space: 8 }) { this.FieldLabel({ label: '心理风格(九型·单选)', err: this.styleErr, required: true }) Flex({ wrap: FlexWrap.Wrap }) { ForEach(STYLE_OPTIONS, (s: string) => { Text(s) .fontSize(13) .fontColor(this.psychStyle === s ? '#FFFFFF' : '#444444') .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.psychStyle === s ? COLOR_PRIMARY : '#F3F4F6') .borderRadius(13) .margin({ right: 8, bottom: 8 }) .onClick(() => { this.psychStyle = s this.styleErr = '' }) }, (s: string) => s) } .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) .id('field_style') } .width('100%') .padding(16) .borderRadius(14) .backgroundColor('#FFFFFF') Column({ space: 8 }) { Text('照片(模拟)') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#333333') Text('V1 骨架:COS 直传未接入,此处模拟对象键') .fontSize(11) .fontColor('#999999') TextInput({ placeholder: 'report_photo/xxx.jpg(选填)', text: this.photoKeyInput }) .height(44) .fontSize(13) .backgroundColor('#F5F6F8') .borderRadius(10) .onChange((v: string) => { this.photoKeyInput = v }) Text('留空时自动生成 report_photo/{userId}/{随机}.jpg') .fontSize(11) .fontColor('#AAAAAA') } .width('100%') .padding(16) .borderRadius(14) .backgroundColor('#FFFFFF') .alignItems(HorizontalAlign.Start) if (this.formSummary !== '') { Text(this.formSummary) .fontSize(13) .fontColor('#E5484D') .width('100%') .padding(10) .backgroundColor('#FDECEC') .borderRadius(8) .textAlign(TextAlign.Start) .onClick(() => { this.scrollToFirstError() }) } if (this.submitError !== '') { Text(this.submitError) .fontSize(12) .fontColor('#E5484D') .width('100%') .textAlign(TextAlign.Start) } Button(this.submitting ? '提交中…' : '提交并生成报告') .width('100%') .height(48) .fontSize(16) .backgroundColor(COLOR_PRIMARY) .enabled(!this.submitting) .margin({ bottom: 24 }) .onClick(() => { this.onSubmitTap() }) } .padding({ left: 16, right: 16 }) .id('form_root') } .layoutWeight(1) .width('100%') } @Builder PollingBody() { Column({ space: 16 }) { Text('正在生成你的专属报告') .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#333333') Text('32 维形象诊断通常需要 1~3 分钟,生成完成后自动进入结果页') .fontSize(13) .fontColor('#999999') .textAlign(TextAlign.Center) TaskPollingPanel({ taskId: this.taskId, onSucceeded: (): void => { this.onTaskSucceeded() }, onFailed: (reason: string): void => { this.onTaskFailed(reason) } }) .width('86%') if (this.navError !== '') { Text(this.navError) .fontSize(12) .fontColor('#E5484D') .width('86%') .textAlign(TextAlign.Center) } // 兜底:自动跳转若失败(或用户中途离开又回来),手动进结果页。 // reportId 在提交成功时即已保存,轮询页始终可进。 if (this.reportId !== '') { Button('查看报告') .fontSize(14) .backgroundColor('#FFFFFF') .fontColor('#C2185B') .borderRadius(20) .padding({ left: 24, right: 24 }) .margin({ top: 8 }) .onClick(() => { this.onTaskSucceeded() }) } } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) .padding({ left: 24, right: 24 }) } @Builder FailedBody() { Column({ space: 12 }) { Text('生成失败') .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#333333') Text(this.failMsg) .fontSize(13) .fontColor('#999999') .textAlign(TextAlign.Center) Text('重试将沿用同一 request_key 重新提交,服务端按幂等重放处理,不会重复消耗额度。') .fontSize(11) .fontColor('#AAAAAA') .textAlign(TextAlign.Center) Button('重试提交') .width('62%') .height(44) .fontSize(15) .backgroundColor(COLOR_PRIMARY) .margin({ top: 8 }) .onClick(() => { this.retrySubmit() }) Button('返回修改问卷') .width('62%') .height(44) .fontSize(15) .backgroundColor('#F3F4F6') .fontColor('#666666') .onClick(() => { this.phase = 'form' }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) .padding({ left: 32, right: 32 }) } @Builder DupDialog() { Column({ space: 14 }) { Text('本期报告已生成') .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor('#333333') Text('每个周期仅可生成一次完整报告,无需重复提交。') .fontSize(13) .fontColor('#666666') .textAlign(TextAlign.Center) if (this.lastReportId !== '') { Text('将为你打开最近一次生成的报告。') .fontSize(12) .fontColor('#999999') } else { Text('本期报告稍后可在任务中心查看。') .fontSize(12) .fontColor('#999999') } Row({ space: 12 }) { Button('我知道了') .layoutWeight(1) .height(40) .fontSize(14) .backgroundColor('#F3F4F6') .fontColor('#666666') .onClick(() => { this.showDupDialog = false }) if (this.lastReportId !== '') { Button('查看我的报告') .layoutWeight(1) .height(40) .fontSize(14) .backgroundColor(COLOR_PRIMARY) .onClick(() => { this.goLastReport() }) } } .width('100%') .margin({ top: 4 }) } .width('82%') .padding(20) .borderRadius(16) .backgroundColor('#FFFFFF') } build() { Stack() { Column() { this.TopBar() if (this.phase === 'form') { this.FormBody() } else if (this.phase === 'polling') { this.PollingBody() } else { this.FailedBody() } } .width('100%') .height('100%') if (this.showDupDialog) { Column() { this.DupDialog() } .width('100%') .height('100%') .backgroundColor('#66000000') .justifyContent(FlexAlign.Center) } } .width('100%') .height('100%') .backgroundColor('#F5F6F8') } }