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

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

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

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

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

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