diff --git a/app/entry/src/main/ets/components/f1/ProfileContent.ets b/app/entry/src/main/ets/components/f1/ProfileContent.ets index ac18368..335b7e1 100644 --- a/app/entry/src/main/ets/components/f1/ProfileContent.ets +++ b/app/entry/src/main/ets/components/f1/ProfileContent.ets @@ -6,15 +6,23 @@ * 改为页内提示 + 「去登录」按钮。 * * 数据契约(全部走 ApiClient/AuthStore,不直接碰 http): - * - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取) + * - 配额:GET /api/v1/quota?quota_key=agent_chat|report_basic|hd_export|report_regen(并行拉取)。 + * 只上 4 张「真实消耗」卡(#24):tryon/hairstyle 走 Phase A 直连第三方、不经后端配额, + * 展示会出现恒满额假象,待 Phase B 配额接入后再上。 * - 流水:GET /api/v1/quota/ledger?limit=10 - * 周期文案按架构文档 §7.4 配额映射:agent_chat/tryon=日、report_basic=终身一次。 + * 周期文案按架构文档 §7.4 配额映射:agent_chat/hd_export=日、report_basic=终身一次、report_regen=月。 */ import router from '@ohos.router' +// hvigor 生成的构建期常量(BUILD_MODE_NAME 等);CREATE_BUILD_PROFILE 先于 ArkTS 编译, +// 即使 clean 构建该文件也先落盘(#19:release 构建隐藏调试入口的判定来源) +import BuildProfile from '../../../../../build/default/generated/profile/default/BuildProfile' import { ApiClient } from '../../core/ApiClient' -import { ApiError } from '../../core/ApiTypes' +import { ApiError, isAuthError } from '../../core/ApiTypes' import { AuthStore } from '../../core/AuthStore' +import { COLOR_PRIMARY } from '../../core/Theme' +import { FaceConsentStore } from '../f2/FacePhotoConsent' +import { formatIsoTime } from '../f3/ReportTypes' import { ErrorBanner } from './ErrorBanner' import { QuotaCardView } from './QuotaCard' import { LedgerItemView } from './LedgerItem' @@ -72,7 +80,7 @@ const COLOR_POSITIVE = '#2E9E5B' const COLOR_NEGATIVE = '#C0392B' const COLOR_MUTED = '#8A8A8A' -/** quota_key → 中文名(对齐产品方案 §3.2 / 架构文档 §7.4)。 */ +/** quota_key → 中文名(对齐各功能入口的叫法,#24:hd_export=PDF 导出、report_basic=形象报告)。 */ function quotaName(key: string): string { if (key === 'agent_chat') { return 'AI 对话' @@ -84,10 +92,10 @@ function quotaName(key: string): string { return '发型试戴' } if (key === 'hd_export') { - return '高清导出' + return 'PDF 导出' } if (key === 'report_basic') { - return '美学报告' + return '形象报告' } if (key === 'report_regen') { return '报告重新生成' @@ -114,7 +122,8 @@ function resetTextFor(key: string, resetAt: string | null): string { if (isDailyQuota(key)) { return '明日恢复' } - const short = resetAt.length >= 10 ? resetAt.substring(5, 10) : resetAt + // reset_at 为 UTC:经本地时区换算后截 'MM-DD'(N5,直接截串会差 8 小时跨日) + const short = formatIsoTime(resetAt).substring(5, 10) return `${short} 重置` } @@ -175,13 +184,13 @@ function statusBgOf(s: string): string { return '#F4F4F4' } -/** ISO 时间 → 'MM-DD HH:mm'。 */ +/** ISO 时间 → 本地时区 'MM-DD HH:mm'(N5:后端时间为 UTC,须换算后再展示)。 */ function timeText(iso: string): string { if (iso === '') { return '—' } - const t = iso.replace('T', ' ') - return t.length >= 16 ? t.substring(5, 16) : t + const full = formatIsoTime(iso) // 本地时区 'yyyy-MM-dd HH:mm';解析失败原样返回 + return full.length >= 16 ? full.substring(5) : full } /** 用户 ID 脱敏:前 6 后 4。 */ @@ -222,15 +231,18 @@ export struct ProfileContent { @State needLogin: boolean = false @State userIdMasked: string = '' @State cardChat: QuotaCardData = new QuotaCardData('AI 对话') - @State cardTryon: QuotaCardData = new QuotaCardData('换装试穿') - @State cardReport: QuotaCardData = new QuotaCardData('美学报告') + @State cardReport: QuotaCardData = new QuotaCardData('形象报告') + @State cardExport: QuotaCardData = new QuotaCardData('PDF 导出') + @State cardRegen: QuotaCardData = new QuotaCardData('报告重新生成') @State ledgerRows: LedgerRowData[] = [] @State ledgerLoaded: boolean = false @State ledgerError: string = '' @State bannerMessage: string = '' @State bannerHint: string = '' + @State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7) @State refreshing: boolean = false @State loggingOut: boolean = false + @State faceConsentGranted: boolean = false // 人脸照片授权状态(N4:查看/撤回入口数据源) private pendingLoads: number = 0 aboutToAppear(): void { @@ -243,20 +255,30 @@ export struct ProfileContent { const uid = AuthStore.getUserId() this.userIdMasked = maskUserId(uid !== undefined ? uid : '') this.startLoadAll() + this.loadFaceConsent() } - /** 并行拉三张配额卡 + 流水;计数归零时收起下拉刷新。 */ + /** 授权状态读取(init 幂等:通常早已由 EntryAbility 完成hydrate,直接读内存)。 */ + private async loadFaceConsent(): Promise { + await FaceConsentStore.init() + this.faceConsentGranted = FaceConsentStore.isGranted() + } + + /** 并行拉四张配额卡(真实消耗集,#24)+ 流水;计数归零时收起下拉刷新。 */ private startLoadAll(): void { this.bannerMessage = '' this.bannerHint = '' + this.authExpired = false this.ledgerError = '' this.cardChat = new QuotaCardData('AI 对话') - this.cardTryon = new QuotaCardData('换装试穿') - this.cardReport = new QuotaCardData('美学报告') - this.pendingLoads = 4 + this.cardReport = new QuotaCardData('形象报告') + this.cardExport = new QuotaCardData('PDF 导出') + this.cardRegen = new QuotaCardData('报告重新生成') + this.pendingLoads = 5 this.loadQuota('agent_chat') - this.loadQuota('tryon') this.loadQuota('report_basic') + this.loadQuota('hd_export') + this.loadQuota('report_regen') this.loadLedger() } @@ -280,7 +302,10 @@ export struct ProfileContent { data.loaded = true this.applyQuota(key, data) } catch (e) { - // 单卡失败不拖垮整页:错误落在卡片内 + // 单卡失败不拖垮整页:错误落在卡片内;会话过期类单独记,供横幅给重新登录出路 + if (isAuthError(e)) { + this.authExpired = true + } const err = toApiError(e) const data = new QuotaCardData(quotaName(key)) data.loaded = true @@ -294,10 +319,12 @@ export struct ProfileContent { private applyQuota(key: string, data: QuotaCardData): void { if (key === 'agent_chat') { this.cardChat = data - } else if (key === 'tryon') { - this.cardTryon = data - } else { + } else if (key === 'report_basic') { this.cardReport = data + } else if (key === 'hd_export') { + this.cardExport = data + } else { + this.cardRegen = data } } @@ -312,6 +339,9 @@ export struct ProfileContent { this.ledgerLoaded = true this.ledgerError = '' } catch (e) { + if (isAuthError(e)) { + this.authExpired = true + } const err = toApiError(e) this.ledgerLoaded = true this.ledgerError = err.hint !== undefined && err.hint !== '' ? `${err.message}(${err.hint})` : err.message @@ -338,6 +368,28 @@ export struct ProfileContent { }) } + /** 撤回人脸照片授权(N4 / FR-ACC-08):撤回后下次上传前重新弹单独同意窗。 */ + private confirmRevokeFaceConsent(): void { + AlertDialog.show({ + title: '撤回人脸照片授权', + message: '撤回后,再次使用试衣/发型功能上传照片时会重新询问。', + primaryButton: { + value: '取消', + action: () => { + // 仅关闭弹窗 + } + }, + secondaryButton: { + value: '撤回', + fontColor: '#C0392B', + action: () => { + FaceConsentStore.revoke() + this.faceConsentGranted = false + } + } + }) + } + private async performLogout(): Promise { if (this.loggingOut) { return @@ -369,7 +421,7 @@ export struct ProfileContent { Button('去登录') .fontSize(15) .fontColor(Color.White) - .backgroundColor('#C2185B') + .backgroundColor(COLOR_PRIMARY) .borderRadius(22) .height(44) .padding({ left: 28, right: 28 }) @@ -401,7 +453,7 @@ export struct ProfileContent { .width(56) .height(56) .borderRadius(28) - .backgroundColor('#C2185B') + .backgroundColor(COLOR_PRIMARY) .justifyContent(FlexAlign.Center) Column({ space: 4 }) { @@ -422,7 +474,7 @@ export struct ProfileContent { .borderRadius(16) .padding({ left: 14, right: 14 }) .backgroundColor('#F7E3EA') - .fontColor('#C2185B') + .fontColor(COLOR_PRIMARY) .enabled(!this.loggingOut) .onClick(() => this.confirmLogout()) } @@ -432,18 +484,50 @@ export struct ProfileContent { .backgroundColor('#FFFFFF') .alignItems(VerticalAlign.Center) - // T18 SPIKE 调试入口(内部用,正式版移除) - Text('端侧LLM调试') - .fontSize(13) - .fontColor('#9A9A9A') - .width('100%') - .padding(12) - .borderRadius(12) - .backgroundColor('#FFFFFF') - .textAlign(TextAlign.Center) - .onClick(() => { - router.pushUrl({ url: 'pages/LlmTestPage' }) - }) + // 人脸照片授权管理(N4 / FR-ACC-08「可查询、可撤回」的端侧兑现) + Row({ space: 8 }) { + Text('人脸照片授权') + .fontSize(14) + .fontColor('#2B2B2B') + .layoutWeight(1) + if (this.faceConsentGranted) { + Text('已同意') + .fontSize(12) + .fontColor(COLOR_MUTED) + Text('撤回') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .onClick(() => { + this.confirmRevokeFaceConsent() + }) + } else { + Text('未同意') + .fontSize(12) + .fontColor(COLOR_MUTED) + } + } + .width('100%') + .padding({ left: 16, right: 16, top: 14, bottom: 14 }) + .borderRadius(16) + .backgroundColor('#FFFFFF') + .alignItems(VerticalAlign.Center) + + // T18 SPIKE 调试入口(#19):release 构建自动隐藏;debug 内测包保留为真机基准工具 + // (移除前置=T18-REPORT.md 产出,见 R4-pm #28) + if (BuildProfile.BUILD_MODE_NAME === 'debug') { + Text('内部调试 · 端侧模型') + .fontSize(13) + .fontColor('#9A9A9A') + .width('100%') + .padding(12) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .textAlign(TextAlign.Center) + .onClick(() => { + router.pushUrl({ url: 'pages/LlmTestPage' }) + }) + } ErrorBanner({ message: this.bannerMessage, @@ -454,7 +538,31 @@ export struct ProfileContent { } }) - // 配额三卡 + // 会话过期横幅(#7):重试/下拉刷新必然再败,给「重新登录」出路 + if (this.authExpired) { + Row({ space: 8 }) { + Text('登录已过期,重新登录后可查看配额与流水') + .fontSize(12) + .fontColor('#C0392B') + .layoutWeight(1) + Text('重新登录') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .onClick(() => { + this.authExpired = false + router.pushUrl({ url: 'pages/LoginPage' }) + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .borderRadius(10) + .backgroundColor('#FDECEC') + } + + // 配额四卡(#24 真实消耗集,2×2):AI 对话 / 形象报告 / PDF 导出 / 报告重新生成 Row({ space: 10 }) { QuotaCardView({ title: this.cardChat.title, @@ -464,19 +572,7 @@ export struct ProfileContent { resetText: this.cardChat.resetText, loaded: this.cardChat.loaded, errorText: this.cardChat.errorText, - accent: '#C2185B' - }) - .layoutWeight(1) - - QuotaCardView({ - title: this.cardTryon.title, - remaining: this.cardTryon.remaining, - used: this.cardTryon.used, - total: this.cardTryon.total, - resetText: this.cardTryon.resetText, - loaded: this.cardTryon.loaded, - errorText: this.cardTryon.errorText, - accent: '#7A5AF8' + accent: COLOR_PRIMARY }) .layoutWeight(1) @@ -495,6 +591,34 @@ export struct ProfileContent { .width('100%') .alignItems(VerticalAlign.Top) + Row({ space: 10 }) { + QuotaCardView({ + title: this.cardExport.title, + remaining: this.cardExport.remaining, + used: this.cardExport.used, + total: this.cardExport.total, + resetText: this.cardExport.resetText, + loaded: this.cardExport.loaded, + errorText: this.cardExport.errorText, + accent: '#7A5AF8' + }) + .layoutWeight(1) + + QuotaCardView({ + title: this.cardRegen.title, + remaining: this.cardRegen.remaining, + used: this.cardRegen.used, + total: this.cardRegen.total, + resetText: this.cardRegen.resetText, + loaded: this.cardRegen.loaded, + errorText: this.cardRegen.errorText, + accent: '#B9770E' + }) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Top) + // 最近流水 Column({ space: 4 }) { Text('最近流水') diff --git a/app/entry/src/main/ets/components/f1/QuotaCard.ets b/app/entry/src/main/ets/components/f1/QuotaCard.ets index f5030d4..a76f78f 100644 --- a/app/entry/src/main/ets/components/f1/QuotaCard.ets +++ b/app/entry/src/main/ets/components/f1/QuotaCard.ets @@ -6,6 +6,8 @@ * 周期文案由父层根据 quota_key + reset_at 算好传入(日=明日恢复/终身=一次性)。 */ +import { COLOR_PRIMARY } from '../../core/Theme' + @Component export struct QuotaCardView { @Prop title: string = '' @@ -15,7 +17,7 @@ export struct QuotaCardView { @Prop resetText: string = '' @Prop loaded: boolean = false @Prop errorText: string = '' - @Prop accent: string = '#C2185B' + @Prop accent: string = COLOR_PRIMARY build() { Column({ space: 6 }) { diff --git a/app/entry/src/main/ets/components/f2/ErrorView.ets b/app/entry/src/main/ets/components/f2/ErrorView.ets index baf603b..cca798b 100644 --- a/app/entry/src/main/ets/components/f2/ErrorView.ets +++ b/app/entry/src/main/ets/components/f2/ErrorView.ets @@ -8,11 +8,13 @@ * 其它 FE 任务可直接 import 本组件复用(目录带 f2 前缀,迁移由协调者做)。 */ +import { COLOR_PRIMARY } from '../../core/Theme' + const TEXT_PRIMARY: string = '#222222' const TEXT_SECONDARY: string = '#8A9099' const WARN: string = '#FF9F0A' const WARN_BG: string = '#FFF4E0' -const ACCENT: string = '#FF5A79' +const ACCENT: string = COLOR_PRIMARY @Component export struct ErrorView { diff --git a/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets b/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets new file mode 100644 index 0000000..024ec36 --- /dev/null +++ b/app/entry/src/main/ets/components/f2/FacePhotoConsent.ets @@ -0,0 +1,174 @@ +/** + * FacePhotoConsent —— 人脸照片上传前的单独同意弹窗(CR-01 / FR-ACC-08)。 + * + * 合规要求(docs/合规与安全方案.md §2.1.1):人脸属敏感个人信息,须 + * 「独立弹窗 + 单独确认」,明示处理目的、第三方处理方与保存期限,且 + * 拒绝后不阻断非上传功能(发型库浏览、文字功能等)。 + * 文案对照 CR-01 告知义务模板。 + * 试衣间/发型设计两页共用(组件级复用允许 import)。 + * + * R5 · N4 同意记录持久化(镜像 AuthStore 的 preferences 先例): + * - {granted, version, grantedAt} 落 preferences(FR-ACC-08 字段口径), + * 杀 App 重进不再重复弹窗; + * - 版本化:文案/条款改版升 CONSENT_VERSION,旧版同意自动失效重询; + * - revoke() 撤回(「我的」页入口),撤回后下次上传前重新弹窗; + * - 拒绝路径不写任何记录(下次上传前再询问,口径不变)。 + */ + +import preferences from '@ohos.data.preferences' +import { ContextHolder } from '../../core/ContextHolder' +import { COLOR_PRIMARY } from '../../core/Theme' + +const CONSENT_STORAGE_KEY: string = 'face_photo_consent_granted' +const CONSENT_PREF_NAME: string = 'meida_face_consent' +const CONSENT_RECORD_KEY: string = 'record' +const CONSENT_VERSION: string = 'v1' +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' +const ACCENT: string = COLOR_PRIMARY + +/** 持久化的同意记录(对齐 FR-ACC-08:同意记录含版本号与时间,可查询)。 */ +interface ConsentRecord { + granted: boolean + version: string + grantedAt: number +} + +/** 同意状态存取:内存读 + preferences 持久化(init 由 EntryAbility.onCreate 挂一次)。 */ +export class FaceConsentStore { + private static granted: boolean = false + private static prefs: preferences.Preferences | undefined + private static inited: boolean = false + + /** hydrate preferences → 内存;幂等(已初始化直接返回)。 */ + static async init(): Promise { + if (FaceConsentStore.inited) { + return + } + FaceConsentStore.inited = true + try { + FaceConsentStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: CONSENT_PREF_NAME }) + const raw = await FaceConsentStore.prefs.get(CONSENT_RECORD_KEY, '') as string + if (raw !== '') { + try { + const rec = JSON.parse(raw) as ConsentRecord + // 版本化:版本不匹配视为未同意(文案改版后自动重新询问) + FaceConsentStore.granted = rec.granted === true && rec.version === CONSENT_VERSION + } catch (_e) { + FaceConsentStore.granted = false + } + } + } catch (_e) { + FaceConsentStore.granted = false // preferences 不可用不阻断:退化为会话级 + } + } + + static isGranted(): boolean { + return FaceConsentStore.granted + } + + static grant(): void { + FaceConsentStore.granted = true + AppStorage.setOrCreate(CONSENT_STORAGE_KEY, true) + const rec: ConsentRecord = { granted: true, version: CONSENT_VERSION, grantedAt: Date.now() } + FaceConsentStore.persist(JSON.stringify(rec)) + } + + /** 撤回授权:清持久化与内存,下次上传前重新弹窗。 */ + static revoke(): void { + FaceConsentStore.granted = false + AppStorage.setOrCreate(CONSENT_STORAGE_KEY, false) + FaceConsentStore.persist('') + } + + /** 尽力写盘:失败不阻断 UI(最坏情况下次启动重读旧值,多弹一次窗)。 */ + private static async persist(value: string): Promise { + try { + if (FaceConsentStore.prefs === undefined) { + FaceConsentStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: CONSENT_PREF_NAME }) + } + if (value === '') { + await FaceConsentStore.prefs.delete(CONSENT_RECORD_KEY) + } else { + await FaceConsentStore.prefs.put(CONSENT_RECORD_KEY, value) + } + await FaceConsentStore.prefs.flush() + } catch (_e) { + // 忽略:内存态已生效,持久化失败下次启动重询 + } + } +} + +@Component +export struct FacePhotoConsent { + onAgree: () => void = () => {} + onRefuse: () => void = () => {} + + @Builder + ConsentLine(bullet: string, text: string) { + Row({ space: 6 }) { + Text(bullet) + .fontSize(12) + .fontColor(ACCENT) + Text(text) + .fontSize(12) + .fontColor(TEXT_SECONDARY) + .lineHeight(18) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } + + build() { + Column() { + Column({ space: 14 }) { + Text('上传人脸照片须知') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(TEXT_PRIMARY) + Column({ space: 8 }) { + this.ConsentLine('·', '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。') + this.ConsentLine('·', '处理方:照片将上传至第三方 AI 图像服务完成本次生成,不会共享给其他方。') + this.ConsentLine('·', '保存:原图在生成完成后即删除;你保存到相册的效果图可随时自行删除。') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + + Text('不同意时不会上传你的照片,你仍可浏览发型库或使用文字咨询功能。') + .fontSize(11) + .fontColor('#AAAAAA') + + Row({ space: 12 }) { + Button('不同意') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { + this.onRefuse() + }) + Button('同意并上传') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(ACCENT) + .onClick(() => { + this.onAgree() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('86%') + .padding(20) + .borderRadius(16) + .backgroundColor('#FFFFFF') + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .justifyContent(FlexAlign.Center) + } +} diff --git a/app/entry/src/main/ets/components/f2/LoadingView.ets b/app/entry/src/main/ets/components/f2/LoadingView.ets index e27b20e..07bde16 100644 --- a/app/entry/src/main/ets/components/f2/LoadingView.ets +++ b/app/entry/src/main/ets/components/f2/LoadingView.ets @@ -5,7 +5,9 @@ * props 一律 @Prop(父传子单向);颜色硬编码(resources 本批禁改,i18n 后置)。 */ -const ACCENT: string = '#FF5A79' +import { COLOR_PRIMARY } from '../../core/Theme' + +const ACCENT: string = COLOR_PRIMARY const TEXT_SECONDARY: string = '#8A9099' @Component diff --git a/app/entry/src/main/ets/components/f3/DimensionCard.ets b/app/entry/src/main/ets/components/f3/DimensionCard.ets index 7195220..ec93e5b 100644 --- a/app/entry/src/main/ets/components/f3/DimensionCard.ets +++ b/app/entry/src/main/ets/components/f3/DimensionCard.ets @@ -9,8 +9,9 @@ */ import { EMPTY_DIMENSION, DimensionWire, formatDimValue, sourceColor, sourceLabel } from './ReportTypes' +import { COLOR_PRIMARY } from '../../core/Theme' -const CARD_PRIMARY_COLOR: string = '#C2185B' +const CARD_PRIMARY_COLOR: string = COLOR_PRIMARY @Component export struct DimensionCard { diff --git a/app/entry/src/main/ets/components/f3/ReportListTab.ets b/app/entry/src/main/ets/components/f3/ReportListTab.ets new file mode 100644 index 0000000..b254bbb --- /dev/null +++ b/app/entry/src/main/ets/components/f3/ReportListTab.ets @@ -0,0 +1,227 @@ +/** + * ReportListTab(F3)—— 首页「报告」Tab 的我的报告列表。 + * + * 数据:GET /api/v1/reports?limit=20(服务端按归属+时间倒序返回)。 + * 三态:加载中 / 空态(引导去生成)/ 错误态(重试);支持下拉刷新。 + * 点击列表项 → router.pushUrl 结果页(params 带 reportId),重启 App 后 + * 仍能从这里找回已生成的报告(不再依赖易失的 AppStorage lastReportId)。 + * 组件级复用允许 import;作为 TabContent 内嵌(同 ProfileContent 先例), + * 独立路由页无底部 Tab 会把用户锁死在页面里,故不注册为路由页。 + */ + +import { router } from '@kit.ArkUI' +import { ApiClient } from '../../core/ApiClient' +import { ApiError, isAuthError } from '../../core/ApiTypes' +import { COLOR_PRIMARY } from '../../core/Theme' +import { ReportListItemWire, formatIsoTime, statusColorOf, statusLabelOf } from './ReportTypes' +import { ErrorView } from '../f2/ErrorView' +import { LoadingView } from '../f2/LoadingView' + +const PAGE_BG: string = '#F5F6F8' +const ACCENT: string = COLOR_PRIMARY +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' +const LIST_PATH: string = '/api/v1/reports?limit=20' + +@Component +export struct ReportListTab { + @State loading: boolean = true + @State loadError: string = '' + @State loadErrorAuth: boolean = false // 会话/令牌类错误:错误态改供「重新登录」(#7) + @State reports: ReportListItemWire[] = [] + @State refreshing: boolean = false + // 首页切回报告 Tab / 从结果页返回时由宿主自增,触发列表刷新(N2) + @Prop @Watch('onRefreshTick') refreshTick: number = 0 + + aboutToAppear(): void { + this.loadReports() + } + + private onRefreshTick(): void { + this.loadReports() + } + + private async loadReports(): Promise { + // 下拉刷新时已有内容,不整屏转圈 + this.loading = this.reports.length === 0 + this.loadError = '' + this.loadErrorAuth = false + try { + const rows = await ApiClient.get(LIST_PATH) + this.reports = rows + } catch (e) { + this.loadErrorAuth = isAuthError(e) + if (e instanceof ApiError) { + this.loadError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message + } else { + this.loadError = '请求失败,请检查网络' + } + } finally { + this.loading = false + this.refreshing = false + } + } + + private openReport(item: ReportListItemWire): void { + const rid = item.report_id ?? '' + if (rid === '') { + return + } + const params: Record = { 'reportId': rid } + router.pushUrl({ url: 'pages/ReportResultPage', params: params }) + } + + private reportTime(item: ReportListItemWire): string { + const done = item.completed_at + if (done !== undefined && done !== '') { + return `完成于 ${formatIsoTime(done)}` + } + return `创建于 ${formatIsoTime(item.created_at)}` + } + + @Builder + EmptyView() { + Column({ space: 12 }) { + Text('▤') + .fontSize(40) + .fontColor('#C2C7CE') + Text('还没有生成过报告') + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + Text('填写 6 项基础信息,生成你的 32 维形象诊断') + .fontSize(13) + .fontColor(TEXT_SECONDARY) + .textAlign(TextAlign.Center) + Button('去生成报告') + .fontSize(14) + .fontColor(Color.White) + .backgroundColor(ACCENT) + .borderRadius(20) + .height(40) + .padding({ left: 28, right: 28 }) + .margin({ top: 6 }) + .onClick(() => { + router.pushUrl({ url: 'pages/ReportCreatePage' }) + }) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + } + + @Builder + ReportItem(item: ReportListItemWire) { + Row({ space: 12 }) { + Column({ space: 4 }) { + Text(`形象报告 v${item.version ?? 1}`) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + Text(this.reportTime(item)) + .fontSize(12) + .fontColor(TEXT_SECONDARY) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text(statusLabelOf(item.status)) + .fontSize(12) + .fontColor(Color.White) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .backgroundColor(statusColorOf(item.status)) + .borderRadius(10) + Text('›') + .fontSize(20) + .fontColor('#C2C7CE') + } + .width('100%') + .padding(14) + .borderRadius(16) + .backgroundColor(Color.White) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.openReport(item) + }) + } + + build() { + Column() { + Row() { + Text('我的报告') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(TEXT_PRIMARY) + .layoutWeight(1) + if (this.reports.length > 0) { + Text('刷新') + .fontSize(13) + .fontColor(ACCENT) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .onClick(() => { + this.loadReports() + }) + } + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 10 }) + + if (this.loading) { + // N5':换用 f2 通用加载态(包一层 Column 承接 layoutWeight 占位) + Column() { + LoadingView({ text: '报告加载中…' }) + } + .width('100%') + .layoutWeight(1) + } else if (this.loadError !== '') { + // N5':换用 f2 通用错误态;会话过期时在组件外层追加「重新登录」出路(#7 不回归) + Column() { + ErrorView({ + message: this.loadError, + retryText: '重新加载', + onRetry: (): void => { + this.loadReports() + } + }) + if (this.loadErrorAuth) { + Button('重新登录') + .fontSize(14) + .fontColor('#666666') + .backgroundColor('#F3F4F6') + .borderRadius(20) + .height(40) + .padding({ left: 28, right: 28 }) + .margin({ top: 10 }) + .onClick(() => { + router.pushUrl({ url: 'pages/LoginPage' }) + }) + } + } + .width('100%') + .layoutWeight(1) + } else if (this.reports.length === 0) { + this.EmptyView() + } else { + Refresh({ refreshing: $$this.refreshing }) { + List({ space: 10 }) { + ForEach(this.reports, (item: ReportListItemWire) => { + ListItem() { + this.ReportItem(item) + } + }, (item: ReportListItemWire) => item.report_id ?? '') + } + .width('100%') + .scrollBar(BarState.Off) + } + .onRefreshing(() => { + this.loadReports() + }) + .layoutWeight(1) + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/app/entry/src/main/ets/components/f3/ReportTypes.ets b/app/entry/src/main/ets/components/f3/ReportTypes.ets index a3acb08..21c6a9a 100644 --- a/app/entry/src/main/ets/components/f3/ReportTypes.ets +++ b/app/entry/src/main/ets/components/f3/ReportTypes.ets @@ -15,12 +15,13 @@ export interface ReportCreateResp { version?: number } -/** GET /api/v1/tasks/{id} 的响应(status: PENDING/RUNNING/SUCCEEDED/FAILED) */ +/** GET /api/v1/tasks/{id} 的响应(status 后端为小写:pending/running/succeeded/failed) */ export interface TaskWire { task_id?: string status?: string progress?: number stage_text?: string + error_message?: string // FAILED 时的服务端原因,形如 "ExceptionType: 原始信息" } /** 单个诊断维度(diagnosis.dimensions[] 恒 32 项;status: 'ok' | 'skipped') */ @@ -77,6 +78,16 @@ export interface ReportDetailWire { completed_at?: string } +/** GET /api/v1/reports 列表元素(result 字段较重,列表页不消费、不声明) */ +export interface ReportListItemWire { + report_id?: string + tier?: string + status?: string // 后端为小写:pending/running/succeeded/failed + version?: number + created_at?: string + completed_at?: string +} + /** router.getParams() 的参数形态(Create/Result 页之间只传 reportId) */ export interface ReportRouteParams { reportId?: string @@ -159,48 +170,79 @@ export function sourceColor(src: string | undefined): string { return '#6B7280' } -/** 任务/报告状态中文映射 */ +/** 任务/报告状态中文映射(后端为小写、历史代码传大写,统一转大写后比较) */ export function statusLabelOf(s: string | undefined): string { - if (s === 'SUCCEEDED') { + const v = (s ?? '').toUpperCase() + if (v === 'SUCCEEDED') { return '已完成' } - if (s === 'RUNNING' || s === 'GENERATING') { + if (v === 'RUNNING' || v === 'GENERATING' || v === 'QUEUED') { return '生成中' } - if (s === 'PENDING') { + if (v === 'PENDING') { return '排队中' } - if (s === 'FAILED') { + if (v === 'FAILED') { return '生成失败' } - return s === undefined || s === '' ? '—' : s + return v === '' ? '—' : v } export function statusColorOf(s: string | undefined): string { - if (s === 'SUCCEEDED') { + const v = (s ?? '').toUpperCase() + if (v === 'SUCCEEDED') { return '#0E9F6E' } - if (s === 'FAILED') { + if (v === 'FAILED') { return '#E5484D' } - if (s === 'PENDING' || s === 'RUNNING' || s === 'GENERATING') { + if (v === 'PENDING' || v === 'RUNNING' || v === 'GENERATING' || v === 'QUEUED') { return '#D46A0B' } return '#999999' } -/** ISO 时间串截到分钟展示('2026-08-15T10:30:00+00:00' → '2026-08-15 10:30') */ +function containsCjk(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i) + if (c >= 0x4E00 && c <= 0x9FFF) { + return true + } + } + return false +} + +/** + * 任务失败原因的用户友好化(#4): + * 后端 error_message 形如 "ExceptionType: 原始信息",先剥掉英文异常类名前缀; + * 剩余文案含中文(自家错误目录都是中文话术)才直接透出,纯技术串回退 fallback。 + */ +export function friendlyTaskError(raw: string | undefined, fallback: string): string { + if (raw === undefined || raw === null || raw === '') { + return fallback + } + let msg = raw.trim() + const idx = msg.indexOf(': ') + if (idx > 0 && idx <= 40 && !containsCjk(msg.substring(0, idx))) { + msg = msg.substring(idx + 2) + } + return containsCjk(msg) ? msg : fallback +} + +/** + * ISO 时间串按本地时区格式化到分钟('2026-08-15T10:30:00+00:00' 在北京时间 + * 显示 '2026-08-15 18:30')。后端 reset_at/completed_at 等均为 UTC,此前直接 + * 截串等于原样展示 UTC(差 8 小时,N5),必须经 Date 换算到本地时区。 + */ 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 d = new Date(s) + if (isNaN(d.getTime())) { + return s // 非常规时间串:原样返回,不做猜测 } - const date = s.substring(0, t) - const time = s.substring(t + 1, t + 6) - return `${date} ${time}` + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` } function pad2(n: number): string { diff --git a/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets b/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets index de9d52a..7965e44 100644 --- a/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets +++ b/app/entry/src/main/ets/components/f3/TaskPollingPanel.ets @@ -3,18 +3,22 @@ * * - 每 3 秒 GET /api/v1/tasks/{id},展示 progress 进度条 + stage_text * - SUCCEEDED / FAILED 通过回调上抛,由宿主页面决定跳转或失败态 - * - 连续 3 次网络异常视为失败(避免瞬时抖动误杀长任务) + * - 连续 3 次网络异常=「放弃轮询」而非任务失败:原任务可能仍在服务端执行 + * (额度未退、未失败),此时不上抛 onFailed(宿主因此不会清幂等键/误展示 + * 「额度已退还」),由本面板给出引导文案 + 手动「刷新」恢复轮询; + * 只有后端返回终态 FAILED 才走宿主失败流程 * - 定时器随组件销毁自动清理(页面返回/跳转即停);taskId 切换时 * 通过 generation 计数丢弃旧任务的在途响应 */ import { ApiClient } from '../../core/ApiClient' import { ApiError } from '../../core/ApiTypes' -import { TaskWire } from './ReportTypes' +import { COLOR_PRIMARY } from '../../core/Theme' +import { TaskWire, friendlyTaskError } from './ReportTypes' const POLL_INTERVAL_MS: number = 3000 const MAX_CONSECUTIVE_ERRORS: number = 3 -const POLL_PRIMARY_COLOR: string = '#C2185B' +const POLL_PRIMARY_COLOR: string = COLOR_PRIMARY @Component export struct TaskPollingPanel { @@ -24,6 +28,7 @@ export struct TaskPollingPanel { @State progress: number = 0 @State stageText: string = '任务已提交,等待调度…' + @State abandoned: boolean = false // 网络不稳放弃轮询(≠任务失败),展示手动刷新 private timerId: number = -1 private generation: number = 0 @@ -50,6 +55,9 @@ export struct TaskPollingPanel { this.errCount = 0 this.progress = 0 this.stageText = '任务已提交,等待调度…' + this.abandoned = false + // 任务号只进日志排障,不再对用户展示(#6) + console.info(`TaskPollingPanel start polling task: ${this.taskId}`) this.pollOnce(this.generation) } @@ -79,12 +87,14 @@ export struct TaskPollingPanel { if (st !== undefined && st !== null && st !== '') { this.stageText = st } - if (t.status === 'SUCCEEDED') { + // 后端任务状态是小写(pending/running/succeeded/failed),统一转大写再比: + // 之前直接比 'SUCCEEDED' 永远不命中,轮询完成不自动跳转的真因即在此 + const taskStatus = (t.status ?? '').toUpperCase() + if (taskStatus === 'SUCCEEDED') { this.stopTimer() this.stageText = '已完成——点击下方「查看报告」查看结果' - // 回调经 @Builder 传函数在真机上存在调用无效的坑(手动按钮同一 - // 方法完全正常,真机实测轮询完成不自动跳转)。保留回调尝试, - // 失败静默并由宿主的「查看报告」兜底按钮承担导航。 + // 回调经 @Builder 传函数在真机上偶发调用无效(手动按钮同一方法正常)。 + // 保留回调尝试,失败静默并由宿主的「查看报告」兜底按钮承担导航。 try { this.onSucceeded() } catch (e) { @@ -92,9 +102,15 @@ export struct TaskPollingPanel { } return } - if (t.status === 'FAILED') { + if (taskStatus === 'FAILED') { this.stopTimer() - this.onFailed(this.stageText) + // 失败原因优先用任务 error_message(剥异常类名、纯技术串回退通用话术), + // 原始串收敛到日志便于排查 + const em = t.error_message + if (em !== undefined && em !== '') { + console.error(`task ${tid} failed: ${em}`) + } + this.onFailed(friendlyTaskError(em, '生成服务暂时遇到问题,请稍后重试')) return } this.scheduleNext(gen) @@ -105,11 +121,14 @@ export struct TaskPollingPanel { this.errCount += 1 if (this.errCount >= MAX_CONSECUTIVE_ERRORS) { this.stopTimer() - let reason = '网络异常,连续查询任务状态失败' + // 放弃轮询≠任务失败:查询接口连续异常只能说明「本地到服务端的网络不稳」, + // 原任务可能仍在执行(额度未退)。不上抛 onFailed,宿主的幂等键与失败态 + // 都不动;引导用户稍后在报告列表确认,或点「刷新」恢复轮询。 if (e instanceof ApiError) { - reason = `${reason}(${e.message})` + console.error(`task ${tid} polling abandoned: ${e.message}`) } - this.onFailed(reason) + this.abandoned = true + this.stageText = '网络不稳定,任务可能仍在进行,稍后可在首页「报告」查看进度' return } this.scheduleNext(gen) @@ -144,7 +163,21 @@ export struct TaskPollingPanel { .fontColor(POLL_PRIMARY_COLOR) } .width('100%') - Text(`任务 ID:${this.taskId}|每 3 秒自动刷新,返回不会中断生成`) + if (this.abandoned) { + // 手动刷新:恢复轮询同一任务(taskId 未变,服务端幂等键语义不受影响) + Button('刷新') + .fontSize(12) + .height(30) + .backgroundColor('#FFFFFF') + .fontColor(POLL_PRIMARY_COLOR) + .borderRadius(15) + .padding({ left: 18, right: 18 }) + .margin({ top: 2 }) + .onClick(() => { + this.startPolling() + }) + } + Text('每 3 秒自动刷新,返回不会中断生成') .fontSize(11) .fontColor('#AAAAAA') .maxLines(1) diff --git a/app/entry/src/main/ets/core/ApiTypes.ets b/app/entry/src/main/ets/core/ApiTypes.ets index 5a3ea65..f7281df 100644 --- a/app/entry/src/main/ets/core/ApiTypes.ets +++ b/app/entry/src/main/ets/core/ApiTypes.ets @@ -27,3 +27,14 @@ export class ApiError extends Error { this.httpStatus = httpStatus } } + +/** 会话/令牌类错误码(40100~40105,对齐后端 errors.py 4xxxx 客户端段)。 */ +const AUTH_ERROR_CODES: number[] = [40100, 40101, 40102, 40103, 40104, 40105] + +/** + * 是否会话/令牌类错误(#7):ApiClient 自动刷新失败后,这类错误会原样抛给 + * UI 层。此时「重试」必然再败,错误态应改供「重新登录」出路。 + */ +export function isAuthError(e: Object): boolean { + return e instanceof ApiError && AUTH_ERROR_CODES.includes(e.code) +} diff --git a/app/entry/src/main/ets/core/ImageCodec.ets b/app/entry/src/main/ets/core/ImageCodec.ets index 4b0a17b..3b160db 100644 --- a/app/entry/src/main/ets/core/ImageCodec.ets +++ b/app/entry/src/main/ets/core/ImageCodec.ets @@ -5,6 +5,15 @@ * - 上传前统一压到长边 ≤1600、JPEG quality=85——换装服务建议 1024~2048, * 发型接口不收 WebP,统一重编码 JPEG 后不存在格式问题; * - base64 体积约为二进制的 1.33 倍,压缩后单张 ~300KB,弱网也可接受。 + * + * R5 · R4N1 照片输入链修复(真机相册/拍照静默死亡,收口本文件): + * - 修复 A:pick 链不再 describe()(width/height 零消费,getImageInfo 是挂起载体); + * - 修复 B:select/pick 套 Promise.race 超时(选图 60s / 拍照 120s), + * 超时 reject → 页面既有 catch → statusText,静默死亡变可感知; + * - 修复 C:toJpegBuffer/describe 改 image.createImageSource(uri) URI 直建, + * 不再依赖 openSync 的 fd(媒体 fd + 懒加载是 H1 怀疑载体); + * - R5N2 回归修复:部分真机 URI 直建返回空 → openSource 回退 fd 口径,双路兜底; + * - 全链 MEIDA_IMG hilog 打点(select 前后/信息/解码/打包/各 stage 失败),真机定位用。 */ import { image } from '@kit.ImageKit' @@ -13,6 +22,7 @@ import { photoAccessHelper } from '@kit.MediaLibraryKit' import { cameraPicker } from '@kit.CameraKit' import { camera } from '@kit.CameraKit' import { common } from '@kit.AbilityKit' +import { hilog } from '@kit.PerformanceAnalysisKit' import fs from '@ohos.file.fs' import { ContextHolder } from './ContextHolder' @@ -22,53 +32,136 @@ export interface PickedImage { height: number } +interface OpenedSource { + src: image.ImageSource + file?: fs.File +} + const LONG_EDGE: number = 1600 const JPEG_QUALITY: number = 85 +const IMG_DOMAIN: number = 0x0001 +const IMG_TAG: string = 'MEIDA_IMG' +const SELECT_TIMEOUT_MS: number = 60000 // 相册选图兜底:系统弹窗最多等 60s +const CAMERA_TIMEOUT_MS: number = 120000 // 拍照兜底:给足取景与确认时间 export class ImageCodec { + /** + * R4N1 修复 B(H2 兜底):select/pick 是系统 Promise,真机存在「结果已送达 + * 但续体不回调」的案例——不决即超时 reject,让页面既有 catch 走 statusText, + * 把静默挂死变成可感知、可重试。 + */ + private static withTimeout(p: Promise, ms: number, stage: string): Promise { + let timerId: number = -1 + const timeout: Promise = new Promise((_resolve: (v: T) => void, reject: (r: Error) => void) => { + timerId = setTimeout(() => { + hilog.error(IMG_DOMAIN, IMG_TAG, '%{public}s not settled in %{public}d ms, give up', stage, ms) + reject(new Error(`${stage} timeout`)) + }, ms) + }) + return Promise.race([p, timeout]).then( + (v: T) => { + clearTimeout(timerId) + return v + }, + (e: Error) => { + clearTimeout(timerId) + throw e + } + ) + } + /** 相册选 1 张。用户取消返回 undefined。 */ static async pickFromGallery(): Promise { const picker = new photoAccessHelper.PhotoViewPicker() const opts = new photoAccessHelper.PhotoSelectOptions() opts.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE opts.maxSelectNumber = 1 - const res = await picker.select(opts) - if (res.photoUris.length === 0) { - return undefined + hilog.info(IMG_DOMAIN, IMG_TAG, 'gallery: select begin', '') + try { + const res = await ImageCodec.withTimeout(picker.select(opts), SELECT_TIMEOUT_MS, 'gallery select') + hilog.info(IMG_DOMAIN, IMG_TAG, 'gallery: select end, %{public}d uri(s), first=%{public}s', + res.photoUris.length, res.photoUris.length > 0 ? res.photoUris[0].substring(0, 64) : '') + if (res.photoUris.length === 0) { + return undefined + } + // R4N1 修复 A(H1 载体裁除):不再 describe()——openSync/createImageSource(fd)/ + // getImageInfo 在真机媒体 URI 上有整类挂起风险,且 width/height 全仓零消费。 + // 槽位预览由 Image 组件原生渲染 file://media/ URI。 + return { uri: res.photoUris[0], width: 0, height: 0 } + } catch (e) { + hilog.error(IMG_DOMAIN, IMG_TAG, 'gallery: pick failed: %{public}s', String(e)) + throw e as Error } - return ImageCodec.describe(res.photoUris[0]) } /** 调相机拍 1 张。用户取消返回 undefined。 */ static async takePhoto(): Promise { const ctx = ContextHolder.get() as common.UIAbilityContext const profile: cameraPicker.PickerProfile = { cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK } - const res = await cameraPicker.pick(ctx, [cameraPicker.PickerMediaType.PHOTO], profile) - if (res.resultUri.length === 0) { - return undefined + hilog.info(IMG_DOMAIN, IMG_TAG, 'camera: pick begin', '') + try { + const res = await ImageCodec.withTimeout( + cameraPicker.pick(ctx, [cameraPicker.PickerMediaType.PHOTO], profile), CAMERA_TIMEOUT_MS, 'camera pick') + hilog.info(IMG_DOMAIN, IMG_TAG, 'camera: pick end, resultUri length=%{public}d', res.resultUri.length) + if (res.resultUri.length === 0) { + return undefined + } + return { uri: res.resultUri, width: 0, height: 0 } + } catch (e) { + hilog.error(IMG_DOMAIN, IMG_TAG, 'camera: pick failed: %{public}s', String(e)) + throw e as Error } - return ImageCodec.describe(res.resultUri) } - /** 读取图片尺寸(不解码全图)。 */ - static async describe(uri: string): Promise { + /** + * R5N2 回归修复:部分真机 ROM 上 image.createImageSource('file://media/…') 返回空, + * 导致 toJpegBuffer 抛 TypeError、换装/发型生成链全断。URI 直建失败时回退 + * 官方 fd 口径(fs.openSync 媒体 URI → createImageSource(fd)),两条路都留打点。 + * 返回的 file(若有)由调用方在 src.release() 后 closeSync。 + */ + private static openSource(uri: string): OpenedSource { + const src: image.ImageSource = image.createImageSource(uri) + if (src) { + return { src: src } + } + hilog.warn(IMG_DOMAIN, IMG_TAG, 'source: URI direct build empty, fallback to fd: %{public}s', + uri.substring(0, 64)) const file = fs.openSync(uri, fs.OpenMode.READ_ONLY) + return { src: image.createImageSource(file.fd), file: file } + } + + private static async closeSource(opened: OpenedSource): Promise { try { - const src = image.createImageSource(file.fd) - const info = await src.getImageInfo() - await src.release() + await opened.src.release() + } finally { + if (opened.file) { + fs.closeSync(opened.file) + } + } + } + + /** + * 读取图片尺寸(不解码全图)。选图链自 R5 起不再调用(修复 A),保留导出 + * 避免删接口引额外 diff;实现同步改为 URI 直建(修复 C 同款口径)。 + */ + static async describe(uri: string): Promise { + const opened = ImageCodec.openSource(uri) + try { + const info = await opened.src.getImageInfo() return { uri: uri, width: info.size.width, height: info.size.height } } finally { - fs.closeSync(file) + await ImageCodec.closeSource(opened) } } /** 压缩 → JPEG ArrayBuffer(multipart 上传用)。 */ static async toJpegBuffer(uri: string): Promise { - const file = fs.openSync(uri, fs.OpenMode.READ_ONLY) + const startedAt: number = Date.now() + const opened: OpenedSource = ImageCodec.openSource(uri) try { - const src = image.createImageSource(file.fd) - const info = await src.getImageInfo() + const info = await opened.src.getImageInfo() + hilog.info(IMG_DOMAIN, IMG_TAG, 'jpeg: info %{public}d x %{public}d (%{public}d ms)', + info.size.width, info.size.height, Date.now() - startedAt) const longSide: number = Math.max(info.size.width, info.size.height) const scale: number = longSide > LONG_EDGE ? LONG_EDGE / longSide : 1 const decodeOpts: image.DecodingOptions = { @@ -77,14 +170,18 @@ export class ImageCodec { height: Math.max(1, Math.floor(info.size.height * scale)) } } - const pm = await src.createPixelMap(decodeOpts) + const pm = await opened.src.createPixelMap(decodeOpts) + hilog.info(IMG_DOMAIN, IMG_TAG, 'jpeg: pixelMap decoded (%{public}d ms)', Date.now() - startedAt) const packer = image.createImagePacker() const buf = await packer.packToData(pm, { format: 'image/jpeg', quality: JPEG_QUALITY }) pm.release() - await src.release() + hilog.info(IMG_DOMAIN, IMG_TAG, 'jpeg: packed %{public}d bytes (%{public}d ms)', buf.byteLength, Date.now() - startedAt) return buf + } catch (e) { + hilog.error(IMG_DOMAIN, IMG_TAG, 'jpeg: compress failed: %{public}s', String(e)) + throw e as Error } finally { - fs.closeSync(file) + await ImageCodec.closeSource(opened) } } @@ -106,12 +203,24 @@ export class ImageCodec { return pm } - /** Base64(可含 data: 前缀)→ 写入缓存文件,返回路径(保存相册用)。 */ + /** Base64(可含 data: 前缀)→ 写入缓存文件,返回路径(保存相册兜底用)。 */ static base64ToCacheFile(base64: string, fileName: string): string { + return ImageCodec.writeBase64File(base64, ContextHolder.get().cacheDir, fileName) + } + + /** Base64(可含 data: 前缀)→ 写入 filesDir/results 持久文件(#17/#18 结果留存,杀 App 不丢)。 */ + static base64ToPersistFile(base64: string, fileName: string): string { + return ImageCodec.writeBase64File(base64, `${ContextHolder.get().filesDir}/results`, fileName) + } + + private static writeBase64File(base64: string, dir: string, fileName: string): string { const pure: string = base64.includes(',') ? base64.substring(base64.indexOf(',') + 1) : base64 const helper = new util.Base64Helper() const bytes = helper.decodeSync(pure) - const path: string = `${ContextHolder.get().cacheDir}/${fileName}` + if (!fs.accessSync(dir)) { + fs.mkdirSync(dir, true) + } + const path: string = `${dir}/${fileName}` const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC) try { fs.writeSync(file.fd, bytes.buffer as ArrayBuffer) diff --git a/app/entry/src/main/ets/core/Theme.ets b/app/entry/src/main/ets/core/Theme.ets new file mode 100644 index 0000000..daa7315 --- /dev/null +++ b/app/entry/src/main/ets/core/Theme.ets @@ -0,0 +1,19 @@ +/** + * 全局视觉常量(#10 最小收敛):主色的唯一来源。 + * + * 此前页面里 #C2185B 与 #FF5A79 两套主色并存,报告 Tab 列表与报告结果页 + * 同一旅程肉眼可见变色。现收敛为一个常量,各页/组件 import 使用; + * 新增界面禁止再散落硬编码主色。 + */ + +/** 品牌主色(玫红):按钮、强调文字、进度条、选中态统一使用 */ +export const COLOR_PRIMARY: string = '#C2185B' + +/** 主色浅底(约 8% 粉):次级按钮底色/选中底,与主色成对出现 */ +export const COLOR_PRIMARY_SOFT: string = '#F7E3EA' + +/** 正文主色 */ +export const COLOR_TEXT_PRIMARY: string = '#222222' + +/** 次级文字(返回控件、辅助说明统一用此色) */ +export const COLOR_TEXT_SECONDARY: string = '#8A9099' diff --git a/app/entry/src/main/ets/core/TryonApi.ets b/app/entry/src/main/ets/core/TryonApi.ets index c124df2..ea2dc66 100644 --- a/app/entry/src/main/ets/core/TryonApi.ets +++ b/app/entry/src/main/ets/core/TryonApi.ets @@ -49,6 +49,9 @@ const HAIR_TIMEOUT_MS: number = 120000 const CLOTH_TIMEOUT_MS: number = 320000 // 服务端最长 ~300s,客户端必须更长(产品方案 §2.1.1) export class TryonApi { + /** 在途换装请求:供 cancelChangeCloth 从外部放弃等待(destroy 使 await 立即失败)。 */ + private static clothClient: http.HttpRequest | null = null + /** 发型列表(60 款)。 */ static async listHairstyles(): Promise { const client = http.createHttp() @@ -105,6 +108,7 @@ export class TryonApi { static async changeCloth(humanBase64: string, clothBase64: string, kuziBase64: string, changeDesc: string): Promise { const client = http.createHttp() + TryonApi.clothClient = client try { const body: Record = { 'human_img': humanBase64, @@ -128,10 +132,31 @@ export class TryonApi { } catch (e) { throw TryonApi.wrap(e, '换装请求失败') } finally { + if (TryonApi.clothClient === client) { + TryonApi.clothClient = null + } client.destroy() } } + /** + * 放弃在途换装请求:destroy 使 await 立即结束(拒绝)。 + * Phase A 直连模式无服务端任务、不走后端配额——取消即「放弃等待本次结果」, + * 不存在额度扣减或退还要告知用户。 + */ + static cancelChangeCloth(): void { + const client = TryonApi.clothClient + if (client === null) { + return + } + TryonApi.clothClient = null + try { + client.destroy() + } catch (_e) { + // 请求已结束时 destroy 可能报错,忽略即可 + } + } + private static async postHairForm(url: string, form: http.MultiFormData[]): Promise { const client = http.createHttp() try { diff --git a/app/entry/src/main/ets/entryability/EntryAbility.ets b/app/entry/src/main/ets/entryability/EntryAbility.ets index daf2b92..dea97e9 100644 --- a/app/entry/src/main/ets/entryability/EntryAbility.ets +++ b/app/entry/src/main/ets/entryability/EntryAbility.ets @@ -3,6 +3,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { ContextHolder } from '../core/ContextHolder'; import { AuthStore } from '../core/AuthStore'; +import { FaceConsentStore } from '../components/f2/FacePhotoConsent'; const DOMAIN = 0x0000; @@ -11,6 +12,8 @@ export default class EntryAbility extends UIAbility { // 全局 Context 挂点:core 层(preferences/http)从这里取,UI 不传参 ContextHolder.set(this.context) AuthStore.init() + // 人脸同意记录持久化(N4):hydrate preferences → 内存,两页门控零改动读内存 + FaceConsentStore.init() try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); } catch (err) { diff --git a/app/entry/src/main/ets/pages/ChatPage.ets b/app/entry/src/main/ets/pages/ChatPage.ets index dcb14b2..2090b94 100644 --- a/app/entry/src/main/ets/pages/ChatPage.ets +++ b/app/entry/src/main/ets/pages/ChatPage.ets @@ -1,8 +1,10 @@ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' -import { ApiError } from '../core/ApiTypes' +import { ApiError, isAuthError } from '../core/ApiTypes' +import { COLOR_TEXT_SECONDARY } from '../core/Theme' import { VoiceInputEngine } from '../components/f4/VoiceInputEngine' import { TtsEngine } from '../components/f4/TtsEngine' +import { formatIsoTime, randomHex } from '../components/f3/ReportTypes' interface ChatUsage { tokens_in: number @@ -23,6 +25,16 @@ interface ChatResp { quota_remaining: number } +/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */ +interface QuotaWireResp { + quota_key: string + used: number + limit_value: number + bonus_granted: boolean + remaining: number + reset_at: string +} + interface ChatMsg { role: 'user' | 'ai' text: string @@ -33,6 +45,18 @@ const ROLE_USER = 'user' const ROLE_AI = 'ai' const THINKING_TEXT = '正在思考…' const WELCOME_TEXT = '你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~' +// 输入上限(需求文档 §3.1.1:单条 ≤500 字;后端硬上限 2000) +const INPUT_MAX_LEN: number = 500 +const INPUT_COUNT_FROM: number = 400 // 超过该字数展示 x/500 计数 +// 额度耗尽错误码(与后端 packages/shared/errors.py QUOTA_EXHAUSTED 对齐;42910 是短信频控,勿混用) +const QUOTA_EXHAUSTED_CODE: number = 40301 +// 快捷问题入口(FR-AGT-07):场景/色彩/身材/风格四类各一,女性用户向 +const QUICK_QUESTIONS: string[] = [ + '面试怎么穿更得体?', + '黄皮适合什么颜色?', + '小个子怎么穿显高?', + '微胖女生夏天怎么穿?' +] @Component struct MessageBubble { @@ -41,7 +65,9 @@ struct MessageBubble { @Prop meta: string = '' @Prop isSpeaking: boolean = false @Prop msgIdx: number = -1 + @Prop retryable: boolean = false // 发送失败的 AI 气泡:挂「重试」入口原样重发 speakHandler: (idx: number) => void = () => {} + retryHandler: () => void = () => {} private isUser(): boolean { return this.role === ROLE_USER @@ -79,6 +105,18 @@ struct MessageBubble { .borderRadius(12) .backgroundColor(this.isUser() ? '#D9605A' : '#FFFFFF') .constraintSize({ maxWidth: '82%' }) + if (this.retryable) { + Text('重试') + .fontSize(12) + .fontColor('#C0392B') + .backgroundColor('#FDECEC') + .borderRadius(12) + .padding({ left: 12, right: 12, top: 4, bottom: 4 }) + .margin({ top: 6, left: 4 }) + .onClick(() => { + this.retryHandler() + }) + } } .width('100%') .alignItems(this.isUser() ? HorizontalAlign.End : HorizontalAlign.Start) @@ -93,21 +131,28 @@ struct ChatPage { @State inputText: string = '' @State sending: boolean = false @State quotaRemaining: number = -1 + @State quotaResetAt: string = '' // 额度恢复时间(剩余 0 时展示,N3) + @State failedMsgIdx: number = -1 // 最新失败气泡下标(挂「重试」用) @State voiceMode: boolean = false @State holdState: string = '' @State partialText: string = '' @State voiceHint: string = '' @State speakingMsgIdx: number = -1 + @State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7) private fingerDown: boolean = false private holdStartY: number = 0 private voiceEngine: VoiceInputEngine = new VoiceInputEngine() private ttsEngine: TtsEngine = new TtsEngine() private scroller: Scroller = new Scroller() private autoSpeak: boolean = true + private lastFailedText: string = '' // 失败待重试的原文 + private failedRequestKey: string = '' // 失败那次请求的幂等键:重试复用,防重复扣额度 aboutToAppear(): void { const welcome: ChatMsg = { role: ROLE_AI, text: WELCOME_TEXT } this.messages.push(welcome) + // 进入页面即预拉额度:首条消息发送前就让用户看到剩余量(设计纪律 3) + this.loadQuotaRemaining() // 朗读结束(播完/被停/出错)→ 复位消息气泡的 🔊 高亮 this.ttsEngine.setOnStateChanged((speaking: boolean) => { if (!speaking) { @@ -169,25 +214,85 @@ struct ChatPage { if (text.length === 0 || this.sending) { return } + if (this.quotaRemaining === 0) { + // 额度 0:发送入口已置灰,这里再兜一层(语音输入路径也走 send) + return + } this.doSend(text) } - private async doSend(text: string): Promise { + /** 快捷问题(FR-AGT-07):点击即填入并发送。 */ + private onQuickQuestion(q: string): void { + if (this.sending || this.quotaRemaining === 0) { + return + } + this.inputText = q + this.send() + } + + /** 预拉今日对话额度;失败保持 -1(横幅不展示,是否放行交给服务端校验)。 */ + private async loadQuotaRemaining(): Promise { + try { + const d = await ApiClient.get('/api/v1/quota?quota_key=agent_chat') + this.quotaRemaining = d.remaining + this.quotaResetAt = d.reset_at ?? '' + } catch (_e) { + this.quotaRemaining = -1 + this.quotaResetAt = '' + } + } + + private doSend(text: string): void { + this.startTurn(text, '') + } + + /** 失败气泡上的「重试」:原样重发原文,复用同一 request_key(幂等,不重复扣额度)。 */ + private retryFailed(): void { + if (this.sending || this.lastFailedText === '') { + return + } + // 只重试最新一条失败(其必为列表末位):旧失败气泡不再提供入口 + if (this.failedMsgIdx !== this.messages.length - 1) { + return + } + const text: string = this.lastFailedText + const key: string = this.failedRequestKey + this.lastFailedText = '' + this.failedRequestKey = '' + this.failedMsgIdx = -1 + // 移除失败气泡与其用户气泡:重试会原样重新入列,不产生重复 + this.messages.pop() + this.messages.pop() + this.startTurn(text, key) + } + + /** + * 一轮对话:入列用户消息 + 占位思考气泡 → POST /agent/chat。 + * retryKey 非空表示重试上一失败轮(复用幂等键);新轮一律新 key, + * 成功后即作废(下一轮重新生成)。 + */ + private async startTurn(text: string, retryKey: string): Promise { + const requestKey: string = retryKey !== '' ? retryKey : randomHex(16) this.inputText = '' this.sending = true this.ttsEngine.stop() this.speakingMsgIdx = -1 + this.lastFailedText = '' + this.failedRequestKey = '' + this.failedMsgIdx = -1 const userMsg: ChatMsg = { role: ROLE_USER, text: text } this.messages.push(userMsg) const thinkingMsg: ChatMsg = { role: ROLE_AI, text: THINKING_TEXT } this.messages.push(thinkingMsg) this.scrollToEnd() try { - const req: Record = { 'message': text } + const req: Record = { 'message': text, 'request_key': requestKey } const data: ChatResp = await ApiClient.post('/api/v1/agent/chat', req) - const meta: string = `${data.model} · ${data.usage.latency_ms}ms` + // 气泡 meta 用用户语言(#6):不透出模型名/耗时等技术串 + const meta: string = 'AI 生成' this.replaceThinking(data.reply, meta) this.quotaRemaining = data.quota_remaining + this.authExpired = false if (this.autoSpeak) { const aiIdx: number = this.messages.length - 1 this.speakText(data.reply, aiIdx, false) @@ -195,7 +300,19 @@ struct ChatPage { } catch (e) { if (e instanceof ApiError) { this.replaceThinking(e.message, e.hint ?? '') + if (e.code === QUOTA_EXHAUSTED_CODE) { + // 额度耗尽(N7):同步刷新额度态——横幅换「已用完+恢复时间」、发送按钮置灰 + this.loadQuotaRemaining() + } } + // 会话过期:重试必然再败,横幅给「重新登录」出路(#7) + this.authExpired = isAuthError(e) + // 失败止损:输入回填、气泡挂「重试」;保留 request_key 供重试幂等重发 + this.inputText = text + this.lastFailedText = text + this.failedRequestKey = requestKey + this.failedMsgIdx = this.messages.length - 1 + this.scrollToEnd() } finally { this.sending = false } @@ -323,12 +440,9 @@ struct ChatPage { Stack() { Column() { Row() { - Text('‹') - .fontSize(26) - .fontColor('#333333') - .width(32) - .height(32) - .textAlign(TextAlign.Center) + Text('‹ 返回') + .fontSize(15) + .fontColor(COLOR_TEXT_SECONDARY) .onClick(() => { router.back() }) @@ -357,14 +471,48 @@ struct ChatPage { .padding({ left: 12, right: 12 }) .backgroundColor('#FFFFFF') - if (this.quotaRemaining >= 0) { - Text(`今日剩余 ${this.quotaRemaining} 次`) + if (this.quotaRemaining > 0) { + Text(`发送消息将使用 1 次对话额度,今日剩余 ${this.quotaRemaining} 次`) .fontSize(12) .fontColor('#B3554D') .backgroundColor('#FBEAE8') .width('100%') .textAlign(TextAlign.Center) .padding({ top: 4, bottom: 4 }) + } else if (this.quotaRemaining === 0) { + // 剩余 0:明确告知恢复时间(口径同报告域确认框),发送入口同步置灰 + Text(this.quotaResetAt !== '' + ? `今日对话额度已用完,${formatIsoTime(this.quotaResetAt)} 后恢复` + : '今日对话额度已用完,请明天再试') + .fontSize(12) + .fontColor('#C0392B') + .backgroundColor('#FDECEC') + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 4, bottom: 4 }) + } + + // 会话过期横幅(#7):此时重试必败,唯一出路是重新登录 + if (this.authExpired) { + Row({ space: 8 }) { + Text('登录已过期,重新登录后继续对话') + .fontSize(12) + .fontColor('#C0392B') + .layoutWeight(1) + Text('重新登录') + .fontSize(12) + .fontColor('#C0392B') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .onClick(() => { + this.authExpired = false + router.pushUrl({ url: 'pages/LoginPage' }) + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .backgroundColor('#FDECEC') } List({ scroller: this.scroller }) { @@ -376,8 +524,12 @@ struct ChatPage { meta: msg.meta ?? '', isSpeaking: this.speakingMsgIdx === idx, msgIdx: idx as number, + retryable: idx === this.failedMsgIdx && idx === this.messages.length - 1, speakHandler: (i: number) => { this.onSpeakToggle(i) + }, + retryHandler: (): void => { + this.retryFailed() } }) } @@ -398,6 +550,50 @@ struct ChatPage { .padding({ top: 4, bottom: 4 }) } + // 输入区上方常驻 AI 生成标识(CR-05):固定位置,不随消息滚动消失 + Text('内容由 AI 生成,仅供参考') + .fontSize(11) + .fontColor('#999999') + .backgroundColor('#FFFFFF') + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 4, bottom: 2 }) + + // 输入为空且未在发送:展示快捷问题(FR-AGT-07),点击即发送 + if (this.inputText.trim().length === 0 && !this.sending) { + Scroll() { + Row({ space: 8 }) { + ForEach(QUICK_QUESTIONS, (q: string) => { + Text(q) + .fontSize(12) + .fontColor('#B3554D') + .backgroundColor('#FBEAE8') + .borderRadius(14) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick(() => { + this.onQuickQuestion(q) + }) + }, (q: string) => q) + } + .padding({ left: 12, right: 12, top: 4, bottom: 4 }) + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') + .backgroundColor('#FFFFFF') + } + + // 接近输入上限时给出计数提示(与快捷问题互斥:一空一满) + if (this.inputText.length > INPUT_COUNT_FROM) { + Text(`${this.inputText.length}/${INPUT_MAX_LEN}`) + .fontSize(11) + .fontColor(this.inputText.length >= INPUT_MAX_LEN ? '#C0392B' : '#999999') + .backgroundColor('#FFFFFF') + .width('100%') + .textAlign(TextAlign.RIGHT) + .padding({ top: 2, bottom: 2, right: 12 }) + } + Row() { Text(this.voiceMode ? '⌨' : '🎤') .fontSize(20) @@ -430,25 +626,26 @@ struct ChatPage { text: this.inputText, placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?' }) - .layoutWeight(1) - .height(40) - .fontSize(14) - .backgroundColor('#F2F2F4') - .borderRadius(8) - .enabled(!this.sending) - .enterKeyType(EnterKeyType.Send) - .onChange((value: string) => { - this.inputText = value - }) - .onSubmit(() => { - this.send() - }) - Button(this.sending ? '回复中' : '发送') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor('#F2F2F4') + .borderRadius(8) + .enabled(!this.sending) + .maxLength(INPUT_MAX_LEN) + .enterKeyType(EnterKeyType.Send) + .onChange((value: string) => { + this.inputText = value + }) + .onSubmit(() => { + this.send() + }) + Button(this.sending ? '回复中' : (this.quotaRemaining === 0 ? '额度已用完' : '发送')) .fontSize(14) .height(36) .margin({ left: 8 }) - .backgroundColor(this.sending ? '#C9C9CD' : '#D9605A') - .enabled(!this.sending) + .backgroundColor(this.sending || this.quotaRemaining === 0 ? '#C9C9CD' : '#D9605A') + .enabled(!this.sending && this.quotaRemaining !== 0) .onClick(() => { this.send() }) diff --git a/app/entry/src/main/ets/pages/HairDesignPage.ets b/app/entry/src/main/ets/pages/HairDesignPage.ets index 1239067..b6e1a81 100644 --- a/app/entry/src/main/ets/pages/HairDesignPage.ets +++ b/app/entry/src/main/ets/pages/HairDesignPage.ets @@ -3,6 +3,9 @@ * * 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。 * 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。 + * 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。 + * 结果归宿(#18,与 #17 统一):试戴/换发色成功即落 filesDir/results/hair_last.jpg + * (换发色后覆盖为最新版本),保存优先复用该文件,杀 App 后「上次结果」卡可回看。 */ import { router } from '@kit.ArkUI' @@ -10,10 +13,13 @@ import { image } from '@kit.ImageKit' import { util } from '@kit.ArkTS' import { photoAccessHelper } from '@kit.MediaLibraryKit' import { fileUri } from '@kit.CoreFileKit' +import fs from '@ohos.file.fs' import { TryonApi, Hairstyle } from '../core/TryonApi' import { ImageCodec } from '../core/ImageCodec' import { ApiError } from '../core/ApiTypes' import { ContextHolder } from '../core/ContextHolder' +import { COLOR_PRIMARY } from '../core/Theme' +import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' interface PresetColor { name: string @@ -32,7 +38,7 @@ const PRESET_COLORS: PresetColor[] = [ ] const PAGE_BG: string = '#F5F6F8' -const ACCENT: string = '#FF5A79' +const ACCENT: string = COLOR_PRIMARY const TEXT_PRIMARY: string = '#222222' const TEXT_SECONDARY: string = '#8A9099' @@ -42,17 +48,35 @@ struct HairDesignPage { @State photoUri: string = '' @State styles: Hairstyle[] = [] @State listError: string = '' + @State listLoaded: boolean = false // 列表三态(#18):false=加载中,true 后看空/错误 @State selectedStyleId: string = '' @State generating: boolean = false @State statusText: string = '' @State resultPm: image.PixelMap | undefined = undefined @State resultBase64: string = '' // 换发色/保存都用它,避免二次编码 + @State resultLocalPath: string = '' // 本机留存副本(#18):保存优先复用 + @State lastLocalPath: string = '' // 「上次结果」卡数据源(aboutToAppear 回看,#18) @State showOriginal: boolean = false @State colorIdx: number = 1 @State ratio: number = 0.9 + @State showFaceConsent: boolean = false + private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口 + + /** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */ + private resultStorePath(): string { + return `${ContextHolder.get().filesDir}/results/hair_last.jpg` + } aboutToAppear(): void { this.loadStyles() + // 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢 + try { + if (fs.accessSync(this.resultStorePath())) { + this.lastLocalPath = this.resultStorePath() + } + } catch (_e) { + // 目录不存在等:视为无上次结果 + } // 预留:Agent 联动(V2)预选发型,见产品方案 §7 const params = router.getParams() as Record | undefined if (params !== undefined && typeof params['hairstyle_id'] === 'string') { @@ -61,14 +85,29 @@ struct HairDesignPage { } private async loadStyles(): Promise { + this.listError = '' + this.listLoaded = false try { this.styles = await TryonApi.listHairstyles() } catch (e) { + this.styles = [] this.listError = e instanceof ApiError ? e.message : '发型列表加载失败' + } finally { + this.listLoaded = true } } - private async pickPhoto(fromCamera: boolean): Promise { + /** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */ + private pickPhoto(fromCamera: boolean): void { + if (!FaceConsentStore.isGranted()) { + this.consentPickCamera = fromCamera + this.showFaceConsent = true + return + } + this.doPickPhoto(fromCamera) + } + + private async doPickPhoto(fromCamera: boolean): Promise { try { const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() if (picked === undefined) { @@ -77,6 +116,7 @@ struct HairDesignPage { this.photoUri = picked.uri this.resultPm = undefined this.resultBase64 = '' + this.resultLocalPath = '' this.selectedStyleId = '' this.statusText = '' } catch (e) { @@ -84,6 +124,17 @@ struct HairDesignPage { } } + private onConsentAgree(): void { + FaceConsentStore.grant() + this.showFaceConsent = false + this.doPickPhoto(this.consentPickCamera) + } + + private onConsentRefuse(): void { + this.showFaceConsent = false + this.statusText = '未同意照片授权,你可以先浏览下方发型库' + } + private async tryOn(style: Hairstyle): Promise { if (this.photoUri.length === 0 || this.generating) { return @@ -97,6 +148,7 @@ struct HairDesignPage { this.resultBase64 = await TryonApi.changeHair(buf, style.id) this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64) this.statusText = '试戴完成,点「对比」看原图' + this.persistResult() } catch (e) { this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' } finally { @@ -121,6 +173,7 @@ struct HairDesignPage { this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio) this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64) this.statusText = '换发色完成' + this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④) } catch (e) { this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试' } finally { @@ -128,35 +181,70 @@ struct HairDesignPage { } } + /** 结果留存(#18):当前结果 Base64 落 filesDir/results/hair_last.jpg;失败不阻断展示。 */ + private persistResult(): void { + try { + const path: string = ImageCodec.base64ToPersistFile(this.resultBase64, 'hair_last.jpg') + this.resultLocalPath = path + this.lastLocalPath = path + } catch (_e) { + this.resultLocalPath = '' // 落盘失败:保存时走 base64 现场写缓存兜底 + } + } + + /** 保存到相册(#18):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */ private async saveResult(): Promise { if (this.resultBase64.length === 0) { return } try { - const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`) - const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) - const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } - await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) - this.statusText = '已保存到相册' - } catch { - this.statusText = '已取消保存' + const path: string = this.resultLocalPath.length > 0 + ? this.resultLocalPath + : ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`) + this.statusText = await this.saveFileToGallery(path) + } catch (_e) { + this.statusText = '保存失败,请重试' } } - build() { - Column() { - // 顶部标题栏 - Row() { - Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) - Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) - Text('').width(48) - } - .width('100%') - .padding({ left: 16, right: 16, top: 12, bottom: 12 }) - .justifyContent(FlexAlign.SpaceBetween) - .backgroundColor(Color.White) + /** + * 系统保存对话框 → 三态文案(#17/#18 统一口径): + * 返回 URI 列表非空=已保存;空数组=用户取消;抛异常=保存失败(磁盘满等不再谎报成取消)。 + */ + private async saveFileToGallery(path: string): Promise { + try { + const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) + const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } + const uris: Array = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) + return uris.length > 0 ? '已保存到相册' : '已取消保存' + } catch (_e) { + return '保存失败,请重试' + } + } - Scroll() { + /** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */ + private async saveLastResult(): Promise { + if (this.lastLocalPath.length === 0) { + return + } + this.statusText = await this.saveFileToGallery(this.lastLocalPath) + } + + build() { + Stack() { + Column() { + // 顶部标题栏 + Row() { + Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) + Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + Text('').width(48) + } + .width('100%') + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + + Scroll() { Column({ space: 12 }) { // 照片区:左原图 / 右结果 Row({ space: 12 }) { @@ -202,7 +290,7 @@ struct HairDesignPage { .borderRadius(12).backgroundColor('#EDEFF3') } Row({ space: 8 }) { - Text('对比') + Text(this.showOriginal ? '看效果' : '对比') .fontSize(12) .fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY) .onClick(() => { @@ -227,6 +315,28 @@ struct HairDesignPage { Text(this.statusText).fontSize(12).fontColor(TEXT_SECONDARY).width('100%') } + // 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除) + if (this.lastLocalPath.length > 0 && this.resultPm === undefined) { + Column({ space: 8 }) { + Text('上次发型结果') + .fontSize(13).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY).width('100%') + Image(fileUri.getUriFromPath(this.lastLocalPath)) + .width('100%').height(280).objectFit(ImageFit.Contain) + .borderRadius(12).backgroundColor('#EDEFF3') + Row({ space: 16 }) { + Text('保存到相册').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.saveLastResult() }) + Text('知道了').fontSize(13).fontColor(TEXT_SECONDARY) + .onClick(() => { this.lastLocalPath = '' }) + } + .width('100%').justifyContent(FlexAlign.Center) + } + .width('100%') + .padding(12) + .borderRadius(12) + .backgroundColor(Color.White) + } + // 发型网格 Text('选择发型(点选即试戴)').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) if (this.styles.length > 0) { @@ -250,9 +360,41 @@ struct HairDesignPage { .columnsGap(8) .rowsGap(8) .width('100%') + } else if (this.listError.length > 0) { + // 失败态(#8/#18):错误文案 + 重新加载出口 + Column({ space: 8 }) { + Text(this.listError).fontSize(12).fontColor('#C0392B') + Text('重新加载') + .fontSize(13).fontColor(ACCENT) + .padding({ left: 16, right: 16, top: 6, bottom: 6 }) + .borderRadius(14).backgroundColor(Color.White) + .onClick(() => { this.loadStyles() }) + } + .width('100%') + .alignItems(HorizontalAlign.Center) + .padding({ top: 8, bottom: 8 }) + } else if (this.listLoaded) { + // 空态(#18):加载成功但无上架发型 + Column({ space: 8 }) { + Text('发型暂未上架,稍后再试').fontSize(12).fontColor(TEXT_SECONDARY) + Text('重新加载') + .fontSize(13).fontColor(ACCENT) + .padding({ left: 16, right: 16, top: 6, bottom: 6 }) + .borderRadius(14).backgroundColor(Color.White) + .onClick(() => { this.loadStyles() }) + } + .width('100%') + .alignItems(HorizontalAlign.Center) + .padding({ top: 8, bottom: 8 }) } else { - Text(this.listError.length > 0 ? this.listError : '发型加载中…') - .fontSize(12).fontColor(TEXT_SECONDARY) + // 加载中(#18):不再让空列表永远停在「加载中」 + Row({ space: 8 }) { + LoadingProgress().width(18).height(18).color(ACCENT) + Text('发型加载中…').fontSize(12).fontColor(TEXT_SECONDARY) + } + .width('100%') + .justifyContent(FlexAlign.Center) + .padding({ top: 8, bottom: 8 }) } // 换发色(有结果才可用) @@ -296,6 +438,20 @@ struct HairDesignPage { } .layoutWeight(1) .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + + if (this.showFaceConsent) { + FacePhotoConsent({ + onAgree: (): void => { + this.onConsentAgree() + }, + onRefuse: (): void => { + this.onConsentRefuse() + } + }) + } } .width('100%') .height('100%') diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index c403256..4e2f510 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -11,7 +11,9 @@ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' +import { COLOR_PRIMARY } from '../core/Theme' import { ProfileContent } from '../components/f1/ProfileContent' +import { ReportListTab } from '../components/f3/ReportListTab' /** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */ interface QuotaWireResp { @@ -24,7 +26,7 @@ interface QuotaWireResp { } const PAGE_BG: string = '#F5F6F8' -const ACCENT: string = '#FF5A79' +const ACCENT: string = COLOR_PRIMARY const ACCENT_AI: string = '#6C5CE7' const TEXT_PRIMARY: string = '#222222' const TEXT_SECONDARY: string = '#8A9099' @@ -38,12 +40,20 @@ struct HomePage { @State currentTabIndex: number = 0 @State dateLine: string = '' @State quotaRemaining: string = '…' + @State reportRefreshTick: number = 0 // 自增通知报告 Tab 刷新(N2) aboutToAppear(): void { this.dateLine = this.buildDateLine() this.loadQuota() } + /** 从报告结果页等返回(应用壳重新可见):当前停在报告 Tab 时刷新列表(N2)。 */ + onPageShow(): void { + if (this.currentTabIndex === 1) { + this.reportRefreshTick += 1 + } + } + /** 返回键:不拦截(返回 false 走系统默认)。Tab 壳是应用首页,再按返回即退出应用——本批不加复杂逻辑。 */ onBackPress(): boolean { return false @@ -223,12 +233,14 @@ struct HomePage { .tabBar(this.TabBarItem('⌂', '首页', 0)) TabContent() { - this.PlaceholderTab('▤', '报告', '形象报告的查看与管理在这里(建设中)\n新建报告请前往报告创建页', '去生成报告', 'pages/ReportCreatePage') + // 我的报告列表(GET /api/v1/reports):已生成的报告随时找回,不再依赖易失内存; + // 切回本 Tab / 返回应用壳时经 refreshTick 自增联动刷新(N2) + ReportListTab({ refreshTick: this.reportRefreshTick }) } .tabBar(this.TabBarItem('▤', '报告', 1)) TabContent() { - this.PlaceholderTab('✦', 'AI 顾问', '与 AI 穿搭顾问的对话在独立页面进行(建设中)', '进入 AI 对话', 'pages/ChatPage') + this.PlaceholderTab('✦', 'AI 顾问', '对话式穿搭顾问已上线,随时提问', '进入 AI 对话', 'pages/ChatPage') } .tabBar(this.TabBarItem('✦', 'AI', 2)) @@ -245,6 +257,10 @@ struct HomePage { .divider({ strokeWidth: 0.5, color: '#E5E7EB' }) .onChange((index: number) => { this.currentTabIndex = index + // 切回报告 Tab 即刷新:刚生成/消耗完额度的用户回列表第一眼就要看到新状态(N2) + if (index === 1) { + this.reportRefreshTick += 1 + } }) } .width('100%') diff --git a/app/entry/src/main/ets/pages/LlmTestPage.ets b/app/entry/src/main/ets/pages/LlmTestPage.ets index 1feceac..b8e620b 100644 --- a/app/entry/src/main/ets/pages/LlmTestPage.ets +++ b/app/entry/src/main/ets/pages/LlmTestPage.ets @@ -155,7 +155,7 @@ struct LlmTestPage { build() { Column({ space: 12 }) { - Text('端侧小模型调试(T18 SPIKE)').fontSize(18).fontWeight(FontWeight.Bold) + Text('端侧小模型调试(T18 SPIKE · 内部调试页)').fontSize(18).fontWeight(FontWeight.Bold) TextInput({ text: this.baseUrl, placeholder: '开发机地址,如 http://192.168.1.100:8080' }) .onChange((v: string) => { this.baseUrl = v }) Row({ space: 8 }) { diff --git a/app/entry/src/main/ets/pages/LoginPage.ets b/app/entry/src/main/ets/pages/LoginPage.ets index a1cf9e6..e6a8d5e 100644 --- a/app/entry/src/main/ets/pages/LoginPage.ets +++ b/app/entry/src/main/ets/pages/LoginPage.ets @@ -16,11 +16,11 @@ import preferences from '@ohos.data.preferences' import { ApiError } from '../core/ApiTypes' import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore' import { ContextHolder } from '../core/ContextHolder' +import { COLOR_PRIMARY } from '../core/Theme' import { ErrorBanner } from '../components/f1/ErrorBanner' const PREF_NAME_LOGIN = 'meida_login' const KEY_DEVICE_KEY = 'device_key' -const COLOR_PRIMARY = '#C2185B' /** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */ function generateDeviceKey(): string { @@ -32,14 +32,6 @@ function generateDeviceKey(): string { return `dev-${suffix}` } -/** 未知异常统一包 ApiError(与 ApiClient.wrapUnknown 同一话术)。 */ -function toApiError(e: Object): ApiError { - if (e instanceof ApiError) { - return e - } - return new ApiError(50000, '请求失败,请检查网络', '请稍后重试') -} - @Entry @Component struct LoginPage { @@ -93,7 +85,7 @@ struct LoginPage { this.errHint = '' this.savedTip = '' if (key === '') { - this.errMessage = '请先填写设备标识' + this.errMessage = '请先填写内测账号标识' this.errHint = '可点「随机生成」重新生成一个' return } @@ -103,9 +95,14 @@ struct LoginPage { await AuthStore.loginByDevToken(key) router.replaceUrl({ url: 'pages/HomePage' }) } catch (e) { - const err = toApiError(e) - this.errMessage = err.message - this.errHint = err.hint !== undefined ? err.hint : '' + if (e instanceof ApiError) { + this.errMessage = e.message + this.errHint = e.hint !== undefined ? e.hint : '' + } else { + // 连接层失败(未进统一信封,#25):给内测口径指引,不改 ApiClient 全局文案 + this.errMessage = '无法连接内测服务,请确认已连接指定网络,或联系测试人员' + this.errHint = '' + } } finally { this.logging = false } @@ -183,13 +180,20 @@ struct LoginPage { Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质') .fontSize(14) .fontColor('#8A8A8A') + // 内测标识(#25):常驻,不藏进高级设置 + Text('内测版 · 需连接指定网络后使用') + .fontSize(12) + .fontColor('#B3554D') + .padding({ left: 14, right: 14, top: 5, bottom: 5 }) + .borderRadius(14) + .backgroundColor('#F7E3EA') } .margin({ top: 72, bottom: 8 }) - // 设备标识 + // 内测账号标识(#25:话术去开发黑话,说明账号机制) Column({ space: 8 }) { Row({ space: 8 }) { - Text('设备标识') + Text('内测账号标识') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor('#2B2B2B') @@ -214,7 +218,7 @@ struct LoginPage { this.deviceKey = value }) - Text('同一标识对应同一账号与配额,换标识即换新用户') + Text('无需注册手机号;同一标识对应同一账号,换一个标识相当于新账号') .fontSize(11) .fontColor('#B3B3B3') } @@ -277,7 +281,7 @@ struct LoginPage { } .width('100%') - Text('模拟器直连本机可用默认地址;真机请填开发机局域网 IP(如 http://192.168.1.100:8000)') + Text('内测版需连接指定网络;如无法登录,请联系测试人员') .fontSize(11) .fontColor('#B3B3B3') } diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index 1c6d34f..d2f50f6 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -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 { + try { + const d = await ApiClient.get('/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 { this.showDupDialog = false if (this.lastReportId === '') { + // 重启后本地记录可能丢失:从服务端列表兜底取最近一份报告 + try { + const rows = await ApiClient.get('/api/v1/reports?limit=1') + const rid = rows.length > 0 ? (rows[0].report_id ?? '') : '' + if (rid !== '') { + this.lastReportId = rid + AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, rid) + } + } catch (_e) { + // 拉取失败走下方提示,不打断用户 + } + } + if (this.lastReportId === '') { + this.submitError = '暂时找不到你的报告,可稍后在首页「报告」中查看' return } const params: Record = { '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%') diff --git a/app/entry/src/main/ets/pages/ReportPdfPage.ets b/app/entry/src/main/ets/pages/ReportPdfPage.ets index ad05cf8..e02464c 100644 --- a/app/entry/src/main/ets/pages/ReportPdfPage.ets +++ b/app/entry/src/main/ets/pages/ReportPdfPage.ets @@ -6,6 +6,10 @@ * - 分享:Share Kit systemShare(系统分享面板,目标应用是否出现取决于其注册类型); * - 另存:DocumentViewPicker(免权限,用户选位置后写入)。 * 入参(router.getParams()):{ pdfPath: string, title?: string } + * + * 时序(#23):PdfView 常驻挂载(不按 ready 条件挂载),loadDocument 在其上 + * 叠加的加载/失败态遮罩下进行——与 PDF Kit 官方示例「先挂视图后加载」一致, + * 避免控制器先于视图工作的未定义行为。失败态提供「重新加载」出路。 */ import { router } from '@kit.ArkUI' @@ -16,11 +20,10 @@ import { common } from '@kit.AbilityKit' import { picker, fileUri } from '@kit.CoreFileKit' import fs from '@ohos.file.fs' import { ContextHolder } from '../core/ContextHolder' +import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' const PAGE_BG: string = '#F5F6F8' -const ACCENT: string = '#C2185B' const TEXT_PRIMARY: string = '#222222' -const TEXT_SECONDARY: string = '#8A9099' interface PdfRouteParams { pdfPath?: string @@ -38,6 +41,16 @@ struct ReportPdfPage { private controller: pdfViewManager.PdfController = new pdfViewManager.PdfController() + /** 操作提示统一入口(N1'):3 秒自动清空;连续操作时旧计时只清旧文案(白名单比对)。 */ + private setActionText(text: string): void { + this.actionText = text + setTimeout(() => { + if (this.actionText === text) { + this.actionText = '' + } + }, 3000) + } + aboutToAppear(): void { const raw = router.getParams() if (raw !== undefined && raw !== null) { @@ -50,22 +63,27 @@ struct ReportPdfPage { } } if (this.pdfPath === '') { - this.loadError = '缺少 PDF 文件路径' + this.loadError = 'PDF 文件缺失,请返回重新导出' return } this.loadDocument() } private async loadDocument(): Promise { + if (this.pdfPath === '') { + return + } + this.loadError = '' try { const result = await this.controller.loadDocument(this.pdfPath) if (result === pdfService.ParseResult.PARSE_SUCCESS) { this.ready = true } else { - this.loadError = `PDF 解析失败(${result})` + this.loadError = 'PDF 打不开,请点「重新加载」重试;若仍失败,请返回重新导出' } } catch (e) { - this.loadError = `PDF 加载异常:${(e as Error).message}` + console.error(`PDF loadDocument failed: ${(e as Error).message}`) + this.loadError = 'PDF 打不开,请点「重新加载」重试;若仍失败,请返回重新导出' } } @@ -82,8 +100,10 @@ struct ReportPdfPage { previewMode: systemShare.SharePreviewMode.DEFAULT } await controller.show(ContextHolder.get() as common.UIAbilityContext, opts) + // 面板正常收起(含用户主动取消):不打扰,不提示 } catch (e) { - this.actionText = `分享取消或失败:${(e as Error).message}` + console.error(`PDF share failed: ${(e as Error).message}`) + this.setActionText('分享失败,请重试') } } @@ -94,6 +114,7 @@ struct ReportPdfPage { opts.newFileNames = [`${this.title}.pdf`] const uris = await docPicker.save(opts) if (uris.length === 0) { + this.setActionText('已取消保存') return } const src = fs.openSync(this.pdfPath, fs.OpenMode.READ_ONLY) @@ -109,9 +130,10 @@ struct ReportPdfPage { fs.closeSync(src) fs.closeSync(dst) } - this.actionText = '已保存到文件管理' + this.setActionText('已保存到文件管理') } catch (e) { - this.actionText = `另存取消或失败:${(e as Error).message}` + console.error(`PDF saveAs failed: ${(e as Error).message}`) + this.setActionText('保存失败,请重试') } } @@ -119,7 +141,7 @@ struct ReportPdfPage { Column() { // 顶部栏 Row() { - Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) + Text('‹ 返回').fontSize(15).fontColor(COLOR_TEXT_SECONDARY).onClick(() => router.back()) Text(this.title).fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) Text('').width(48) } @@ -128,8 +150,8 @@ struct ReportPdfPage { .justifyContent(FlexAlign.SpaceBetween) .backgroundColor(Color.White) - // PDF 预览区 - if (this.ready) { + // PDF 预览区:PdfView 常驻挂载,加载/失败态作为遮罩叠在其上(#23 时序) + Stack() { PdfView({ controller: this.controller, pageLayout: pdfService.PageLayout.LAYOUT_SINGLE, @@ -137,24 +159,45 @@ struct ReportPdfPage { showScroll: true, pageFit: pdfService.PageFit.FIT_WIDTH }) - .layoutWeight(1) .width('100%') - } else { - Column({ space: 10 }) { - if (this.loadError === '') { - LoadingProgress().width(40).height(40).color(ACCENT) - Text('PDF 加载中…').fontSize(13).fontColor(TEXT_SECONDARY) - } else { - Text(this.loadError).fontSize(13).fontColor('#E5484D') + .height('100%') + + if (!this.ready) { + Column({ space: 10 }) { + if (this.loadError === '') { + LoadingProgress().width(40).height(40).color(COLOR_PRIMARY) + Text('PDF 加载中…').fontSize(13).fontColor(COLOR_TEXT_SECONDARY) + } else { + Text(this.loadError) + .fontSize(13) + .fontColor('#E5484D') + .textAlign(TextAlign.Center) + .padding({ left: 24, right: 24 }) + if (this.pdfPath !== '') { + Button('重新加载') + .fontSize(14) + .height(38) + .borderRadius(19) + .padding({ left: 24, right: 24 }) + .backgroundColor(COLOR_PRIMARY) + .margin({ top: 4 }) + .onClick(() => { + this.loadDocument() + }) + } + } } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor(PAGE_BG) } - .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) } + .layoutWeight(1) + .width('100%') if (this.actionText !== '') { - Text(this.actionText).fontSize(12).fontColor(TEXT_SECONDARY) + Text(this.actionText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY) .width('100%').padding({ left: 16 }) } @@ -162,12 +205,12 @@ struct ReportPdfPage { Row({ space: 12 }) { Button('分享') .layoutWeight(1).height(44) - .backgroundColor(ACCENT) + .backgroundColor(COLOR_PRIMARY) .enabled(this.ready) .onClick(() => { this.share() }) Button('另存到文件') .layoutWeight(1).height(44) - .fontColor(ACCENT) + .fontColor(COLOR_PRIMARY) .backgroundColor('#F7E3EA') .enabled(this.ready) .onClick(() => { this.saveAs() }) diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index 24a7d9a..dd32dc3 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -10,19 +10,26 @@ * 卡片内含来源标签中文映射、置信度百分比、<0.7“仅供参考”(NFR-UX-05)、 * rule_hits 折叠展开 * - 重新生成:POST /api/v1/reports/{id}/regenerate → 拿新 task_id 在本页复用 - * TaskPollingPanel 轮询(比跳回 Create 页携带参数更简单),SUCCEEDED 后重拉报告; - * 失败重试沿用同一 request_key(服务端幂等重放) + * TaskPollingPanel 轮询(比跳回 Create 页携带参数更简单);重新生成在后端 + * 是新建一行报告(新 report_id、版本 v+1),响应里必须采纳新 report_id, + * 轮询 SUCCEEDED 后按新 id 重拉;任务 FAILED 后重试换新 request_key + * (服务端幂等重放不过滤状态,同 key 只会重放回原失败任务) + * - 导出 PDF / 重新生成均为消耗配额的操作:提交前弹确认框展示本次代价 + * 与剩余次数(产品方案设计纪律 3「禁止静默扣减」) */ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' -import { ApiError } from '../core/ApiTypes' +import { ApiError, isAuthError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' import { ContextHolder } from '../core/ContextHolder' +import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import request from '@ohos.request' import fs from '@ohos.file.fs' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' import { DimensionCard } from '../components/f3/DimensionCard' +import { ErrorView } from '../components/f2/ErrorView' +import { LoadingView } from '../components/f2/LoadingView' import { CopySectionWire, DimensionWire, @@ -36,14 +43,24 @@ import { statusLabelOf } from '../components/f3/ReportTypes' -const COLOR_PRIMARY: string = '#C2185B' const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id' +/** GET /api/v1/quota?quota_key=… 的响应(字段名对齐后端 JSON)。 */ +interface QuotaWireResp { + quota_key: string + used: number + limit_value: number + bonus_granted: boolean + remaining: number + reset_at: string +} + @Entry @Component struct ReportResultPage { @State loading: boolean = true @State loadError: string = '' + @State loadErrorAuth: boolean = false // 会话/令牌类错误:错误态改供「重新登录」(#7) @State reportId: string = '' @State versionLabel: string = 'v1' @State statusRaw: string = '' @@ -59,11 +76,15 @@ struct ReportResultPage { @State regenerating: boolean = false @State regenTaskId: string = '' @State regenError: string = '' - private regenRequestKey: string = '' // 重新生成失败重试时沿用同一 key:regenerate 幂等重放 + private regenRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key(同 key 只会重放回失败任务) @State exporting: boolean = false @State exportTaskId: string = '' @State exportError: string = '' - private exportRequestKey: string = '' // 导出失败重试沿用同一 key(幂等重放,不重复扣 hd_export 配额) + private exportRequestKey: string = '' // 同上:FAILED 后置空换新 key + @State confirmAction: string = '' // '' | 'regen' | 'export':配额确认框当前动作 + @State confirmRemaining: number = -1 + @State confirmResetAt: string = '' + @State confirmLoaded: boolean = false aboutToAppear(): void { const raw = router.getParams() @@ -85,10 +106,12 @@ struct ReportResultPage { private async loadReport(): Promise { this.loading = true this.loadError = '' + this.loadErrorAuth = false 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 ?? '' + // 后端报告状态为小写(succeeded/failed/…),统一转大写供下方状态门禁比较 + this.statusRaw = (d.status ?? '').toUpperCase() this.statusText = statusLabelOf(d.status) this.completedText = formatIsoTime(d.completed_at) const result = d.result @@ -108,9 +131,13 @@ struct ReportResultPage { AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, rid) } this.loading = false - this.regenRequestKey = '' // 报告已成功拉到,下次重新生成用新 key + // 报告已成功拉到(含「导出→重新生成→采纳新 id 后重拉」场景):两个幂等键 + // 一并换新,防止新报告复用旧导出键撞后端 IDEMPOTENCY_CONFLICT(#22) + this.regenRequestKey = '' + this.exportRequestKey = '' } catch (e) { this.loading = false + this.loadErrorAuth = isAuthError(e) if (e instanceof ApiError) { this.loadError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message } else { @@ -135,6 +162,13 @@ struct ReportResultPage { this.regenError = '服务端未返回任务 ID,请稍后重试' return } + // 重新生成在后端是新建一行报告(report_id 变化、版本 v+1):必须采纳新 id, + // 否则轮询成功后 loadReport 拉回的仍是旧版报告 + const newRid = data.report_id ?? '' + if (newRid !== '') { + this.reportId = newRid + AppStorage.setOrCreate(LAST_REPORT_STORAGE_KEY, newRid) + } this.regenTaskId = tid this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉 } catch (e) { @@ -155,6 +189,8 @@ struct ReportResultPage { private onRegenFailed(reason: string): void { this.regenerating = false this.regenError = `重新生成失败:${reason}` + // 清空幂等键:重试以新 key 创建新任务(旧失败任务的额度已由服务端退还) + this.regenRequestKey = '' } /** 导出 PDF:提交渲染任务(quota_key=hd_export,服务端熔断 tier2 可拦)→ 轮询 → 下载 → 预览页。 */ @@ -195,6 +231,15 @@ struct ReportResultPage { fs.mkdirSync(dir, true) } const path: string = `${dir}/report_${this.reportId}.pdf` + // OHOS request.download 不能覆盖已存在文件(真机实测 2300037,#21): + // 下载前先清理旧文件/半截文件,保证同报告多次导出与失败重试可重入 + try { + if (fs.accessSync(path)) { + fs.unlinkSync(path) + } + } catch (_e) { + // 清理失败不阻断:继续下载,失败走下方统一兜底 + } const token = AuthStore.getAccessToken() const header: Record = {} if (token !== undefined) { @@ -210,16 +255,63 @@ struct ReportResultPage { task.on('complete', () => resolve()) task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) }) - const params: Record = { 'pdfPath': path, 'title': `形象报告_${this.reportId}` } + // 预览页标题/另存文件名对用户展示:用「形象报告 vN」,不内嵌服务端报告 ID(#6) + const params: Record = { 'pdfPath': path, 'title': `形象报告 ${this.versionLabel}` } router.pushUrl({ url: 'pages/ReportPdfPage', params: params }) } catch (e) { - this.exportError = `PDF 下载失败:${(e as Error).message}` + // 原始错误码(2300037 等)进日志排障,用户只看友好话术(#6) + console.error(`PDF download failed: ${(e as Error).message}`) + this.exportError = 'PDF 下载失败,请检查网络后重试' } } private onExportFailed(reason: string): void { this.exporting = false this.exportError = `导出失败:${reason}` + // 同上:清空幂等键,重试换新 key,避免重放回原失败任务 + this.exportRequestKey = '' + } + + /** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次)。 */ + private openConfirm(action: string): void { + if (action !== 'regen' && action !== 'export') { + return + } + if (action === 'regen' && (this.regenerating || this.reportId === '')) { + return + } + if (action === 'export' && (this.exporting || this.reportId === '')) { + return + } + this.confirmAction = action + this.confirmLoaded = false + this.confirmRemaining = -1 + this.confirmResetAt = '' + this.loadConfirmQuota() + } + + private async loadConfirmQuota(): Promise { + const key = this.confirmAction === 'export' ? 'hd_export' : 'report_regen' + try { + const d = await ApiClient.get(`/api/v1/quota?quota_key=${key}`) + this.confirmRemaining = d.remaining + this.confirmResetAt = d.reset_at ?? '' + this.confirmLoaded = true + } catch (_e) { + // 拉取失败只展示代价文案,是否放行交给服务端校验 + this.confirmRemaining = -1 + this.confirmLoaded = false + } + } + + private onConfirmOk(): void { + const action = this.confirmAction + this.confirmAction = '' + if (action === 'regen') { + this.doRegenerate() + } else if (action === 'export') { + this.doExport() + } } private dimKey(d: DimensionWire): string { @@ -234,9 +326,9 @@ struct ReportResultPage { @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() @@ -250,49 +342,6 @@ struct ReportResultPage { .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 }) { @@ -300,7 +349,7 @@ struct ReportResultPage { .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#333333') - Text('重新生成沿用同一报告 ID,完成后自动展示新版本') + Text('重新生成会基于你的资料产出新版本,完成后自动展示') .fontSize(13) .fontColor('#999999') .textAlign(TextAlign.Center) @@ -349,6 +398,63 @@ struct ReportResultPage { .padding({ left: 24, right: 24 }) } + /** 消耗配额前的确认框(#5):展示本次代价与剩余次数,剩余 0 时阻止提交。 */ + @Builder + QuotaConfirmDialog() { + Column({ space: 14 }) { + Text(this.confirmAction === 'export' ? '确认导出 PDF' : '确认重新生成') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Text(this.confirmAction === 'export' + ? '导出 PDF 每日可使用 1 次,本次将使用 1 次。' + : '重新生成每 30 天可使用 1 次,本次将使用 1 次。') + .fontSize(13) + .fontColor('#666666') + .textAlign(TextAlign.Center) + if (this.confirmLoaded && this.confirmRemaining > 0) { + Text(this.confirmAction === 'export' + ? `今日还可导出 ${this.confirmRemaining} 次` + : `本周期还可重新生成 ${this.confirmRemaining} 次`) + .fontSize(13) + .fontColor(COLOR_PRIMARY) + } else if (this.confirmLoaded && this.confirmRemaining <= 0) { + Text(this.confirmResetAt !== '' + ? `次数已用完,${formatIsoTime(this.confirmResetAt)} 后恢复` + : '次数已用完,请等待恢复后再试') + .fontSize(12) + .fontColor('#D46A0B') + .textAlign(TextAlign.Center) + } + Row({ space: 12 }) { + Button('取消') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { + this.confirmAction = '' + }) + Button('确认') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(COLOR_PRIMARY) + .enabled(!(this.confirmLoaded && this.confirmRemaining <= 0)) + .onClick(() => { + this.onConfirmOk() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor('#FFFFFF') + } + @Builder HeaderCard() { Column({ space: 6 }) { @@ -377,11 +483,6 @@ struct ReportResultPage { 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('报告仍在生成中,可点击刷新查看最新状态。') @@ -568,7 +669,7 @@ struct ReportResultPage { .fontColor(COLOR_PRIMARY) .backgroundColor('#F7E3EA') .onClick(() => { - this.doExport() + this.openConfirm('export') }) } Button(this.regenError !== '' ? '重试重新生成' : '重新生成报告') @@ -577,7 +678,7 @@ struct ReportResultPage { .fontSize(15) .backgroundColor(COLOR_PRIMARY) .onClick(() => { - this.doRegenerate() + this.openConfirm('regen') }) } .width('100%') @@ -588,18 +689,60 @@ struct ReportResultPage { } build() { - Column() { - this.TopBar() - if (this.exporting) { - this.ExportView() - } else if (this.regenerating) { - this.RegenView() - } else if (this.loading) { - this.LoadingView() - } else if (this.loadError !== '') { - this.ErrorView() - } else { - this.ContentBody() + Stack() { + Column() { + this.TopBar() + if (this.exporting) { + this.ExportView() + } else if (this.regenerating) { + this.RegenView() + } else if (this.loading) { + // N5':换用 f2 通用加载态(包一层 Column 承接 layoutWeight 占位) + Column() { + LoadingView({ text: '报告加载中…' }) + } + .width('100%') + .layoutWeight(1) + } else if (this.loadError !== '') { + // N5':换用 f2 通用错误态;会话过期时在组件外层追加「重新登录」出路(#7 不回归) + Column() { + ErrorView({ + message: this.loadError, + retryText: '重新加载', + onRetry: (): void => { + this.loadReport() + } + }) + if (this.loadErrorAuth) { + Button('重新登录') + .width('50%') + .height(44) + .fontSize(15) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .margin({ top: 10 }) + .onClick(() => { + router.pushUrl({ url: 'pages/LoginPage' }) + }) + } + } + .width('100%') + .layoutWeight(1) + } else { + this.ContentBody() + } + } + .width('100%') + .height('100%') + + if (this.confirmAction !== '') { + Column() { + this.QuotaConfirmDialog() + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .justifyContent(FlexAlign.Center) } } .width('100%') diff --git a/app/entry/src/main/ets/pages/TryonPage.ets b/app/entry/src/main/ets/pages/TryonPage.ets index f3c49c1..d35845b 100644 --- a/app/entry/src/main/ets/pages/TryonPage.ets +++ b/app/entry/src/main/ets/pages/TryonPage.ets @@ -3,23 +3,40 @@ * * 流程(产品方案 §2.1.1):本人全身照 + 上衣(必选)+ 裤子(可选)+ 提示词(可选) * → 生成(长等待,显示已等待秒数,超时 320s)→ 结果对比 / 保存相册 / 再来一次。 + * 生成期防中断(#16):屏幕保持常亮;返回先挽留确认;提供「取消生成」出口 + * (直连模式取消=放弃等待,不涉及后端配额;压缩完成后也复查 genSeq,N6)。 + * 结果归宿(#17):生成成功即转存 filesDir/results/tryon_last.jpg(仅保留最近一次), + * 展示与保存优先走本机副本(离线可存、杀 App 后「上次结果」卡可回看); + * 转存失败降级用 OSS URL 现场下载并提示尽快保存。 * 约束:只选裤子不传上衣时服务端会解码失败——UI 直接禁掉该组合。 */ -import { router } from '@kit.ArkUI' +import { router, window } from '@kit.ArkUI' import { photoAccessHelper } from '@kit.MediaLibraryKit' import { fileUri } from '@kit.CoreFileKit' import request from '@ohos.request' +import fs from '@ohos.file.fs' import { TryonApi } from '../core/TryonApi' import { ImageCodec } from '../core/ImageCodec' import { ApiError } from '../core/ApiTypes' import { ContextHolder } from '../core/ContextHolder' +import { COLOR_PRIMARY } from '../core/Theme' +import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' const PAGE_BG: string = '#F5F6F8' -const ACCENT: string = '#FF5A79' +const ACCENT: string = COLOR_PRIMARY const TEXT_PRIMARY: string = '#222222' const TEXT_SECONDARY: string = '#8A9099' +/** @Builder $$ 按引用传参的参数接口(R5N1:原始类型按值传参不响应 @State)。 */ +interface PhotoSlotArgs { + title: string + hint: string + uri: string + slot: string + weight: number +} + @Entry @Component struct TryonPage { @@ -31,9 +48,24 @@ struct TryonPage { @State elapsedSec: number = 0 @State statusText: string = '' @State resultUrl: string = '' + @State resultLocalPath: string = '' // 本机留存副本(#17):展示与保存优先走它 + @State lastLocalPath: string = '' // 「上次结果」卡数据源(aboutToAppear 回看,#17) @State showOriginal: boolean = false + @State showFaceConsent: boolean = false + @State showLeaveConfirm: boolean = false // 生成中离开的挽留确认 + private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口 private timerId: number = -1 + private genSeq: number = 0 // 生成代号:取消/放弃后使在途请求的收尾与迟到结果失效 + + /** 本机结果留存目录(#17):filesDir 而非 cacheDir(后者有系统配额可能被清)。 */ + private resultStoreDir(): string { + return `${ContextHolder.get().filesDir}/results` + } + + private resultStorePath(): string { + return `${this.resultStoreDir()}/tryon_last.jpg` + } aboutToAppear(): void { // 预留:Agent 联动(V2)自动填入,见产品方案 §7。Phase A 仅支持提示词。 @@ -41,10 +73,28 @@ struct TryonPage { if (params !== undefined && typeof params['change_desc'] === 'string') { this.desc = params['change_desc'] as string } + // 上次结果回看(#17):沙盒留存最近一次换装结果,杀 App/重启不丢 + try { + if (fs.accessSync(this.resultStorePath())) { + this.lastLocalPath = this.resultStorePath() + } + } catch (_e) { + // 目录不存在等:视为无上次结果 + } } aboutToDisappear(): void { - this.stopTimer() + this.abandonGeneration() + } + + /** 生成期间保持屏幕常亮(#16):1~5 分钟等待不允许被系统自动锁屏打断。 */ + private async setKeepScreenOn(keep: boolean): Promise { + try { + const win = await window.getLastWindow(ContextHolder.get()) + await win.setWindowKeepScreenOn(keep) + } catch (e) { + console.error(`MEIDA_TRYON keepScreenOn(${keep}) failed: ${String(e)}`) + } } private stopTimer(): void { @@ -54,7 +104,28 @@ struct TryonPage { } } - private async pick(slot: string, fromCamera: boolean): Promise { + /** 终止在途生成并复位等待态(取消/离开/页面销毁共用);不动已选照片。 */ + private abandonGeneration(): void { + this.genSeq += 1 // 使在途 generate 的收尾与迟到结果全部失效 + TryonApi.cancelChangeCloth() + this.stopTimer() + if (this.generating) { + this.generating = false + this.setKeepScreenOn(false) + } + } + + /** 选图入口:全身照含人脸,上传前须单独同意(CR-01);服饰图不涉及。 */ + private pick(slot: string, fromCamera: boolean): void { + if (slot === 'human' && !FaceConsentStore.isGranted()) { + this.consentPickCamera = fromCamera + this.showFaceConsent = true + return + } + this.doPick(slot, fromCamera) + } + + private async doPick(slot: string, fromCamera: boolean): Promise { try { const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery() if (picked === undefined) { @@ -63,6 +134,8 @@ struct TryonPage { if (slot === 'human') { this.humanUri = picked.uri this.resultUrl = '' + this.resultLocalPath = '' + this.statusText = '' // 换新全身照回到选人台:清掉上一轮生成/保存的残留提示 } else if (slot === 'cloth') { this.clothUri = picked.uri } else { @@ -73,6 +146,17 @@ struct TryonPage { } } + private onConsentAgree(): void { + FaceConsentStore.grant() + this.showFaceConsent = false + this.doPick('human', this.consentPickCamera) + } + + private onConsentRefuse(): void { + this.showFaceConsent = false + this.statusText = '未同意照片授权,本次不上传照片' + } + private canGenerate(): boolean { return this.humanUri.length > 0 && this.clothUri.length > 0 && !this.generating } @@ -81,11 +165,13 @@ struct TryonPage { if (!this.canGenerate()) { return } + const gen: number = ++this.genSeq this.generating = true this.elapsedSec = 0 this.resultUrl = '' this.showOriginal = false this.statusText = '正在压缩上传…' + this.setKeepScreenOn(true) this.timerId = setInterval(() => { this.elapsedSec += 1 }, 1000) @@ -93,21 +179,101 @@ struct TryonPage { const human = await ImageCodec.toJpegBase64(this.humanUri) const cloth = await ImageCodec.toJpegBase64(this.clothUri) const kuzi = this.kuziUri.length > 0 ? await ImageCodec.toJpegBase64(this.kuziUri) : '' + if (gen !== this.genSeq) { + return // 压缩期已取消(N6):不再发起真实换装请求,取消才真正省下一次服务调用 + } this.statusText = '已提交,AI 生成中(一般 1~3 分钟,请留在本页)…' - this.resultUrl = await TryonApi.changeCloth(human, cloth, kuzi, this.desc.trim()) - this.statusText = '生成完成,点「对比」看原图' + const url = await TryonApi.changeCloth(human, cloth, kuzi, this.desc.trim()) + if (gen !== this.genSeq) { + return // 已被取消/离开,丢弃迟到结果 + } + this.resultUrl = url + this.statusText = '生成完成,正在留存到本机…' + const saved: boolean = await this.persistResult(url) + if (gen !== this.genSeq) { + return + } + this.statusText = saved ? '生成完成,点「对比」看原图' : '结果已生成,本地暂存失败,请尽快保存到相册' } catch (e) { - this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' + if (gen === this.genSeq) { + this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' + } } finally { - this.stopTimer() - this.generating = false + if (gen === this.genSeq) { + this.stopTimer() + this.generating = false + this.setKeepScreenOn(false) + } } } + /** 取消生成:直连模式无服务端任务、不涉及后端配额,取消即放弃等待本次结果。 */ + private cancelGenerate(): void { + if (!this.generating) { + return + } + this.abandonGeneration() + this.statusText = '已取消本次生成(内测直连服务,不涉及额度扣减)' + } + + /** 生成中点返回:先挽留,确认离开才真正退出。 */ + private tryBack(): void { + if (this.generating) { + this.showLeaveConfirm = true + return + } + router.back() + } + + onBackPress(): boolean { + if (this.generating) { + this.showLeaveConfirm = true + return true // 拦截系统返回,交由挽留确认处理 + } + return false + } + + /** + * 生成成功即转存本机(#17):下载结果到 filesDir/results/tryon_last.jpg。 + * 下载前先清旧文件——request.download 不能覆盖已存在文件(#21 的 2300037 同款坑); + * 固定文件名自动限定沙盒占用(每类一张)。失败返回 false,调用方降级用 URL。 + */ + private async persistResult(url: string): Promise { + try { + const path: string = this.resultStorePath() + if (!fs.accessSync(this.resultStoreDir())) { + fs.mkdirSync(this.resultStoreDir(), true) + } + try { + if (fs.accessSync(path)) { + fs.unlinkSync(path) + } + } catch (_e) { + // 清理失败不阻断:下载失败走统一降级 + } + const task = await request.downloadFile(ContextHolder.get(), { url: url, filePath: path }) + await new Promise((resolve, reject) => { + task.on('complete', () => resolve()) + task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) + }) + this.resultLocalPath = path + this.lastLocalPath = path + return true + } catch (_e) { + this.resultLocalPath = '' + return false + } + } + + /** 保存到相册(#17):本机副本在手时跳过下载直接存(离线可存);转存失败过的现场下载兜底。 */ private async saveResult(): Promise { if (this.resultUrl.length === 0) { return } + if (this.resultLocalPath.length > 0) { + this.statusText = await this.saveFileToGallery(this.resultLocalPath) + return + } try { const path: string = `${ContextHolder.get().cacheDir}/tryon_${Date.now()}.jpg` const task = await request.downloadFile(ContextHolder.get(), { url: this.resultUrl, filePath: path }) @@ -115,109 +281,232 @@ struct TryonPage { task.on('complete', () => resolve()) task.on('fail', (err: number) => reject(new Error(`download fail: ${err}`))) }) - const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) - const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } - await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) - this.statusText = '已保存到相册' - } catch { - this.statusText = '保存失败或已取消' + this.statusText = await this.saveFileToGallery(path) + } catch (_e) { + this.statusText = '保存失败,请重试' } } - /** 三槽位选图块:label + 预览或占位,点击进相册,长按拍照(简化:下方小字两个入口)。 */ + /** + * 系统保存对话框 → 三态文案(#17/#18 统一口径): + * 返回 URI 列表非空=已保存;空数组=用户取消;抛异常=保存失败(不再谎报成取消)。 + */ + private async saveFileToGallery(path: string): Promise { + try { + const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get()) + const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE } + const uris: Array = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]) + return uris.length > 0 ? '已保存到相册' : '已取消保存' + } catch (_e) { + return '保存失败,请重试' + } + } + + /** 「上次结果」卡的保存(#17):直接走本机副本,不依赖网络。 */ + private async saveLastResult(): Promise { + if (this.lastLocalPath.length === 0) { + return + } + this.statusText = await this.saveFileToGallery(this.lastLocalPath) + } + + /** 结果区图源(#17):本机副本优先,转存失败降级 OSS URL。 */ + private resultImageSrc(): string { + return this.resultLocalPath.length > 0 ? fileUri.getUriFromPath(this.resultLocalPath) : this.resultUrl + } + + /** 三槽位选图块:label + 预览或占位,点击进相册,长按拍照(简化:下方小字两个入口)。 + * R5N1:@Builder 原始类型按值传参不响应 @State(选图后预览不刷新),改 $$ 对象字面量按引用传参。 */ @Builder - PhotoSlot(title: string, hint: string, uri: string, slot: string, weight: number) { + PhotoSlot($$: PhotoSlotArgs) { Column({ space: 6 }) { - if (uri.length > 0) { - Image(uri) - .width('100%').height(slot === 'human' ? 260 : 120).objectFit(ImageFit.Cover).borderRadius(12) + if ($$.uri.length > 0) { + Image($$.uri) + .width('100%').height($$.slot === 'human' ? 260 : 120).objectFit(ImageFit.Cover).borderRadius(12) } else { Column({ space: 6 }) { Text('+').fontSize(24).fontColor(TEXT_SECONDARY) - Text(hint).fontSize(11).fontColor(TEXT_SECONDARY).textAlign(TextAlign.Center) + Text($$.hint).fontSize(11).fontColor(TEXT_SECONDARY).textAlign(TextAlign.Center) } - .width('100%').height(slot === 'human' ? 260 : 120).justifyContent(FlexAlign.Center) + .width('100%').height($$.slot === 'human' ? 260 : 120).justifyContent(FlexAlign.Center) .borderRadius(12).backgroundColor('#EDEFF3') - .onClick(() => { this.pick(slot, false) }) + .onClick(() => { this.pick($$.slot, false) }) } Row({ space: 8 }) { - Text(title).fontSize(12).fontColor(TEXT_PRIMARY) - Text('相册').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick(slot, false) }) - Text('拍照').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick(slot, true) }) + Text($$.title).fontSize(12).fontColor(TEXT_PRIMARY) + Text('相册').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick($$.slot, false) }) + Text('拍照').fontSize(11).fontColor(ACCENT).onClick(() => { this.pick($$.slot, true) }) } } - .layoutWeight(weight) + .layoutWeight($$.weight) } build() { - Column() { - Row() { - Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back()) - Text('试衣间').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) - Text('').width(48) - } - .width('100%') - .padding({ left: 16, right: 16, top: 12, bottom: 12 }) - .justifyContent(FlexAlign.SpaceBetween) - .backgroundColor(Color.White) - - Scroll() { - Column({ space: 12 }) { - // 结果优先展示;否则显示选人台 - if (this.resultUrl.length > 0) { - Column({ space: 8 }) { - Image(this.showOriginal ? this.humanUri : this.resultUrl) - .width('100%').height(380).objectFit(ImageFit.Contain) - .borderRadius(12).backgroundColor('#EDEFF3') - Row({ space: 16 }) { - Text('对比').fontSize(13).fontColor(ACCENT) - .onClick(() => { this.showOriginal = !this.showOriginal }) - Text('保存到相册').fontSize(13).fontColor(ACCENT) - .onClick(() => { this.saveResult() }) - Text('再来一次').fontSize(13).fontColor(ACCENT) - .onClick(() => { this.resultUrl = '' }) - } - .width('100%').justifyContent(FlexAlign.Center) - } - .width('100%') - } - - this.PhotoSlot('我的全身照', '正面全身照\n(尽量露出手腕和腿)', this.humanUri, 'human', 3) - - Row({ space: 12 }) { - this.PhotoSlot('上衣(必选)', '平铺/挂拍单品图', this.clothUri, 'cloth', 1) - this.PhotoSlot('裤子(可选)', '选了裤子将同时换上下装', this.kuziUri, 'kuzi', 1) - } - .width('100%') - - TextInput({ text: this.desc, placeholder: '提示词(可选):如「换成户外街拍背景」' }) - .width('100%') - .onChange((v: string) => { this.desc = v }) - - Text('请使用本人照片,上传即确认不侵犯他人肖像权') - .fontSize(11).fontColor(TEXT_SECONDARY).width('100%') - - if (this.statusText.length > 0) { - Text(this.generating ? `${this.statusText}(${this.elapsedSec}s)` : this.statusText) - .fontSize(12).fontColor(TEXT_SECONDARY).width('100%') - } - - Button(this.generating ? `生成中 ${this.elapsedSec}s…` : '开始试穿') - .width('100%').height(48) - .backgroundColor(this.canGenerate() ? ACCENT : '#C2C7CE') - .enabled(this.canGenerate()) - .onClick(() => { this.generate() }) - - if (this.humanUri.length > 0 && this.kuziUri.length > 0 && this.clothUri.length === 0) { - Text('只选裤子无法生成:裤装流程需要同时提供上衣图') - .fontSize(11).fontColor('#E6A23C').width('100%') - } + Stack() { + Column() { + Row() { + Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => this.tryBack()) + Text('试衣间').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY) + Text('').width(48) } .width('100%') - .padding(16) + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .justifyContent(FlexAlign.SpaceBetween) + .backgroundColor(Color.White) + + Scroll() { + Column({ space: 12 }) { + // 上次试衣结果回看(#17):无本次结果时展示留存副本(保存/移除;不鼓励分享传播) + if (this.lastLocalPath.length > 0 && this.resultUrl.length === 0) { + Column({ space: 8 }) { + Text('上次试衣结果') + .fontSize(13).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY).width('100%') + Image(fileUri.getUriFromPath(this.lastLocalPath)) + .width('100%').height(320).objectFit(ImageFit.Contain) + .borderRadius(12).backgroundColor('#EDEFF3') + Row({ space: 16 }) { + Text('保存到相册').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.saveLastResult() }) + Text('知道了').fontSize(13).fontColor(TEXT_SECONDARY) + .onClick(() => { this.lastLocalPath = '' }) + } + .width('100%').justifyContent(FlexAlign.Center) + } + .width('100%') + } + + // 结果优先展示;否则显示选人台 + if (this.resultUrl.length > 0) { + Column({ space: 8 }) { + Image(this.showOriginal ? this.humanUri : this.resultImageSrc()) + .width('100%').height(380).objectFit(ImageFit.Contain) + .borderRadius(12).backgroundColor('#EDEFF3') + Row({ space: 16 }) { + Text(this.showOriginal ? '看效果' : '对比').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.showOriginal = !this.showOriginal }) + Text('保存到相册').fontSize(13).fontColor(ACCENT) + .onClick(() => { this.saveResult() }) + Text('再来一次').fontSize(13).fontColor(ACCENT) + .onClick(() => { + this.resultUrl = '' + this.resultLocalPath = '' + this.showOriginal = false + this.statusText = '' // 回到选人台:清掉上一轮「生成完成…」等提示 + }) + } + .width('100%').justifyContent(FlexAlign.Center) + Text('结果已保存在本页(仅保留最近一次);保存到相册后可长期查看') + .fontSize(11).fontColor(TEXT_SECONDARY).width('100%').textAlign(TextAlign.Center) + } + .width('100%') + } + + this.PhotoSlot({ title: '我的全身照', hint: '正面全身照\n(尽量露出手腕和腿)', uri: this.humanUri, slot: 'human', weight: 3 }) + + Row({ space: 12 }) { + this.PhotoSlot({ title: '上衣(必选)', hint: '平铺/挂拍单品图', uri: this.clothUri, slot: 'cloth', weight: 1 }) + this.PhotoSlot({ title: '裤子(可选)', hint: '选了裤子将同时换上下装', uri: this.kuziUri, slot: 'kuzi', weight: 1 }) + } + .width('100%') + + TextInput({ text: this.desc, placeholder: '提示词(可选):如「换成户外街拍背景」' }) + .width('100%') + .onChange((v: string) => { this.desc = v }) + + Text('请使用本人照片,上传即确认不侵犯他人肖像权') + .fontSize(11).fontColor(TEXT_SECONDARY).width('100%') + + if (this.statusText.length > 0) { + Text(this.generating ? `${this.statusText}(${this.elapsedSec}s)` : this.statusText) + .fontSize(12).fontColor(TEXT_SECONDARY).width('100%') + } + + Button(this.generating ? `生成中 ${this.elapsedSec}s…` : '开始试穿') + .width('100%').height(48) + .backgroundColor(this.canGenerate() ? ACCENT : '#C2C7CE') + .enabled(this.canGenerate()) + .onClick(() => { this.generate() }) + + // 等待期出口:允许主动取消(取消=放弃等待,不涉及额度) + if (this.generating) { + Button('取消生成') + .width('100%').height(40) + .fontSize(13) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .margin({ top: 8 }) + .onClick(() => { this.cancelGenerate() }) + } + + if (this.humanUri.length > 0 && this.kuziUri.length > 0 && this.clothUri.length === 0) { + Text('只选裤子无法生成:裤装流程需要同时提供上衣图') + .fontSize(11).fontColor('#E6A23C').width('100%') + } + } + .width('100%') + .padding(16) + } + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + + if (this.showFaceConsent) { + FacePhotoConsent({ + onAgree: (): void => { + this.onConsentAgree() + }, + onRefuse: (): void => { + this.onConsentRefuse() + } + }) + } + + // 生成中离开的挽留确认(#16):继续等待 / 仍要离开 + if (this.showLeaveConfirm) { + Column() { + Column({ space: 14 }) { + Text('生成仍在进行') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(TEXT_PRIMARY) + Text('换装一般需要 1~3 分钟,离开将无法收到本次结果') + .fontSize(13) + .fontColor('#666666') + .textAlign(TextAlign.Center) + Row({ space: 12 }) { + Button('继续等待') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor('#F3F4F6') + .fontColor('#666666') + .onClick(() => { this.showLeaveConfirm = false }) + Button('仍要离开') + .layoutWeight(1) + .height(40) + .fontSize(14) + .backgroundColor(ACCENT) + .onClick(() => { + this.showLeaveConfirm = false + this.abandonGeneration() + router.back() + }) + } + .width('100%') + .margin({ top: 4 }) + } + .width('82%') + .padding(20) + .borderRadius(16) + .backgroundColor(Color.White) + } + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .justifyContent(FlexAlign.Center) } - .layoutWeight(1) - .scrollBar(BarState.Off) } .width('100%') .height('100%') diff --git a/meida_backend b/meida_backend index cd0ddb1..00ef56b 160000 --- a/meida_backend +++ b/meida_backend @@ -1 +1 @@ -Subproject commit cd0ddb131b62ee56816f5a67dada79c499611414 +Subproject commit 00ef56b08b96eb446f5dc5795209f44c3b91a079