/** * ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。 * * 流程: * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验; * 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入 * 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg * 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key, * 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务) * 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录, * 无记录时实时拉报告列表兜底,重启后也找得回) * 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回) * 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 新 key 重试 * 7) 提交前弹配额确认框(report_basic 终身 1 次,产品方案设计纪律 3「禁止静默扣减」) */ import { router, componentUtils } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' import { ReportCreateResp, ReportListItemWire, buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes' 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 } /** GET /api/v1/quota?quota_key=report_basic 的响应(字段名对齐后端 JSON)。 */ interface QuotaWireResp { quota_key: string used: number limit_value: number bonus_granted: boolean remaining: number reset_at: string } @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 = '' @State showQuotaConfirm: boolean = false @State quotaRemaining: number = -1 @State quotaLoaded: boolean = false private requestKey: string = '' // FAILED 后置空:重试换新 key 创建新任务(同 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 } // 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段, // 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。 const params = router.getParams() as Record | undefined if (params !== undefined) { if (typeof params['gender'] === 'string' && params['gender'].length > 0) { this.gender = params['gender'] as string } if (typeof params['birthday'] === 'string' && params['birthday'].length >= 8) { const d = new Date(params['birthday'] as string) if (!isNaN(d.getTime())) { this.birthday = d } } if (typeof params['height_cm'] === 'string' && (params['height_cm'] as string).length > 0) { this.heightInput = params['height_cm'] as string } if (typeof params['weight_kg'] === 'string' && (params['weight_kg'] as string).length > 0) { this.weightInput = params['weight_kg'] as string } } } 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 } // 提交前确认配额代价(产品方案设计纪律 3:禁止静默扣减) this.openQuotaConfirm() } /** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */ private openQuotaConfirm(): void { this.showQuotaConfirm = true this.quotaLoaded = false this.quotaRemaining = -1 this.loadQuotaForConfirm() } private async loadQuotaForConfirm(): Promise { try { const d = await ApiClient.get('/api/v1/quota?quota_key=report_basic') this.quotaRemaining = d.remaining this.quotaLoaded = true } catch (_e) { // 拉取失败只展示代价文案,是否放行交给服务端校验 this.quotaRemaining = -1 this.quotaLoaded = false } } 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 后 requestKey 已被置空,此处即生成新 key 走全新任务 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 { // onTaskFailed 已清空 requestKey:重试以新 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 // 清空幂等键:服务端重放不过滤任务状态,沿用旧 key 重试只会拿回同一失败任务 this.requestKey = '' } private async goLastReport(): Promise { this.showDupDialog = false if (this.lastReportId === '') { // 重启后本地记录可能丢失:从服务端列表兜底取最近一份报告 try { const rows = await ApiClient.get('/api/v1/reports?limit=1') const rid = rows.length > 0 ? (rows[0].report_id ?? '') : '' if (rid !== '') { this.lastReportId = rid AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, rid) } } catch (_e) { // 拉取失败走下方提示,不打断用户 } } if (this.lastReportId === '') { this.submitError = '暂时找不到你的报告,可稍后在首页「报告」中查看' return } const params: Record = { 'reportId': this.lastReportId } router.pushUrl({ url: 'pages/ReportResultPage', params: params }) } @Builder TopBar() { Row({ space: 8 }) { Text('‹ 返回') .fontSize(15) .fontColor(COLOR_TEXT_SECONDARY) .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(COLOR_PRIMARY) .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') // 照片区(#6):上传能力未开放,只做预告;对象键输入框对用户隐藏 // (后端本就自动生成 report_photo/{userId}/{随机}.jpg,输入无用户价值) Column({ space: 8 }) { Text('照片(选填)') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#333333') Text('上传照片可以让报告更准,即将开放') .fontSize(11) .fontColor('#999999') } .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(COLOR_PRIMARY) .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('本次生成未成功,额度已自动退还;重试将重新提交,不会重复消耗。') .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) Text('将为你打开最近一次生成的报告。') .fontSize(12) .fontColor('#999999') Row({ space: 12 }) { Button('我知道了') .layoutWeight(1) .height(40) .fontSize(14) .backgroundColor('#F3F4F6') .fontColor('#666666') .onClick(() => { this.showDupDialog = false }) 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') } /** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */ @Builder QuotaConfirmDialog() { Column({ space: 14 }) { Text('确认生成报告') .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor('#333333') Text('首份形象报告为一次性权益,本次生成将使用 1 次。') .fontSize(13) .fontColor('#666666') .textAlign(TextAlign.Center) if (this.quotaLoaded && this.quotaRemaining > 0) { Text(`当前剩余 ${this.quotaRemaining} 次`) .fontSize(13) .fontColor(COLOR_PRIMARY) } else if (this.quotaLoaded && this.quotaRemaining <= 0) { Text('你的报告权益已使用,可在首页「报告」查看已生成的报告。') .fontSize(12) .fontColor('#D46A0B') .textAlign(TextAlign.Center) } Row({ space: 12 }) { Button('取消') .layoutWeight(1) .height(40) .fontSize(14) .backgroundColor('#F3F4F6') .fontColor('#666666') .onClick(() => { this.showQuotaConfirm = false }) Button('确认生成') .layoutWeight(1) .height(40) .fontSize(14) .backgroundColor(COLOR_PRIMARY) .enabled(!(this.quotaLoaded && this.quotaRemaining <= 0)) .onClick(() => { this.showQuotaConfirm = false this.doSubmit() }) } .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) } if (this.showQuotaConfirm) { Column() { this.QuotaConfirmDialog() } .width('100%') .height('100%') .backgroundColor('#66000000') .justifyContent(FlexAlign.Center) } } .width('100%') .height('100%') .backgroundColor('#F5F6F8') } }