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:
xsl
2026-09-11 21:51:25 +08:00
parent 9228f67820
commit a850735d60
17 changed files with 1433 additions and 29 deletions
@@ -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
})
}
}