fix(app): 我的页 Tab 内嵌修复 + 身高体重改滚轮选择器——ProfileContent 组件化(占位+跳页方案把用户锁死在独立路由页,只能登出);身高 100-250/体重 25-300 滚轮选择(showTextPickerDialog),不再键盘输入

This commit is contained in:
xsl
2026-08-16 11:40:42 +08:00
parent 256f850a61
commit db9c4da0e3
4 changed files with 675 additions and 532 deletions
@@ -0,0 +1,558 @@
/**
* 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%')
}
}
}