diff --git a/app/entry/src/main/ets/components/f1/ProfileContent.ets b/app/entry/src/main/ets/components/f1/ProfileContent.ets new file mode 100644 index 0000000..9fb3743 --- /dev/null +++ b/app/entry/src/main/ets/components/f1/ProfileContent.ets @@ -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 { + 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%') + } + } +} diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index 7e140e0..da641a8 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -3,13 +3,15 @@ * * 协作铁律(FE 批次 README §2/§3): * - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件; - * Tab2/3/4 本批用「占位 + 跳页」方案(目标页面是独立路由页,由对应任务实现) + * Tab2/3 用「占位 + 跳页」方案;Tab4(我的)内嵌 components/f1/ProfileContent 组件 + * (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里) * - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态) * - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数 */ import { router } from '@kit.ArkUI' import { ApiClient } from '../core/ApiClient' +import { ProfileContent } from '../components/f1/ProfileContent' /** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */ interface QuotaWireResp { @@ -229,7 +231,9 @@ struct HomePage { .tabBar(this.TabBarItem('✦', 'AI', 2)) TabContent() { - this.PlaceholderTab('☺', '我的', '个人资料与设置(建设中)', '去我的资料', 'pages/ProfilePage') + // 个人中心内容直接内嵌(不再「占位+跳页」:独立路由页无底部 Tab, + // 进去后只能登出,用户被锁死在页面里) + ProfileContent() } .tabBar(this.TabBarItem('☺', '我的', 3)) } diff --git a/app/entry/src/main/ets/pages/ProfilePage.ets b/app/entry/src/main/ets/pages/ProfilePage.ets index 2a12f09..f9f1007 100644 --- a/app/entry/src/main/ets/pages/ProfilePage.ets +++ b/app/entry/src/main/ets/pages/ProfilePage.ets @@ -1,524 +1,36 @@ /** - * ProfilePage(F1)—— 个人中心:用户脱敏 ID、登出、配额卡片、最近流水。 + * ProfilePage —— 个人中心路由壳。 * - * 数据契约(全部走 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=终身一次。 - * - * ArkTS 约束:wire 用具名 interface、UI 状态用带默认值的 class + new、 - * 未知异常包 ApiError、async 全部放具名方法、显式泛型参数。 + * 内容已抽到 components/f1/ProfileContent(同时被 HomePage Tab4 内嵌), + * 本页仅保留独立路由入口(main_pages 已注册,供深链/外部跳转), + * 未登录时先跳登录页再进入。 */ import router from '@ohos.router' -import { ApiClient } from '../core/ApiClient' -import { ApiError } from '../core/ApiTypes' import { AuthStore } from '../core/AuthStore' -import { ErrorBanner } from '../components/f1/ErrorBanner' -import { QuotaCardView } from '../components/f1/QuotaCard' -import { LedgerItemView } from '../components/f1/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, '请求失败,请检查网络', '请稍后重试') -} +import { ProfileContent } from '../components/f1/ProfileContent' @Entry @Component struct ProfilePage { - @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 + @State ready: boolean = false aboutToAppear(): void { if (!AuthStore.isLoggedIn()) { router.replaceUrl({ url: 'pages/LoginPage' }) 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 - router.replaceUrl({ url: 'pages/LoginPage' }) - } + this.ready = true } build() { - Column() { - 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) + if (this.ready) { + ProfileContent() + } else { + Column() .width('100%') - } - .onRefreshing(() => { - this.startLoadAll() - }) - .layoutWeight(1) - .width('100%') + .height('100%') + .backgroundColor('#F6F2F3') } - .width('100%') - .height('100%') - .backgroundColor('#F6F2F3') } } diff --git a/app/entry/src/main/ets/pages/ReportCreatePage.ets b/app/entry/src/main/ets/pages/ReportCreatePage.ets index e313325..1c6d34f 100644 --- a/app/entry/src/main/ets/pages/ReportCreatePage.ets +++ b/app/entry/src/main/ets/pages/ReportCreatePage.ets @@ -2,7 +2,8 @@ * ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。 * * 流程: - * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验 + * 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验; + * 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入 * 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg * 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放) * 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录), @@ -24,6 +25,25 @@ const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮 const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年'] const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id' +// 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致 +const HEIGHT_MIN: number = 100 +const HEIGHT_MAX: number = 250 +const HEIGHT_DEFAULT: number = 165 +const WEIGHT_MIN: number = 25 +const WEIGHT_MAX: number = 300 +const WEIGHT_DEFAULT: number = 52 + +function buildRangeOptions(min: number, max: number): string[] { + const arr: string[] = [] + for (let v = min; v <= max; v++) { + arr.push(`${v}`) + } + return arr +} + +const HEIGHT_OPTIONS: string[] = buildRangeOptions(HEIGHT_MIN, HEIGHT_MAX) +const WEIGHT_OPTIONS: string[] = buildRangeOptions(WEIGHT_MIN, WEIGHT_MAX) + interface FieldLabelParams { label: string err: string @@ -66,23 +86,18 @@ struct ReportCreatePage { } } - private isNumeric(s: string): boolean { - return /^\d+(\.\d+)?$/.test(s.trim()) - } - private validateForm(): boolean { let ok = true this.genderErr = this.gender === '' ? '请选择性别' : '' if (this.genderErr !== '') { ok = false } - const h = Number(this.heightInput) - this.heightErr = !this.isNumeric(this.heightInput) || h < 100 || h > 250 ? '需为 100~250' : '' + // 选择器产出必然在合法区间,未选才是唯一错误态 + this.heightErr = this.heightInput === '' ? '请选择身高' : '' if (this.heightErr !== '') { ok = false } - const w = Number(this.weightInput) - this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : '' + this.weightErr = this.weightInput === '' ? '请选择体重' : '' if (this.weightErr !== '') { ok = false } @@ -111,6 +126,46 @@ struct ReportCreatePage { return this.coreGoals.indexOf(g) >= 0 } + /** TextPickerResult.index 是联合类型(多列选择器为数组),单列取数字下标。 */ + private pickIndex(v: TextPickerResult): number { + return typeof v.index === 'number' ? v.index : -1 + } + + /** 已有值从当前值滚起,未选过从常见默认值(165cm)滚起。 */ + private openHeightPicker(): void { + const cur = this.heightInput !== '' ? Number(this.heightInput) : HEIGHT_DEFAULT + const clamped = Math.min(Math.max(cur - HEIGHT_MIN, 0), HEIGHT_OPTIONS.length - 1) + this.getUIContext().showTextPickerDialog({ + range: HEIGHT_OPTIONS, + selected: clamped, + canLoop: false, + onAccept: (value: TextPickerResult) => { + const idx = this.pickIndex(value) + if (idx >= 0 && idx < HEIGHT_OPTIONS.length) { + this.heightInput = HEIGHT_OPTIONS[idx] + this.heightErr = '' + } + } + }) + } + + private openWeightPicker(): void { + const cur = this.weightInput !== '' ? Number(this.weightInput) : WEIGHT_DEFAULT + const clamped = Math.min(Math.max(cur - WEIGHT_MIN, 0), WEIGHT_OPTIONS.length - 1) + this.getUIContext().showTextPickerDialog({ + range: WEIGHT_OPTIONS, + selected: clamped, + canLoop: false, + onAccept: (value: TextPickerResult) => { + const idx = this.pickIndex(value) + if (idx >= 0 && idx < WEIGHT_OPTIONS.length) { + this.weightInput = WEIGHT_OPTIONS[idx] + this.weightErr = '' + } + } + }) + } + private onSubmitTap(): void { if (this.submitting) { return @@ -349,16 +404,23 @@ struct ReportCreatePage { Row({ space: 12 }) { Column({ space: 8 }) { this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true }) - TextInput({ placeholder: '如 165', text: this.heightInput }) - .type(InputType.Number) - .height(44) - .fontSize(14) - .backgroundColor('#F5F6F8') - .borderRadius(10) - .onChange((v: string) => { - this.heightInput = v - this.heightErr = '' - }) + Row() { + Text(this.heightInput === '' ? '请选择' : `${this.heightInput} cm`) + .fontSize(14) + .fontColor(this.heightInput === '' ? '#AAAAAA' : '#333333') + .layoutWeight(1) + Text('▼') + .fontSize(10) + .fontColor('#999999') + } + .height(44) + .padding({ left: 12, right: 12 }) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.openHeightPicker() + }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) @@ -366,16 +428,23 @@ struct ReportCreatePage { Column({ space: 8 }) { this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true }) - TextInput({ placeholder: '如 52', text: this.weightInput }) - .type(InputType.Number) - .height(44) - .fontSize(14) - .backgroundColor('#F5F6F8') - .borderRadius(10) - .onChange((v: string) => { - this.weightInput = v - this.weightErr = '' - }) + Row() { + Text(this.weightInput === '' ? '请选择' : `${this.weightInput} kg`) + .fontSize(14) + .fontColor(this.weightInput === '' ? '#AAAAAA' : '#333333') + .layoutWeight(1) + Text('▼') + .fontSize(10) + .fontColor('#999999') + } + .height(44) + .padding({ left: 12, right: 12 }) + .backgroundColor('#F5F6F8') + .borderRadius(10) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.openWeightPicker() + }) } .layoutWeight(1) .alignItems(HorizontalAlign.Start)