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:
xsl
2026-09-12 00:25:08 +08:00
parent fcc506920f
commit f244f14ab8
6 changed files with 403 additions and 41 deletions
@@ -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('奖励以实际到账为准,额度每日重置')
+1 -1
View File
@@ -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
}
+39 -1
View File
@@ -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) ====================
/** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */