feat(app): report flow with polling and 32-dimension result view(F3)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<void> {
|
||||
const tid = this.taskId
|
||||
if (tid === '') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const t = await ApiClient.get<TaskWire>(`/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%')
|
||||
}
|
||||
}
|
||||
@@ -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<string>(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<void> {
|
||||
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<string, Object> = {
|
||||
'gender': this.gender,
|
||||
'height_cm': heightVal,
|
||||
'weight_kg': weightVal,
|
||||
'birthday': formatDate(this.birthday),
|
||||
'core_goals': goalsObj,
|
||||
'psych_style': this.psychStyle
|
||||
}
|
||||
const qObj: Object = questionnaire
|
||||
const photoInput = this.photoKeyInput.trim()
|
||||
const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous')
|
||||
const req: Record<string, Object> = {
|
||||
'questionnaire': qObj,
|
||||
'photo_object_key': photoKey,
|
||||
'request_key': this.requestKey
|
||||
}
|
||||
try {
|
||||
const data = await ApiClient.post<ReportCreateResp>('/api/v1/reports', req)
|
||||
this.reportId = data.report_id ?? ''
|
||||
this.taskId = data.task_id ?? ''
|
||||
if (this.reportId !== '') {
|
||||
this.lastReportId = this.reportId
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.reportId)
|
||||
}
|
||||
if (this.taskId === '') {
|
||||
this.submitError = '服务端未返回任务 ID,请稍后重试'
|
||||
this.submitting = false
|
||||
return
|
||||
}
|
||||
this.submitting = false
|
||||
this.phase = 'polling'
|
||||
} catch (e) {
|
||||
this.submitting = false
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 40920) {
|
||||
// 本期报告已生成:给“查看我的报告”入口(无记录则提示稍后在任务中心看)
|
||||
this.showDupDialog = true
|
||||
return
|
||||
}
|
||||
this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
return
|
||||
}
|
||||
this.submitError = '请求失败,请检查网络'
|
||||
}
|
||||
}
|
||||
|
||||
private retrySubmit(): void {
|
||||
// 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理,
|
||||
// 不会重复生成报告或重复扣减额度
|
||||
this.failMsg = ''
|
||||
this.doSubmit()
|
||||
}
|
||||
|
||||
private onTaskSucceeded(): void {
|
||||
const params: Record<string, Object> = { '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<string, Object> = { '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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
this.loading = true
|
||||
this.loadError = ''
|
||||
try {
|
||||
const d = await ApiClient.get<ReportDetailWire>(`/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<string>(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<void> {
|
||||
if (this.regenerating || this.reportId === '') {
|
||||
return
|
||||
}
|
||||
this.regenError = ''
|
||||
if (this.regenRequestKey === '') {
|
||||
this.regenRequestKey = randomHex(16)
|
||||
}
|
||||
const req: Record<string, Object> = { 'request_key': this.regenRequestKey }
|
||||
try {
|
||||
const data = await ApiClient.post<ReportCreateResp>(`/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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user