feat(app): report flow with polling and 32-dimension result view(F3)
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user