Files
meida/app/entry/src/main/ets/components/f1/ProfileContent.ets
T

559 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void> {
try {
const q = await ApiClient.get<QuotaWire>(`/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<void> {
try {
const list = await ApiClient.get<LedgerWire[]>('/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<void> {
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%')
}
}
}