feat(app): report flow with polling and 32-dimension result view(F3)

This commit is contained in:
xsl
2026-08-15 00:49:47 +08:00
parent 9503c6e259
commit 995c18e7d5
5 changed files with 1573 additions and 10 deletions
@@ -1,17 +1,582 @@
/**
* ReportCreatePage —— 占位桩(FE 批次对应任务填充实现)。
* 已注册进 main_pages.json,其它页面可按路径跳转。
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
*
* 流程:
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验
* 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 } 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'
@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 submitError: string = ''
@State failMsg: string = ''
@State showDupDialog: boolean = false
@State lastReportId: string = ''
private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度
private taskId: string = ''
private reportId: string = ''
aboutToAppear(): void {
const cached = AppStorage.get<string>(LAST_REPORT_STORAGE_KEY)
if (cached !== undefined && cached !== '') {
this.lastReportId = cached
}
}
private isNumeric(s: string): boolean {
return /^\d+(\.\d+)?$/.test(s.trim())
}
private validateForm(): boolean {
let ok = true
this.genderErr = this.gender === '' ? '请选择性别' : ''
if (this.genderErr !== '') {
ok = false
}
const h = Number(this.heightInput)
this.heightErr = !this.isNumeric(this.heightInput) || h < 100 || h > 250 ? '需为 100~250' : ''
if (this.heightErr !== '') {
ok = false
}
const w = Number(this.weightInput)
this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : ''
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
}
private onSubmitTap(): void {
if (this.submitting) {
return
}
if (!this.validateForm()) {
return
}
this.doSubmit()
}
private async doSubmit(): Promise<void> {
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<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 {
// 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理,
// 不会重复生成报告或重复扣减额度
this.failMsg = ''
this.doSubmit()
}
private onTaskSucceeded(): void {
const params: Record<string, Object> = { 'reportId': this.reportId }
router.replaceUrl({ url: 'pages/ReportResultPage', params: params })
}
private onTaskFailed(reason: string): void {
this.phase = 'failed'
this.failMsg = reason
}
private goLastReport(): void {
this.showDupDialog = false
if (this.lastReportId === '') {
return
}
const params: Record<string, Object> = { '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
FieldLabel(label: string, err: string) {
Row({ space: 6 }) {
Text(label)
.fontSize(13)
.fontColor('#666666')
if (err !== '') {
Text(err)
.fontSize(11)
.fontColor('#E5484D')
.layoutWeight(1)
.maxLines(1)
}
}
.width('100%')
}
@Builder
FormBody() {
Scroll() {
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('性别', this.genderErr)
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)
Row({ space: 12 }) {
Column({ space: 8 }) {
this.FieldLabel('身高 (cm)', this.heightErr)
TextInput({ placeholder: '如 165', text: this.heightInput })
.type(InputType.Number)
.height(44)
.fontSize(14)
.backgroundColor('#F5F6F8')
.borderRadius(10)
.onChange((v: string) => {
this.heightInput = v
this.heightErr = ''
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
this.FieldLabel('体重 (kg)', this.weightErr)
TextInput({ placeholder: '如 52', text: this.weightInput })
.type(InputType.Number)
.height(44)
.fontSize(14)
.backgroundColor('#F5F6F8')
.borderRadius(10)
.onChange((v: string) => {
this.weightInput = v
this.weightErr = ''
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Column({ space: 8 }) {
this.FieldLabel('生日', '')
Text(`当前选择:${formatDate(this.birthday)}`)
.fontSize(12)
.fontColor('#999999')
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('核心诉求(多选)', this.goalsErr)
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)
Column({ space: 8 }) {
this.FieldLabel('心理风格(九型·单选)', this.styleErr)
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)
}
.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.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 })
}
.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%')
}
.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() {
Column() {
Text('ReportCreatePage(建设中)').fontSize(16).fontColor('#666666')
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%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F6F8')
}
}