feat(billing): 任务换额度端侧闭环(T2b 定稿 4 任务)
- TaskBonusPanel 重写:每日任务/新手任务分组、五态按钮、真实「去完成」跳转(first_report→诊断落地页;report_feedback/share_report→报告结果页)、40311/40310 文案、panel_view/task_tap 埋点 - ReportPdfPage 分享成功→静默 claim share_report;ReportResultPage SUCCEEDED 展示→静默 claim first_report(后端 40910 幂等兜底,老用户首进补发一次属预期回填) - ChatPage/ReportResultPage 修 bonus_granted 类型误写(boolean→number) - docs:T2b 任务书(验收 11 条)+ 产品方案 §3.2 任务目录定稿(签到对话+10/日、报告反馈发型+2/日、分享换装+3/日、首测重生成+1/终身;邀请好友砍) 偏差说明:share_report 落点为报告结果页而非 PDF 预览页(该页强依赖本地 pdfPath,直达需下迭代加持久化)。
This commit is contained in:
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04)。
|
||||
* TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04,T2b 闭环)。
|
||||
*
|
||||
* 供弹层/区块复用:ProfileContent 入口卡、ChatPage 耗尽面板、试穿/发型软限制
|
||||
* 耗尽面板都以 bindSheet 承载本组件。
|
||||
*
|
||||
* 纪律:
|
||||
* - 任务表本地静态(icon/名称/说明),奖励数值一律不硬编码——展示文案统一
|
||||
* 「领额度」,实际到账以 claim 响应 amount 为准;
|
||||
* - 任务表本地静态(产品方案 §3.2 定稿 4 项,invite_friend/profile_complete 已
|
||||
* 下架);desc 里的奖励数值是定稿展示文案,实际到账仍以 claim 响应 amount 为准;
|
||||
* - 文案纪律(FR-BILL-07):不出现价格/购买/会员/升级字样;
|
||||
* - 已领判定走 core/TaskBonusState(preferences meida_task_bonus 的共享读写,
|
||||
* 与报告页反馈自动 claim 互通),跨重启保留;claim 返回 40910(本周期已领)
|
||||
* 时同步标记——本地状态永远以服务端口径为准;
|
||||
* - 本迭代只接 sign_in 的真实 claim 链路;其余 4 项为「去完成」轻引导 toast,
|
||||
* 完成判定属后续迭代。
|
||||
* 与报告页反馈/分享/首测的自动 claim 互通),跨重启保留;claim 返回 40910
|
||||
* (本周期已领)时同步标记——本地状态永远以服务端口径为准;
|
||||
* - 「去完成」真实跳转:first_report → 速测落地页;report_feedback / share_report
|
||||
* → 报告链路页(无报告时给引导 toast,不报错);sign_in 点击即领。
|
||||
* first_report 在服务端已有完成报告且未领过时直接变为「领额度」走 claim。
|
||||
*/
|
||||
|
||||
import { promptAction } from '@kit.ArkUI'
|
||||
import { promptAction, router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../../core/ApiClient'
|
||||
import { ApiError } from '../../core/ApiTypes'
|
||||
import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../../core/TaskBonusState'
|
||||
@@ -37,7 +38,17 @@ interface ClaimWire {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 任务定义。direct=true 表示点击即调 claim;false 为「去完成」轻引导。 */
|
||||
/** GET /api/v1/reports 列表元素(本面板只消费这两个字段)。 */
|
||||
interface ReportListItemWire {
|
||||
report_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务定义。direct=true 表示点击即调 claim(签到);
|
||||
* false 为「去完成」跳转(first_report 在服务端已有完成报告时升格为可领)。
|
||||
* successToast 是领取成功的 toast 前缀,amount 取响应值拼接。
|
||||
*/
|
||||
interface TaskDef {
|
||||
key: string
|
||||
icon: string
|
||||
@@ -45,36 +56,37 @@ interface TaskDef {
|
||||
desc: string
|
||||
period: string // day / lifetime
|
||||
direct: boolean
|
||||
guide: string // direct=false 时的引导 toast 文案
|
||||
successToast: string
|
||||
}
|
||||
|
||||
/** 五项任务(task_key 与后端 TASK_BONUS_RULES_JSON 对齐)。 */
|
||||
const TASKS: TaskDef[] = [
|
||||
/** 每日任务(task_key 与后端 TASK_BONUS_RULES_JSON 定稿目录对齐)。 */
|
||||
const DAILY_TASKS: TaskDef[] = [
|
||||
{
|
||||
key: 'sign_in', icon: '✓', name: '每日签到', desc: '每天来一次,领 AI 对话额度',
|
||||
period: PERIOD_DAY, direct: true, guide: ''
|
||||
key: 'sign_in', icon: '✓', name: '每日签到', desc: '每日 · 领 10 轮 AI 对话额度',
|
||||
period: PERIOD_DAY, direct: true, successToast: '签到成功'
|
||||
},
|
||||
{
|
||||
key: 'profile_complete', icon: '☺', name: '完善画像资料', desc: '补全资料,建议更合身',
|
||||
period: PERIOD_LIFETIME, direct: false, guide: '完善资料功能即将上线'
|
||||
key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '每日 · 领 2 次发型试戴额度',
|
||||
period: PERIOD_DAY, direct: false, successToast: '感谢反馈'
|
||||
},
|
||||
{
|
||||
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: '邀请功能即将上线'
|
||||
key: 'share_report', icon: '↗', name: '分享我的报告', desc: '每日 · 领 3 次换装额度',
|
||||
period: PERIOD_DAY, direct: false, successToast: '感谢分享'
|
||||
}
|
||||
]
|
||||
|
||||
/** 本周期已领 / 奖励封顶 / 未知任务(与后端 errors.py 对齐)。 */
|
||||
/** 新手任务(一次性)。 */
|
||||
const NEWBIE_TASKS: TaskDef[] = [
|
||||
{
|
||||
key: 'first_report', icon: '★', name: '完成首次形象速测', desc: '一次性 · 领 1 次报告重生成额度',
|
||||
period: PERIOD_LIFETIME, direct: false, successToast: '形象速测完成'
|
||||
}
|
||||
]
|
||||
|
||||
/** 本周期已领 / 奖励封顶 / 任务未完成 / 未知任务(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
const CODE_BONUS_CAPPED: number = 40310
|
||||
const CODE_NOT_ELIGIBLE: number = 40311
|
||||
|
||||
@Component
|
||||
export struct TaskBonusPanel {
|
||||
@@ -83,15 +95,51 @@ export struct TaskBonusPanel {
|
||||
|
||||
@State claimingKey: string = '' // 领取中的 task_key(防空点)
|
||||
@State claimsTick: number = 0 // claims 变更计数:驱动已领态重渲染
|
||||
@State firstReportReady: boolean = false // 服务端已有完成报告:first_report 可直接领
|
||||
@State latestReportId: string = '' // 报告链路跳转用(优先最新一份已完成报告)
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.hydrate()
|
||||
}
|
||||
|
||||
/** 已领态由 TaskBonusState 共享(报告页反馈自动 claim 也要互通),这里只 hydrate + 触发重渲染。 */
|
||||
/** 已领态由 TaskBonusState 共享(报告页自动 claim 也要互通):hydrate + 曝光埋点 + 报告态探测。 */
|
||||
private async hydrate(): Promise<void> {
|
||||
await TaskBonusState.init()
|
||||
this.claimsTick += 1
|
||||
Tracker.track('task_bonus.panel_view')
|
||||
this.refreshReportState()
|
||||
}
|
||||
|
||||
/** 拉我的报告列表,定 first_report 可领态与报告链路跳转目标;失败静默(点击时再实时拉)。 */
|
||||
private async refreshReportState(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
|
||||
const succeeded: ReportListItemWire | undefined =
|
||||
list.find((r: ReportListItemWire) => r.status === 'succeeded')
|
||||
this.firstReportReady = succeeded !== undefined
|
||||
this.latestReportId = succeeded !== undefined
|
||||
? succeeded.report_id
|
||||
: (list.length > 0 ? list[0].report_id : '')
|
||||
} catch (_e) {
|
||||
// 网络失败不打扰:面板其余功能照常,「去完成」点击时再拉一次
|
||||
}
|
||||
}
|
||||
|
||||
/** 实时取最新报告 id(跳转前兜底再拉一次,hydrate 失败也能补救);无报告返回 ''。 */
|
||||
private async fetchLatestReportId(): Promise<string> {
|
||||
try {
|
||||
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
|
||||
const succeeded: ReportListItemWire | undefined =
|
||||
list.find((r: ReportListItemWire) => r.status === 'succeeded')
|
||||
const rid: string = succeeded !== undefined
|
||||
? succeeded.report_id
|
||||
: (list.length > 0 ? list[0].report_id : '')
|
||||
this.latestReportId = rid
|
||||
this.firstReportReady = succeeded !== undefined
|
||||
return rid
|
||||
} catch (_e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private isClaimed(task: TaskDef): boolean {
|
||||
@@ -115,12 +163,36 @@ export struct TaskBonusPanel {
|
||||
if (this.isClaimed(task) || this.claimingKey !== '') {
|
||||
return
|
||||
}
|
||||
if (!task.direct) {
|
||||
// 「去完成」轻引导:本迭代不接真实完成判定
|
||||
this.toast(task.guide)
|
||||
Tracker.track('task_bonus.task_tap', { 'task_key': task.key })
|
||||
if (task.direct) {
|
||||
this.claim(task)
|
||||
return
|
||||
}
|
||||
this.claim(task)
|
||||
if (task.key === 'first_report') {
|
||||
if (this.firstReportReady) {
|
||||
// 服务端已有完成报告且未领过:直接领(服务端还会再校验一次 rp_report)
|
||||
this.claim(task)
|
||||
} else {
|
||||
router.pushUrl({ url: 'pages/DiagnosisLandingPage' })
|
||||
}
|
||||
return
|
||||
}
|
||||
// report_feedback / share_report:跳报告链路页(反馈在报告结果页,分享经
|
||||
// 报告结果页导出 PDF 后到预览页;ReportPdfPage 需要本地 pdfPath 无法直达)
|
||||
this.goReportChain()
|
||||
}
|
||||
|
||||
private async goReportChain(): Promise<void> {
|
||||
let rid: string = this.latestReportId
|
||||
if (rid === '') {
|
||||
rid = await this.fetchLatestReportId()
|
||||
}
|
||||
if (rid === '') {
|
||||
this.toast('先生成你的形象速测,再来反馈')
|
||||
return
|
||||
}
|
||||
const params: Record<string, Object> = { 'reportId': rid }
|
||||
router.pushUrl({ url: 'pages/ReportResultPage', params: params })
|
||||
}
|
||||
|
||||
private async claim(task: TaskDef): Promise<void> {
|
||||
@@ -129,7 +201,7 @@ export struct TaskBonusPanel {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>(`/api/v1/quota/tasks/${task.key}/claim`, req)
|
||||
await this.markClaimed(task)
|
||||
this.toast(`签到成功,额度 +${d.amount}`)
|
||||
this.toast(`${task.successToast},额度 +${d.amount}`)
|
||||
Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': true })
|
||||
this.onClaimed(d.quota_key)
|
||||
} catch (e) {
|
||||
@@ -139,7 +211,9 @@ export struct TaskBonusPanel {
|
||||
await this.markClaimed(task)
|
||||
this.toast('今天已经领过啦')
|
||||
} else if (err.code === CODE_BONUS_CAPPED) {
|
||||
this.toast('今日任务奖励已达上限')
|
||||
this.toast('今日任务奖励已达上限,明天再来')
|
||||
} else if (err.code === CODE_NOT_ELIGIBLE) {
|
||||
this.toast('先完成任务,再来领奖励')
|
||||
} else {
|
||||
this.toast(err.message)
|
||||
}
|
||||
@@ -157,7 +231,10 @@ export struct TaskBonusPanel {
|
||||
if (this.claimingKey === task.key) {
|
||||
return '领取中…'
|
||||
}
|
||||
return task.direct ? '领额度' : '去完成'
|
||||
if (task.direct || (task.key === 'first_report' && this.firstReportReady)) {
|
||||
return '领额度'
|
||||
}
|
||||
return '去完成'
|
||||
}
|
||||
|
||||
@Builder
|
||||
@@ -197,6 +274,15 @@ export struct TaskBonusPanel {
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
GroupHeader(title: string) {
|
||||
Text(title)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.width('100%')
|
||||
.margin({ top: 8, bottom: 2 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 4 }) {
|
||||
Text('做任务换额度')
|
||||
@@ -205,7 +291,12 @@ export struct TaskBonusPanel {
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
.margin({ bottom: 4 })
|
||||
ForEach(TASKS, (task: TaskDef) => {
|
||||
this.GroupHeader('每日任务')
|
||||
ForEach(DAILY_TASKS, (task: TaskDef) => {
|
||||
this.TaskRow(task)
|
||||
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
|
||||
this.GroupHeader('新手任务')
|
||||
ForEach(NEWBIE_TASKS, (task: TaskDef) => {
|
||||
this.TaskRow(task)
|
||||
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
|
||||
Text('奖励以实际到账为准,额度每日重置')
|
||||
|
||||
@@ -33,7 +33,7 @@ interface QuotaWireResp {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: boolean
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string
|
||||
}
|
||||
|
||||
@@ -20,11 +20,27 @@ import { common } from '@kit.AbilityKit'
|
||||
import { picker, fileUri } from '@kit.CoreFileKit'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
|
||||
const PAGE_BG: string = '#F5F6F8'
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
|
||||
/** POST /quota/tasks/share_report/claim 响应(share_report 任务闭环,T2b)。 */
|
||||
interface ClaimWire {
|
||||
task_key: string
|
||||
quota_key: string
|
||||
amount: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 本周期已领(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
|
||||
interface PdfRouteParams {
|
||||
pdfPath?: string
|
||||
title?: string
|
||||
@@ -100,13 +116,35 @@ struct ReportPdfPage {
|
||||
previewMode: systemShare.SharePreviewMode.DEFAULT
|
||||
}
|
||||
await controller.show(ContextHolder.get() as common.UIAbilityContext, opts)
|
||||
// 面板正常收起(含用户主动取消):不打扰,不提示
|
||||
// 面板正常收起(含用户主动取消):不打扰,不提示。
|
||||
// share_report 任务闭环(T2b):调起成功即视为完成(端侧自报,内测水位,
|
||||
// 产品方案 §3.2 决策理由 4);埋点 + 静默 claim 互不阻断。
|
||||
Tracker.track('report.share_invoke')
|
||||
this.claimShareBonus()
|
||||
} catch (e) {
|
||||
console.error(`PDF share failed: ${(e as Error).message}`)
|
||||
this.setActionText('分享失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* share_report 任务真实闭环:奖励到账提示;已领(40910)静默但同步已领态,
|
||||
* 其他失败静默——分享动作本身已完成,不拿奖励失败打扰用户。
|
||||
*/
|
||||
private async claimShareBonus(): Promise<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/share_report/claim', req)
|
||||
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
|
||||
this.setActionText(`感谢分享,额度 +${d.amount}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
|
||||
// 今日已领:静默不打扰,但同步已领态(任务面板显示一致)
|
||||
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAs(): Promise<void> {
|
||||
try {
|
||||
const docPicker = new picker.DocumentViewPicker(ContextHolder.get())
|
||||
|
||||
@@ -23,7 +23,7 @@ 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 { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../core/TaskBonusState'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
import request from '@ohos.request'
|
||||
@@ -52,7 +52,7 @@ interface QuotaWireResp {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: boolean
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string
|
||||
}
|
||||
@@ -189,6 +189,9 @@ struct ReportResultPage {
|
||||
if (this.statusRaw === 'SUCCEEDED') {
|
||||
// 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21)
|
||||
this.loadFeedback()
|
||||
// first_report 任务闭环(T2b):报告成功展示的入口路径上静默 claim
|
||||
// (lifetime 周期,后端幂等;领过则 40910 静默同步已领态)
|
||||
this.claimFirstReport()
|
||||
// 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次
|
||||
if (!this.cardViewTracked) {
|
||||
this.cardViewTracked = true
|
||||
@@ -447,6 +450,25 @@ struct ReportResultPage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* first_report 任务真实闭环(T2b):报告成功展示即完成首次速测,静默 claim;
|
||||
* 已领(40910,含 lifetime 重复进页)静默同步已领态,其他失败静默——
|
||||
* 报告展示是主流程,奖励失败不打扰。
|
||||
*/
|
||||
private async claimFirstReport(): Promise<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/first_report/claim', req)
|
||||
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
|
||||
this.toast(`形象速测完成,额度 +${d.amount}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
|
||||
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
|
||||
}
|
||||
// 其他失败静默
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 全面报告预约留资卡(T-P3-22) ====================
|
||||
|
||||
/** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */
|
||||
|
||||
Reference in New Issue
Block a user