- 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 收口;首页入口改版
903 lines
30 KiB
Plaintext
903 lines
30 KiB
Plaintext
/**
|
||
* 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')
|
||
}
|
||
}
|