From 995c18e7d5ec6c591f2e342f7c20bed0b2e63b19 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sat, 15 Aug 2026 00:49:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20report=20flow=20with=20polling=20a?= =?UTF-8?q?nd=2032-dimension=20result=20view=EF=BC=88F3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/ets/components/f3/DimensionCard.ets | 138 +++++ .../main/ets/components/f3/ReportTypes.ets | 243 ++++++++ .../ets/components/f3/TaskPollingPanel.ets | 147 +++++ .../src/main/ets/pages/ReportCreatePage.ets | 575 +++++++++++++++++- .../src/main/ets/pages/ReportResultPage.ets | 480 ++++++++++++++- 5 files changed, 1573 insertions(+), 10 deletions(-) create mode 100644 app/entry/src/main/ets/components/f3/DimensionCard.ets create mode 100644 app/entry/src/main/ets/components/f3/ReportTypes.ets create mode 100644 app/entry/src/main/ets/components/f3/TaskPollingPanel.ets diff --git a/app/entry/src/main/ets/components/f3/DimensionCard.ets b/app/entry/src/main/ets/components/f3/DimensionCard.ets new file mode 100644 index 0000000..7195220 --- /dev/null +++ b/app/entry/src/main/ets/components/f3/DimensionCard.ets @@ -0,0 +1,138 @@ +/** + * DimensionCard —— 32 维诊断单项卡片(F3)。 + * + * - ok 维度:name_cn + value(四种形态由 formatDimValue 统一处理:字符串直显 / + * 数字转字符串 / 数组 join '、' / 空值显示 '—')+ 来源标签中文映射 + 置信度百分比 + * - confidence < 0.7 加“仅供参考”小标(NFR-UX-05) + * - skipped 维度:整体灰显,evidence 作为未覆盖原因文案 + * - rule_hits 非空时折叠展示“依据规则 N 条” + */ + +import { EMPTY_DIMENSION, DimensionWire, formatDimValue, sourceColor, sourceLabel } from './ReportTypes' + +const CARD_PRIMARY_COLOR: string = '#C2185B' + +@Component +export struct DimensionCard { + dim: DimensionWire = EMPTY_DIMENSION + @State ruleExpanded: boolean = false + + private isSkipped(): boolean { + return this.dim.status !== 'ok' + } + + private titleText(): string { + const n = this.dim.name_cn + if (n !== undefined && n !== '') { + return n + } + const k = this.dim.key + if (k !== undefined && k !== '') { + return k + } + const num = this.dim.number + return num !== undefined ? `维度 ${num}` : '未知维度' + } + + private hasConfidence(): boolean { + const c = this.dim.confidence + return c !== undefined && c !== null + } + + private confidencePercent(): string { + const c = this.dim.confidence + return `${Math.round((c ?? 0) * 100)}%` + } + + private isLowConfidence(): boolean { + const c = this.dim.confidence + return c !== undefined && c !== null && c < 0.7 + } + + private ruleHits(): string[] { + const r = this.dim.rule_hits + return r ?? [] + } + + private reasonText(): string { + const ev = this.dim.evidence + if (ev !== undefined && ev !== null && ev !== '') { + return ev + } + return '本期未采集到足够信息' + } + + build() { + Column({ space: 8 }) { + Row({ space: 8 }) { + Text(this.titleText()) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(this.isSkipped() ? '#9AA0A6' : '#333333') + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (!this.isSkipped()) { + Text(sourceLabel(this.dim.source)) + .fontSize(10) + .fontColor(sourceColor(this.dim.source)) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor('#F5F5F7') + .borderRadius(4) + if (this.hasConfidence()) { + Text(this.confidencePercent()) + .fontSize(12) + .fontColor('#666666') + } + if (this.isLowConfidence()) { + Text('仅供参考') + .fontSize(10) + .fontColor('#D46A0B') + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor('#FDF3E3') + .borderRadius(4) + } + } + } + .width('100%') + + if (this.isSkipped()) { + Text(`未覆盖:${this.reasonText()}`) + .fontSize(13) + .fontColor('#9AA0A6') + .width('100%') + } else { + Text(formatDimValue(this.dim.value)) + .fontSize(14) + .fontColor('#333333') + .width('100%') + } + + if (this.ruleHits().length > 0) { + Text(this.ruleExpanded ? `收起依据(规则 ${this.ruleHits().length} 条)` : `依据规则 ${this.ruleHits().length} 条`) + .fontSize(12) + .fontColor(CARD_PRIMARY_COLOR) + .onClick(() => { + this.ruleExpanded = !this.ruleExpanded + }) + if (this.ruleExpanded) { + Column({ space: 4 }) { + ForEach(this.ruleHits(), (r: string) => { + Text(`· ${r}`) + .fontSize(12) + .fontColor('#888888') + .width('100%') + }, (r: string) => r) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + } + } + .width('100%') + .padding(12) + .borderRadius(12) + .backgroundColor(this.isSkipped() ? '#F7F7F8' : '#FFFFFF') + .opacity(this.isSkipped() ? 0.85 : 1) + } +} diff --git a/app/entry/src/main/ets/components/f3/ReportTypes.ets b/app/entry/src/main/ets/components/f3/ReportTypes.ets new file mode 100644 index 0000000..a3acb08 --- /dev/null +++ b/app/entry/src/main/ets/components/f3/ReportTypes.ets @@ -0,0 +1,243 @@ +/** + * F3 报告流共享:wire 接口(字段名对齐后端 JSON)+ 纯函数工具。 + * + * ArkTS 约束(对齐 core 风格): + * - wire 类型全部是文件内具名 interface,字段可选以防御后端缺省 + * - 维度 value 是动态形态(string | number | string[]),wire 侧收 Object、 + * 渲染前用 formatDimValue 做运行时分派,四种形态统一转展示文本 + */ + +/** POST /api/v1/reports 与 POST /api/v1/reports/{id}/regenerate 的响应 */ +export interface ReportCreateResp { + report_id?: string + task_id?: string + status?: string + version?: number +} + +/** GET /api/v1/tasks/{id} 的响应(status: PENDING/RUNNING/SUCCEEDED/FAILED) */ +export interface TaskWire { + task_id?: string + status?: string + progress?: number + stage_text?: string +} + +/** 单个诊断维度(diagnosis.dimensions[] 恒 32 项;status: 'ok' | 'skipped') */ +export interface DimensionWire { + number?: number + key?: string + name_cn?: string + value?: Object // string | number | string[],渲染前用 formatDimValue 分派 + confidence?: number // 0~1;< 0.7 需标“仅供参考”(NFR-UX-05) + source?: string // questionnaire|vision|rule|derived|kb + evidence?: string // skipped 时的原因文案 + rule_hits?: string[] + status?: string +} + +export interface CoverageWire { + basic_full?: boolean + total?: number +} + +export interface DiagnosisWire { + dimensions?: DimensionWire[] + coverage?: CoverageWire +} + +export interface CopySectionWire { + title?: string + paragraphs?: string[] +} + +export interface CopyWire { + summary?: string + sections?: CopySectionWire[] +} + +export interface ValidationWire { + passed?: boolean + degraded?: boolean // true = 规则引擎降级生成,UI 需黄条透明提示 +} + +export interface ReportResultWire { + diagnosis?: DiagnosisWire + copy?: CopyWire + validation?: ValidationWire +} + +/** GET /api/v1/reports/{id} 的响应 */ +export interface ReportDetailWire { + report_id?: string + status?: string + version?: number + result?: ReportResultWire + created_at?: string + completed_at?: string +} + +/** router.getParams() 的参数形态(Create/Result 页之间只传 reportId) */ +export interface ReportRouteParams { + reportId?: string +} + +export interface DimensionGroups { + analyzed: DimensionWire[] + skipped: DimensionWire[] +} + +export const EMPTY_DIMENSION: DimensionWire = {} + +/** + * 四种 value 形态统一转展示文本: + * 字符串直显 / 数字转字符串 / 数组 join '、' / 空值(undefined、null、'')显示 '—' + */ +export function formatDimValue(v: Object | undefined): string { + if (v === undefined || v === null) { + return '—' + } + if (typeof v === 'string') { + return v === '' ? '—' : v + } + if (typeof v === 'number') { + return v.toString() + } + if (v instanceof Array) { + const arr: Object[] = v as Object[] + const parts: string[] = [] + for (let i = 0; i < arr.length; i++) { + const s = formatDimValue(arr[i]) + if (s !== '—') { + parts.push(s) + } + } + return parts.length > 0 ? parts.join('、') : '—' + } + return v.toString() +} + +/** 来源标签中文映射 */ +export function sourceLabel(src: string | undefined): string { + if (src === undefined || src === '') { + return '综合' + } + if (src === 'questionnaire') { + return '你填写' + } + if (src === 'vision') { + return '照片分析' + } + if (src === 'rule') { + return '规则推导' + } + if (src === 'derived') { + return '推导' + } + if (src === 'kb') { + return '知识库' + } + return src +} + +export function sourceColor(src: string | undefined): string { + if (src === 'questionnaire') { + return '#3B82C4' + } + if (src === 'vision') { + return '#8B5CF6' + } + if (src === 'rule') { + return '#0891B2' + } + if (src === 'derived') { + return '#D46A0B' + } + if (src === 'kb') { + return '#0E9F6E' + } + return '#6B7280' +} + +/** 任务/报告状态中文映射 */ +export function statusLabelOf(s: string | undefined): string { + if (s === 'SUCCEEDED') { + return '已完成' + } + if (s === 'RUNNING' || s === 'GENERATING') { + return '生成中' + } + if (s === 'PENDING') { + return '排队中' + } + if (s === 'FAILED') { + return '生成失败' + } + return s === undefined || s === '' ? '—' : s +} + +export function statusColorOf(s: string | undefined): string { + if (s === 'SUCCEEDED') { + return '#0E9F6E' + } + if (s === 'FAILED') { + return '#E5484D' + } + if (s === 'PENDING' || s === 'RUNNING' || s === 'GENERATING') { + return '#D46A0B' + } + return '#999999' +} + +/** ISO 时间串截到分钟展示('2026-08-15T10:30:00+00:00' → '2026-08-15 10:30') */ +export function formatIsoTime(s: string | undefined): string { + if (s === undefined || s === null || s === '') { + return '—' + } + const t = s.indexOf('T') + if (t < 0) { + return s + } + const date = s.substring(0, t) + const time = s.substring(t + 1, t + 6) + return `${date} ${time}` +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}` +} + +/** Date → 'yyyy-MM-dd'(本地时区,问卷 birthday 用) */ +export function formatDate(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +} + +/** 随机 hex 串(request_key 用;重试沿用同一 key 即幂等重放) */ +export function randomHex(len: number): string { + const chars = '0123456789abcdef' + let out = '' + for (let i = 0; i < len; i++) { + out += chars.charAt(Math.floor(Math.random() * 16)) + } + return out +} + +/** 模拟照片对象键:report_photo/{userId}/{随机}.jpg(V1 骨架,COS 直传未接入) */ +export function buildMockPhotoKey(userId: string): string { + const uid = userId === '' ? 'anonymous' : userId + return `report_photo/${uid}/${randomHex(12)}.jpg` +} + +/** 32 维按 status 分两组:已分析(ok)与未覆盖(skipped,含 status 缺失的兜底) */ +export function splitDimensions(dims: DimensionWire[]): DimensionGroups { + const groups: DimensionGroups = { analyzed: [], skipped: [] } + for (let i = 0; i < dims.length; i++) { + const d = dims[i] + if (d.status === 'ok') { + groups.analyzed.push(d) + } else { + groups.skipped.push(d) + } + } + return groups +} diff --git a/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets b/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets new file mode 100644 index 0000000..38c3d4d --- /dev/null +++ b/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets @@ -0,0 +1,147 @@ +/** + * TaskPollingPanel —— 任务轮询通用面板(F3,Create 提交后与 Result 重新生成共用)。 + * + * - 每 3 秒 GET /api/v1/tasks/{id},展示 progress 进度条 + stage_text + * - SUCCEEDED / FAILED 通过回调上抛,由宿主页面决定跳转或失败态 + * - 连续 3 次网络异常视为失败(避免瞬时抖动误杀长任务) + * - 定时器随组件销毁自动清理(页面返回/跳转即停);taskId 切换时 + * 通过 generation 计数丢弃旧任务的在途响应 + */ + +import { ApiClient } from '../../core/ApiClient' +import { ApiError } from '../../core/ApiTypes' +import { TaskWire } from './ReportTypes' + +const POLL_INTERVAL_MS: number = 3000 +const MAX_CONSECUTIVE_ERRORS: number = 3 +const POLL_PRIMARY_COLOR: string = '#C2185B' + +@Component +export struct TaskPollingPanel { + @Prop @Watch('onTaskIdChanged') taskId: string = '' + onSucceeded: () => void = () => {} + onFailed: (reason: string) => void = () => {} + + @State progress: number = 0 + @State stageText: string = '任务已提交,等待调度…' + + private timerId: number = -1 + private generation: number = 0 + private errCount: number = 0 + + aboutToAppear(): void { + if (this.taskId !== '') { + this.startPolling() + } + } + + aboutToDisappear(): void { + // 页面销毁/返回清理定时器:离开页面不会残留轮询 + this.stopTimer() + } + + private onTaskIdChanged(): void { + this.startPolling() + } + + private startPolling(): void { + this.stopTimer() + this.generation += 1 + this.errCount = 0 + this.progress = 0 + this.stageText = '任务已提交,等待调度…' + this.pollOnce(this.generation) + } + + private stopTimer(): void { + if (this.timerId !== -1) { + clearTimeout(this.timerId) + this.timerId = -1 + } + } + + private async pollOnce(gen: number): Promise { + const tid = this.taskId + if (tid === '') { + return + } + try { + const t = await ApiClient.get(`/api/v1/tasks/${tid}`) + if (gen !== this.generation) { + return // 已切换到新任务,丢弃旧任务的在途响应 + } + this.errCount = 0 + const p = t.progress + if (p !== undefined && p !== null) { + this.progress = Math.min(100, Math.max(0, p)) + } + const st = t.stage_text + if (st !== undefined && st !== null && st !== '') { + this.stageText = st + } + if (t.status === 'SUCCEEDED') { + this.stopTimer() + this.onSucceeded() + return + } + if (t.status === 'FAILED') { + this.stopTimer() + this.onFailed(this.stageText) + return + } + this.scheduleNext(gen) + } catch (e) { + if (gen !== this.generation) { + return + } + this.errCount += 1 + if (this.errCount >= MAX_CONSECUTIVE_ERRORS) { + this.stopTimer() + let reason = '网络异常,连续查询任务状态失败' + if (e instanceof ApiError) { + reason = `${reason}(${e.message})` + } + this.onFailed(reason) + return + } + this.scheduleNext(gen) + } + } + + private scheduleNext(gen: number): void { + this.stopTimer() + this.timerId = setTimeout((): void => { + this.pollOnce(gen) + }, POLL_INTERVAL_MS) + } + + build() { + Column({ space: 10 }) { + Progress({ value: this.progress, total: 100, type: ProgressType.Linear }) + .width('100%') + .height(8) + .color(POLL_PRIMARY_COLOR) + .backgroundColor('#F3D9E3') + .borderRadius(4) + Row({ space: 8 }) { + Text(this.stageText) + .fontSize(13) + .fontColor('#666666') + .layoutWeight(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(`${Math.round(this.progress)}%`) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(POLL_PRIMARY_COLOR) + } + .width('100%') + Text(`任务 ID:${this.taskId}|每 3 秒自动刷新,返回不会中断生成`) + .fontSize(11) + .fontColor('#AAAAAA') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + } +} diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index 9905f86..3817817 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -1,17 +1,582 @@ /** - * ReportCreatePage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。 + * + * 流程: + * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验 + * 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg + * 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放) + * 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录), + * 无记录则提示稍后在任务中心查看 + * 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回) + * 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 幂等重试 */ +import { router } from '@kit.ArkUI' +import { ApiClient } from '../core/ApiClient' +import { ApiError } from '../core/ApiTypes' +import { AuthStore } from '../core/AuthStore' +import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' +import { ReportCreateResp, buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes' + +const COLOR_PRIMARY: string = '#C2185B' +const GENDER_OPTIONS: string[] = ['女', '男', '不便告知'] +const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈'] +const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年'] +const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id' + @Entry @Component struct ReportCreatePage { + @State phase: string = 'form' // form | polling | failed + @State gender: string = '' + @State heightInput: string = '' + @State weightInput: string = '' + @State birthday: Date = new Date(2000, 0, 15) + @State coreGoals: string[] = [] + @State psychStyle: string = '' + @State photoKeyInput: string = '' + @State genderErr: string = '' + @State heightErr: string = '' + @State weightErr: string = '' + @State goalsErr: string = '' + @State styleErr: string = '' + @State submitting: boolean = false + @State submitError: string = '' + @State failMsg: string = '' + @State showDupDialog: boolean = false + @State lastReportId: string = '' + private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度 + private taskId: string = '' + private reportId: string = '' + + aboutToAppear(): void { + const cached = AppStorage.get(LAST_REPORT_STORAGE_KEY) + if (cached !== undefined && cached !== '') { + this.lastReportId = cached + } + } + + private isNumeric(s: string): boolean { + return /^\d+(\.\d+)?$/.test(s.trim()) + } + + private validateForm(): boolean { + let ok = true + this.genderErr = this.gender === '' ? '请选择性别' : '' + if (this.genderErr !== '') { + ok = false + } + const h = Number(this.heightInput) + this.heightErr = !this.isNumeric(this.heightInput) || h < 100 || h > 250 ? '需为 100~250' : '' + if (this.heightErr !== '') { + ok = false + } + const w = Number(this.weightInput) + this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : '' + if (this.weightErr !== '') { + ok = false + } + this.goalsErr = this.coreGoals.length === 0 ? '至少选择 1 项' : '' + if (this.goalsErr !== '') { + ok = false + } + this.styleErr = this.psychStyle === '' ? '请选择心理风格' : '' + if (this.styleErr !== '') { + ok = false + } + return ok + } + + private toggleGoal(g: string): void { + const idx = this.coreGoals.indexOf(g) + if (idx >= 0) { + this.coreGoals.splice(idx, 1) + } else { + this.coreGoals.push(g) + } + this.goalsErr = '' + } + + private isSelectedGoal(g: string): boolean { + return this.coreGoals.indexOf(g) >= 0 + } + + private onSubmitTap(): void { + if (this.submitting) { + return + } + if (!this.validateForm()) { + return + } + this.doSubmit() + } + + private async doSubmit(): Promise { + this.submitting = true + this.submitError = '' + if (this.requestKey === '') { + // 随机 hex;FAILED 后的重试复用同一 key(见 retrySubmit 注释) + this.requestKey = randomHex(16) + } + const heightVal: number = Number(this.heightInput) + const weightVal: number = Number(this.weightInput) + const goalsObj: Object = this.coreGoals + const questionnaire: Record = { + 'gender': this.gender, + 'height_cm': heightVal, + 'weight_kg': weightVal, + 'birthday': formatDate(this.birthday), + 'core_goals': goalsObj, + 'psych_style': this.psychStyle + } + const qObj: Object = questionnaire + const photoInput = this.photoKeyInput.trim() + const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous') + const req: Record = { + 'questionnaire': qObj, + 'photo_object_key': photoKey, + 'request_key': this.requestKey + } + try { + const data = await ApiClient.post('/api/v1/reports', req) + this.reportId = data.report_id ?? '' + this.taskId = data.task_id ?? '' + if (this.reportId !== '') { + this.lastReportId = this.reportId + AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, this.reportId) + } + if (this.taskId === '') { + this.submitError = '服务端未返回任务 ID,请稍后重试' + this.submitting = false + return + } + this.submitting = false + this.phase = 'polling' + } catch (e) { + this.submitting = false + if (e instanceof ApiError) { + if (e.code === 40920) { + // 本期报告已生成:给“查看我的报告”入口(无记录则提示稍后在任务中心看) + this.showDupDialog = true + return + } + this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + return + } + this.submitError = '请求失败,请检查网络' + } + } + + private retrySubmit(): void { + // 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理, + // 不会重复生成报告或重复扣减额度 + this.failMsg = '' + this.doSubmit() + } + + private onTaskSucceeded(): void { + const params: Record = { 'reportId': this.reportId } + router.replaceUrl({ url: 'pages/ReportResultPage', params: params }) + } + + private onTaskFailed(reason: string): void { + this.phase = 'failed' + this.failMsg = reason + } + + private goLastReport(): void { + this.showDupDialog = false + if (this.lastReportId === '') { + return + } + const params: Record = { 'reportId': this.lastReportId } + router.pushUrl({ url: 'pages/ReportResultPage', params: params }) + } + + @Builder + TopBar() { + Row({ space: 8 }) { + Text('←') + .fontSize(20) + .fontColor('#333333') + .padding({ left: 4, right: 4, top: 2, bottom: 2 }) + .onClick(() => { + router.back() + }) + Text('生成报告') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + } + .width('100%') + .padding({ left: 12, right: 16, top: 10, bottom: 6 }) + } + + @Builder + FieldLabel(label: string, err: string) { + Row({ space: 6 }) { + Text(label) + .fontSize(13) + .fontColor('#666666') + if (err !== '') { + Text(err) + .fontSize(11) + .fontColor('#E5484D') + .layoutWeight(1) + .maxLines(1) + } + } + .width('100%') + } + + @Builder + FormBody() { + Scroll() { + Column({ space: 12 }) { + Column({ space: 4 }) { + Text('生成你的形象诊断报告') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告') + .fontSize(12) + .fontColor('#999999') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ top: 4, bottom: 4 }) + + Column({ space: 14 }) { + Text('基础信息') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + Column({ space: 8 }) { + this.FieldLabel('性别', this.genderErr) + Row({ space: 8 }) { + ForEach(GENDER_OPTIONS, (g: string) => { + Text(g) + .fontSize(14) + .fontColor(this.gender === g ? '#FFFFFF' : '#444444') + .padding({ left: 14, right: 14, top: 7, bottom: 7 }) + .backgroundColor(this.gender === g ? COLOR_PRIMARY : '#F3F4F6') + .borderRadius(14) + .onClick(() => { + this.gender = g + this.genderErr = '' + }) + }, (g: string) => g) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + + Row({ space: 12 }) { + Column({ space: 8 }) { + this.FieldLabel('身高 (cm)', this.heightErr) + TextInput({ placeholder: '如 165', text: this.heightInput }) + .type(InputType.Number) + .height(44) + .fontSize(14) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .onChange((v: string) => { + this.heightInput = v + this.heightErr = '' + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Column({ space: 8 }) { + this.FieldLabel('体重 (kg)', this.weightErr) + TextInput({ placeholder: '如 52', text: this.weightInput }) + .type(InputType.Number) + .height(44) + .fontSize(14) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .onChange((v: string) => { + this.weightInput = v + this.weightErr = '' + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + + Column({ space: 8 }) { + this.FieldLabel('生日', '') + Text(`当前选择:${formatDate(this.birthday)}`) + .fontSize(12) + .fontColor('#999999') + DatePicker({ + start: new Date(1930, 0, 1), + end: new Date(), + selected: this.birthday + }) + .onDateChange((d: Date) => { + this.birthday = d + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + + Column({ space: 14 }) { + Text('风格偏好') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + Column({ space: 8 }) { + this.FieldLabel('核心诉求(多选)', this.goalsErr) + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(GOAL_OPTIONS, (g: string) => { + Text(g) + .fontSize(13) + .fontColor(this.isSelectedGoal(g) ? '#FFFFFF' : '#444444') + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .backgroundColor(this.isSelectedGoal(g) ? COLOR_PRIMARY : '#F3F4F6') + .borderRadius(13) + .margin({ right: 8, bottom: 8 }) + .onClick(() => { + this.toggleGoal(g) + }) + }, (g: string) => g) + } + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + + Column({ space: 8 }) { + this.FieldLabel('心理风格(九型·单选)', this.styleErr) + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(STYLE_OPTIONS, (s: string) => { + Text(s) + .fontSize(13) + .fontColor(this.psychStyle === s ? '#FFFFFF' : '#444444') + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .backgroundColor(this.psychStyle === s ? COLOR_PRIMARY : '#F3F4F6') + .borderRadius(13) + .margin({ right: 8, bottom: 8 }) + .onClick(() => { + this.psychStyle = s + this.styleErr = '' + }) + }, (s: string) => s) + } + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + + Column({ space: 8 }) { + Text('照片(模拟)') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + Text('V1 骨架:COS 直传未接入,此处模拟对象键') + .fontSize(11) + .fontColor('#999999') + TextInput({ placeholder: 'report_photo/xxx.jpg(选填)', text: this.photoKeyInput }) + .height(44) + .fontSize(13) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .onChange((v: string) => { + this.photoKeyInput = v + }) + Text('留空时自动生成 report_photo/{userId}/{随机}.jpg') + .fontSize(11) + .fontColor('#AAAAAA') + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Start) + + if (this.submitError !== '') { + Text(this.submitError) + .fontSize(12) + .fontColor('#E5484D') + .width('100%') + .textAlign(TextAlign.Start) + } + + Button(this.submitting ? '提交中…' : '提交并生成报告') + .width('100%') + .height(48) + .fontSize(16) + .backgroundColor(COLOR_PRIMARY) + .enabled(!this.submitting) + .margin({ bottom: 24 }) + .onClick(() => { + this.onSubmitTap() + }) + } + .padding({ left: 16, right: 16 }) + } + .layoutWeight(1) + .width('100%') + } + + @Builder + PollingBody() { + Column({ space: 16 }) { + Text('正在生成你的专属报告') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('32 维形象诊断通常需要 1~3 分钟,生成完成后自动进入结果页') + .fontSize(13) + .fontColor('#999999') + .textAlign(TextAlign.Center) + TaskPollingPanel({ + taskId: this.taskId, + onSucceeded: (): void => { + this.onTaskSucceeded() + }, + onFailed: (reason: string): void => { + this.onTaskFailed(reason) + } + }) + .width('86%') + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 24, right: 24 }) + } + + @Builder + FailedBody() { + Column({ space: 12 }) { + Text('生成失败') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text(this.failMsg) + .fontSize(13) + .fontColor('#999999') + .textAlign(TextAlign.Center) + Text('重试将沿用同一 request_key 重新提交,服务端按幂等重放处理,不会重复消耗额度。') + .fontSize(11) + .fontColor('#AAAAAA') + .textAlign(TextAlign.Center) + Button('重试提交') + .width('62%') + .height(44) + .fontSize(15) + .backgroundColor(COLOR_PRIMARY) + .margin({ top: 8 }) + .onClick(() => { + this.retrySubmit() + }) + Button('返回修改问卷') + .width('62%') + .height(44) + .fontSize(15) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { + this.phase = 'form' + }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + } + + @Builder + DupDialog() { + Column({ space: 14 }) { + Text('本期报告已生成') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('每个周期仅可生成一次完整报告,无需重复提交。') + .fontSize(13) + .fontColor('#666666') + .textAlign(TextAlign.Center) + if (this.lastReportId !== '') { + Text('将为你打开最近一次生成的报告。') + .fontSize(12) + .fontColor('#999999') + } else { + Text('本期报告稍后可在任务中心查看。') + .fontSize(12) + .fontColor('#999999') + } + Row({ space: 12 }) { + Button('我知道了') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { + this.showDupDialog = false + }) + if (this.lastReportId !== '') { + Button('查看我的报告') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.goLastReport() + }) + } + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor('#FFFFFF') + } + build() { - Column() { - Text('ReportCreatePage(建设中)').fontSize(16).fontColor('#666666') + Stack() { + Column() { + this.TopBar() + if (this.phase === 'form') { + this.FormBody() + } else if (this.phase === 'polling') { + this.PollingBody() + } else { + this.FailedBody() + } + } + .width('100%') + .height('100%') + + if (this.showDupDialog) { + Column() { + this.DupDialog() + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .justifyContent(FlexAlign.Center) + } } .width('100%') .height('100%') - .justifyContent(FlexAlign.Center) + .backgroundColor('#F5F6F8') } } diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index bc28924..3bd0d22 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -1,17 +1,487 @@ /** - * ReportResultPage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * ReportResultPage —— 报告结果展示(F3)。 + * + * - reportId 从 router.getParams() 取,GET /api/v1/reports/{id} + * - 顶部状态:version / status / completed_at(生成中给刷新入口) + * - 降级透明化:result.validation.degraded === true 时顶部黄条 + * “本报告由规则引擎直接生成” + * - 文案区:result.copy.summary + sections(title + paragraphs) + * - 32 维诊断区:status=ok(已分析)与 skipped(未覆盖,灰显附原因)两组; + * 卡片内含来源标签中文映射、置信度百分比、<0.7“仅供参考”(NFR-UX-05)、 + * rule_hits 折叠展开 + * - 重新生成:POST /api/v1/reports/{id}/regenerate → 拿新 task_id 在本页复用 + * TaskPollingPanel 轮询(比跳回 Create 页携带参数更简单),SUCCEEDED 后重拉报告; + * 失败重试沿用同一 request_key(服务端幂等重放) */ +import { router } from '@kit.ArkUI' +import { ApiClient } from '../core/ApiClient' +import { ApiError } from '../core/ApiTypes' +import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' +import { DimensionCard } from '../components/f3/DimensionCard' +import { + CopySectionWire, + DimensionWire, + ReportCreateResp, + ReportDetailWire, + ReportRouteParams, + formatIsoTime, + randomHex, + splitDimensions, + statusColorOf, + statusLabelOf +} from '../components/f3/ReportTypes' + +const COLOR_PRIMARY: string = '#C2185B' +const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id' + @Entry @Component struct ReportResultPage { - build() { + @State loading: boolean = true + @State loadError: string = '' + @State reportId: string = '' + @State versionLabel: string = 'v1' + @State statusRaw: string = '' + @State statusText: string = '—' + @State completedText: string = '—' + @State degraded: boolean = false + @State summaryText: string = '' + @State sections: CopySectionWire[] = [] + @State analyzed: DimensionWire[] = [] + @State skipped: DimensionWire[] = [] + @State totalLabel: string = '32' + @State basicFullText: string = '' + @State regenerating: boolean = false + @State regenTaskId: string = '' + @State regenError: string = '' + private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放 + + aboutToAppear(): void { + const raw = router.getParams() + if (raw !== undefined && raw !== null) { + const p = raw as ReportRouteParams + const rid = p.reportId + if (rid !== undefined && rid !== '') { + this.reportId = rid + } + } + if (this.reportId === '') { + this.loading = false + this.loadError = '缺少报告 ID,请从报告创建页进入' + return + } + this.loadReport() + } + + private async loadReport(): Promise { + this.loading = true + this.loadError = '' + try { + const d = await ApiClient.get(`/api/v1/reports/${this.reportId}`) + this.versionLabel = d.version !== undefined && d.version !== null ? `v${d.version}` : 'v1' + this.statusRaw = d.status ?? '' + this.statusText = statusLabelOf(d.status) + this.completedText = formatIsoTime(d.completed_at) + const result = d.result + this.degraded = result?.validation?.degraded === true + this.summaryText = result?.copy?.summary ?? '(本报告暂无摘要)' + this.sections = result?.copy?.sections ?? [] + const dims = result?.diagnosis?.dimensions ?? [] + const groups = splitDimensions(dims) + this.analyzed = groups.analyzed + this.skipped = groups.skipped + const total = result?.diagnosis?.coverage?.total + this.totalLabel = total !== undefined && total !== null ? `${total}` : `${dims.length}` + const basicFull = result?.diagnosis?.coverage?.basic_full + this.basicFullText = basicFull === true ? '基础信息完整' : (basicFull === false ? '基础信息不完整' : '') + const rid = d.report_id + if (rid !== undefined && rid !== '') { + AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, rid) + } + this.loading = false + this.regenRequestKey = '' // 报告已成功拉到,下次重新生成用新 key + } catch (e) { + this.loading = false + if (e instanceof ApiError) { + this.loadError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + } else { + this.loadError = '请求失败,请检查网络' + } + } + } + + private async doRegenerate(): Promise { + if (this.regenerating || this.reportId === '') { + return + } + this.regenError = '' + if (this.regenRequestKey === '') { + this.regenRequestKey = randomHex(16) + } + const req: Record = { 'request_key': this.regenRequestKey } + try { + const data = await ApiClient.post(`/api/v1/reports/${this.reportId}/regenerate`, req) + const tid = data.task_id ?? '' + if (tid === '') { + this.regenError = '服务端未返回任务 ID,请稍后重试' + return + } + this.regenTaskId = tid + this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉 + } catch (e) { + if (e instanceof ApiError) { + this.regenError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + } else { + this.regenError = '请求失败,请检查网络' + } + } + } + + private onRegenSucceeded(): void { + this.regenerating = false + this.regenError = '' + this.loadReport() + } + + private onRegenFailed(reason: string): void { + this.regenerating = false + this.regenError = `重新生成失败:${reason}` + } + + private dimKey(d: DimensionWire): string { + const k = d.key + if (k !== undefined && k !== '') { + return k + } + const n = d.number + return n !== undefined ? `dim-${n}` : `dim-${d.name_cn ?? ''}` + } + + @Builder + TopBar() { + Row({ space: 8 }) { + Text('←') + .fontSize(20) + .fontColor('#333333') + .padding({ left: 4, right: 4, top: 2, bottom: 2 }) + .onClick(() => { + router.back() + }) + Text('报告结果') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + } + .width('100%') + .padding({ left: 12, right: 16, top: 10, bottom: 6 }) + } + + @Builder + LoadingView() { + Column({ space: 12 }) { + LoadingProgress() + .width(44) + .height(44) + .color(COLOR_PRIMARY) + Text('报告加载中…') + .fontSize(14) + .fontColor('#999999') + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + @Builder + ErrorView() { + Column({ space: 12 }) { + Text('报告加载失败') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text(this.loadError) + .fontSize(13) + .fontColor('#999999') + .textAlign(TextAlign.Center) + Button('重试') + .width('50%') + .height(44) + .fontSize(15) + .backgroundColor(COLOR_PRIMARY) + .margin({ top: 8 }) + .onClick(() => { + this.loadReport() + }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + } + + @Builder + RegenView() { + Column({ space: 16 }) { + Text('正在重新生成报告') + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text('重新生成沿用同一报告 ID,完成后自动展示新版本') + .fontSize(13) + .fontColor('#999999') + .textAlign(TextAlign.Center) + TaskPollingPanel({ + taskId: this.regenTaskId, + onSucceeded: (): void => { + this.onRegenSucceeded() + }, + onFailed: (reason: string): void => { + this.onRegenFailed(reason) + } + }) + .width('86%') + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 24, right: 24 }) + } + + @Builder + HeaderCard() { + Column({ space: 6 }) { + Row({ space: 8 }) { + Column({ space: 2 }) { + Text('形象诊断报告') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text(`${this.versionLabel}`) + .fontSize(12) + .fontColor('#999999') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text(this.statusText) + .fontSize(12) + .fontColor('#FFFFFF') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .backgroundColor(statusColorOf(this.statusRaw)) + .borderRadius(10) + } + .width('100%') + + Text(`完成时间:${this.completedText}`) + .fontSize(11) + .fontColor('#AAAAAA') + Text(`报告编号:${this.reportId}`) + .fontSize(11) + .fontColor('#AAAAAA') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + if (this.statusRaw !== 'SUCCEEDED') { + Text('报告仍在生成中,可点击刷新查看最新状态。') + .fontSize(12) + .fontColor('#D46A0B') + Button('刷新状态') + .height(34) + .fontSize(13) + .backgroundColor('#FDF3E3') + .fontColor('#D46A0B') + .margin({ top: 4 }) + .onClick(() => { + this.loadReport() + }) + } + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Start) + } + + @Builder + DegradedBanner() { + Column({ space: 2 }) { + Text('本报告由规则引擎直接生成') + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor('#8A6D3B') + Text('(AI 文案通道暂不可用,已透明降级)') + .fontSize(11) + .fontColor('#A98F5F') + } + .width('100%') + .padding(12) + .borderRadius(10) + .backgroundColor('#FFF7E0') + .border({ width: 1, color: '#F0D48A' }) + .alignItems(HorizontalAlign.Start) + } + + @Builder + CopyCard() { + Column({ space: 10 }) { + Text('报告文案') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + Text(this.summaryText) + .fontSize(14) + .fontColor('#333333') + .lineHeight(22) + ForEach(this.sections, (s: CopySectionWire, idx: number) => { + Column({ space: 6 }) { + Text(s.title ?? '') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#444444') + ForEach(s.paragraphs ?? [], (p: string) => { + Text(p) + .fontSize(13) + .fontColor('#555555') + .lineHeight(21) + }, (p: string, i: number) => `${idx}-${i}`) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + }, (s: CopySectionWire, idx: number) => `${idx}-${s.title ?? ''}`) + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Start) + } + + @Builder + DimGroupTitle(title: string, count: number, extra: string) { + Row({ space: 8 }) { + Text(title) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + Text(`${count} 项`) + .fontSize(12) + .fontColor('#999999') + if (extra !== '') { + Text(extra) + .fontSize(11) + .fontColor('#AAAAAA') + .layoutWeight(1) + .maxLines(1) + .textAlign(TextAlign.End) + } + } + .width('100%') + .margin({ top: 4 }) + } + + @Builder + DiagnosisArea() { + Column({ space: 10 }) { + Row() { + Text('32 维形象诊断') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + .layoutWeight(1) + Text(`已分析 ${this.analyzed.length} / ${this.totalLabel}`) + .fontSize(12) + .fontColor('#999999') + } + .width('100%') + + if (this.basicFullText !== '') { + Text(this.basicFullText) + .fontSize(11) + .fontColor('#AAAAAA') + } + + this.DimGroupTitle('已分析', this.analyzed.length, '') + if (this.analyzed.length === 0) { + Text('暂无已分析维度') + .fontSize(13) + .fontColor('#999999') + } + ForEach(this.analyzed, (d: DimensionWire) => { + DimensionCard({ dim: d }) + }, (d: DimensionWire) => this.dimKey(d)) + + this.DimGroupTitle('未覆盖', this.skipped.length, '灰显·附原因') + if (this.skipped.length === 0) { + Text('全部维度已覆盖') + .fontSize(13) + .fontColor('#999999') + } + ForEach(this.skipped, (d: DimensionWire) => { + DimensionCard({ dim: d }) + }, (d: DimensionWire) => this.dimKey(d)) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + @Builder + ContentBody() { Column() { - Text('ReportResultPage(建设中)').fontSize(16).fontColor('#666666') + Scroll() { + Column({ space: 12 }) { + this.HeaderCard() + if (this.degraded) { + this.DegradedBanner() + } + this.CopyCard() + this.DiagnosisArea() + } + .padding(16) + } + .layoutWeight(1) + .width('100%') + + Column({ space: 8 }) { + if (this.regenError !== '') { + Text(this.regenError) + .fontSize(12) + .fontColor('#E5484D') + .width('100%') + .textAlign(TextAlign.Start) + } + Button(this.regenError !== '' ? '重试重新生成' : '重新生成报告') + .width('100%') + .height(46) + .fontSize(15) + .backgroundColor(COLOR_PRIMARY) + .onClick(() => { + this.doRegenerate() + }) + } + .width('100%') + .padding({ left: 16, right: 16, top: 10, bottom: 14 }) } .width('100%') .height('100%') - .justifyContent(FlexAlign.Center) + } + + build() { + Column() { + this.TopBar() + if (this.regenerating) { + this.RegenView() + } else if (this.loading) { + this.LoadingView() + } else if (this.loadError !== '') { + this.ErrorView() + } else { + this.ContentBody() + } + } + .width('100%') + .height('100%') + .backgroundColor('#F5F6F8') } }