/** * ProfileContent —— 个人中心内容组件(可内嵌 Tab,也可被 ProfilePage 路由壳复用)。 * * 与 ProfilePage 的分工:本组件承载全部 UI 与数据逻辑;路由壳只负责 @Entry。 * 未登录时不再强制 router.replaceUrl(内嵌 Tab 时会劫持整个应用壳), * 改为页内提示 + 「去登录」按钮。 * * 数据契约(全部走 ApiClient/AuthStore,不直接碰 http): * - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取) * - 流水:GET /api/v1/quota/ledger?limit=10 * 周期文案按架构文档 §7.4 配额映射:agent_chat/tryon=日、report_basic=终身一次。 */ import router from '@ohos.router' import { ApiClient } from '../../core/ApiClient' import { ApiError } from '../../core/ApiTypes' import { AuthStore } from '../../core/AuthStore' import { ErrorBanner } from './ErrorBanner' import { QuotaCardView } from './QuotaCard' import { LedgerItemView } from './LedgerItem' /** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */ interface QuotaWire { quota_key: string used: number limit_value: number bonus_granted: number remaining: number reset_at: string | null } /** GET /api/v1/quota/ledger 数组元素。 */ interface LedgerWire { tx_id: string delta: number tx_type: string status: string quota_key: string balance_after: number created_at: string } /** 配额卡片的 UI 状态模型。 */ class QuotaCardData { title: string used: number = 0 total: number = 0 remaining: number = 0 resetText: string = '' loaded: boolean = false errorText: string = '' constructor(title: string) { this.title = title } } /** 流水行的 UI 状态模型(映射/配色在构造期完成)。 */ class LedgerRowData { typeText: string = '' statusText: string = '' statusColor: string = '#999999' statusBg: string = '#F4F4F4' quotaText: string = '' timeText: string = '' deltaText: string = '' deltaColor: string = '#666666' balanceText: string = '' } const COLOR_POSITIVE = '#2E9E5B' const COLOR_NEGATIVE = '#C0392B' const COLOR_MUTED = '#8A8A8A' /** quota_key → 中文名(对齐产品方案 §3.2 / 架构文档 §7.4)。 */ function quotaName(key: string): string { if (key === 'agent_chat') { return 'AI 对话' } if (key === 'tryon') { return '换装试穿' } if (key === 'hairstyle') { return '发型试戴' } if (key === 'hd_export') { return '高清导出' } if (key === 'report_basic') { return '美学报告' } if (key === 'report_regen') { return '报告重新生成' } return key } /** 是否日配额(决定「明日恢复」文案)。 */ function isDailyQuota(key: string): boolean { return key === 'agent_chat' || key === 'tryon' || key === 'hairstyle' || key === 'hd_export' } /** 周期文案:日配额=明日恢复;月/终身有 reset_at 显示日期;null 按周期区分。 */ function resetTextFor(key: string, resetAt: string | null): string { if (resetAt === null || resetAt === '') { if (key === 'report_basic') { return '一次性额度' } if (key === 'report_regen') { return '每月重置' } return '每日重置' } if (isDailyQuota(key)) { return '明日恢复' } const short = resetAt.length >= 10 ? resetAt.substring(5, 10) : resetAt return `${short} 重置` } /** tx_type → 中文(V1 三种,V2 预留 purchase/gift)。 */ function txTypeText(t: string): string { if (t === 'consume') { return '消耗' } if (t === 'refund') { return '退还' } if (t === 'system_grant') { return '奖励' } if (t === 'purchase') { return '购买' } if (t === 'gift') { return '赠送' } return t } /** status → 中文(预扣三段式:reserved → committed;退还写反向流水)。 */ function statusTextOf(s: string): string { if (s === 'reserved') { return '进行中' } if (s === 'committed') { return '已入账' } if (s === 'refunded') { return '已退还' } return s } function statusColorOf(s: string): string { if (s === 'reserved') { return '#B9770E' } if (s === 'committed') { return COLOR_POSITIVE } if (s === 'refunded') { return COLOR_MUTED } return '#999999' } function statusBgOf(s: string): string { if (s === 'reserved') { return '#FBF0DC' } if (s === 'committed') { return '#E4F3EA' } return '#F4F4F4' } /** ISO 时间 → 'MM-DD HH:mm'。 */ function timeText(iso: string): string { if (iso === '') { return '—' } const t = iso.replace('T', ' ') return t.length >= 16 ? t.substring(5, 16) : t } /** 用户 ID 脱敏:前 6 后 4。 */ function maskUserId(id: string): string { if (id === '') { return '未登录' } if (id.length <= 10) { return id } return `${id.substring(0, 6)}…${id.substring(id.length - 4)}` } /** wire → 行视图模型(对象字面量禁裸用,故用 class + 字段赋值)。 */ function toLedgerRow(w: LedgerWire): LedgerRowData { const row = new LedgerRowData() row.typeText = txTypeText(w.tx_type) row.statusText = statusTextOf(w.status) row.statusColor = statusColorOf(w.status) row.statusBg = statusBgOf(w.status) row.quotaText = quotaName(w.quota_key) row.timeText = timeText(w.created_at) row.deltaText = w.delta > 0 ? `+${w.delta}` : `${w.delta}` row.deltaColor = w.delta > 0 ? COLOR_POSITIVE : (w.delta < 0 ? COLOR_NEGATIVE : COLOR_MUTED) row.balanceText = `余额 ${w.balance_after}` return row } function toApiError(e: Object): ApiError { if (e instanceof ApiError) { return e } return new ApiError(50000, '请求失败,请检查网络', '请稍后重试') } @Component export struct ProfileContent { @State needLogin: boolean = false @State userIdMasked: string = '' @State cardChat: QuotaCardData = new QuotaCardData('AI 对话') @State cardTryon: QuotaCardData = new QuotaCardData('换装试穿') @State cardReport: QuotaCardData = new QuotaCardData('美学报告') @State ledgerRows: LedgerRowData[] = [] @State ledgerLoaded: boolean = false @State ledgerError: string = '' @State bannerMessage: string = '' @State bannerHint: string = '' @State refreshing: boolean = false @State loggingOut: boolean = false private pendingLoads: number = 0 aboutToAppear(): void { // 内嵌 Tab 场景下不能强制跳转登录页(会劫持整个应用壳), // 未登录展示页内提示;ProfilePage 路由壳另有拦截 if (!AuthStore.isLoggedIn()) { this.needLogin = true return } const uid = AuthStore.getUserId() this.userIdMasked = maskUserId(uid !== undefined ? uid : '') this.startLoadAll() } /** 并行拉三张配额卡 + 流水;计数归零时收起下拉刷新。 */ private startLoadAll(): void { this.bannerMessage = '' this.bannerHint = '' this.ledgerError = '' this.cardChat = new QuotaCardData('AI 对话') this.cardTryon = new QuotaCardData('换装试穿') this.cardReport = new QuotaCardData('美学报告') this.pendingLoads = 4 this.loadQuota('agent_chat') this.loadQuota('tryon') this.loadQuota('report_basic') this.loadLedger() } private finishOne(): void { if (this.pendingLoads > 0) { this.pendingLoads -= 1 } if (this.pendingLoads === 0) { this.refreshing = false } } private async loadQuota(key: string): Promise { try { const q = await ApiClient.get(`/api/v1/quota?quota_key=${key}`) const data = new QuotaCardData(quotaName(key)) data.used = q.used data.total = q.limit_value + q.bonus_granted data.remaining = q.remaining data.resetText = resetTextFor(key, q.reset_at) data.loaded = true this.applyQuota(key, data) } catch (e) { // 单卡失败不拖垮整页:错误落在卡片内 const err = toApiError(e) const data = new QuotaCardData(quotaName(key)) data.loaded = true data.errorText = err.message this.applyQuota(key, data) } finally { this.finishOne() } } private applyQuota(key: string, data: QuotaCardData): void { if (key === 'agent_chat') { this.cardChat = data } else if (key === 'tryon') { this.cardTryon = data } else { this.cardReport = data } } private async loadLedger(): Promise { try { const list = await ApiClient.get('/api/v1/quota/ledger?limit=10') const rows: LedgerRowData[] = [] for (let i = 0; i < list.length; i++) { rows.push(toLedgerRow(list[i])) } this.ledgerRows = rows this.ledgerLoaded = true this.ledgerError = '' } catch (e) { const err = toApiError(e) this.ledgerLoaded = true this.ledgerError = err.hint !== undefined && err.hint !== '' ? `${err.message}(${err.hint})` : err.message } finally { this.finishOne() } } private confirmLogout(): void { AlertDialog.show({ title: '确认登出', message: '登出后本地会话将清除,配额与报告数据仍保留在账号下。', primaryButton: { value: '取消', action: () => { // 仅关闭弹窗 } }, secondaryButton: { value: '登出', fontColor: '#C0392B', action: () => this.performLogout() } }) } private async performLogout(): Promise { if (this.loggingOut) { return } this.loggingOut = true try { await AuthStore.logout() } finally { this.loggingOut = false // 内嵌 Tab 时 replaceUrl 替换掉的是整个 HomePage 应用壳,行为正确 router.replaceUrl({ url: 'pages/LoginPage' }) } } /** 未登录占位:页内提示 + 去登录(不强制跳转)。 */ @Builder LoginPrompt() { Column({ space: 12 }) { Text('☺') .fontSize(44) .fontColor('#C2C7CE') Text('请先登录') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#2B2B2B') Text('登录后可查看配额与流水') .fontSize(13) .fontColor('#9A9A9A') Button('去登录') .fontSize(15) .fontColor(Color.White) .backgroundColor('#C2185B') .borderRadius(22) .height(44) .padding({ left: 28, right: 28 }) .margin({ top: 8 }) .onClick(() => { router.pushUrl({ url: 'pages/LoginPage' }) }) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } build() { if (this.needLogin) { this.LoginPrompt() } else { Refresh({ refreshing: $$this.refreshing }) { Scroll() { Column({ space: 14 }) { // 顶部:用户信息 + 登出 Row({ space: 12 }) { Column() { Text('美') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#FFFFFF') } .width(56) .height(56) .borderRadius(28) .backgroundColor('#C2185B') .justifyContent(FlexAlign.Center) Column({ space: 4 }) { Text('美搭用户') .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor('#2B2B2B') Text(`ID ${this.userIdMasked}`) .fontSize(12) .fontColor('#9A9A9A') } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Button(this.loggingOut ? '登出中…' : '登出') .fontSize(13) .height(32) .borderRadius(16) .padding({ left: 14, right: 14 }) .backgroundColor('#F7E3EA') .fontColor('#C2185B') .enabled(!this.loggingOut) .onClick(() => this.confirmLogout()) } .width('100%') .padding(16) .borderRadius(16) .backgroundColor('#FFFFFF') .alignItems(VerticalAlign.Center) ErrorBanner({ message: this.bannerMessage, hint: this.bannerHint, onClose: () => { this.bannerMessage = '' this.bannerHint = '' } }) // 配额三卡 Row({ space: 10 }) { QuotaCardView({ title: this.cardChat.title, remaining: this.cardChat.remaining, used: this.cardChat.used, total: this.cardChat.total, resetText: this.cardChat.resetText, loaded: this.cardChat.loaded, errorText: this.cardChat.errorText, accent: '#C2185B' }) .layoutWeight(1) QuotaCardView({ title: this.cardTryon.title, remaining: this.cardTryon.remaining, used: this.cardTryon.used, total: this.cardTryon.total, resetText: this.cardTryon.resetText, loaded: this.cardTryon.loaded, errorText: this.cardTryon.errorText, accent: '#7A5AF8' }) .layoutWeight(1) QuotaCardView({ title: this.cardReport.title, remaining: this.cardReport.remaining, used: this.cardReport.used, total: this.cardReport.total, resetText: this.cardReport.resetText, loaded: this.cardReport.loaded, errorText: this.cardReport.errorText, accent: '#2E9E5B' }) .layoutWeight(1) } .width('100%') .alignItems(VerticalAlign.Top) // 最近流水 Column({ space: 4 }) { Text('最近流水') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#2B2B2B') .width('100%') if (!this.ledgerLoaded) { Text('加载中…') .fontSize(13) .fontColor('#BBBBBB') .width('100%') .textAlign(TextAlign.Center) .padding({ top: 24, bottom: 24 }) } else if (this.ledgerError !== '') { Text(this.ledgerError) .fontSize(13) .fontColor('#C0392B') .width('100%') .textAlign(TextAlign.Center) .padding({ top: 24, bottom: 24 }) } else if (this.ledgerRows.length === 0) { Text('暂无流水记录') .fontSize(13) .fontColor('#BBBBBB') .width('100%') .textAlign(TextAlign.Center) .padding({ top: 24, bottom: 24 }) } else { ForEach(this.ledgerRows, (row: LedgerRowData) => { LedgerItemView({ typeText: row.typeText, statusText: row.statusText, statusColor: row.statusColor, statusBg: row.statusBg, quotaText: row.quotaText, timeText: row.timeText, deltaText: row.deltaText, deltaColor: row.deltaColor, balanceText: row.balanceText }) }, (row: LedgerRowData) => `${row.typeText}-${row.quotaText}-${row.timeText}`) } if (this.ledgerLoaded && this.ledgerRows.length > 0) { Text('仅展示最近 10 条,下拉可刷新') .fontSize(11) .fontColor('#B3B3B3') .width('100%') .textAlign(TextAlign.Center) .padding({ top: 8, bottom: 8 }) } } .width('100%') .padding(16) .borderRadius(16) .backgroundColor('#FFFFFF') } .width('100%') .padding({ left: 16, right: 16, top: 14, bottom: 32 }) } .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .width('100%') } .onRefreshing(() => { this.startLoadAll() }) .layoutWeight(1) .width('100%') } } }