feat(app): 五轮产品评审迭代 35 项修复(报告域/对话/试衣发型/照片链/合规)

报告域:
- 报告 Tab 列表接入 GET /reports(重启可找回);重新生成采纳新 report_id
- 任务状态前后端大小写归一——轮询完成检测与「导出 PDF」按钮首次真正可达
- FAILED 重试换新幂等键;导出→重生成→再导出幂等冲突死路修复
- PDF 下载前 unlink 可重入;预览页常驻挂载+失败态重试;分享/另存文案拆分

对话页:
- 「内容由 AI 生成」常驻标识(CR-05)+ 快捷问题 chips + 额度 0 置灰与恢复时间
- 发送失败回填输入+重试+request_key 幂等;40301 额度同步

试衣间/发型设计:
- 人脸照片知情同意弹窗(preferences 持久化+版本+「我的」撤回入口)
- 生成期防中断(keepScreenOn/返回挽留/取消出口/genSeq 防串扰)
- 结果转存 filesDir/results:杀 App 回看、飞行模式离线保存、保存三态文案
- R4N1 照片输入链修复:裁除 describe 挂起载体、选图/拍照超时兜底、
  openSource URI 直建失败回退 fd 口径(R5N2)
- @Builder 改 $$ 对象传参修复槽位预览不刷新(R5N1);发型列表三态

全局:
- 会话过期四处「重新登录」出路;UTC 时间显示归正(差 8h)
- 10 处技术黑话清理;主色统一 Theme.ets;配额卡 4 张真实消耗集
- 登录页内测口径;f2 ErrorView/LoadingView 换用;调试入口 debug 守卫

