Files
meida/app/entry/src/main/ets/pages/ReportCreatePage.ets
T
xsl 8a901176c4 feat(app): 美搭配 v2(衣橱+精选30件)/发型 v2(男女分栏+换发色修复)/美学诊断两页——多 Agent 一轮交付
- fix(tryon): 鸿蒙 http.multiFormDataList 真机发空包(400 请上传图片)→ TryonApi 手工构造 multipart,真机验证通过;换发型/换发色/换装全链路 MEIDA_TRYON 打点
- feat(hair): 60 款发型视觉分类男女(26/34)Segment 分栏;双图对比、8 色圆点、吸底应用发色
- feat(fitting): FittingRoomPage 预览+试穿黑胶囊+我的衣橱(preferences+filesDir 持久化)+精选衣橱(rawfile 30 件+manifest)+品类过滤+双槽位生成
- feat(diagnosis): 紫渐变落地页+五字段填写页(@Prop 修选择器回填)+预填跳 ReportCreatePage
- chore(theme): 9 个色板 token 收口;首页入口改版
2026-09-02 23:40:16 +08:00

903 lines
30 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string>(LAST_REPORT_STORAGE_KEY)
if (cached !== undefined && cached !== '') {
this.lastReportId = cached
}
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段,
// 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。
const params = router.getParams() as Record<string, Object> | 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<void> {
try {
const d = await ApiClient.get<QuotaWireResp>('/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<void> {
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<string, Object> = {
'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<string, Object> = {
'questionnaire': qObj,
'photo_object_key': photoKey,
'request_key': this.requestKey
}
try {
const data = await ApiClient.post<ReportCreateResp>('/api/v1/reports', req)
this.reportId = data.report_id ?? ''
this.taskId = data.task_id ?? ''
if (this.reportId !== '') {
this.lastReportId = this.reportId
AppStorage.setOrCreate<string>(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<string, Object> = { '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<void> {
this.showDupDialog = false
if (this.lastReportId === '') {
// 重启后本地记录可能丢失:从服务端列表兜底取最近一份报告
try {
const rows = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports?limit=1')
const rid = rows.length > 0 ? (rows[0].report_id ?? '') : ''
if (rid !== '') {
this.lastReportId = rid
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
}
} catch (_e) {
// 拉取失败走下方提示,不打断用户
}
}
if (this.lastReportId === '') {
this.submitError = '暂时找不到你的报告,可稍后在首页「报告」中查看'
return
}
const params: Record<string, Object> = { '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')
}
}