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