From 247da63509fc18c25448db3796aadb2a014e2974 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Tue, 15 Sep 2026 00:03:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E8=AF=8A=E6=96=AD=E6=B5=81?= =?UTF-8?q?=E5=9B=9B=E9=A1=B9=E4=BF=AE=E5=A4=8D=20+=20=E5=BD=A2=E8=B1=A1?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E8=AF=8A=E6=96=AD=E7=AB=AF=E4=BE=A7=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=A1=A8=E5=8D=95=E8=8D=89=E7=A8=BF=E8=B7=A8=E9=A1=B5?= =?UTF-8?q?=E4=BF=9D=E7=95=99=EF=BC=88AppStorage=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E7=85=A7=E7=89=87=E9=A2=84=E8=A7=88=E6=94=B9=E7=AB=96=E7=89=88?= =?UTF-8?q?=203:4=EF=BC=9B40920=20=E6=94=B9=E4=B8=AD=E6=80=A7=E5=85=9C?= =?UTF-8?q?=E5=BA=95+=E5=8E=86=E5=8F=B2=E6=8A=A5=E5=91=8A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=88initialTab=20=E5=8F=82=E6=95=B0=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=2040900=20?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=BC=95=E5=AF=BC=EF=BC=9B=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E5=8D=A1=E6=9B=BF=E6=8D=A2=E9=A2=84=E7=BA=A6?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=EF=BC=88=E7=94=9F=E6=88=90/=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B/=E9=98=B2=E5=A5=97=E5=A8=83=EF=BC=89+=20=E8=89=B2?= =?UTF-8?q?=E6=9D=BF/=E5=9C=BA=E6=99=AF/30=E5=A4=A9=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20=E3=80=8C=E6=B7=B1=E5=BA=A6=E3=80=8D?= =?UTF-8?q?=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/ets/components/f3/ReportListTab.ets | 18 +- .../main/ets/components/f3/ReportTypes.ets | 47 ++ .../src/main/ets/pages/DiagnosisFormPage.ets | 49 +- app/entry/src/main/ets/pages/HomePage.ets | 17 +- .../src/main/ets/pages/ReportCreatePage.ets | 97 ++- .../src/main/ets/pages/ReportResultPage.ets | 575 +++++++++++++++--- 6 files changed, 695 insertions(+), 108 deletions(-) diff --git a/app/entry/src/main/ets/components/f3/ReportListTab.ets b/app/entry/src/main/ets/components/f3/ReportListTab.ets index b254bbb..06b1363 100644 --- a/app/entry/src/main/ets/components/f3/ReportListTab.ets +++ b/app/entry/src/main/ets/components/f3/ReportListTab.ets @@ -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) diff --git a/app/entry/src/main/ets/components/f3/ReportTypes.ets b/app/entry/src/main/ets/components/f3/ReportTypes.ets index e507c9d..eb9f950 100644 --- a/app/entry/src/main/ets/components/f3/ReportTypes.ets +++ b/app/entry/src/main/ets/components/f3/ReportTypes.ets @@ -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 diff --git a/app/entry/src/main/ets/pages/DiagnosisFormPage.ets b/app/entry/src/main/ets/pages/DiagnosisFormPage.ets index 6d31b5b..7b3d2ad 100644 --- a/app/entry/src/main/ets/pages/DiagnosisFormPage.ets +++ b/app/entry/src/main/ets/pages/DiagnosisFormPage.ets @@ -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(DRAFT_GENDER_KEY) + if (gender !== undefined) { + this.gender = gender + } + const birthday = AppStorage.get(DRAFT_BIRTHDAY_KEY) + if (birthday !== undefined) { + this.birthday = birthday + } + const height = AppStorage.get(DRAFT_HEIGHT_KEY) + if (height !== undefined) { + this.heightInput = height + } + const weight = AppStorage.get(DRAFT_WEIGHT_KEY) + if (weight !== undefined) { + this.weightInput = weight + } + const occupation = AppStorage.get(OCCUPATION_STORAGE_KEY) + if (occupation !== undefined) { + this.occupation = occupation + } + } + + /** 草稿写回(write-through):每次字段变更即落 AppStorage。 */ + private saveDraft(): void { + AppStorage.setOrCreate(DRAFT_GENDER_KEY, this.gender) + AppStorage.setOrCreate(DRAFT_BIRTHDAY_KEY, this.birthday) + AppStorage.setOrCreate(DRAFT_HEIGHT_KEY, this.heightInput) + AppStorage.setOrCreate(DRAFT_WEIGHT_KEY, this.weightInput) + AppStorage.setOrCreate(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(OCCUPATION_STORAGE_KEY, this.occupation) + // 职业仅本地留存,不进提交体(需求 §3.6/验收 4);整份草稿一并写回 + this.saveDraft() // 四个值全部为 string,接收端 ReportCreatePage 按 string 读取 const params: Record = { '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) } diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index 3de1eba..f63c8ca 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -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 | 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() } diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index 717de85..19d20c9 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -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 | 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(DRAFT_GENDER_KEY) + if (gender !== undefined) { + this.gender = gender + } + const height = AppStorage.get(DRAFT_HEIGHT_KEY) + if (height !== undefined) { + this.heightInput = height + } + const weight = AppStorage.get(DRAFT_WEIGHT_KEY) + if (weight !== undefined) { + this.weightInput = weight + } + const birthday = AppStorage.get(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(DRAFT_GOALS_KEY) + if (goals !== undefined && goals !== '') { + this.coreGoals = goals.split('|') + } + const style = AppStorage.get(DRAFT_STYLE_KEY) + if (style !== undefined) { + this.psychStyle = style + } + } + + /** 草稿写回(write-through):每次字段变更即落 AppStorage;提交成功不清。 */ + private saveDraft(): void { + AppStorage.setOrCreate(DRAFT_GENDER_KEY, this.gender) + AppStorage.setOrCreate(DRAFT_HEIGHT_KEY, this.heightInput) + AppStorage.setOrCreate(DRAFT_WEIGHT_KEY, this.weightInput) + AppStorage.setOrCreate(DRAFT_BIRTHDAY_KEY, formatDate(this.birthday)) + AppStorage.setOrCreate(DRAFT_GOALS_KEY, this.coreGoals.join('|')) + AppStorage.setOrCreate(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) diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index 1314a7c..9dc89cf 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -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 = {} @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(`/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(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 { - 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(`/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 { + /** + * 查报告列表(limit=20 对齐首页报告 Tab):找最新一份 tier=full 且 + * SUCCEEDED 的报告(已生成态 → 「查看深度诊断报告」),同时记下最新 + * basic 报告 id(full 报告页「查看基础版报告」的跳转目标)。 + * 失败静默:卡片按未生成态展示,生成按钮仍可用(服务端幂等兜底)。 + */ + private async loadFullReportList(): Promise { try { - const d = await ApiClient.get(`/api/v1/reservations?topic=${RESERVE_TOPIC}`) - this.reserved = d.topics.includes(RESERVE_TOPIC) + const list = await ApiClient.get('/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 { - if (this.reserved || this.reserveLoading) { + /** 生成深度诊断:模式同 doRegenerate(新 report_id 必须采纳,轮询成功本页重拉)。 */ + private async doFullGenerate(): Promise { + if (this.fullGenerating || this.reportId === '') { return } - this.reserveLoading = true - try { - const req: Record = { 'topic': RESERVE_TOPIC } - const d = await ApiClient.post('/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 = { 'request_key': this.fullRequestKey } + try { + const data = await ApiClient.post(`/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(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(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(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 = { '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%')