feat(app): 任务换额度+端侧埋点+试穿限次+报告反馈预约卡+网络错误态——PM/开发双Agent迭代2-3
- 新增 core/Tracker.ets:埋点唯一入口,事件名正则校验,preferences 队列(≥20立即/ 30s节流/后台flush,每批50,上限200丢最旧),PII 键剔除,失败静默;首批10点位 - 新增 core/DailyLimiter.ets:试穿/试戴各3次/日本地软限制,自然日重置,失败不计数; setBonus 支持服务端 bonus_granted 加成,「做任务多换几次」真兑现 - 新增 components/f1/TaskBonusPanel.ets + core/TaskBonusState.ets:签到真实 claim 链路,40910/40310 处理,已领态面板与报告页互通;个人中心与对话耗尽态接入 - 报告页:维度「准/不准」反馈卡(DimensionCard 无网络走回调)+ 静默 claim report_feedback 闭环;全面报告预约留资卡(card_view/reserve_result 埋点) - 新增 components/f2/NetworkBanner.ets:首页/对话/个人中心三处网络错误统一横幅+重试; 对话耗尽横幅恢复 reset_at 时间显示 - 文案纪律:无价格/购买/会员/升级字样;hvigor assembleHap 构建 0 error - 子模块 meida_backend 指向 7bcbb8d(迭代1-3 后端)
This commit is contained in:
@@ -21,7 +21,10 @@ import { fileUri } from '@kit.CoreFileKit'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { TryonApi, Hairstyle } from '../core/TryonApi'
|
||||
import { ImageCodec } from '../core/ImageCodec'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { DailyLimiter } from '../core/DailyLimiter'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
@@ -37,6 +40,17 @@ import {
|
||||
import { Segment } from '../components/f2/Segment'
|
||||
import { ColorDot } from '../components/f2/ColorDot'
|
||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=hairstyle 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */
|
||||
interface HairQuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
interface PresetColor {
|
||||
name: string
|
||||
@@ -73,6 +87,10 @@ struct HairDesignPage {
|
||||
@State ratio: number = 0.9
|
||||
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款 34 / 男款 26)
|
||||
@State showFaceConsent: boolean = false
|
||||
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)——
|
||||
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
||||
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
|
||||
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
|
||||
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
|
||||
|
||||
/** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */
|
||||
@@ -82,6 +100,7 @@ struct HairDesignPage {
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadStyles()
|
||||
this.loadHairRemaining()
|
||||
// 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢
|
||||
try {
|
||||
if (fs.accessSync(this.resultStorePath())) {
|
||||
@@ -110,6 +129,23 @@ struct HairDesignPage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 试戴剩余额度(P0-5):端侧 DailyLimiter 为兜底权威(Phase A 直连不经后端),
|
||||
* 服务端配额可拉时取 min(服务端, 端侧);拉取失败静默用端侧值,不阻断功能。
|
||||
*/
|
||||
private async loadHairRemaining(): Promise<void> {
|
||||
await DailyLimiter.init()
|
||||
try {
|
||||
const q = await ApiClient.get<HairQuotaWire>('/api/v1/quota?quota_key=hairstyle')
|
||||
// 奖励加成(迭代 3 修正):服务端 bonus_granted 抬当日端侧上限,再取 min
|
||||
DailyLimiter.setBonus('hairstyle', q.bonus_granted)
|
||||
this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle'))
|
||||
} catch (_e) {
|
||||
// 拉取失败保持 bonus=0(兜底 3 次不变)
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 Tab 的发型(unknown 款两 Tab 都展示,服务端新增款不丢)。 */
|
||||
private visibleStyles(): Hairstyle[] {
|
||||
const out: Hairstyle[] = []
|
||||
@@ -175,6 +211,13 @@ struct HairDesignPage {
|
||||
if (this.photoUri.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
this.selectedStyleId = style.id
|
||||
this.showOriginal = false
|
||||
@@ -184,8 +227,17 @@ struct HairDesignPage {
|
||||
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '试戴完成,点「对比」看原图'
|
||||
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.persistResult()
|
||||
} catch (e) {
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
@@ -196,6 +248,13 @@ struct HairDesignPage {
|
||||
if (this.resultBase64.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
const c: PresetColor = PRESET_COLORS[this.colorIdx]
|
||||
this.statusText = `正在换发色(${c.name})…`
|
||||
@@ -210,8 +269,17 @@ struct HairDesignPage {
|
||||
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '换发色完成'
|
||||
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④)
|
||||
} catch (e) {
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
@@ -267,6 +335,63 @@ struct HairDesignPage {
|
||||
this.statusText = await this.saveFileToGallery(this.lastLocalPath)
|
||||
}
|
||||
|
||||
/** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
|
||||
@Builder
|
||||
QuotaBlockedDialog() {
|
||||
Column() {
|
||||
Column({ space: 14 }) {
|
||||
Text('今日试戴额度已用完')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('明天再来,或做任务多换几次')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
Row({ space: 12 }) {
|
||||
Button('知道了')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
})
|
||||
Button('做任务换额度')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):到账后重拉试戴额度(服务端 Phase B 接入后生效)。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (_quotaKey: string): void => {
|
||||
this.loadHairRemaining()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
@@ -342,6 +467,22 @@ struct HairDesignPage {
|
||||
.width('100%').height(240).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
}
|
||||
|
||||
// 右下「今日还可试戴 N 次」胶囊(P0-5,试戴+换发色合并计数):仅展示不参与命中
|
||||
if (this.hairRemaining >= 0) {
|
||||
Row() {
|
||||
Text(`今日还可试戴 ${this.hairRemaining} 次`)
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.padding({ right: 8, bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.onClick(() => {
|
||||
@@ -496,6 +637,10 @@ struct HairDesignPage {
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
|
||||
// 合并计数脚注(P0-5):试戴与换发色共用 hairstyle 每日额度
|
||||
Text('试戴与换发色合并计算每日额度')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
@@ -537,9 +682,16 @@ struct HairDesignPage {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.showQuotaBlocked) {
|
||||
this.QuotaBlockedDialog()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_PAGE_BG)
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user