Compare commits
2
Commits
8b3a66a9fc
...
a850735d60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a850735d60 | ||
|
|
9228f67820 |
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await TaskBonusState.init()
|
||||
this.claimsTick += 1
|
||||
}
|
||||
|
||||
private isClaimed(task: TaskDef): boolean {
|
||||
return TaskBonusState.isClaimed(task.key, task.period)
|
||||
}
|
||||
|
||||
private async markClaimed(task: TaskDef): Promise<void> {
|
||||
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<void> {
|
||||
this.claimingKey = task.key
|
||||
try {
|
||||
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}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, DailyCounts>
|
||||
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<string, DailyCounts> = {}
|
||||
data[DailyLimiter.day] = DailyLimiter.counts
|
||||
DailyLimiter.prefs.put(KEY_COUNTS, JSON.stringify(data))
|
||||
DailyLimiter.prefs.flush()
|
||||
} catch (_e) {
|
||||
// 内存态已生效,持久化失败下次启动重置即可
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {}
|
||||
private static prefs: preferences.Preferences | undefined = undefined
|
||||
|
||||
/** preferences → 内存(幂等;失败退化为空记录,功能仍可用)。 */
|
||||
static async init(): Promise<void> {
|
||||
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<string, string>
|
||||
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<void> {
|
||||
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) {
|
||||
// 内存态已生效;持久化失败最坏下次启动再领一次
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | number | boolean>
|
||||
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<void> {
|
||||
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<string, string | number | boolean>): 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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const eventsObj: Object = batch
|
||||
const body: Record<string, Object> = { 'events': eventsObj }
|
||||
await ApiClient.post<void>('/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<string, string | number | boolean> | undefined
|
||||
): Record<string, string | number | boolean> {
|
||||
const out: Record<string, string | number | boolean> = {}
|
||||
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) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
this.quotaNetError = false
|
||||
try {
|
||||
const d = await ApiClient.get<QuotaWireResp>('/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<void> {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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<void> {
|
||||
await DailyLimiter.init()
|
||||
try {
|
||||
const q = await ApiClient.get<TryonQuotaWire>('/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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import { fileUri } from '@kit.CoreFileKit'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { TryonApi, Hairstyle } from '../core/TryonApi'
|
||||
import { ImageCodec } from '../core/ImageCodec'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { DailyLimiter } from '../core/DailyLimiter'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
@@ -37,6 +40,17 @@ import {
|
||||
import { Segment } from '../components/f2/Segment'
|
||||
import { ColorDot } from '../components/f2/ColorDot'
|
||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=hairstyle 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */
|
||||
interface HairQuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
interface PresetColor {
|
||||
name: string
|
||||
@@ -73,6 +87,10 @@ struct HairDesignPage {
|
||||
@State ratio: number = 0.9
|
||||
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款 34 / 男款 26)
|
||||
@State showFaceConsent: boolean = false
|
||||
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;试戴与换发色合并计数)——
|
||||
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
||||
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
|
||||
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
|
||||
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
|
||||
|
||||
/** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */
|
||||
@@ -82,6 +100,7 @@ struct HairDesignPage {
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadStyles()
|
||||
this.loadHairRemaining()
|
||||
// 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢
|
||||
try {
|
||||
if (fs.accessSync(this.resultStorePath())) {
|
||||
@@ -110,6 +129,23 @@ struct HairDesignPage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 试戴剩余额度(P0-5):端侧 DailyLimiter 为兜底权威(Phase A 直连不经后端),
|
||||
* 服务端配额可拉时取 min(服务端, 端侧);拉取失败静默用端侧值,不阻断功能。
|
||||
*/
|
||||
private async loadHairRemaining(): Promise<void> {
|
||||
await DailyLimiter.init()
|
||||
try {
|
||||
const q = await ApiClient.get<HairQuotaWire>('/api/v1/quota?quota_key=hairstyle')
|
||||
// 奖励加成(迭代 3 修正):服务端 bonus_granted 抬当日端侧上限,再取 min
|
||||
DailyLimiter.setBonus('hairstyle', q.bonus_granted)
|
||||
this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle'))
|
||||
} catch (_e) {
|
||||
// 拉取失败保持 bonus=0(兜底 3 次不变)
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 Tab 的发型(unknown 款两 Tab 都展示,服务端新增款不丢)。 */
|
||||
private visibleStyles(): Hairstyle[] {
|
||||
const out: Hairstyle[] = []
|
||||
@@ -175,6 +211,13 @@ struct HairDesignPage {
|
||||
if (this.photoUri.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
this.selectedStyleId = style.id
|
||||
this.showOriginal = false
|
||||
@@ -184,8 +227,17 @@ struct HairDesignPage {
|
||||
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '试戴完成,点「对比」看原图'
|
||||
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.persistResult()
|
||||
} catch (e) {
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
@@ -196,6 +248,13 @@ struct HairDesignPage {
|
||||
if (this.resultBase64.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
const c: PresetColor = PRESET_COLORS[this.colorIdx]
|
||||
this.statusText = `正在换发色(${c.name})…`
|
||||
@@ -210,8 +269,17 @@ struct HairDesignPage {
|
||||
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '换发色完成'
|
||||
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④)
|
||||
} catch (e) {
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
@@ -267,6 +335,63 @@ struct HairDesignPage {
|
||||
this.statusText = await this.saveFileToGallery(this.lastLocalPath)
|
||||
}
|
||||
|
||||
/** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
|
||||
@Builder
|
||||
QuotaBlockedDialog() {
|
||||
Column() {
|
||||
Column({ space: 14 }) {
|
||||
Text('今日试戴额度已用完')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('明天再来,或做任务多换几次')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
Row({ space: 12 }) {
|
||||
Button('知道了')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
})
|
||||
Button('做任务换额度')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):到账后重拉试戴额度(服务端 Phase B 接入后生效)。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (_quotaKey: string): void => {
|
||||
this.loadHairRemaining()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
@@ -342,6 +467,22 @@ struct HairDesignPage {
|
||||
.width('100%').height(240).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
}
|
||||
|
||||
// 右下「今日还可试戴 N 次」胶囊(P0-5,试戴+换发色合并计数):仅展示不参与命中
|
||||
if (this.hairRemaining >= 0) {
|
||||
Row() {
|
||||
Text(`今日还可试戴 ${this.hairRemaining} 次`)
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.padding({ right: 8, bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.onClick(() => {
|
||||
@@ -496,6 +637,10 @@ struct HairDesignPage {
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
|
||||
// 合并计数脚注(P0-5):试戴与换发色共用 hairstyle 每日额度
|
||||
Text('试戴与换发色合并计算每日额度')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
@@ -537,9 +682,16 @@ struct HairDesignPage {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.showQuotaBlocked) {
|
||||
this.QuotaBlockedDialog()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_PAGE_BG)
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
this.quotaNetError = false
|
||||
try {
|
||||
const data = await ApiClient.get<QuotaWireResp>(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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = {}
|
||||
@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<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<FeedbackWireItem[]>(`/api/v1/reports/${this.reportId}/feedback`)
|
||||
const map: Record<string, string> = {}
|
||||
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<void> {
|
||||
if (this.feedbackSubmitting !== '') {
|
||||
return
|
||||
}
|
||||
this.feedbackSubmitting = dimKey
|
||||
try {
|
||||
const req: Record<string, Object> = { 'dimension_key': dimKey, 'verdict': verdict }
|
||||
if (correction !== '') {
|
||||
req['correction'] = correction
|
||||
}
|
||||
await ApiClient.post<FeedbackUpsertResp>(`/api/v1/reports/${this.reportId}/feedback`, req)
|
||||
const next: Record<string, string> = {}
|
||||
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<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/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<void> {
|
||||
try {
|
||||
const d = await ApiClient.get<ReservationListWire>(`/api/v1/reservations?topic=${RESERVE_TOPIC}`)
|
||||
this.reserved = d.topics.includes(RESERVE_TOPIC)
|
||||
} catch (_e) {
|
||||
this.reserved = false
|
||||
}
|
||||
}
|
||||
|
||||
private async doReserve(): Promise<void> {
|
||||
if (this.reserved || this.reserveLoading) {
|
||||
return
|
||||
}
|
||||
this.reserveLoading = true
|
||||
try {
|
||||
const req: Record<string, Object> = { 'topic': RESERVE_TOPIC }
|
||||
const d = await ApiClient.post<ReservationWire>('/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)
|
||||
}
|
||||
|
||||
+11
-7
@@ -48,19 +48,19 @@
|
||||
| `T-P1-25b` 四档熔断 | ✅(阈值待重算) | 60/80/90/100% 四档降级 + critical 豁免 + Redis 手动 override + Prometheus 指标;**"被拒请求配额净扣减 = 0"有集成断言**;⚠️ `LLM_DAILY_BUDGET_CENTS=100000` 仍是旧价占位,8-17 后按实测重算 |
|
||||
| `T-P1-13` 双令牌会话 | ✅ | refresh 一次性轮换(FOR UPDATE 行锁)+ 服务端撤销 + 重放检测告警;dev-token 向后兼容 |
|
||||
| `T-P1-10` 埋点通道(服务端) | ✅ | `POST /events/batch` fail-close 整批校验 + PII 剔除;事件字典校验待 `TD-23` 定稿后收紧 |
|
||||
| `T-P1-27` 直传预签名 | ✅(联调待 COS) | 场景/类型/大小三重校验 + 可审计 key 布局;SDK 隔离在 Protocol 后,16 单测零网络;真实桶联调归《前置开通清单》§2 |
|
||||
| `T-P1-27` 直传预签名 | ✅(联调待 COS)→ **E2 变更后由本地存储替代** | 原三重校验 + 可审计 key 布局保留;现行路径 `POST /storage/upload` 后端中转 + 签名下载 + temp 2h 清理(celery beat),COS 链路降为回迁保留 |
|
||||
| `T-P1-12` 华为账号登录(代码) | ✅(联调待 AGC) | OIDC JWKS 验签(kid 轮换、alg 锁定、audience 空配置拒绝)+ UnionID 映射;自造密钥全测,19 单测 |
|
||||
| `T-P1-12b/12c` 短信登录(代码) | ✅(发送待签名审核) | 验证码哈希存储/一次性/5 次作废 + 三重频控(脚本化刷量 11 次断言拦截)+ 掩码纪律;fake/tencent 双通道可切 |
|
||||
| `T-P1-12b/12c` 邮箱登录(代码) | ✅(发送待 SMTP 授权码) | **2026-08-15 需求变更:短信→邮箱(E1)并已交付**——`email_login.py` 验证码哈希存储/一次性/5 次作废 + 三重频控(脚本化刷量 11 次断言拦截)+ 掩码纪律;fake/smtp 双通道可切(QQ/163 授权码),旧短信通道代码已移除 |
|
||||
|
||||
### 1.2 本 Sprint 待补(BE)
|
||||
|
||||
| 任务 | 内容 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| `T-P1-04` 🔴 | 腾讯云基础设施开通(VPC/CVM/PG/Redis/COS 四桶/CDN/CLS) | 四桶权限与生命周期策略生效;**临时桶 2 小时清理实测**(`CR-02`) |
|
||||
| `T-P1-04` 🔴 | ~~腾讯云基础设施开通~~ **已按《前置开通清单》v2 降级路线收缩**:腾讯云全家桶暂缓,仅剩华为 AGC(§1)+ 发信邮箱授权码(§2) | 华为调试证书可拉起授权页;测试邮箱能收到验证码 |
|
||||
| `T-P1-05` | 密钥改由 SSM/KMS 注入 | 镜像与代码中无任何密钥;`APP_ENV=prod` 时占位密钥直接拒绝启动(已实现,需接 SSM) |
|
||||
| `T-P1-27` | 图片直传预签名 URL + 临时桶生命周期 | 图片不经应用服务器;2 小时自动清理 |
|
||||
| `T-P1-27` | ~~图片直传预签名~~ **已按 E2 变更为本地磁盘并交付**:`POST /storage/upload` 后端中转 + HMAC 签名下载 URL + temp 目录 2 小时清理(celery beat);COS 预签名链路保留为回迁路径 | 上传下载闭环;2 小时自动清理可观察 |
|
||||
| `T-P1-11`/`12`/`13` | 用户模型、华为账号登录、双令牌会话 | UnionID 映射唯一;登出后旧令牌立即失效 |
|
||||
| `T-P1-12b`/`12c` 🔴 | 短信登录 + 三重频控防刷 | **断开华为依赖后本通道仍可登录**;脚本化刷量测试证明拦截有效 |
|
||||
| `T-P1-12b`/`12c` 🔴 | ~~短信登录~~ 邮箱登录(E1 变更,代码已交付)+ 三重频控防刷 | **断开华为依赖后本通道仍可登录**;脚本化刷量测试证明拦截有效;待 SMTP 授权码落位后真实发信验证 |
|
||||
| `T-P1-25b` | 日成本熔断补齐四档降级与后台开关 | 注入成本可复现四档;**被拒请求配额净扣减 = 0**;在途任务放行 |
|
||||
| `T-P1-09`/`10` | 三张看板 + 埋点事件字典 | 能回答架构 §15 的三个问题 |
|
||||
|
||||
@@ -249,9 +249,13 @@
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.1
|
||||
**文档版本**:v1.2
|
||||
**创建日期**:2026-08-13
|
||||
**最后更新**:2026-08-14
|
||||
**最后更新**:2026-08-16
|
||||
**状态**:待评审
|
||||
**2026-08-16 变更**(对应《前置开通清单》v2 个人开发者降级路线):`T-P1-12b/12c`
|
||||
短信登录→邮箱验证码登录(E1,代码已交付,待 SMTP 授权码);`T-P1-27` COS 直传→
|
||||
本地磁盘存储(E2,已交付:multipart 上传 + HMAC 签名下载 + temp 2h 清理挂 celery beat);
|
||||
`T-P1-04` 腾讯云全家桶收缩为华为 AGC + 发信邮箱两件事。
|
||||
**关联文档**:《开发任务拆解》(任务定义与依赖)、《需求规格说明》(验收指标唯一事实源)、
|
||||
《技术架构文档》(token 预算与成本模型)、《技术选型与调研报告》(`TD-00`~`TD-33`)
|
||||
|
||||
+5
-4
@@ -151,7 +151,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| T-P1-01 | DEV | 仓库结构与包边界搭建 | 可运行骨架 | 目录结构符合架构 §16.1;`packages/domain` 无 IO 依赖 | NFR-MAINT-03 |
|
||||
| T-P1-02 | OPS | CI 流水线(ruff + mypy strict + pytest + 依赖规则检查) | CI 配置 | 违规提交被阻断 | NFR-MAINT-02/03 |
|
||||
| T-P1-03 | OPS | 本地开发环境(Compose:PG+pgvector、Redis、COS mock) | compose 文件 + 文档 | 新成员一条命令起环境 | — |
|
||||
| T-P1-04 | OPS | 腾讯云基础设施开通与配置(VPC/CVM/PG/Redis/COS 四桶/CDN/CLS) | 环境清单 | 四桶权限与生命周期策略生效 | TD-03、CR-02 |
|
||||
| T-P1-04 | OPS | ~~腾讯云基础设施开通~~ **降级路线(2026-08-15):腾讯云暂缓**,仅剩华为 AGC + 发信邮箱授权码(《前置开通清单》v2 §1/§2);PG/Redis 已在本地 WSL 运行 | 环境清单 | 华为调试证书可拉起授权页;测试邮箱能收到验证码 | TD-03、CR-02 |
|
||||
| T-P1-05 | DEV | 配置与密钥管理(SSM/KMS 接入) | 配置模块 | 无密钥进代码与镜像 | NFR-SEC-01 |
|
||||
| T-P1-06 | DEV | Alembic 迁移基线与规范 | 首个迁移 | 迁移可正向与回滚 | NFR-MAINT-06 |
|
||||
| T-P1-07 | DEV | 统一响应、错误码体系、异常处理中间件 | 中间件 + 错误码表 | 每个错误码有 message + hint | FR-SYS-02 |
|
||||
@@ -166,8 +166,8 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| T-P1-11 | DEV | 用户数据模型与身份映射 | 表 + 迁移 | UnionID 映射唯一;预留 `profile_id` | FR-ACC-01、FR-ACC-12 |
|
||||
| T-P1-12 | DEV | 华为账号登录(服务端) | 接口 | 登录成功率 ≥ 99%(联调环境) | FR-ACC-01 |
|
||||
| T-P1-13 | DEV | 双令牌会话与撤销 | 接口 | 登出后旧令牌立即失效 | FR-ACC-05 |
|
||||
| T-P1-12b | DEV | **短信验证码登录**(下发 + 校验 + 腾讯云 SMS 接入) | 接口 | 6 位码、5 分钟有效、一次性、失败 5 次作废;**华为侧异常时本通道仍可登录**(须有断开华为依赖的测试) | FR-ACC-04、**TD-32** |
|
||||
| T-P1-12c | DEV | **短信防刷**(号码/IP/设备三重频控 + 人机校验 + 发送量告警) | 中间件 + 告警 | **脚本化刷量测试证明拦截有效**;号码在日志与后台全部掩码 | FR-ACC-14、NFR-SEC-03/05 |
|
||||
| T-P1-12b | DEV | **邮箱验证码登录**(2026-08-15 变更:原短信,个人主体办不了签名;下发 + 校验 + 个人邮箱 SMTP 接入,**E1 已交付**) | 接口 | 6 位码、5 分钟有效、一次性、失败 5 次作废;**华为侧异常时本通道仍可登录**(须有断开华为依赖的测试) | FR-ACC-04、**TD-32** |
|
||||
| T-P1-12c | DEV | **邮箱防刷**(邮箱/IP/设备三重频控,参数沿短信原值;人机校验 + 发送量告警后置) | 中间件 + 告警 | **脚本化刷量测试证明拦截有效**(已交付);邮箱在日志与后台全部掩码 | FR-ACC-14、NFR-SEC-03/05 |
|
||||
| T-P1-14 | DEV | 游客模式与数据合并 | 接口 | 合并幂等;多设备场景有确定策略;**游客配额按设备维度终身计量**(非每日),用尽引导登录而非报错 | FR-ACC-02/03 |
|
||||
| T-P1-16b | DEV | **清空我的照片**(保留账号) | 接口 + 页面 | COS 对象与 DB 引用同步清除;签名 URL 立即失效;二次确认 + 回执 + 审计日志 | **FR-ACC-15**、CR-02 |
|
||||
| T-P1-15 | DEV | 同意授权记录(含人脸单独同意) | 表 + 接口 | 记录含版本与时间,可查询 | CR-01、FR-ACC-08 |
|
||||
@@ -191,7 +191,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| **T-P1-25c** | DEV | **峰谷单价按调用时刻计价** | 两套价目表 + 时段判定 | 高峰(北京时间 09:00-12:00、14:00-18:00)与空闲各一套单价;成本在**调用当时**算好落库,事后不按当前价重算;跨时段有测试 | `NFR-COST-01`、TD-04 |
|
||||
| T-P1-26 | DEV | **配额账本**(余额、流水、预扣三段式、周期重置、规则配置化) | 模块 + 接口 | 余额可由流水重算;失败自动退还;**表结构含 `tx_type` 字段,V2 上支付无需迁移**;服务端硬校验不可绕过;**`bl_quota_rule` 种子必须与产品方案 §3.2 一致**(含 `hairstyle` / `hd_export` / `report_basic` / `report_regen`),禁止开发期另写一套数字 | FR-BILL-01/02/04/05/06 |
|
||||
| T-P1-26b | DEV | 额度耗尽文案与提示 | 端侧组件 | **不出现任何价格、购买、会员、升级字样**;消耗前提示剩余量 | FR-BILL-07 |
|
||||
| T-P1-27 | DEV | 图片直传(预签名 URL)与临时桶生命周期 | 接口 + 桶策略 | 图片不经应用服务器;2 小时自动清理 | FR-SYS-03、CR-02 |
|
||||
| T-P1-27 | DEV | 图片上传与临时目录生命周期(2026-08-15 变更:原 COS 直传预签名,**E2 已改为本地磁盘 `meida_data/` 四目录并交付**——后端 multipart 中转 + HMAC 短期签名下载 URL;COS 链路保留为回迁路径) | 接口 + 清理任务 | temp 目录 2 小时自动清理(celery beat,CR-02);上传下载闭环 | FR-SYS-03、CR-02 |
|
||||
| T-P1-28 | DEV | 推送接入(Push Kit + Token 管理) | 模块 | 到达率 ≥ 90%;文案无敏感结论 | FR-TASK-04 |
|
||||
|
||||
### 3.4 客户端骨架
|
||||
@@ -429,6 +429,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-16 | v1.6 | **个人开发者降级路线落地(《前置开通清单》v2 的 E1~E4)**:`T-P1-12b/12c` 短信登录→邮箱验证码登录(E1,后端已交付:`packages/identity/email_login.py`,fake/smtp 双通道,防刷参数原样沿用);`T-P1-27` COS 直传→本地磁盘存储(E2,后端已交付:multipart 上传 + HMAC 签名下载 + temp 2h 清理挂 celery beat);`T-P1-04` 腾讯云全家桶收缩。FE 登录页邮箱输入(E3)与联调验证待办 |
|
||||
| 2026-08-14 | v1.5 | **两批多 agent 并行开发交付回写**(任务包见 `开发任务包/`,实测详情见《Sprint 执行计划》§9):`T-P1-25b` 四档熔断(阈值待 8-17 重算)、`T-P1-26` 收尾 claim、`T-P1-10/13/27` 交付、`T-P1-12/12b/12c` 登录双通道代码先行(联调待凭证)、`T-P2-01~06` 知识库三件套交付(BGE-M3 真模型待接)、`T-P0-16` ✅(2.8ms)、`T-P0-10/12` SPIKE 初步结论。**新风险登记:RTMPose 合成图置信度 0.003(域差异),真实照片重验为 `T-P0-10` 前置;BiRefNet CPU 3.1s/张需异步化** |
|
||||
| 2026-08-14 | v1.4 | 交叉评审回写:`T-P1-25b` 验收补「reserve 之后熔断」集成断言与日预算按新价重算;`T-P1-26` 明确种子必须与产品方案 §3.2 一致 |
|
||||
| 2026-08-13 | v1.3 | **后端脚手架落地后的回写**。新增两项由真机实测暴露、原 WBS 完全未覆盖的任务:**`T-P1-24a` 思考模式按场景显式配置** —— `deepseek-v4-flash` **默认开启**思考模式,实测同一单句问答开启 13.5s、关闭 1.8s,思考 token 还按输出价计费,漏配会同时打破 `NFR-PERF-02b` 与成本模型;**`T-P1-25c` 峰谷单价按调用时刻计价** —— DeepSeek 自 2026-08-17 起高峰单价为空闲时段 2 倍,用单一均价记账会让 `ob_ai_call` 与真实账单差出近一倍,而它是 V2 定价的唯一依据。另:`T-P1-01`/`02`/`06`/`07`/`24`/`25` 已在 `meida_backend` 仓库交付并实测通过,各任务的完成状态见《Sprint 执行计划》§1.1 |
|
||||
|
||||
+1
-1
Submodule meida_backend updated: 00ef56b08b...7bcbb8d40b
Reference in New Issue
Block a user