门禁:App 构建 BUILD SUCCESSFUL;后端 576 单测绿(子仓 00ef56b);
28 项静态检查 PASS;真机 PDF 全链/照片送达/零崩溃验证通过
This commit is contained in:
xsl
2026-08-30 23:17:11 +08:00
parent 9e5ce14bce
commit 0b5d151e50
24 changed files with 2151 additions and 426 deletions
+155 -52
View File
@@ -5,21 +5,29 @@
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
* 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放)
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录),
* 无记录则提示稍后在任务中心查看
* 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key,
* 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务)
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录,
* 无记录时实时拉报告列表兜底,重启后也找得回)
* 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回)
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 幂等重试
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 新 key 重试
* 7) 提交前弹配额确认框(report_basic 终身 1 次,产品方案设计纪律 3「禁止静默扣减」)
*/
import { router, componentUtils } from '@kit.ArkUI'
import { ApiClient } from '../core/ApiClient'
import { ApiError } from '../core/ApiTypes'
import { AuthStore } from '../core/AuthStore'
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
import { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
import { ReportCreateResp, buildMockPhotoKey, formatDate, randomHex } from '../components/f3/ReportTypes'
import {
ReportCreateResp,
ReportListItemWire,
buildMockPhotoKey,
formatDate,
randomHex
} from '../components/f3/ReportTypes'
const COLOR_PRIMARY: string = '#C2185B'
const GENDER_OPTIONS: string[] = ['女', '男', '不便告知']
const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈']
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
@@ -50,6 +58,16 @@ interface FieldLabelParams {
required: boolean
}
/** GET /api/v1/quota?quota_key=report_basic 的响应(字段名对齐后端 JSON)。 */
interface QuotaWireResp {
quota_key: string
used: number
limit_value: number
bonus_granted: boolean
remaining: number
reset_at: string
}
@Entry
@Component
struct ReportCreatePage {
@@ -74,7 +92,10 @@ struct ReportCreatePage {
@State failMsg: string = ''
@State showDupDialog: boolean = false
@State lastReportId: string = ''
private requestKey: string = '' // 失败重试沿用同一 key:重新提交即服务端幂等重放,不重复消耗额度
@State showQuotaConfirm: boolean = false
@State quotaRemaining: number = -1
@State quotaLoaded: boolean = false
private requestKey: string = '' // FAILED 后置空:重试换新 key 创建新任务(同 key 只会重放回失败任务)
private taskId: string = ''
private reportId: string = ''
private scroller: Scroller = new Scroller()
@@ -179,7 +200,28 @@ struct ReportCreatePage {
this.scrollToFirstError()
return
}
this.doSubmit()
// 提交前确认配额代价(产品方案设计纪律 3:禁止静默扣减)
this.openQuotaConfirm()
}
/** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */
private openQuotaConfirm(): void {
this.showQuotaConfirm = true
this.quotaLoaded = false
this.quotaRemaining = -1
this.loadQuotaForConfirm()
}
private async loadQuotaForConfirm(): Promise<void> {
try {
const d = await ApiClient.get<QuotaWireResp>('/api/v1/quota?quota_key=report_basic')
this.quotaRemaining = d.remaining
this.quotaLoaded = true
} catch (_e) {
// 拉取失败只展示代价文案,是否放行交给服务端校验
this.quotaRemaining = -1
this.quotaLoaded = false
}
}
private missingLabels(): string[] {
@@ -232,7 +274,7 @@ struct ReportCreatePage {
this.submitting = true
this.submitError = ''
if (this.requestKey === '') {
// 随机 hex;FAILED 后的重试复用同一 key(见 retrySubmit 注释)
// 随机 hex;FAILED 后 requestKey 已被置空,此处即生成新 key 走全新任务
this.requestKey = randomHex(16)
}
const heightVal: number = Number(this.heightInput)
@@ -273,7 +315,7 @@ struct ReportCreatePage {
this.submitting = false
if (e instanceof ApiError) {
if (e.code === 40920) {
// 本期报告已生成:给“查看我的报告”入口(无记录则提示稍后在任务中心看)
// 本期报告已生成:始终给「查看我的报告」入口(无本地记录也能从服务端找回)
this.showDupDialog = true
return
}
@@ -285,8 +327,8 @@ struct ReportCreatePage {
}
private retrySubmit(): void {
// 幂等重试:沿用同一 request_key 重新提交,服务端按幂等重放处理,
// 不会重复生成报告或重复扣减额度
// onTaskFailed 已清空 requestKey:重试以新 key 创建全新任务(旧失败任务的
// 额度已由服务端自动退还,不会重复消耗)
this.failMsg = ''
this.doSubmit()
}
@@ -304,11 +346,27 @@ struct ReportCreatePage {
private onTaskFailed(reason: string): void {
this.phase = 'failed'
this.failMsg = reason
// 清空幂等键:服务端重放不过滤任务状态,沿用旧 key 重试只会拿回同一失败任务
this.requestKey = ''
}
private goLastReport(): void {
private async goLastReport(): Promise<void> {
this.showDupDialog = false
if (this.lastReportId === '') {
// 重启后本地记录可能丢失:从服务端列表兜底取最近一份报告
try {
const rows = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports?limit=1')
const rid = rows.length > 0 ? (rows[0].report_id ?? '') : ''
if (rid !== '') {
this.lastReportId = rid
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
}
} catch (_e) {
// 拉取失败走下方提示,不打断用户
}
}
if (this.lastReportId === '') {
this.submitError = '暂时找不到你的报告,可稍后在首页「报告」中查看'
return
}
const params: Record<string, Object> = { 'reportId': this.lastReportId }
@@ -318,9 +376,9 @@ struct ReportCreatePage {
@Builder
TopBar() {
Row({ space: 8 }) {
Text('←')
.fontSize(20)
.fontColor('#333333')
Text('‹ 返回')
.fontSize(15)
.fontColor(COLOR_TEXT_SECONDARY)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.onClick(() => {
router.back()
@@ -460,7 +518,7 @@ struct ReportCreatePage {
.layoutWeight(1)
Text(this.showDatePicker ? '收起 ▲' : '修改 ▼')
.fontSize(13)
.fontColor('#C2185B')
.fontColor(COLOR_PRIMARY)
.onClick(() => {
this.showDatePicker = !this.showDatePicker
})
@@ -543,25 +601,16 @@ struct ReportCreatePage {
.borderRadius(14)
.backgroundColor('#FFFFFF')
// 照片区(#6):上传能力未开放,只做预告;对象键输入框对用户隐藏
// (后端本就自动生成 report_photo/{userId}/{随机}.jpg,输入无用户价值)
Column({ space: 8 }) {
Text('照片(模拟)')
Text('照片(选填)')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text('V1 骨架:COS 直传未接入,此处模拟对象键')
Text('上传照片可以让报告更准,即将开放')
.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)
@@ -642,7 +691,7 @@ struct ReportCreatePage {
Button('查看报告')
.fontSize(14)
.backgroundColor('#FFFFFF')
.fontColor('#C2185B')
.fontColor(COLOR_PRIMARY)
.borderRadius(20)
.padding({ left: 24, right: 24 })
.margin({ top: 8 })
@@ -668,7 +717,7 @@ struct ReportCreatePage {
.fontSize(13)
.fontColor('#999999')
.textAlign(TextAlign.Center)
Text('重试将沿用同一 request_key 重新提交,服务端按幂等重放处理,不会重复消耗额度。')
Text('本次生成未成功,额度已自动退还;重试将重新提交,不会重复消耗。')
.fontSize(11)
.fontColor('#AAAAAA')
.textAlign(TextAlign.Center)
@@ -704,19 +753,13 @@ struct ReportCreatePage {
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('每个周期仅可生成一次完整报告,无需重复提交。')
Text('首份形象报告为一次性权益,已生成的报告可随时查看。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
if (this.lastReportId !== '') {
Text('将为你打开最近一次生成的报告。')
.fontSize(12)
.fontColor('#999999')
} else {
Text('本期报告稍后可在任务中心查看。')
.fontSize(12)
.fontColor('#999999')
}
Text('将为你打开最近一次生成的报告。')
.fontSize(12)
.fontColor('#999999')
Row({ space: 12 }) {
Button('我知道了')
.layoutWeight(1)
@@ -727,16 +770,66 @@ struct ReportCreatePage {
.onClick(() => {
this.showDupDialog = false
})
if (this.lastReportId !== '') {
Button('查看我的报告')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.goLastReport()
})
}
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')
}
/** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */
@Builder
QuotaConfirmDialog() {
Column({ space: 14 }) {
Text('确认生成报告')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text('首份形象报告为一次性权益,本次生成将使用 1 次。')
.fontSize(13)
.fontColor('#666666')
.textAlign(TextAlign.Center)
if (this.quotaLoaded && this.quotaRemaining > 0) {
Text(`当前剩余 ${this.quotaRemaining} 次`)
.fontSize(13)
.fontColor(COLOR_PRIMARY)
} else if (this.quotaLoaded && this.quotaRemaining <= 0) {
Text('你的报告权益已使用,可在首页「报告」查看已生成的报告。')
.fontSize(12)
.fontColor('#D46A0B')
.textAlign(TextAlign.Center)
}
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor('#F3F4F6')
.fontColor('#666666')
.onClick(() => {
this.showQuotaConfirm = false
})
Button('确认生成')
.layoutWeight(1)
.height(40)
.fontSize(14)
.backgroundColor(COLOR_PRIMARY)
.enabled(!(this.quotaLoaded && this.quotaRemaining <= 0))
.onClick(() => {
this.showQuotaConfirm = false
this.doSubmit()
})
}
.width('100%')
.margin({ top: 4 })
@@ -771,6 +864,16 @@ struct ReportCreatePage {
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
if (this.showQuotaConfirm) {
Column() {
this.QuotaConfirmDialog()
}
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.height('100%')