feat(report): 诊断流四项修复 + 形象深度诊断端侧——表单草稿跨页保留(AppStorage);照片预览改竖版 3:4;40920 改中性兜底+历史报告入口(initialTab 参数);重新生成 40900 弹窗引导;深度诊断卡替换预约占位(生成/查看/防套娃)+ 色板/场景/30天计划渲染 + 「深度」徽标

This commit is contained in:
xsl
2026-09-15 00:03:11 +08:00
parent 1a6fde919b
commit 247da63509
6 changed files with 695 additions and 108 deletions
@@ -115,10 +115,20 @@ export struct ReportListTab {
ReportItem(item: ReportListItemWire) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(`形象报告 v${item.version ?? 1}`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(TEXT_PRIMARY)
Row({ space: 6 }) {
Text(item.tier === 'full' ? `深度诊断报告 v${item.version ?? 1}` : `形象报告 v${item.version ?? 1}`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(TEXT_PRIMARY)
if (item.tier === 'full') {
Text('深度')
.fontSize(10)
.fontColor(ACCENT)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor('#F7E3EA')
}
}
Text(this.reportTime(item))
.fontSize(12)
.fontColor(TEXT_SECONDARY)
@@ -62,15 +62,62 @@ export interface ValidationWire {
degraded?: boolean // true = 规则引擎降级生成,UI 需黄条透明提示
}
/** 色板单色(result.full.palette.{best,neutral,avoid}[];hex 为 '#RRGGBB') */
export interface PaletteColorWire {
name?: string
hex?: string
}
/** 十二季色彩定位(result.full.season;analysis 为 LLM 季型解析文案) */
export interface FullSeasonWire {
code?: string
detailed?: string
name_cn?: string
analysis?: string
}
export interface FullPaletteWire {
best?: PaletteColorWire[]
neutral?: PaletteColorWire[]
avoid?: PaletteColorWire[]
}
export interface FullOutfitWire {
name?: string
points?: string[]
}
export interface FullSceneWire {
key?: string
title?: string
outfits?: FullOutfitWire[]
}
export interface PlanWeekWire {
week?: number
title?: string
tasks?: string[]
}
/** 深度诊断(tier=full)增量内容:色板 / 场景穿搭 / 30 天计划 */
export interface FullResultWire {
season?: FullSeasonWire
palette?: FullPaletteWire
scenes?: FullSceneWire[]
plan30?: PlanWeekWire[]
}
export interface ReportResultWire {
diagnosis?: DiagnosisWire
copy?: CopyWire
validation?: ValidationWire
full?: FullResultWire
}
/** GET /api/v1/reports/{id} 的响应 */
export interface ReportDetailWire {
report_id?: string
tier?: string // 'basic' | 'full'
status?: string
version?: number
result?: ReportResultWire
@@ -14,6 +14,8 @@
* params 键名精确为 gender/birthday/height_cm/weight_kg,值全部为 string
* (接收端 ReportCreatePage 按 string 读取);跳转失败页内红字兜底。
* - 职业仅本地留存(AppStorage),不进提交体(需求 §3.6/验收 4)。
* - 全部字段做 AppStorage 草稿(f3_diagnosis_*):aboutToAppear 恢复、
* 每次变更写回(write-through),离开/重进诊断流程不丢已填内容。
*/
import { router, componentUtils } from '@kit.ArkUI'
@@ -50,6 +52,11 @@ const BIRTHDAY_FALLBACK_SELECTED: Date = new Date(2000, 0, 1)
/** 职业为本地留存字段(后端问卷 schema 无 occupation,Phase B 随 schema 扩展提交) */
const OCCUPATION_STORAGE_KEY: string = 'f3_diagnosis_occupation'
// 表单草稿(F3 跨页面保留):离开/重进诊断流程不丢已填内容,提交成功也不清
const DRAFT_GENDER_KEY: string = 'f3_diagnosis_gender'
const DRAFT_BIRTHDAY_KEY: string = 'f3_diagnosis_birthday'
const DRAFT_HEIGHT_KEY: string = 'f3_diagnosis_height'
const DRAFT_WEIGHT_KEY: string = 'f3_diagnosis_weight'
function buildRangeOptions(min: number, max: number): string[] {
const arr: string[] = []
@@ -78,6 +85,39 @@ struct DiagnosisFormPage {
@State navError: string = ''
private scroller: Scroller = new Scroller()
aboutToAppear(): void {
// 恢复草稿:用户离开诊断流程(回首页/切页)后重进不丢已填内容
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
if (gender !== undefined) {
this.gender = gender
}
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
if (birthday !== undefined) {
this.birthday = birthday
}
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
if (height !== undefined) {
this.heightInput = height
}
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
if (weight !== undefined) {
this.weightInput = weight
}
const occupation = AppStorage.get<string>(OCCUPATION_STORAGE_KEY)
if (occupation !== undefined) {
this.occupation = occupation
}
}
/** 草稿写回(write-through):每次字段变更即落 AppStorage。 */
private saveDraft(): void {
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, this.birthday)
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
}
/** 由出生日期派生只读年龄(周岁)。 */
private ageOf(): number {
if (this.birthday === '') {
@@ -114,6 +154,7 @@ struct DiagnosisFormPage {
onDateAccept: (value: Date): void => {
this.birthday = formatDate(value)
this.birthdayErr = ''
this.saveDraft()
}
})
}
@@ -131,6 +172,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
this.heightInput = HEIGHT_OPTIONS[idx]
this.heightErr = ''
this.saveDraft()
}
}
})
@@ -148,6 +190,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
this.weightInput = WEIGHT_OPTIONS[idx]
this.weightErr = ''
this.saveDraft()
}
}
})
@@ -215,8 +258,8 @@ struct DiagnosisFormPage {
this.scrollToFirstError()
return
}
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4)
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4);整份草稿一并写回
this.saveDraft()
// 四个值全部为 string,接收端 ReportCreatePage 按 string 读取
const params: Record<string, Object> = {
'gender': this.gender,
@@ -260,6 +303,7 @@ struct DiagnosisFormPage {
if (idx >= 0 && idx < GENDER_OPTIONS.length) {
this.gender = GENDER_OPTIONS[idx]
this.genderErr = ''
this.saveDraft()
}
}
})
@@ -278,6 +322,7 @@ struct DiagnosisFormPage {
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.occupation = this.occupation === o ? '' : o
this.saveDraft()
})
}, (o: string) => o)
}
+16 -1
View File
@@ -7,6 +7,8 @@
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
* - 可选路由参数 initialTab(number 0~3):外部页面可指定落地 Tab
* (如报告结果页「查看历史报告」传 1 落地「报告」Tab)
*/
import { router } from '@kit.ArkUI'
@@ -45,14 +47,27 @@ struct HomePage {
@State quotaRemaining: string = '…'
@State quotaNetError: boolean = false // 额度拉取遇网络错误(P0-6):配额卡位置换 NetworkBanner
@State reportRefreshTick: number = 0 // 自增通知报告 Tab 刷新(N2)
private tabsController: TabsController = new TabsController() // 供 initialTab 参数落地指定 Tab
aboutToAppear(): void {
// 可选参数 initialTab:外部页面(如报告结果页「查看历史报告」)可指定落地 Tab
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined && typeof params['initialTab'] === 'number') {
const idx = params['initialTab'] as number
if (idx >= 0 && idx <= 3) {
this.currentTabIndex = idx
}
}
this.dateLine = this.buildDateLine()
this.loadQuota()
}
/** 从报告结果页等返回(应用壳重新可见):当前停在报告 Tab 时刷新列表(N2)。 */
onPageShow(): void {
// initialTab 落地:Tabs 已挂载后 controller 才生效(aboutToAppear 里调用是空操作)
if (this.currentTabIndex !== 0) {
this.tabsController.changeIndex(this.currentTabIndex)
}
if (this.currentTabIndex === 1) {
this.reportRefreshTick += 1
}
@@ -248,7 +263,7 @@ struct HomePage {
build() {
Column() {
Tabs({ barPosition: BarPosition.End }) {
Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
TabContent() {
this.HomeTab()
}
@@ -9,11 +9,14 @@
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
* 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key,
* 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务)
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录,
* 无记录时实时拉报告列表兜底,重启后也找得回)
* 4) 40920(本期已生成)→ 页内对话框兜底:后端已不再抛 40920(重复生成改由
* report_basic 配额兜底),此处仅为兼容旧端的兜底分支,可跳最近一次报告
* (AppStorage 记录,无记录时实时拉报告列表兜底,重启后也找得回)
* 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回)
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 新 key 重试
* 7) 提交前弹配额确认框(report_basic 终身 1 次,产品方案设计纪律 3「禁止静默扣减」)
* 7) 提交前弹配额确认框(report_basic 配额,产品方案设计纪律 3「禁止静默扣减」)
* 8) 表单字段做 AppStorage 草稿(f3_report_*):aboutToAppear 恢复、变更即写回,
* 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)
*/
import { router, componentUtils } from '@kit.ArkUI'
@@ -36,6 +39,14 @@ const GENDER_OPTIONS: string[] = ['女', '男', '不便告知']
const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈']
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
// 本页表单草稿(f3_ 前缀,与 DiagnosisFormPage 同套风格):跨页面保留已填内容,
// 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)。
const DRAFT_GENDER_KEY: string = 'f3_report_gender'
const DRAFT_HEIGHT_KEY: string = 'f3_report_height'
const DRAFT_WEIGHT_KEY: string = 'f3_report_weight'
const DRAFT_BIRTHDAY_KEY: string = 'f3_report_birthday'
const DRAFT_GOALS_KEY: string = 'f3_report_goals' // 多选用 '|' 拼接成串存储
const DRAFT_STYLE_KEY: string = 'f3_report_style'
// 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致
const HEIGHT_MIN: number = 100
@@ -112,28 +123,79 @@ struct ReportCreatePage {
if (cached !== undefined && cached !== '') {
this.lastReportId = cached
}
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段,
// 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。
// 先恢复本页草稿(跨页面保留已填内容),再应用路由参数:
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6)时参数优先,
// 并同步更新草稿;无参数时行为与旧直达完全一致。
this.restoreDraft()
const params = router.getParams() as Record<string, Object> | undefined
if (params !== undefined) {
let paramsApplied = false
if (typeof params['gender'] === 'string' && params['gender'].length > 0) {
this.gender = params['gender'] as string
paramsApplied = true
}
if (typeof params['birthday'] === 'string' && params['birthday'].length >= 8) {
const d = new Date(params['birthday'] as string)
if (!isNaN(d.getTime())) {
this.birthday = d
paramsApplied = true
}
}
if (typeof params['height_cm'] === 'string' && (params['height_cm'] as string).length > 0) {
this.heightInput = params['height_cm'] as string
paramsApplied = true
}
if (typeof params['weight_kg'] === 'string' && (params['weight_kg'] as string).length > 0) {
this.weightInput = params['weight_kg'] as string
paramsApplied = true
}
if (paramsApplied) {
this.saveDraft()
}
}
}
/** 恢复草稿(photoUri 不在草稿内:媒体 URI 会失效,每次需重选)。 */
private restoreDraft(): void {
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
if (gender !== undefined) {
this.gender = gender
}
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
if (height !== undefined) {
this.heightInput = height
}
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
if (weight !== undefined) {
this.weightInput = weight
}
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
if (birthday !== undefined && birthday.length >= 8) {
const d = new Date(birthday)
if (!isNaN(d.getTime())) {
this.birthday = d
}
}
const goals = AppStorage.get<string>(DRAFT_GOALS_KEY)
if (goals !== undefined && goals !== '') {
this.coreGoals = goals.split('|')
}
const style = AppStorage.get<string>(DRAFT_STYLE_KEY)
if (style !== undefined) {
this.psychStyle = style
}
}
/** 草稿写回(write-through):每次字段变更即落 AppStorage;提交成功不清。 */
private saveDraft(): void {
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, formatDate(this.birthday))
AppStorage.setOrCreate<string>(DRAFT_GOALS_KEY, this.coreGoals.join('|'))
AppStorage.setOrCreate<string>(DRAFT_STYLE_KEY, this.psychStyle)
}
private validateForm(): boolean {
let ok = true
this.photoErr = this.photoUri === '' ? '请上传个人照片' : ''
@@ -172,6 +234,7 @@ struct ReportCreatePage {
this.coreGoals.push(g)
}
this.goalsErr = ''
this.saveDraft()
}
private isSelectedGoal(g: string): boolean {
@@ -196,6 +259,7 @@ struct ReportCreatePage {
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
this.heightInput = HEIGHT_OPTIONS[idx]
this.heightErr = ''
this.saveDraft()
}
}
})
@@ -213,6 +277,7 @@ struct ReportCreatePage {
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
this.weightInput = WEIGHT_OPTIONS[idx]
this.weightErr = ''
this.saveDraft()
}
}
})
@@ -273,7 +338,7 @@ struct ReportCreatePage {
this.openQuotaConfirm()
}
/** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */
/** 打开配额确认框并拉取当前剩余(report_basic 终身配额)。 */
private openQuotaConfirm(): void {
this.showQuotaConfirm = true
this.quotaLoaded = false
@@ -403,7 +468,8 @@ struct ReportCreatePage {
this.submitting = false
if (e instanceof ApiError) {
if (e.code === 40920) {
// 本期报告已生成:始终给「查看我的报告」入口(无本地记录也能从服务端找回)
// 兜底分支:后端已不再抛 40920(重复生成由 report_basic 配额兜底),
// 仅兼容旧端;始终给「查看我的报告」入口(无本地记录也能从服务端找回)
this.showDupDialog = true
return
}
@@ -538,7 +604,7 @@ struct ReportCreatePage {
.fontColor('#999999')
}
.width('100%')
.height(140)
.aspectRatio(0.75) // 竖版 3:4:与全身照取景一致,不再用横条占位
.justifyContent(FlexAlign.Center)
.backgroundColor('#F5F6F8')
.borderRadius(10)
@@ -550,7 +616,7 @@ struct ReportCreatePage {
Stack() {
Image(this.photoUri)
.width('100%')
.height(220)
.aspectRatio(0.75) // 竖版 3:4:竖屏全身照不再被上下截断
.objectFit(ImageFit.Cover)
.borderRadius(10)
Text('重选')
@@ -598,6 +664,7 @@ struct ReportCreatePage {
.onClick(() => {
this.gender = g
this.genderErr = ''
this.saveDraft()
})
}, (g: string) => g)
}
@@ -682,6 +749,7 @@ struct ReportCreatePage {
})
.onDateChange((d: Date) => {
this.birthday = d
this.saveDraft()
})
}
}
@@ -734,6 +802,7 @@ struct ReportCreatePage {
.onClick(() => {
this.psychStyle = s
this.styleErr = ''
this.saveDraft()
})
}, (s: string) => s)
}
@@ -879,15 +948,15 @@ struct ReportCreatePage {
@Builder
DupDialog() {
Column({ space: 14 }) {
Text('本期报告已生成')
Text('已有生成过的报告')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('首份形象报告为一次性权益,已生成的报告可随时查看。')
Text('可直接查看最近一次的报告,也可返回修改后重新提交生成新报告。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
Text('将为你打开最近一次生成的报告。')
Text('选择「查看我的报告」将为你打开最近一次生成的报告。')
.fontSize(12)
.fontColor('#999999')
Row({ space: 12 }) {
@@ -918,7 +987,7 @@ struct ReportCreatePage {
.backgroundColor('#FFFFFF')
}
/** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */
/** 提交前配额确认框(#5:消耗额度禁止静默扣减)。 */
@Builder
QuotaConfirmDialog() {
Column({ space: 14 }) {
@@ -926,7 +995,7 @@ struct ReportCreatePage {
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('首份形象报告为一次性权益,本次生成将使用 1 次。')
Text('生成报告将消耗 1 次报告额度。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
+488 -87
View File
@@ -16,6 +16,17 @@
* (服务端幂等重放不过滤状态,同 key 只会重放回原失败任务)
* - 导出 PDF / 重新生成均为消耗配额的操作:提交前弹确认框展示本次代价
* 与剩余次数(产品方案设计纪律 3「禁止静默扣减」)
* - 重新生成遇 40900(早期种子报告无 rp_input 输入行)弹对话框引导去生成
* 新报告,其它错误维持底部红字;底部另有「查看历史报告」次级入口
* (pushUrl HomePage + initialTab=1 落地「报告」Tab)
* - 深度诊断(tier=full):basic 报告页底部的入口卡——未生成显示内容清单 +
* 「生成深度诊断报告」(配额确认框 → POST /reports/{id}/full → 同页轮询 →
* 采纳新 report_id 重拉,幂等键 FAILED 换新,模式同 doRegenerate);
* 已生成显示「查看深度诊断报告」(进页时 GET /reports?limit=20 找最新
* tier=full 且 SUCCEEDED 的一份);当前页本身是 full 报告时卡片换成
* 「查看基础版报告」,防止 full→full 套娃
* - full 报告增量渲染:result.full 存在时在 32 维诊断区之后渲染十二季色板
* (色块 chips)、4 场景穿搭(分组卡)、30 天计划(按周)三个区块
*/
import { router, promptAction } from '@kit.ArkUI'
@@ -35,8 +46,14 @@ import { LoadingView } from '../components/f2/LoadingView'
import {
CopySectionWire,
DimensionWire,
FullOutfitWire,
FullResultWire,
FullSceneWire,
PaletteColorWire,
PlanWeekWire,
ReportCreateResp,
ReportDetailWire,
ReportListItemWire,
ReportRouteParams,
formatIsoTime,
randomHex,
@@ -81,22 +98,16 @@ interface ClaimWire {
remaining: number
}
/** POST /reservations 响应(T-P3-22)。created=false 表示幂等命中已有记录。 */
interface ReservationWire {
topic: string
created: boolean
created_at: string
}
/** GET /reservations?topic=… 响应。 */
interface ReservationListWire {
topics: string[]
}
/** 本周期已领(与后端 errors.py 对齐)。 */
const CODE_ALREADY_CLAIMED: number = 40910
/** 预约主题(T-P3-22 首期唯一值)。 */
const RESERVE_TOPIC: string = 'full_report'
/** 状态冲突(与后端 errors.py 对齐):重新生成时输入缺失的早期种子报告走此码。 */
const CODE_STATE_CONFLICT: number = 40900
/** PaletteGroup @Builder 的引用传递参数($$ 形态,数组重赋值才能刷新)。 */
interface PaletteGroupParams {
title: string
colors: PaletteColorWire[]
}
@Entry
@Component
@@ -119,6 +130,7 @@ struct ReportResultPage {
@State regenerating: boolean = false
@State regenTaskId: string = ''
@State regenError: string = ''
@State showRegenMissingDialog: boolean = false // 40900:早期种子报告无 rp_input,引导去生成新报告
private regenRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key(同 key 只会重放回失败任务)
@State exporting: boolean = false
@State exportTaskId: string = ''
@@ -131,9 +143,22 @@ struct ReportResultPage {
// —— 结论反馈(T-P3-21):dimension_key → verdict 回显表 ——
@State feedbackVerdicts: Record<string, string> = {}
@State feedbackSubmitting: string = '' // 提交中的 dimension_key(防空点)
// —— 全面报告预约留资卡(T-P3-22)——
@State reserved: boolean = false
@State reserveLoading: boolean = false
// —— 深度诊断(tier=full):入口卡 + 增量渲染 ——
@State tier: string = 'basic'
@State fullReportId: string = '' // 已有 SUCCEEDED 的 full 报告(进页时列表查出)
@State basicReportId: string = '' // full 报告页「查看基础版报告」的目标
@State fullGenerating: boolean = false
@State fullTaskId: string = ''
@State fullError: string = ''
private fullRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key
@State hasFull: boolean = false
@State fullSeasonName: string = ''
@State fullSeasonAnalysis: string = ''
@State paletteBest: PaletteColorWire[] = []
@State paletteNeutral: PaletteColorWire[] = []
@State paletteAvoid: PaletteColorWire[] = []
@State fullScenes: FullSceneWire[] = []
@State fullPlan: PlanWeekWire[] = []
private cardViewTracked: boolean = false // full_report.card_view 每进入结果页只打一次
aboutToAppear(): void {
@@ -159,6 +184,7 @@ struct ReportResultPage {
this.loadErrorAuth = false
try {
const d = await ApiClient.get<ReportDetailWire>(`/api/v1/reports/${this.reportId}`)
this.tier = d.tier ?? 'basic'
this.versionLabel = d.version !== undefined && d.version !== null ? `v${d.version}` : 'v1'
// 后端报告状态为小写(succeeded/failed/…),统一转大写供下方状态门禁比较
this.statusRaw = (d.status ?? '').toUpperCase()
@@ -177,6 +203,16 @@ struct ReportResultPage {
this.totalLabel = total !== undefined && total !== null ? `${total}` : `${dims.length}`
const basicFull = result?.diagnosis?.coverage?.basic_full
this.basicFullText = basicFull === true ? '基础信息完整' : (basicFull === false ? '基础信息不完整' : '')
// 深度诊断增量内容(tier=full 才有;basic 报告恒空,区块不渲染)
const full: FullResultWire | undefined = result?.full
this.hasFull = full !== undefined && full !== null
this.fullSeasonName = full?.season?.name_cn ?? ''
this.fullSeasonAnalysis = full?.season?.analysis ?? ''
this.paletteBest = full?.palette?.best ?? []
this.paletteNeutral = full?.palette?.neutral ?? []
this.paletteAvoid = full?.palette?.avoid ?? []
this.fullScenes = full?.scenes ?? []
this.fullPlan = full?.plan30 ?? []
const rid = d.report_id
if (rid !== undefined && rid !== '') {
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
@@ -186,18 +222,19 @@ struct ReportResultPage {
// 一并换新,防止新报告复用旧导出键撞后端 IDEMPOTENCY_CONFLICT(#22)
this.regenRequestKey = ''
this.exportRequestKey = ''
this.fullRequestKey = ''
if (this.statusRaw === 'SUCCEEDED') {
// 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21)
this.loadFeedback()
// first_report 任务闭环(T2b):报告成功展示的入口路径上静默 claim
// (lifetime 周期,后端幂等;领过则 40910 静默同步已领态)
this.claimFirstReport()
// 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次
// 深度诊断入口卡:卡片渲染即曝光埋点,每进入结果页只打一次
if (!this.cardViewTracked) {
this.cardViewTracked = true
Tracker.track('full_report.card_view')
}
this.loadReservation()
this.loadFullReportList()
}
} catch (e) {
this.loading = false
@@ -237,6 +274,12 @@ struct ReportResultPage {
this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉
} catch (e) {
if (e instanceof ApiError) {
if (e.code === CODE_STATE_CONFLICT) {
// 40900:早期种子报告没有 rp_input 输入行,无法复用照片/问卷直接重新生成。
// 一行红字感知太弱(像"点了没反应"),改弹对话框引导去生成新报告。
this.showRegenMissingDialog = true
return
}
this.regenError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
} else {
this.regenError = '请求失败,请检查网络'
@@ -336,9 +379,9 @@ struct ReportResultPage {
this.exportRequestKey = ''
}
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次)。 */
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次 / full=full_report 每日 1 次)。 */
private openConfirm(action: string): void {
if (action !== 'regen' && action !== 'export') {
if (action !== 'regen' && action !== 'export' && action !== 'full') {
return
}
if (action === 'regen' && (this.regenerating || this.reportId === '')) {
@@ -347,6 +390,9 @@ struct ReportResultPage {
if (action === 'export' && (this.exporting || this.reportId === '')) {
return
}
if (action === 'full' && (this.fullGenerating || this.reportId === '')) {
return
}
this.confirmAction = action
this.confirmLoaded = false
this.confirmRemaining = -1
@@ -355,7 +401,9 @@ struct ReportResultPage {
}
private async loadConfirmQuota(): Promise<void> {
const key = this.confirmAction === 'export' ? 'hd_export' : 'report_regen'
const key = this.confirmAction === 'export'
? 'hd_export'
: (this.confirmAction === 'full' ? 'full_report' : 'report_regen')
try {
const d = await ApiClient.get<QuotaWireResp>(`/api/v1/quota?quota_key=${key}`)
this.confirmRemaining = d.remaining
@@ -375,6 +423,8 @@ struct ReportResultPage {
this.doRegenerate()
} else if (action === 'export') {
this.doExport()
} else if (action === 'full') {
this.doFullGenerate()
}
}
@@ -469,39 +519,87 @@ struct ReportResultPage {
}
}
// ==================== 全面报告预约留资卡(T-P3-22) ====================
// ==================== 深度诊断(tier=full)入口卡 ====================
/** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */
private async loadReservation(): Promise<void> {
/**
* 查报告列表(limit=20 对齐首页报告 Tab):找最新一份 tier=full 且
* SUCCEEDED 的报告(已生成态 → 「查看深度诊断报告」),同时记下最新
* basic 报告 id(full 报告页「查看基础版报告」的跳转目标)。
* 失败静默:卡片按未生成态展示,生成按钮仍可用(服务端幂等兜底)。
*/
private async loadFullReportList(): Promise<void> {
try {
const d = await ApiClient.get<ReservationListWire>(`/api/v1/reservations?topic=${RESERVE_TOPIC}`)
this.reserved = d.topics.includes(RESERVE_TOPIC)
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports?limit=20')
let fullId: string = ''
let basicId: string = ''
for (const item of list) {
const ok = (item.status ?? '').toUpperCase() === 'SUCCEEDED'
const rid = item.report_id ?? ''
if (!ok || rid === '') {
continue
}
if (fullId === '' && item.tier === 'full') {
fullId = rid
}
if (basicId === '' && item.tier !== 'full') {
basicId = rid
}
}
this.fullReportId = fullId
this.basicReportId = basicId
} catch (_e) {
this.reserved = false
this.fullReportId = ''
this.basicReportId = ''
}
}
private async doReserve(): Promise<void> {
if (this.reserved || this.reserveLoading) {
/** 生成深度诊断:模式同 doRegenerate(新 report_id 必须采纳,轮询成功本页重拉)。 */
private async doFullGenerate(): Promise<void> {
if (this.fullGenerating || this.reportId === '') {
return
}
this.reserveLoading = true
try {
const req: Record<string, Object> = { 'topic': RESERVE_TOPIC }
const d = await ApiClient.post<ReservationWire>('/api/v1/reservations', req)
this.reserved = true // created=false(幂等命中)同样是已预约态
Tracker.track('full_report.reserve_result', { 'ok': true })
if (d.created) {
this.toast('预约成功,上线后第一时间告诉你')
}
} catch (e) {
Tracker.track('full_report.reserve_result', {
'ok': false, 'code': e instanceof ApiError ? e.code : 50000
})
this.toast(e instanceof ApiError ? e.message : '预约失败,请稍后重试')
} finally {
this.reserveLoading = false
this.fullError = ''
if (this.fullRequestKey === '') {
this.fullRequestKey = randomHex(16)
}
const req: Record<string, Object> = { 'request_key': this.fullRequestKey }
try {
const data = await ApiClient.post<ReportCreateResp>(`/api/v1/reports/${this.reportId}/full`, req)
const tid = data.task_id ?? ''
if (tid === '') {
this.fullError = '服务端未返回任务 ID,请稍后重试'
return
}
// 深度诊断在后端是新建一行报告(tier=full、新 report_id):必须采纳新 id,
// 否则轮询成功后 loadReport 拉回的仍是基础版报告
const newRid = data.report_id ?? ''
if (newRid !== '') {
this.reportId = newRid
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, newRid)
}
this.fullTaskId = tid
this.fullGenerating = true
} catch (e) {
if (e instanceof ApiError) {
this.fullError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
} else {
this.fullError = '请求失败,请检查网络'
}
}
}
private onFullSucceeded(): void {
this.fullGenerating = false
this.fullError = ''
this.fullReportId = this.reportId // 本页已是 full 报告
this.loadReport()
}
private onFullFailed(reason: string): void {
this.fullGenerating = false
this.fullError = `深度诊断生成失败:${reason}`
// 清空幂等键:重试以新 key 创建新任务(旧失败任务的额度已由服务端退还)
this.fullRequestKey = ''
}
private dimKey(d: DimensionWire): string {
@@ -588,24 +686,50 @@ struct ReportResultPage {
.padding({ left: 24, right: 24 })
}
private confirmTitle(): string {
if (this.confirmAction === 'export') {
return '确认导出 PDF'
}
if (this.confirmAction === 'full') {
return '确认生成深度诊断'
}
return '确认重新生成'
}
private confirmDesc(): string {
if (this.confirmAction === 'export') {
return '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
}
if (this.confirmAction === 'full') {
return '深度诊断每日可生成 1 次,本次将使用 1 次。'
}
return '重新生成每 30 天可使用 1 次,本次将使用 1 次。'
}
private confirmRemainText(): string {
if (this.confirmAction === 'export') {
return `今日还可导出 ${this.confirmRemaining} 次`
}
if (this.confirmAction === 'full') {
return `今日还可生成 ${this.confirmRemaining} 次`
}
return `本周期还可重新生成 ${this.confirmRemaining} 次`
}
/** 消耗配额前的确认框(#5):展示本次代价与剩余次数,剩余 0 时阻止提交。 */
@Builder
QuotaConfirmDialog() {
Column({ space: 14 }) {
Text(this.confirmAction === 'export' ? '确认导出 PDF' : '确认重新生成')
Text(this.confirmTitle())
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.confirmAction === 'export'
? '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
: '重新生成每 30 天可使用 1 次,本次将使用 1 次。')
Text(this.confirmDesc())
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
if (this.confirmLoaded && this.confirmRemaining > 0) {
Text(this.confirmAction === 'export'
? `今日还可导出 ${this.confirmRemaining} 次`
: `本周期还可重新生成 ${this.confirmRemaining} 次`)
Text(this.confirmRemainText())
.fontSize(13)
.fontColor(COLOR_PRIMARY)
} else if (this.confirmLoaded && this.confirmRemaining <= 0) {
@@ -645,12 +769,53 @@ struct ReportResultPage {
.backgroundColor('#FFFFFF')
}
/** 40900 兜底引导:早期种子报告缺输入行,无法直接重新生成 → 引导去生成新报告。 */
@Builder
RegenMissingDialog() {
Column({ space: 14 }) {
Text('无法直接重新生成')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('该报告为早期版本,缺少可复用的照片与问卷信息。可以重新拍摄照片,生成一份全新报告。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor('#F3F4F6')
.fontColor('#666666')
.onClick(() => {
this.showRegenMissingDialog = false
})
Button('去生成新报告')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.showRegenMissingDialog = false
router.pushUrl({ url: 'pages/ReportCreatePage' })
})
}
.width('100%')
.margin({ top: 4 })
}
.width('82%')
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
}
@Builder
HeaderCard() {
Column({ space: 6 }) {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('形象诊断报告')
Text(this.tier === 'full' ? '形象深度诊断报告' : '形象诊断报告')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
@@ -824,51 +989,103 @@ struct ReportResultPage {
.alignItems(HorizontalAlign.Start)
}
/** 全面报告预约留资卡(T-P3-22):模糊目录 teaser,无价格字样(FR-BILL-07 纪律同 apply)。 */
@Builder
ReservationCard() {
FullGenView() {
Column({ space: 16 }) {
Text('正在生成深度诊断报告')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('基于你的基础报告生成更完整的色彩、场景与计划内容,完成后自动展示')
.fontSize(13)
.fontColor('#999999')
.textAlign(TextAlign.Center)
TaskPollingPanel({
taskId: this.fullTaskId,
onSucceeded: (): void => {
this.onFullSucceeded()
},
onFailed: (reason: string): void => {
this.onFullFailed(reason)
}
})
.width('86%')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ left: 24, right: 24 })
}
/**
* 深度诊断入口卡(内容区底部)。三态:当前页是 full →「查看基础版报告」
* (防 full→full 套娃);已有 full →「查看深度诊断报告」(本页内采纳 id
* 重拉,不压路由栈);未生成 → 内容清单 +「生成深度诊断报告」(配额确认框)。
*/
@Builder
FullReportCard() {
Column({ space: 10 }) {
Text('形象深度诊断 · 即将上线')
Text(this.tier === 'full' ? '当前为深度诊断报告' : '形象深度诊断')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
Column({ space: 6 }) {
Text('· 十二季色彩完整色板')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 4+ 场景穿搭方案')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 30 天形象提升计划')
.fontSize(13)
.fontColor('#555555')
.width('100%')
if (this.tier !== 'full' && this.fullReportId === '') {
Column({ space: 6 }) {
Text('· 十二季色彩完整色板')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 4+ 场景穿搭方案')
.fontSize(13)
.fontColor('#555555')
.width('100%')
Text('· 30 天形象提升计划')
.fontSize(13)
.fontColor('#555555')
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
if (this.reserved) {
Text('已预约,上线后第一时间告诉你')
.fontSize(14)
.fontColor('#9AA0A6')
.width('100%')
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(22)
.backgroundColor('#F3F4F6')
} else {
Button(this.reserveLoading ? '预约中…' : '抢先预约,上线提醒我')
if (this.tier === 'full') {
if (this.basicReportId !== '') {
Button('查看基础版报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#F7E3EA')
.borderRadius(22)
.onClick(() => {
this.reportId = this.basicReportId
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.basicReportId)
this.loadReport()
})
}
} else if (this.fullReportId !== '') {
Button('查看深度诊断报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(22)
.enabled(!this.reserveLoading)
.onClick(() => {
this.doReserve()
this.reportId = this.fullReportId
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.fullReportId)
this.loadReport()
})
} else {
Button(this.fullError !== '' ? '重试生成深度诊断报告' : '生成深度诊断报告')
.width('100%')
.height(44)
.fontSize(15)
.fontColor(Color.White)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(22)
.onClick(() => {
this.openConfirm('full')
})
}
}
@@ -879,6 +1096,154 @@ struct ReportResultPage {
.alignItems(HorizontalAlign.Start)
}
// ==================== 深度诊断增量渲染(result.full 存在时) ====================
@Builder
PaletteGroup($$: PaletteGroupParams) {
if ($$.colors.length > 0) {
Column({ space: 8 }) {
Text(`${$$.title}(${$$.colors.length} 色)`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#555555')
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach($$.colors, (c: PaletteColorWire, idx: number) => {
Column({ space: 4 }) {
Blank()
.width(44)
.height(30)
.borderRadius(6)
.backgroundColor(c.hex ?? '#CCCCCC')
.border({ width: 1, color: '#E5E7EB' })
Text(c.name ?? '')
.fontSize(10)
.fontColor('#666666')
.maxLines(1)
}
.width(56)
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 8 })
}, (c: PaletteColorWire, idx: number) => `${idx}-${c.name ?? ''}`)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
@Builder
FullSeasonPaletteCard() {
Column({ space: 12 }) {
Text(this.fullSeasonName !== '' ? `十二季色彩定位 · ${this.fullSeasonName}` : '十二季色彩定位')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
if (this.fullSeasonAnalysis !== '') {
Text(this.fullSeasonAnalysis)
.fontSize(13)
.fontColor('#555555')
.lineHeight(21)
.width('100%')
}
this.PaletteGroup({ title: '适合色', colors: this.paletteBest })
this.PaletteGroup({ title: '中性色', colors: this.paletteNeutral })
this.PaletteGroup({ title: '避雷色', colors: this.paletteAvoid })
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
FullScenesCard() {
Column({ space: 12 }) {
Text('场景穿搭方案')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
ForEach(this.fullScenes, (scene: FullSceneWire, sIdx: number) => {
Column({ space: 8 }) {
Text(scene.title ?? '')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#444444')
.width('100%')
ForEach(scene.outfits ?? [], (outfit: FullOutfitWire, oIdx: number) => {
Column({ space: 4 }) {
Text(outfit.name ?? '')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_PRIMARY)
.width('100%')
ForEach(outfit.points ?? [], (point: string, pIdx: number) => {
Text(`· ${point}`)
.fontSize(12)
.fontColor('#555555')
.lineHeight(19)
.width('100%')
}, (point: string, pIdx: number) => `${sIdx}-${oIdx}-${pIdx}`)
}
.width('100%')
.padding(10)
.borderRadius(10)
.backgroundColor('#F7F8FA')
.alignItems(HorizontalAlign.Start)
}, (outfit: FullOutfitWire, oIdx: number) => `${sIdx}-${oIdx}-${outfit.name ?? ''}`)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}, (scene: FullSceneWire, sIdx: number) => `${sIdx}-${scene.key ?? ''}`)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
FullPlanCard() {
Column({ space: 12 }) {
Text('30 天形象提升计划')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
ForEach(this.fullPlan, (week: PlanWeekWire, wIdx: number) => {
Column({ space: 6 }) {
Text(week.title ?? `第 ${week.week ?? wIdx + 1} 周`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#444444')
.width('100%')
ForEach(week.tasks ?? [], (task: string, tIdx: number) => {
Text(`· ${task}`)
.fontSize(12)
.fontColor('#555555')
.lineHeight(19)
.width('100%')
}, (task: string, tIdx: number) => `${wIdx}-${tIdx}`)
}
.width('100%')
.padding(10)
.borderRadius(10)
.backgroundColor('#F7F8FA')
.alignItems(HorizontalAlign.Start)
}, (week: PlanWeekWire, wIdx: number) => `${wIdx}-${week.week ?? 0}`)
}
.width('100%')
.padding(16)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
}
@Builder
ContentBody() {
Column() {
@@ -890,9 +1255,15 @@ struct ReportResultPage {
}
this.CopyCard()
this.DiagnosisArea()
// 预约留资卡(T-P3-22):内容区底部,导出/重新生成按钮区之前
// 深度诊断增量区块(仅 tier=full 报告有 result.full):色板 / 场景 / 30 天计划
if (this.hasFull) {
this.FullSeasonPaletteCard()
this.FullScenesCard()
this.FullPlanCard()
}
// 深度诊断入口卡:内容区底部,导出/重新生成按钮区之前
if (this.statusRaw === 'SUCCEEDED') {
this.ReservationCard()
this.FullReportCard()
}
}
.padding(16)
@@ -915,6 +1286,13 @@ struct ReportResultPage {
.width('100%')
.textAlign(TextAlign.Start)
}
if (this.fullError !== '') {
Text(this.fullError)
.fontSize(12)
.fontColor('#E5484D')
.width('100%')
.textAlign(TextAlign.Start)
}
// 仅成功结案的报告可导出 PDF(服务端同样校验,端侧只是提前挡)
if (this.statusRaw === 'SUCCEEDED') {
Button(this.exportError !== '' ? '重试导出 PDF' : '导出 PDF 报告')
@@ -935,6 +1313,17 @@ struct ReportResultPage {
.onClick(() => {
this.openConfirm('regen')
})
// 次级入口:回首页「报告」Tab 查看全部历史报告(initialTab=1)
Button('查看历史报告')
.width('100%')
.height(46)
.fontSize(15)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#FFFFFF')
.onClick(() => {
const params: Record<string, Object> = { 'initialTab': 1 }
router.pushUrl({ url: 'pages/HomePage', params: params })
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
@@ -951,6 +1340,8 @@ struct ReportResultPage {
this.ExportView()
} else if (this.regenerating) {
this.RegenView()
} else if (this.fullGenerating) {
this.FullGenView()
} else if (this.loading) {
// N5':换用 f2 通用加载态(包一层 Column 承接 layoutWeight 占位)
Column() {
@@ -999,6 +1390,16 @@ struct ReportResultPage {
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
if (this.showRegenMissingDialog) {
Column() {
this.RegenMissingDialog()
}
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.height('100%')