feat(report): 诊断流四项修复 + 形象深度诊断端侧——表单草稿跨页保留(AppStorage);照片预览改竖版 3:4;40920 改中性兜底+历史报告入口(initialTab 参数);重新生成 40900 弹窗引导;深度诊断卡替换预约占位(生成/查看/防套娃)+ 色板/场景/30天计划渲染 + 「深度」徽标
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user