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:
@@ -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%')
|
||||
|
||||
Reference in New Issue
Block a user