Files
meida/app/entry/src/main/ets/pages/ReportCreatePage.ets
T

780 lines
25 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;失败重试沿用同一 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<string>(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<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 }
// 显式 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<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 按值传参时 @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')
}
}