From a850735d6029d4081586c41f7e73c77bc188ceb7 Mon Sep 17 00:00:00 2001 From: xsl Date: Fri, 11 Sep 2026 21:51:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E4=BB=BB=E5=8A=A1=E6=8D=A2?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6+=E7=AB=AF=E4=BE=A7=E5=9F=8B=E7=82=B9+?= =?UTF-8?q?=E8=AF=95=E7=A9=BF=E9=99=90=E6=AC=A1+=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E9=A2=84=E7=BA=A6=E5=8D=A1+=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E9=94=99=E8=AF=AF=E6=80=81=E2=80=94=E2=80=94PM/?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=8F=8CAgent=E8=BF=AD=E4=BB=A32-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 后端) --- .../main/ets/components/f1/ProfileContent.ets | 56 +++- .../main/ets/components/f1/TaskBonusPanel.ets | 222 ++++++++++++++++ .../main/ets/components/f2/NetworkBanner.ets | 36 +++ .../main/ets/components/f3/DimensionCard.ets | 85 +++++++ app/entry/src/main/ets/core/ApiTypes.ets | 8 + app/entry/src/main/ets/core/DailyLimiter.ets | 158 ++++++++++++ .../src/main/ets/core/TaskBonusState.ets | 75 ++++++ app/entry/src/main/ets/core/Tracker.ets | 169 +++++++++++++ .../main/ets/entryability/EntryAbility.ets | 5 + app/entry/src/main/ets/pages/ChatPage.ets | 82 ++++-- .../main/ets/pages/DiagnosisLandingPage.ets | 3 + .../src/main/ets/pages/FittingRoomPage.ets | 129 ++++++++++ .../src/main/ets/pages/HairDesignPage.ets | 152 +++++++++++ app/entry/src/main/ets/pages/HomePage.ets | 39 ++- .../src/main/ets/pages/ReportCreatePage.ets | 2 + .../src/main/ets/pages/ReportResultPage.ets | 239 +++++++++++++++++- meida_backend | 2 +- 17 files changed, 1433 insertions(+), 29 deletions(-) create mode 100644 app/entry/src/main/ets/components/f1/TaskBonusPanel.ets create mode 100644 app/entry/src/main/ets/components/f2/NetworkBanner.ets create mode 100644 app/entry/src/main/ets/core/DailyLimiter.ets create mode 100644 app/entry/src/main/ets/core/TaskBonusState.ets create mode 100644 app/entry/src/main/ets/core/Tracker.ets diff --git a/app/entry/src/main/ets/components/f1/ProfileContent.ets b/app/entry/src/main/ets/components/f1/ProfileContent.ets index 335b7e1..a2f0ac9 100644 --- a/app/entry/src/main/ets/components/f1/ProfileContent.ets +++ b/app/entry/src/main/ets/components/f1/ProfileContent.ets @@ -18,14 +18,17 @@ import router from '@ohos.router' // 即使 clean 构建该文件也先落盘(#19:release 构建隐藏调试入口的判定来源) import BuildProfile from '../../../../../build/default/generated/profile/default/BuildProfile' import { ApiClient } from '../../core/ApiClient' -import { ApiError, isAuthError } from '../../core/ApiTypes' +import { ApiError, isAuthError, isNetworkError } from '../../core/ApiTypes' import { AuthStore } from '../../core/AuthStore' import { COLOR_PRIMARY } from '../../core/Theme' import { FaceConsentStore } from '../f2/FacePhotoConsent' +import { NetworkBanner } from '../f2/NetworkBanner' +import { Tracker } from '../../core/Tracker' import { formatIsoTime } from '../f3/ReportTypes' import { ErrorBanner } from './ErrorBanner' import { QuotaCardView } from './QuotaCard' import { LedgerItemView } from './LedgerItem' +import { TaskBonusPanel } from './TaskBonusPanel' /** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */ interface QuotaWire { @@ -240,8 +243,10 @@ export struct ProfileContent { @State bannerMessage: string = '' @State bannerHint: string = '' @State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7) + @State networkFailed: boolean = false // 网络/服务类失败(P0-6):顶部 NetworkBanner;与 authExpired 互斥 @State refreshing: boolean = false @State loggingOut: boolean = false + @State showTaskPanel: boolean = false // 「做任务换额度」半屏面板(P0-4) @State faceConsentGranted: boolean = false // 人脸照片授权状态(N4:查看/撤回入口数据源) private pendingLoads: number = 0 @@ -269,6 +274,7 @@ export struct ProfileContent { this.bannerMessage = '' this.bannerHint = '' this.authExpired = false + this.networkFailed = false this.ledgerError = '' this.cardChat = new QuotaCardData('AI 对话') this.cardReport = new QuotaCardData('形象报告') @@ -305,6 +311,9 @@ export struct ProfileContent { // 单卡失败不拖垮整页:错误落在卡片内;会话过期类单独记,供横幅给重新登录出路 if (isAuthError(e)) { this.authExpired = true + } else if (isNetworkError(e)) { + // P0-6:网络/服务类失败上顶部 NetworkBanner(重试有意义) + this.networkFailed = true } const err = toApiError(e) const data = new QuotaCardData(quotaName(key)) @@ -341,6 +350,8 @@ export struct ProfileContent { } catch (e) { if (isAuthError(e)) { this.authExpired = true + } else if (isNetworkError(e)) { + this.networkFailed = true } const err = toApiError(e) this.ledgerLoaded = true @@ -404,6 +415,16 @@ export struct ProfileContent { } } + /** 「做任务换额度」半屏面板(P0-4):claim 到账后全量刷新四张配额卡。 */ + @Builder + TaskBonusSheet() { + TaskBonusPanel({ + onClaimed: (_quotaKey: string): void => { + this.startLoadAll() + } + }) + } + /** 未登录占位:页内提示 + 去登录(不强制跳转)。 */ @Builder LoginPrompt() { @@ -442,6 +463,16 @@ export struct ProfileContent { Refresh({ refreshing: $$this.refreshing }) { Scroll() { Column({ space: 14 }) { + // P0-6:网络/服务类失败横幅(与 authExpired 的「重新登录」出路互斥展示) + if (this.networkFailed && !this.authExpired) { + NetworkBanner({ + onRetry: (): void => { + Tracker.track('network.banner_retry', { 'page': 'profile' }) + this.startLoadAll() + } + }) + } + // 顶部:用户信息 + 登出 Row({ space: 12 }) { Column() { @@ -619,6 +650,25 @@ export struct ProfileContent { .width('100%') .alignItems(VerticalAlign.Top) + // 「做任务换额度」入口(P0-4 / FR-BILL-03):配额四卡下方,点开半屏任务面板 + Row({ space: 8 }) { + Text('做任务换额度') + .fontSize(14) + .fontColor('#2B2B2B') + .layoutWeight(1) + Text('每日签到领 AI 对话次数 ›') + .fontSize(12) + .fontColor(COLOR_PRIMARY) + } + .width('100%') + .padding({ left: 16, right: 16, top: 14, bottom: 14 }) + .borderRadius(16) + .backgroundColor('#FFFFFF') + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.showTaskPanel = true + }) + // 最近流水 Column({ space: 4 }) { Text('最近流水') @@ -690,6 +740,10 @@ export struct ProfileContent { }) .layoutWeight(1) .width('100%') + .bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), { + height: '60%', + showClose: true + }) } } } diff --git a/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets b/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets new file mode 100644 index 0000000..50b7e23 --- /dev/null +++ b/app/entry/src/main/ets/components/f1/TaskBonusPanel.ets @@ -0,0 +1,222 @@ +/** + * TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04)。 + * + * 供弹层/区块复用:ProfileContent 入口卡、ChatPage 耗尽面板、试穿/发型软限制 + * 耗尽面板都以 bindSheet 承载本组件。 + * + * 纪律: + * - 任务表本地静态(icon/名称/说明),奖励数值一律不硬编码——展示文案统一 + * 「领额度」,实际到账以 claim 响应 amount 为准; + * - 文案纪律(FR-BILL-07):不出现价格/购买/会员/升级字样; + * - 已领判定走 core/TaskBonusState(preferences meida_task_bonus 的共享读写, + * 与报告页反馈自动 claim 互通),跨重启保留;claim 返回 40910(本周期已领) + * 时同步标记——本地状态永远以服务端口径为准; + * - 本迭代只接 sign_in 的真实 claim 链路;其余 4 项为「去完成」轻引导 toast, + * 完成判定属后续迭代。 + */ + +import { promptAction } from '@kit.ArkUI' +import { ApiClient } from '../../core/ApiClient' +import { ApiError } from '../../core/ApiTypes' +import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../../core/TaskBonusState' +import { Tracker } from '../../core/Tracker' +import { + COLOR_PRIMARY, + COLOR_TEXT_PRIMARY, + COLOR_TEXT_SECONDARY, + COLOR_TEXT_DISABLED, + COLOR_IMAGE_BG +} from '../../core/Theme' + +/** POST /api/v1/quota/tasks/{task_key}/claim 响应(字段名对齐后端 JSON)。 */ +interface ClaimWire { + task_key: string + quota_key: string + amount: number + bonus_granted: number + remaining: number +} + +/** 任务定义。direct=true 表示点击即调 claim;false 为「去完成」轻引导。 */ +interface TaskDef { + key: string + icon: string + name: string + desc: string + period: string // day / lifetime + direct: boolean + guide: string // direct=false 时的引导 toast 文案 +} + +/** 五项任务(task_key 与后端 TASK_BONUS_RULES_JSON 对齐)。 */ +const TASKS: TaskDef[] = [ + { + key: 'sign_in', icon: '✓', name: '每日签到', desc: '每天来一次,领 AI 对话额度', + period: PERIOD_DAY, direct: true, guide: '' + }, + { + key: 'profile_complete', icon: '☺', name: '完善画像资料', desc: '补全资料,建议更合身', + period: PERIOD_LIFETIME, direct: false, guide: '完善资料功能即将上线' + }, + { + key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '告诉我们要不要调整', + period: PERIOD_DAY, direct: false, guide: '在报告页给结论打反馈可领额度' + }, + { + key: 'share_report', icon: '↗', name: '分享我的报告', desc: '把形象报告分享给朋友', + period: PERIOD_DAY, direct: false, guide: '生成报告后可分享领额度' + }, + { + key: 'invite_friend', icon: '♡', name: '邀请好友', desc: '邀请朋友一起用美搭', + period: PERIOD_DAY, direct: false, guide: '邀请功能即将上线' + } +] + +/** 本周期已领 / 奖励封顶 / 未知任务(与后端 errors.py 对齐)。 */ +const CODE_ALREADY_CLAIMED: number = 40910 +const CODE_BONUS_CAPPED: number = 40310 + +@Component +export struct TaskBonusPanel { + /** claim 成功回调:quotaKey 为到账的配额 key,宿主据此刷新对应额度卡。 */ + onClaimed: (quotaKey: string) => void = () => {} + + @State claimingKey: string = '' // 领取中的 task_key(防空点) + @State claimsTick: number = 0 // claims 变更计数:驱动已领态重渲染 + + aboutToAppear(): void { + this.hydrate() + } + + /** 已领态由 TaskBonusState 共享(报告页反馈自动 claim 也要互通),这里只 hydrate + 触发重渲染。 */ + private async hydrate(): Promise { + await TaskBonusState.init() + this.claimsTick += 1 + } + + private isClaimed(task: TaskDef): boolean { + return TaskBonusState.isClaimed(task.key, task.period) + } + + private async markClaimed(task: TaskDef): Promise { + await TaskBonusState.markClaimed(task.key, task.period) + this.claimsTick += 1 + } + + private toast(message: string): void { + try { + promptAction.showToast({ message: message, duration: 2000 }) + } catch (_e) { + // 无 UIContext 的极端场景:toast 失败不阻断主流程 + } + } + + private onTapTask(task: TaskDef): void { + if (this.isClaimed(task) || this.claimingKey !== '') { + return + } + if (!task.direct) { + // 「去完成」轻引导:本迭代不接真实完成判定 + this.toast(task.guide) + return + } + this.claim(task) + } + + private async claim(task: TaskDef): Promise { + this.claimingKey = task.key + try { + const req: Record = {} + const d = await ApiClient.post(`/api/v1/quota/tasks/${task.key}/claim`, req) + await this.markClaimed(task) + this.toast(`签到成功,额度 +${d.amount}`) + Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': true }) + this.onClaimed(d.quota_key) + } catch (e) { + const err: ApiError = e instanceof ApiError ? e : new ApiError(50000, '网络异常,请稍后重试') + if (err.code === CODE_ALREADY_CLAIMED) { + // 服务端说本周期已领:同步本地已领态(换机/重装后的对齐通道) + await this.markClaimed(task) + this.toast('今天已经领过啦') + } else if (err.code === CODE_BONUS_CAPPED) { + this.toast('今日任务奖励已达上限') + } else { + this.toast(err.message) + } + Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': false, 'code': err.code }) + } finally { + this.claimingKey = '' + } + } + + /** 按钮文案/置灰:已领 > 领取中 > 可领取/去完成。 */ + private actionText(task: TaskDef): string { + if (this.isClaimed(task)) { + return task.period === PERIOD_LIFETIME ? '已领取' : '今日已领' + } + if (this.claimingKey === task.key) { + return '领取中…' + } + return task.direct ? '领额度' : '去完成' + } + + @Builder + TaskRow(task: TaskDef) { + Row({ space: 12 }) { + Text(task.icon) + .fontSize(18) + .fontColor(COLOR_PRIMARY) + .width(40) + .height(40) + .textAlign(TextAlign.Center) + .borderRadius(12) + .backgroundColor(COLOR_IMAGE_BG) + Column({ space: 3 }) { + Text(task.name) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(COLOR_TEXT_PRIMARY) + Text(task.desc) + .fontSize(12) + .fontColor(COLOR_TEXT_SECONDARY) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Text(this.claimsTick >= 0 ? this.actionText(task) : '') + .fontSize(13) + .fontColor(this.isClaimed(task) ? COLOR_TEXT_DISABLED : COLOR_PRIMARY) + .padding({ left: 14, right: 14, top: 6, bottom: 6 }) + .borderRadius(16) + .backgroundColor(this.isClaimed(task) ? COLOR_IMAGE_BG : '#F7E3EA') + .onClick(() => { + this.onTapTask(task) + }) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + } + + build() { + Column({ space: 4 }) { + Text('做任务换额度') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor(COLOR_TEXT_PRIMARY) + .width('100%') + .margin({ bottom: 4 }) + ForEach(TASKS, (task: TaskDef) => { + this.TaskRow(task) + }, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`) + Text('奖励以实际到账为准,额度每日重置') + .fontSize(11) + .fontColor(COLOR_TEXT_DISABLED) + .width('100%') + .textAlign(TextAlign.Center) + .margin({ top: 8 }) + } + .width('100%') + .padding({ left: 16, right: 16, top: 16, bottom: 24 }) + .backgroundColor(Color.White) + } +} diff --git a/app/entry/src/main/ets/components/f2/NetworkBanner.ets b/app/entry/src/main/ets/components/f2/NetworkBanner.ets new file mode 100644 index 0000000..0389b77 --- /dev/null +++ b/app/entry/src/main/ets/components/f2/NetworkBanner.ets @@ -0,0 +1,36 @@ +/** + * NetworkBanner —— 统一网络/服务错误横幅(P0-6)。 + * + * 用于「静默失败」的场景补位:数据没拉到但不值得整页错误态时, + * 告诉用户数据可能不是最新的,并给「重试」出路。 + * 判定口径:isNetworkError(e)(core/ApiTypes,code=50000)。 + * 样式对齐 ErrorBanner:#FDF3E3 底 + #D46A0B 字。 + */ + +@Component +export struct NetworkBanner { + onRetry: () => void = () => {} + + build() { + Row({ space: 8 }) { + Text('网络不太顺畅,数据可能不是最新的') + .fontSize(12) + .fontColor('#D46A0B') + .layoutWeight(1) + Text('重试') + .fontSize(12) + .fontColor('#D46A0B') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .onClick(() => { + this.onRetry() + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .borderRadius(10) + .backgroundColor('#FDF3E3') + .alignItems(VerticalAlign.Center) + } +} diff --git a/app/entry/src/main/ets/components/f3/DimensionCard.ets b/app/entry/src/main/ets/components/f3/DimensionCard.ets index ec93e5b..afbf9b5 100644 --- a/app/entry/src/main/ets/components/f3/DimensionCard.ets +++ b/app/entry/src/main/ets/components/f3/DimensionCard.ets @@ -16,7 +16,13 @@ const CARD_PRIMARY_COLOR: string = COLOR_PRIMARY @Component export struct DimensionCard { dim: DimensionWire = EMPTY_DIMENSION + /** 已提交的反馈判定('' = 未反馈;'accurate' / 'inaccurate' 高亮已选项,宿主回显用)。 */ + feedbackVerdict: string = '' + /** 提交反馈(组件保持无网络):宿主调 POST /reports/{id}/feedback。 */ + onFeedback: (dimKey: string, verdict: string, correction: string) => void = () => {} @State ruleExpanded: boolean = false + @State correctionOpen: boolean = false // 「不准」展开的行内修正输入 + @State correctionText: string = '' private isSkipped(): boolean { return this.dim.status !== 'ok' @@ -63,6 +69,26 @@ export struct DimensionCard { return '本期未采集到足够信息' } + /** 反馈区可用条件(T-P3-21):仅 status=ok 且有 key 的维度卡。 */ + private feedbackable(): boolean { + const k = this.dim.key + return !this.isSkipped() && k !== undefined && k !== '' + } + + private dimKey(): string { + return this.dim.key ?? '' + } + + private submitFeedback(verdict: string): void { + if (verdict === 'inaccurate' && !this.correctionOpen) { + // 点「不准」先展开行内修正输入(可选,≤100 字),再点「提交」才上报 + this.correctionOpen = true + return + } + this.correctionOpen = false + this.onFeedback(this.dimKey(), verdict, verdict === 'inaccurate' ? this.correctionText.trim() : '') + } + build() { Column({ space: 8 }) { Row({ space: 8 }) { @@ -129,6 +155,65 @@ export struct DimensionCard { .alignItems(HorizontalAlign.Start) } } + + // 结论反馈区(T-P3-21):仅 ok 维度;已选项高亮,再点另一项即改判 + if (this.feedbackable()) { + Row({ space: 10 }) { + Text('这个结论准吗?') + .fontSize(12) + .fontColor('#8A9099') + .layoutWeight(1) + Text('准 👍') + .fontSize(12) + .fontColor(this.feedbackVerdict === 'accurate' ? '#FFFFFF' : CARD_PRIMARY_COLOR) + .padding({ left: 12, right: 12, top: 4, bottom: 4 }) + .borderRadius(14) + .backgroundColor(this.feedbackVerdict === 'accurate' ? CARD_PRIMARY_COLOR : '#F7E3EA') + .onClick(() => { + this.submitFeedback('accurate') + }) + Text('不准 👎') + .fontSize(12) + .fontColor(this.feedbackVerdict === 'inaccurate' ? '#FFFFFF' : '#666666') + .padding({ left: 12, right: 12, top: 4, bottom: 4 }) + .borderRadius(14) + .backgroundColor(this.feedbackVerdict === 'inaccurate' ? '#666666' : '#F5F5F7') + .onClick(() => { + this.submitFeedback('inaccurate') + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + .margin({ top: 4 }) + + // 「不准」的行内修正输入(可选,≤100 字) + if (this.correctionOpen) { + TextArea({ text: this.correctionText, placeholder: '你觉得应该是?(可选)' }) + .width('100%') + .height(72) + .fontSize(13) + .maxLength(100) + .backgroundColor('#F5F5F7') + .borderRadius(8) + .onChange((v: string) => { + this.correctionText = v + }) + Row() { + Blank() + Text('提交') + .fontSize(13) + .fontColor(Color.White) + .padding({ left: 20, right: 20, top: 6, bottom: 6 }) + .borderRadius(16) + .backgroundColor(CARD_PRIMARY_COLOR) + .onClick(() => { + this.onFeedback(this.dimKey(), 'inaccurate', this.correctionText.trim()) + this.correctionOpen = false + }) + } + .width('100%') + } + } } .width('100%') .padding(12) diff --git a/app/entry/src/main/ets/core/ApiTypes.ets b/app/entry/src/main/ets/core/ApiTypes.ets index f7281df..cb1d5e9 100644 --- a/app/entry/src/main/ets/core/ApiTypes.ets +++ b/app/entry/src/main/ets/core/ApiTypes.ets @@ -38,3 +38,11 @@ const AUTH_ERROR_CODES: number[] = [40100, 40101, 40102, 40103, 40104, 40105] export function isAuthError(e: Object): boolean { return e instanceof ApiError && AUTH_ERROR_CODES.includes(e.code) } + +/** + * 是否网络/服务类错误(P0-6):ApiClient 里网络异常与网关无信封错误都收敛到 + * 50000(INTERNAL_ERROR 段)。这类错误「重试」有意义,错误态给 NetworkBanner。 + */ +export function isNetworkError(e: Object): boolean { + return e instanceof ApiError && e.code === 50000 +} diff --git a/app/entry/src/main/ets/core/DailyLimiter.ets b/app/entry/src/main/ets/core/DailyLimiter.ets new file mode 100644 index 0000000..34cfe9d --- /dev/null +++ b/app/entry/src/main/ets/core/DailyLimiter.ets @@ -0,0 +1,158 @@ +/** + * DailyLimiter —— 试穿/试戴端侧每日兜底限次(P0-5,与任务换额度并行)。 + * + * - 免费档:tryon / hairstyle 各 3 次/天(产品方案 §3.2),按自然日(本地时区 + * yyyy-MM-dd)重置;持久化 preferences(meida_daily_limit),结构 + * {"yyyy-MM-dd": {"tryon": n, "hairstyle": n, "tryonBonus": n, "hairstyleBonus": n}}, + * 旧日期整串替换不累积;bonus 缺省 0(迭代 2 旧数据读不到按 0 兼容); + * - 奖励加成(迭代 3 修正):服务端配额拉取成功时 setBonus(key, bonus_granted) + * 把当日上限抬为 3 + max(bonus,0)——「做任务多换几次」在试穿/试戴页真正兑现; + * 服务端拉取失败保持 bonus=0(兜底 3 次不变); + * - 口径「失败不计数」:调用点先 peek 判断放行,生成成功后 tryConsume 记账, + * 失败分支不调 tryConsume; + * - 端侧只是兜底/体验层(服务端配额是权威):剩余次数取 min(服务端剩余, + * 端侧剩余),页面自行合并;preferences 不可用时退化为内存态,不阻断功能。 + */ + +import preferences from '@ohos.data.preferences' +import { ContextHolder } from './ContextHolder' + +export type DailyLimitKey = 'tryon' | 'hairstyle' + +interface DailyCounts { + tryon: number + hairstyle: number + tryonBonus: number + hairstyleBonus: number +} + +/** 免费档每日上限(产品方案 §3.2)。 */ +const DAILY_LIMIT: number = 3 +const PREF_NAME: string = 'meida_daily_limit' +const KEY_COUNTS: string = 'counts' + +export class DailyLimiter { + private static counts: DailyCounts = { tryon: 0, hairstyle: 0, tryonBonus: 0, hairstyleBonus: 0 } + private static day: string = '' + private static prefs: preferences.Preferences | undefined + + /** hydrate preferences → 内存;幂等。日期漂移(跨零点)在每次读取时检查。 */ + static async init(): Promise { + if (DailyLimiter.prefs !== undefined) { + DailyLimiter.ensureToday() + return + } + try { + DailyLimiter.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME }) + const raw = await DailyLimiter.prefs.get(KEY_COUNTS, '') as string + if (raw !== '') { + const parsed = JSON.parse(raw) as Record + const today: string = DailyLimiter.today() + const todayCounts: DailyCounts | undefined = parsed[today] + if (todayCounts !== undefined) { + DailyLimiter.day = today + DailyLimiter.counts = { + tryon: DailyLimiter.safeNum(todayCounts.tryon), + hairstyle: DailyLimiter.safeNum(todayCounts.hairstyle), + tryonBonus: DailyLimiter.safeNum(todayCounts.tryonBonus), + hairstyleBonus: DailyLimiter.safeNum(todayCounts.hairstyleBonus) + } + } else { + DailyLimiter.reset() + } + } else { + DailyLimiter.reset() + } + } catch (_e) { + DailyLimiter.reset() + } + } + + /** 剩余次数(含自然日重置检查):上限 = DAILY_LIMIT + 当日 bonus。 */ + static remaining(key: DailyLimitKey): number { + DailyLimiter.ensureToday() + return Math.max(DAILY_LIMIT + DailyLimiter.getBonus(key) - DailyLimiter.getCount(key), 0) + } + + /** 同 remaining,语义上强调「只看不记」:生成前放行判断用。 */ + static peek(key: DailyLimitKey): number { + return DailyLimiter.remaining(key) + } + + /** 设置当日奖励加成(服务端 quota.bonus_granted):上限变 3 + max(bonus,0),随当天结构落盘。 */ + static setBonus(key: DailyLimitKey, bonus: number): void { + DailyLimiter.ensureToday() + const v: number = Math.max(Math.floor(bonus), 0) + if (key === 'tryon') { + DailyLimiter.counts.tryonBonus = v + } else { + DailyLimiter.counts.hairstyleBonus = v + } + DailyLimiter.persist() + } + + /** 记一次成功消耗;返回是否记上(满额返回 false,调用点不应走到这)。 */ + static tryConsume(key: DailyLimitKey): boolean { + DailyLimiter.ensureToday() + if (DailyLimiter.remaining(key) <= 0) { + return false + } + if (key === 'tryon') { + DailyLimiter.counts.tryon += 1 + } else { + DailyLimiter.counts.hairstyle += 1 + } + DailyLimiter.persist() + return true + } + + /** 本地时区自然日 key(yyyy-MM-dd)。 */ + static today(): string { + const d: Date = new Date() + const m: string = `${d.getMonth() + 1}`.padStart(2, '0') + const day: string = `${d.getDate()}`.padStart(2, '0') + return `${d.getFullYear()}-${m}-${day}` + } + + /** 跨零点惰性重置:每次读/写前对齐当天。 */ + private static ensureToday(): void { + const today: string = DailyLimiter.today() + if (DailyLimiter.day !== today) { + DailyLimiter.reset() + } + } + + /** 计数读取(ArkTS 不允许字段索引访问,显式分派)。 */ + private static getCount(key: DailyLimitKey): number { + return key === 'tryon' ? DailyLimiter.counts.tryon : DailyLimiter.counts.hairstyle + } + + /** 加成读取(同上,显式分派)。 */ + private static getBonus(key: DailyLimitKey): number { + return key === 'tryon' ? DailyLimiter.counts.tryonBonus : DailyLimiter.counts.hairstyleBonus + } + + private static reset(): void { + DailyLimiter.day = DailyLimiter.today() + DailyLimiter.counts = { tryon: 0, hairstyle: 0, tryonBonus: 0, hairstyleBonus: 0 } + } + + private static safeNum(v: number): number { + return typeof v === 'number' && v >= 0 ? Math.floor(v) : 0 + } + + /** 只存当天结构(旧日期整串替换)。尽力写盘,失败不阻断。 */ + private static persist(): void { + try { + if (DailyLimiter.prefs === undefined) { + return + } + const data: Record = {} + data[DailyLimiter.day] = DailyLimiter.counts + DailyLimiter.prefs.put(KEY_COUNTS, JSON.stringify(data)) + DailyLimiter.prefs.flush() + } catch (_e) { + // 内存态已生效,持久化失败下次启动重置即可 + } + } +} diff --git a/app/entry/src/main/ets/core/TaskBonusState.ets b/app/entry/src/main/ets/core/TaskBonusState.ets new file mode 100644 index 0000000..8a5f677 --- /dev/null +++ b/app/entry/src/main/ets/core/TaskBonusState.ets @@ -0,0 +1,75 @@ +/** + * TaskBonusState —— 「做任务换额度」已领标记的共享读写(P0-4,迭代 3 抽取)。 + * + * 从 TaskBonusPanel 抽出的公共逻辑:preferences meida_task_bonus / claims + * ({task_key: ISO日期|'done'},与迭代 2 同文件同结构,不迁移旧数据)。 + * 共享动机:报告页反馈成功会自动 claim report_feedback——若已领态只活在 + * 面板里,用户在报告页领了、任务面板里还显示可领。 + * + * 口径:day 任务按本地 ISO 日期(yyyy-MM-dd)判定;lifetime 任务领过即 'done'。 + * 本地状态永远以服务端口径为准:claim 返回 40910(本周期已领)时各调用点 + * 都要 markClaimed 同步。 + */ + +import preferences from '@ohos.data.preferences' +import { ContextHolder } from './ContextHolder' + +export const PERIOD_DAY: string = 'day' +export const PERIOD_LIFETIME: string = 'lifetime' +export const CLAIMED_DONE: string = 'done' // lifetime 任务的已领标记值 + +const PREF_NAME: string = 'meida_task_bonus' +const KEY_CLAIMS: string = 'claims' + +/** 本地 ISO 日期戳(yyyy-MM-dd):日任务的已领判定基准。 */ +export function todayStamp(): string { + const now = new Date() + const m: string = `${now.getMonth() + 1}`.padStart(2, '0') + const d: string = `${now.getDate()}`.padStart(2, '0') + return `${now.getFullYear()}-${m}-${d}` +} + +export class TaskBonusState { + private static claims: Record = {} + private static prefs: preferences.Preferences | undefined = undefined + + /** preferences → 内存(幂等;失败退化为空记录,功能仍可用)。 */ + static async init(): Promise { + if (TaskBonusState.prefs !== undefined) { + return + } + try { + TaskBonusState.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME }) + const raw = await TaskBonusState.prefs.get(KEY_CLAIMS, '') as string + if (raw !== '') { + const parsed = JSON.parse(raw) as Record + TaskBonusState.claims = typeof parsed === 'object' && parsed !== null ? parsed : {} + } + } catch (_e) { + TaskBonusState.claims = {} + } + } + + /** 是否本周期已领(读内存;调用前先 init)。 */ + static isClaimed(taskKey: string, period: string): boolean { + const v: string = TaskBonusState.claims[taskKey] ?? '' + if (period === PERIOD_LIFETIME) { + return v === CLAIMED_DONE + } + return v === todayStamp() + } + + /** 标记已领并尽力写盘;持久化失败不阻断(服务端 40910 会兜底同步)。 */ + static async markClaimed(taskKey: string, period: string): Promise { + TaskBonusState.claims[taskKey] = period === PERIOD_LIFETIME ? CLAIMED_DONE : todayStamp() + try { + if (TaskBonusState.prefs === undefined) { + TaskBonusState.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME }) + } + await TaskBonusState.prefs.put(KEY_CLAIMS, JSON.stringify(TaskBonusState.claims)) + await TaskBonusState.prefs.flush() + } catch (_e) { + // 内存态已生效;持久化失败最坏下次启动再领一次 + } + } +} diff --git a/app/entry/src/main/ets/core/Tracker.ets b/app/entry/src/main/ets/core/Tracker.ets new file mode 100644 index 0000000..a54fadf --- /dev/null +++ b/app/entry/src/main/ets/core/Tracker.ets @@ -0,0 +1,169 @@ +/** + * Tracker —— 端侧埋点(P0-5 / FR-SYS-06),单 API:Tracker.track(eventName, props?)。 + * + * - 事件名在调用点写死字面量;内部用后端同款正则 ^[a-z][a-z0-9_.]{1,63}$ + * 校验,不合法丢弃并 console.warn(不上报脏事件名,后端 40020 是整批 fail-close); + * - 队列:内存数组 + preferences(meida_tracker / queue)持久化,启动时 hydrate; + * track 时补 client_ts(ISO8601);队列 ≥20 条立即 flush,否则 30s 定时器节流; + * EntryAbility.onBackground 尽力 flush 一次; + * - 上报:POST /api/v1/events/batch(只走 ApiClient)。成功 / 40020(内容违规 + * 整批拒)/ 40100(未登录短路)→ 整批丢弃;网络类其他失败 → 保留队列, + * 上限 200 条超出丢最旧; + * - 埋点永不影响业务:内部所有异常吞掉; + * - 隐私纪律:props 只允许页面名、quota_key、task_key、错误码、耗时等低维 + * 枚举/数值;禁止用户输入原文、报告内容、图片 key、设备号。phone/token/ + * authorization 键(大小写不敏感)在入队前强制剔除(后端同款黑名单兜底)。 + */ + +import preferences from '@ohos.data.preferences' +import { ApiClient } from './ApiClient' +import { ApiError } from './ApiTypes' +import { ContextHolder } from './ContextHolder' + +/** 单条事件(字段名对齐后端 EventItem schema)。 */ +export interface TrackEvent { + event_name: string + props: Record + client_ts: string +} + +const PREF_NAME = 'meida_tracker' +const KEY_QUEUE = 'queue' +const EVENT_NAME_RE: RegExp = /^[a-z][a-z0-9_.]{1,63}$/ +const FORBIDDEN_PROP_KEYS: string[] = ['phone', 'token', 'authorization'] +const FLUSH_THRESHOLD: number = 20 // 达到即 flush +const BATCH_SIZE: number = 50 // 后端单批上限 +const QUEUE_CAP: number = 200 // 超出丢最旧 +const FLUSH_INTERVAL_MS: number = 30000 +/** 后端对违规批次 fail-close:40020 整批拒;未登录短路 40100 同样弃批。 */ +const DROP_BATCH_CODES: number[] = [40020, 40100] + +export class Tracker { + private static queue: TrackEvent[] = [] + private static prefs: preferences.Preferences | undefined + private static flushing: boolean = false + private static timerStarted: boolean = false + + /** hydrate 持久化队列 + 启 30s 节流定时器。幂等;失败不阻断 App 启动。 */ + static async init(): Promise { + try { + if (Tracker.prefs === undefined) { + Tracker.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME }) + const raw = await Tracker.prefs.get(KEY_QUEUE, '') as string + if (raw !== '') { + const parsed = JSON.parse(raw) as TrackEvent[] + Tracker.queue = Array.isArray(parsed) + ? parsed.filter((e: TrackEvent): boolean => typeof e.event_name === 'string') + : [] + } + } + } catch (_e) { + Tracker.queue = [] + } + if (!Tracker.timerStarted) { + Tracker.timerStarted = true + setInterval(() => { + Tracker.flush() + }, FLUSH_INTERVAL_MS) + } + } + + /** 唯一公开 API。事件名/props 不合规即丢弃,永不抛异常。 */ + static track(eventName: string, props?: Record): void { + try { + if (!EVENT_NAME_RE.test(eventName)) { + console.warn(`MEIDA_TRACK drop invalid event name: ${eventName}`) + return + } + const event: TrackEvent = { + event_name: eventName, + props: Tracker.sanitizeProps(props), + client_ts: new Date().toISOString() + } + Tracker.queue.push(event) + while (Tracker.queue.length > QUEUE_CAP) { + Tracker.queue.shift() // 上限 200:丢最旧 + } + Tracker.persist() + if (Tracker.queue.length >= FLUSH_THRESHOLD) { + Tracker.flush() + } + } catch (_e) { + // 埋点永不影响业务 + } + } + + /** 退后台尽力 flush(EntryAbility.onBackground 调用,不 await)。 */ + static flushOnBackground(): void { + Tracker.flush() + } + + /** 逐批上报:丢批(成功/40020/40100)继续清下一批;网络失败保留剩余队列。 */ + private static async flush(): Promise { + if (Tracker.flushing || Tracker.queue.length === 0) { + return + } + Tracker.flushing = true + try { + while (Tracker.queue.length > 0) { + const batch: TrackEvent[] = Tracker.queue.slice(0, BATCH_SIZE) + const sent: boolean = await Tracker.sendBatch(batch) + if (!sent) { + break + } + Tracker.queue.splice(0, batch.length) + Tracker.persist() + } + } finally { + Tracker.flushing = false + } + } + + /** true=本批可丢弃(送达或明确被拒);false=网络类失败,保留重试。 */ + private static async sendBatch(batch: TrackEvent[]): Promise { + try { + const eventsObj: Object = batch + const body: Record = { 'events': eventsObj } + await ApiClient.post('/api/v1/events/batch', body) + return true + } catch (e) { + if (e instanceof ApiError && DROP_BATCH_CODES.includes(e.code)) { + console.warn(`MEIDA_TRACK drop batch, code=${e.code}`) + return true + } + return false + } + } + + /** 剔除黑名单键(大小写不敏感):后端 40020 是整批 fail-close,脏键不能上船。 */ + private static sanitizeProps( + props: Record | undefined + ): Record { + const out: Record = {} + if (props === undefined) { + return out + } + const keys: string[] = Object.keys(props) + for (const k of keys) { + if (FORBIDDEN_PROP_KEYS.includes(k.toLowerCase())) { + console.warn(`MEIDA_TRACK drop forbidden prop key: ${k}`) + continue + } + out[k] = props[k] + } + return out + } + + /** 尽力写盘:失败不阻断(内存队列仍在,下次启动重读旧值)。 */ + private static persist(): void { + try { + if (Tracker.prefs === undefined) { + return // init 未完成:内存队列优先,持久化等下次 track + } + Tracker.prefs.put(KEY_QUEUE, JSON.stringify(Tracker.queue)) + Tracker.prefs.flush() + } catch (_e) { + // 忽略 + } + } +} diff --git a/app/entry/src/main/ets/entryability/EntryAbility.ets b/app/entry/src/main/ets/entryability/EntryAbility.ets index dea97e9..bc81843 100644 --- a/app/entry/src/main/ets/entryability/EntryAbility.ets +++ b/app/entry/src/main/ets/entryability/EntryAbility.ets @@ -4,6 +4,7 @@ import { window } from '@kit.ArkUI'; import { ContextHolder } from '../core/ContextHolder'; import { AuthStore } from '../core/AuthStore'; import { FaceConsentStore } from '../components/f2/FacePhotoConsent'; +import { Tracker } from '../core/Tracker'; const DOMAIN = 0x0000; @@ -14,6 +15,8 @@ export default class EntryAbility extends UIAbility { AuthStore.init() // 人脸同意记录持久化(N4):hydrate preferences → 内存,两页门控零改动读内存 FaceConsentStore.init() + // 端侧埋点(P0-5):hydrate 持久化队列 + 启 30s 节流定时器(不 await,不阻断启动) + Tracker.init() try { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); } catch (err) { @@ -52,5 +55,7 @@ export default class EntryAbility extends UIAbility { onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); + // 退后台尽力 flush 一次埋点队列(不 await) + Tracker.flushOnBackground(); } } \ No newline at end of file diff --git a/app/entry/src/main/ets/pages/ChatPage.ets b/app/entry/src/main/ets/pages/ChatPage.ets index 2090b94..fb52907 100644 --- a/app/entry/src/main/ets/pages/ChatPage.ets +++ b/app/entry/src/main/ets/pages/ChatPage.ets @@ -1,10 +1,13 @@ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' -import { ApiError, isAuthError } from '../core/ApiTypes' +import { ApiError, isAuthError, isNetworkError } from '../core/ApiTypes' +import { Tracker } from '../core/Tracker' 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' +import { TaskBonusPanel } from '../components/f1/TaskBonusPanel' +import { NetworkBanner } from '../components/f2/NetworkBanner' interface ChatUsage { tokens_in: number @@ -139,6 +142,8 @@ struct ChatPage { @State voiceHint: string = '' @State speakingMsgIdx: number = -1 @State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7) + @State showTaskPanel: boolean = false // 额度耗尽 →「做任务换额度」半屏面板(P0-4) + @State quotaNetError: boolean = false // 额度预拉遇网络错误(P0-6):列表顶部 NetworkBanner private fingerDown: boolean = false private holdStartY: number = 0 private voiceEngine: VoiceInputEngine = new VoiceInputEngine() @@ -230,13 +235,18 @@ struct ChatPage { this.send() } - /** 预拉今日对话额度;失败保持 -1(横幅不展示,是否放行交给服务端校验)。 */ + /** 预拉今日对话额度;网络错误上横幅给重试(P0-6),其他失败保持 -1 由服务端校验放行。 */ private async loadQuotaRemaining(): Promise { + this.quotaNetError = false try { const d = await ApiClient.get('/api/v1/quota?quota_key=agent_chat') this.quotaRemaining = d.remaining this.quotaResetAt = d.reset_at ?? '' - } catch (_e) { + } catch (e) { + if (isNetworkError(e)) { + this.quotaNetError = true + return + } this.quotaRemaining = -1 this.quotaResetAt = '' } @@ -272,6 +282,7 @@ struct ChatPage { * 成功后即作废(下一轮重新生成)。 */ private async startTurn(text: string, retryKey: string): Promise { + Tracker.track('chat.send', { 'has_quick': QUICK_QUESTIONS.includes(text) }) const requestKey: string = retryKey !== '' ? retryKey : randomHex(16) this.inputText = '' this.sending = true @@ -298,10 +309,12 @@ struct ChatPage { this.speakText(data.reply, aiIdx, false) } } catch (e) { + Tracker.track('chat.send_failed', { 'code': e instanceof ApiError ? e.code : 50000 }) if (e instanceof ApiError) { this.replaceThinking(e.message, e.hint ?? '') if (e.code === QUOTA_EXHAUSTED_CODE) { // 额度耗尽(N7):同步刷新额度态——横幅换「已用完+恢复时间」、发送按钮置灰 + Tracker.track('quota.exhausted_view', { 'quota_key': 'agent_chat' }) this.loadQuotaRemaining() } } @@ -436,6 +449,18 @@ struct ChatPage { .alignItems(HorizontalAlign.Center) } + /** 「做任务换额度」半屏面板(P0-4):到账的是 agent_chat 额度时立即刷新剩余数。 */ + @Builder + TaskBonusSheet() { + TaskBonusPanel({ + onClaimed: (quotaKey: string): void => { + if (quotaKey === 'agent_chat') { + this.loadQuotaRemaining() + } + } + }) + } + build() { Stack() { Column() { @@ -480,16 +505,29 @@ struct ChatPage { .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 }) + // 剩余 0(P0-4 + P0-6):规格文案 + 恢复时间(reset_at 经 formatIsoTime 转本地时区, + // N5 先例见 ProfileContent)+「做任务换额度」出路,发送入口同步置灰 + Row({ space: 8 }) { + Text(this.quotaResetAt !== '' + ? `今日对话额度已用完,${formatIsoTime(this.quotaResetAt)} 后恢复;也可做任务多换几次` + : '今日对话额度已用完,明天再来,或做任务多换几次') + .fontSize(12) + .fontColor('#C0392B') + .layoutWeight(1) + Text('做任务换额度') + .fontSize(12) + .fontColor(COLOR_TEXT_SECONDARY) + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .onClick(() => { + this.showTaskPanel = true + }) + } + .width('100%') + .padding({ left: 12, right: 12, top: 4, bottom: 4 }) + .backgroundColor('#FDECEC') + .alignItems(VerticalAlign.Center) } // 会话过期横幅(#7):此时重试必败,唯一出路是重新登录 @@ -515,8 +553,18 @@ struct ChatPage { .backgroundColor('#FDECEC') } - List({ scroller: this.scroller }) { - ForEach(this.messages, (msg: ChatMsg, idx: number) => { + // P0-6:额度预拉遇网络错误——消息列表顶部横幅,重试重新拉额度 + // (startTurn 的 50000 失败维持现有气泡重试,不重复上横幅) + if (this.quotaNetError) { + NetworkBanner({ + onRetry: (): void => { + Tracker.track('network.banner_retry', { 'page': 'chat' }) + this.loadQuotaRemaining() + } + }) + } + + List({ scroller: this.scroller }) { ForEach(this.messages, (msg: ChatMsg, idx: number) => { ListItem() { MessageBubble({ role: msg.role, @@ -664,5 +712,9 @@ struct ChatPage { } .width('100%') .height('100%') + .bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), { + height: '60%', + showClose: true + }) } } diff --git a/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets b/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets index ea356df..5bb68ac 100644 --- a/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets +++ b/app/entry/src/main/ets/pages/DiagnosisLandingPage.ets @@ -12,6 +12,7 @@ */ import { router, display } from '@kit.ArkUI' +import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_PRIMARY, @@ -53,6 +54,7 @@ struct DiagnosisLandingPage { @State heroHeight: number = HERO_FALLBACK_HEIGHT aboutToAppear(): void { + Tracker.track('diagnosis.landing_view') // 规范 §5:hero 高 = 屏高 38%。滚动容器内百分比基准不稳定,按物理屏高换算 vp。 try { const d = display.getDefaultDisplaySync() @@ -63,6 +65,7 @@ struct DiagnosisLandingPage { } private goForm(): void { + Tracker.track('diagnosis.cta_click') router.pushUrl({ url: 'pages/DiagnosisFormPage' }) .catch((e: Object): void => { console.error(`nav to form failed: ${String(e).substring(0, 80)}`) diff --git a/app/entry/src/main/ets/pages/FittingRoomPage.ets b/app/entry/src/main/ets/pages/FittingRoomPage.ets index 85e3106..c3f487f 100644 --- a/app/entry/src/main/ets/pages/FittingRoomPage.ets +++ b/app/entry/src/main/ets/pages/FittingRoomPage.ets @@ -24,6 +24,9 @@ import request from '@ohos.request' import fs from '@ohos.file.fs' import { TryonApi } from '../core/TryonApi' import { ImageCodec } from '../core/ImageCodec' +import { ApiClient } from '../core/ApiClient' +import { DailyLimiter } from '../core/DailyLimiter' +import { Tracker } from '../core/Tracker' import { WardrobeStore, WardrobeItem, CLOTH_CATEGORIES } from '../core/WardrobeStore' import { BuiltInCloth } from '../core/BuiltInCloth' import { ApiError } from '../core/ApiTypes' @@ -40,6 +43,7 @@ import { COLOR_TEXT_DISABLED } from '../core/Theme' import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent' +import { TaskBonusPanel } from '../components/f1/TaskBonusPanel' import { CategoryChip } from '../components/f2/CategoryChip' import { ClothCard } from '../components/f2/ClothCard' import { CapsuleButton } from '../components/f2/CapsuleButton' @@ -48,6 +52,16 @@ import { ImageViewer } from '../components/f2/ImageViewer' /** 品类过滤 chips:全部 + 五品类(UI 规范 §8 文案)。 */ const FILTER_CHIPS: string[] = ['全部'].concat(CLOTH_CATEGORIES) +/** GET /api/v1/quota?quota_key=tryon 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */ +interface TryonQuotaWire { + quota_key: string + used: number + limit_value: number + bonus_granted: number + remaining: number + reset_at: string | null +} + @Entry @Component struct FittingRoomPage { @@ -78,6 +92,10 @@ struct FittingRoomPage { // —— 高级项 —— @State desc: string = '' @State advancedOpen: boolean = false + // —— 每日试穿额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2)—— + @State tryonRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余 + @State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay + @State showTaskPanel: boolean = false // 「做任务换额度」半屏面板 private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口(本人照) private pendingAddUri: string = '' // 已选待入橱的服装图(品类确认后入库) @@ -102,6 +120,7 @@ struct FittingRoomPage { } this.hydrateWardrobe() this.hydrateBuiltin() + this.loadTryonRemaining() // 上次结果回看:沙盒留存最近一次换装结果,杀 App/重启不丢 try { if (fs.accessSync(this.resultStorePath())) { @@ -131,6 +150,23 @@ struct FittingRoomPage { this.builtinItems = await BuiltInCloth.load() } + /** + * 试穿剩余额度(P0-5):端侧 DailyLimiter 为兜底权威(Phase A 直连不经后端), + * 服务端配额可拉时取 min(服务端, 端侧);拉取失败静默用端侧值,不阻断功能。 + */ + private async loadTryonRemaining(): Promise { + await DailyLimiter.init() + try { + const q = await ApiClient.get('/api/v1/quota?quota_key=tryon') + // 奖励加成(迭代 3 修正):服务端 bonus_granted 抬当日端侧上限,再取 min + DailyLimiter.setBonus('tryon', q.bonus_granted) + this.tryonRemaining = Math.min(q.remaining, DailyLimiter.remaining('tryon')) + } catch (_e) { + // 拉取失败保持 bonus=0(兜底 3 次不变) + this.tryonRemaining = DailyLimiter.remaining('tryon') + } + } + // ==================== 品类过滤与槽位 ==================== /** 当前品类过滤下的衣橱条目(列表本身已按添加时间倒序)。 */ @@ -326,6 +362,12 @@ struct FittingRoomPage { if (!this.canTryOn()) { return } + // 每日额度耗尽(P0-5):不发请求,直接给「做任务换额度」出路 + if (this.tryonRemaining === 0) { + Tracker.track('fitting.generate', { 'ok': false, 'duration_s': 0, 'blocked': true }) + this.showQuotaBlocked = true + return + } const top = this.findItem(this.topSlotId) if (top === undefined) { return @@ -362,8 +404,13 @@ struct FittingRoomPage { return } this.statusText = saved ? '生成完成,点图可查看大图' : '结果已生成,本地暂存失败,请尽快保存到相册' + // 成功才计数(失败不占额度):端侧记账 + 埋点上报 + DailyLimiter.tryConsume('tryon') + this.tryonRemaining = DailyLimiter.remaining('tryon') + Tracker.track('fitting.generate', { 'ok': true, 'duration_s': this.elapsedSec, 'blocked': false }) this.viewerOpen = true // 生成完成自动弹全屏查看器恰一次(§10.1) } catch (e) { + Tracker.track('fitting.generate', { 'ok': false, 'duration_s': this.elapsedSec, 'blocked': false }) if (gen === this.genSeq) { this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试' } @@ -611,6 +658,24 @@ struct FittingRoomPage { .padding({ right: 16 }) .hitTestBehavior(HitTestMode.None) + // 右下「今日还可试穿 N 次」胶囊(P0-5):额度可见性,仅展示不参与命中 + if (this.tryonRemaining >= 0) { + Row() { + Text(`今日还可试穿 ${this.tryonRemaining} 次`) + .fontSize(11) + .fontColor(Color.White) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .borderRadius(12) + .backgroundColor(COLOR_BADGE_BG) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.End) + .alignItems(VerticalAlign.Bottom) + .padding({ right: 16, bottom: 12 }) + .hitTestBehavior(HitTestMode.None) + } + // 生成中覆盖层:阶段文案 + 已等待秒数 + 「取消生成」 if (this.generating) { Column({ space: 10 }) { @@ -945,6 +1010,63 @@ struct FittingRoomPage { .justifyContent(FlexAlign.Center) } + /** 今日试穿额度耗尽的引导 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.loadTryonRemaining() + } + }) + } + /** 删除确认弹窗(UI 规范 §8:移除这件衣服?/取消/移除)。 */ @Builder DeleteConfirmDialog() { @@ -1180,6 +1302,9 @@ struct FittingRoomPage { if (this.showAddSheet) { this.AddCategorySheet() } + if (this.showQuotaBlocked) { + this.QuotaBlockedDialog() + } // 全屏图片查看器(§10.3):与预览共用 showOriginal/resultSrc if (this.viewerOpen) { ImageViewer({ @@ -1203,5 +1328,9 @@ struct FittingRoomPage { .width('100%') .height('100%') .backgroundColor(COLOR_PAGE_BG) + .bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), { + height: '60%', + showClose: true + }) } } diff --git a/app/entry/src/main/ets/pages/HairDesignPage.ets b/app/entry/src/main/ets/pages/HairDesignPage.ets index f7c0410..3983039 100644 --- a/app/entry/src/main/ets/pages/HairDesignPage.ets +++ b/app/entry/src/main/ets/pages/HairDesignPage.ets @@ -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 { + await DailyLimiter.init() + try { + const q = await ApiClient.get('/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 + }) } } diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index 4aa4709..ff4dc8d 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -11,9 +11,12 @@ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' +import { isNetworkError } from '../core/ApiTypes' +import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY } from '../core/Theme' import { ProfileContent } from '../components/f1/ProfileContent' import { ReportListTab } from '../components/f3/ReportListTab' +import { NetworkBanner } from '../components/f2/NetworkBanner' /** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */ interface QuotaWireResp { @@ -40,6 +43,7 @@ struct HomePage { @State currentTabIndex: number = 0 @State dateLine: string = '' @State quotaRemaining: string = '…' + @State quotaNetError: boolean = false // 额度拉取遇网络错误(P0-6):配额卡位置换 NetworkBanner @State reportRefreshTick: number = 0 // 自增通知报告 Tab 刷新(N2) aboutToAppear(): void { @@ -65,12 +69,18 @@ struct HomePage { return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEKDAYS[now.getDay()]}` } - /** 拉今日 AI 对话额度;失败静默降级为 '—'(首页额度只是辅助信息,不值得打断用户)。 */ + /** 拉今日 AI 对话额度;网络错误上横幅给重试(P0-6),其他失败静默降级为 '—'。 */ private async loadQuota(): Promise { + this.quotaNetError = false try { const data = await ApiClient.get(QUOTA_PATH) this.quotaRemaining = `${data.remaining}` - } catch (_e) { + } catch (e) { + if (isNetworkError(e)) { + // 重试有意义的错误:配额卡位置换 NetworkBanner,不再静默 + this.quotaNetError = true + return + } this.quotaRemaining = '—' } } @@ -91,9 +101,9 @@ struct HomePage { .justifyContent(FlexAlign.Center) } - /** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl。 */ + /** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl(埋点 home.entry_click)。 */ @Builder - EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string) { + EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string, target: string) { Row({ space: 12 }) { Text(icon) .fontSize(20) @@ -124,6 +134,7 @@ struct HomePage { .backgroundColor(Color.White) .alignItems(VerticalAlign.Center) .onClick(() => { + Tracker.track('home.entry_click', { 'target': target }) router.pushUrl({ url: targetUrl }) }) } @@ -175,11 +186,21 @@ struct HomePage { .alignItems(HorizontalAlign.Start) .margin({ top: 8 }) - this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage') - this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage') - this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage') - this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage') - this.QuotaCard() + this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis') + this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage', 'fitting') + this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair') + this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage', 'chat') + if (this.quotaNetError) { + // P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复 + NetworkBanner({ + onRetry: (): void => { + Tracker.track('network.banner_retry', { 'page': 'home' }) + this.loadQuota() + } + }) + } else { + this.QuotaCard() + } } .width('100%') .padding(16) diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index 9ae8c42..eee5587 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -18,6 +18,7 @@ import { router, componentUtils } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' +import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import { TaskPollingPanel } from '../components/f3/TaskPollingPanel' import { @@ -330,6 +331,7 @@ struct ReportCreatePage { return } this.submitting = false + Tracker.track('report.create_submit') this.phase = 'polling' } catch (e) { this.submitting = false diff --git a/app/entry/src/main/ets/pages/ReportResultPage.ets b/app/entry/src/main/ets/pages/ReportResultPage.ets index dd32dc3..1e9284c 100644 --- a/app/entry/src/main/ets/pages/ReportResultPage.ets +++ b/app/entry/src/main/ets/pages/ReportResultPage.ets @@ -18,11 +18,13 @@ * 与剩余次数(产品方案设计纪律 3「禁止静默扣减」) */ -import { router } from '@kit.ArkUI' +import { router, promptAction } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' import { ApiError, isAuthError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' import { ContextHolder } from '../core/ContextHolder' +import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState' +import { Tracker } from '../core/Tracker' import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme' import request from '@ohos.request' import fs from '@ohos.file.fs' @@ -55,6 +57,47 @@ interface QuotaWireResp { reset_at: string } +/** GET /reports/{id}/feedback 数组元素(T-P3-21)。 */ +interface FeedbackWireItem { + dimension_key: string + verdict: string + correction: string | null + created_at: string +} + +/** POST /reports/{id}/feedback 响应。updated=true 表示同维度覆盖更新(改判)。 */ +interface FeedbackUpsertResp { + dimension_key: string + verdict: string + updated: boolean +} + +/** POST /quota/tasks/report_feedback/claim 响应(report_feedback 任务真实闭环)。 */ +interface ClaimWire { + task_key: string + quota_key: string + amount: number + bonus_granted: number + remaining: number +} + +/** POST /reservations 响应(T-P3-22)。created=false 表示幂等命中已有记录。 */ +interface ReservationWire { + topic: string + created: boolean + created_at: string +} + +/** GET /reservations?topic=… 响应。 */ +interface ReservationListWire { + topics: string[] +} + +/** 本周期已领(与后端 errors.py 对齐)。 */ +const CODE_ALREADY_CLAIMED: number = 40910 +/** 预约主题(T-P3-22 首期唯一值)。 */ +const RESERVE_TOPIC: string = 'full_report' + @Entry @Component struct ReportResultPage { @@ -85,6 +128,13 @@ struct ReportResultPage { @State confirmRemaining: number = -1 @State confirmResetAt: string = '' @State confirmLoaded: boolean = false + // —— 结论反馈(T-P3-21):dimension_key → verdict 回显表 —— + @State feedbackVerdicts: Record = {} + @State feedbackSubmitting: string = '' // 提交中的 dimension_key(防空点) + // —— 全面报告预约留资卡(T-P3-22)—— + @State reserved: boolean = false + @State reserveLoading: boolean = false + private cardViewTracked: boolean = false // full_report.card_view 每进入结果页只打一次 aboutToAppear(): void { const raw = router.getParams() @@ -116,6 +166,7 @@ struct ReportResultPage { this.completedText = formatIsoTime(d.completed_at) const result = d.result this.degraded = result?.validation?.degraded === true + Tracker.track('report.view', { 'degraded': this.degraded }) this.summaryText = result?.copy?.summary ?? '(本报告暂无摘要)' this.sections = result?.copy?.sections ?? [] const dims = result?.diagnosis?.dimensions ?? [] @@ -135,6 +186,16 @@ struct ReportResultPage { // 一并换新,防止新报告复用旧导出键撞后端 IDEMPOTENCY_CONFLICT(#22) this.regenRequestKey = '' this.exportRequestKey = '' + if (this.statusRaw === 'SUCCEEDED') { + // 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21) + this.loadFeedback() + // 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次 + if (!this.cardViewTracked) { + this.cardViewTracked = true + Tracker.track('full_report.card_view') + } + this.loadReservation() + } } catch (e) { this.loading = false this.loadErrorAuth = isAuthError(e) @@ -314,6 +375,113 @@ struct ReportResultPage { } } + // ==================== 结论反馈(T-P3-21 + report_feedback 任务闭环) ==================== + + private toast(message: string): void { + try { + promptAction.showToast({ message: message, duration: 2000 }) + } catch (_e) { + // toast 失败不阻断主流程 + } + } + + /** 拉已有反馈回显(dimension_key → verdict);失败静默(不影响报告展示)。 */ + private async loadFeedback(): Promise { + try { + const list = await ApiClient.get(`/api/v1/reports/${this.reportId}/feedback`) + const map: Record = {} + for (const item of list) { + map[item.dimension_key] = item.verdict + } + this.feedbackVerdicts = map + } catch (_e) { + // 回显失败不打扰:卡片仍可按未反馈态提交 + } + } + + /** + * 维度卡反馈提交:POST 成功才改本地已选态;随后静默 claim report_feedback + * 任务奖励(成功 toast 到账、40910 静默、其他失败静默)。 + */ + private async onFeedback(dimKey: string, verdict: string, correction: string): Promise { + if (this.feedbackSubmitting !== '') { + return + } + this.feedbackSubmitting = dimKey + try { + const req: Record = { 'dimension_key': dimKey, 'verdict': verdict } + if (correction !== '') { + req['correction'] = correction + } + await ApiClient.post(`/api/v1/reports/${this.reportId}/feedback`, req) + const next: Record = {} + const keys: string[] = Object.keys(this.feedbackVerdicts) + for (const k of keys) { + next[k] = this.feedbackVerdicts[k] + } + next[dimKey] = verdict + this.feedbackVerdicts = next + Tracker.track('report.feedback_submit', { 'verdict': verdict, 'has_correction': correction !== '' }) + this.claimReportFeedback() + } catch (e) { + // 失败(网络/校验)toast 原因,不改卡片已选态 + this.toast(e instanceof ApiError ? e.message : '提交失败,请稍后重试') + } finally { + this.feedbackSubmitting = '' + } + } + + /** report_feedback 任务真实闭环:奖励到账 toast;已领(40910)静默但同步已领态。 */ + private async claimReportFeedback(): Promise { + try { + const req: Record = {} + const d = await ApiClient.post('/api/v1/quota/tasks/report_feedback/claim', req) + await TaskBonusState.markClaimed('report_feedback', PERIOD_DAY) + this.toast(`感谢反馈,额度 +${d.amount}`) + } catch (e) { + if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) { + // 今日已领:静默不打扰,但同步已领态(任务面板显示一致) + await TaskBonusState.markClaimed('report_feedback', PERIOD_DAY) + } + // 其他失败静默:反馈本身已提交成功,不拿奖励失败打扰用户 + } + } + + // ==================== 全面报告预约留资卡(T-P3-22) ==================== + + /** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */ + private async loadReservation(): Promise { + try { + const d = await ApiClient.get(`/api/v1/reservations?topic=${RESERVE_TOPIC}`) + this.reserved = d.topics.includes(RESERVE_TOPIC) + } catch (_e) { + this.reserved = false + } + } + + private async doReserve(): Promise { + if (this.reserved || this.reserveLoading) { + return + } + this.reserveLoading = true + try { + const req: Record = { 'topic': RESERVE_TOPIC } + const d = await ApiClient.post('/api/v1/reservations', req) + this.reserved = true // created=false(幂等命中)同样是已预约态 + Tracker.track('full_report.reserve_result', { 'ok': true }) + if (d.created) { + this.toast('预约成功,上线后第一时间告诉你') + } + } catch (e) { + Tracker.track('full_report.reserve_result', { + 'ok': false, 'code': e instanceof ApiError ? e.code : 50000 + }) + this.toast(e instanceof ApiError ? e.message : '预约失败,请稍后重试') + } finally { + this.reserveLoading = false + } + } + private dimKey(d: DimensionWire): string { const k = d.key if (k !== undefined && k !== '') { @@ -611,8 +779,14 @@ struct ReportResultPage { .fontColor('#999999') } ForEach(this.analyzed, (d: DimensionWire) => { - DimensionCard({ dim: d }) - }, (d: DimensionWire) => this.dimKey(d)) + DimensionCard({ + dim: d, + feedbackVerdict: this.feedbackVerdicts[d.key ?? ''] ?? '', + onFeedback: (dimKey: string, verdict: string, correction: string): void => { + this.onFeedback(dimKey, verdict, correction) + } + }) + }, (d: DimensionWire) => `${this.dimKey(d)}_${this.feedbackVerdicts[d.key ?? ''] ?? ''}`) this.DimGroupTitle('未覆盖', this.skipped.length, '灰显·附原因') if (this.skipped.length === 0) { @@ -628,6 +802,61 @@ struct ReportResultPage { .alignItems(HorizontalAlign.Start) } + /** 全面报告预约留资卡(T-P3-22):模糊目录 teaser,无价格字样(FR-BILL-07 纪律同 apply)。 */ + @Builder + ReservationCard() { + Column({ space: 10 }) { + Text('形象深度诊断 · 即将上线') + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + .width('100%') + Column({ space: 6 }) { + Text('· 十二季色彩完整色板') + .fontSize(13) + .fontColor('#555555') + .width('100%') + Text('· 4+ 场景穿搭方案') + .fontSize(13) + .fontColor('#555555') + .width('100%') + Text('· 30 天形象提升计划') + .fontSize(13) + .fontColor('#555555') + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + if (this.reserved) { + Text('已预约,上线后第一时间告诉你') + .fontSize(14) + .fontColor('#9AA0A6') + .width('100%') + .height(44) + .textAlign(TextAlign.Center) + .borderRadius(22) + .backgroundColor('#F3F4F6') + } else { + Button(this.reserveLoading ? '预约中…' : '抢先预约,上线提醒我') + .width('100%') + .height(44) + .fontSize(15) + .fontColor(Color.White) + .backgroundColor(COLOR_PRIMARY) + .borderRadius(22) + .enabled(!this.reserveLoading) + .onClick(() => { + this.doReserve() + }) + } + } + .width('100%') + .padding(16) + .borderRadius(14) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Start) + } + @Builder ContentBody() { Column() { @@ -639,6 +868,10 @@ struct ReportResultPage { } this.CopyCard() this.DiagnosisArea() + // 预约留资卡(T-P3-22):内容区底部,导出/重新生成按钮区之前 + if (this.statusRaw === 'SUCCEEDED') { + this.ReservationCard() + } } .padding(16) } diff --git a/meida_backend b/meida_backend index 00ef56b..7bcbb8d 160000 --- a/meida_backend +++ b/meida_backend @@ -1 +1 @@ -Subproject commit 00ef56b08b96eb446f5dc5795209f44c3b91a079 +Subproject commit 7bcbb8d40bc3b5dca4275b2847073a262558d413