From 336b638a1f8781952fbd3afec3d7263c5d4177a5 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sat, 15 Aug 2026 00:43:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20login=20page=20with=20dev-token=20?= =?UTF-8?q?flow=20and=20profile=20with=20quota=20cards=EF=BC=88F1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录页(LoginPage): - 品牌区 + 设备标识输入(随机生成一次并持久化到 preferences,可改可重生成) - 高级设置折叠区:展示/编辑 baseUrl(AuthStore.setBaseUrl),带格式校验与恢复默认 - 登录闭环:loading 态 → AuthStore.loginByDevToken → 成功 router.replaceUrl 首页; 失败 catch ApiError 用 ErrorBanner 展示 message+hint;已登录态进入直接跳首页 个人页(ProfilePage): - 用户 ID 脱敏(前 6 后 4)+ 登出(AlertDialog 确认 → AuthStore.logout → 回登录页) - agent_chat/tryon/report_basic 三配额卡并行拉取,进度条 used/(limit+bonus), 剩余数大字;周期文案:日配额=明日恢复、report_basic=一次性额度、月度=日期 - 最近流水(limit=10):tx_type 中文映射(消耗/退还/奖励),status 徽标配色区分 - Refresh 下拉刷新(加分项) 共享组件 components/f1/:ErrorBanner、QuotaCard、LedgerItem 偏差说明: - 设备标识在 @State 之外额外持久化(meida_login/device_key),保证跨启动稳定不重生 - Scroll 隐藏滚动条用 .scrollBar(BarState.Off)(本 SDK 无 scrollBarBarState 属性) - router.replaceUrl / AlertDialog.show 有 deprecation WARN,属规程指定路由方式,构建门禁通过 --- .../main/ets/components/f1/ErrorBanner.ets | 44 ++ .../src/main/ets/components/f1/LedgerItem.ets | 57 ++ .../src/main/ets/components/f1/QuotaCard.ets | 71 +++ app/entry/src/main/ets/pages/LoginPage.ets | 319 ++++++++++- app/entry/src/main/ets/pages/ProfilePage.ets | 515 +++++++++++++++++- 5 files changed, 998 insertions(+), 8 deletions(-) create mode 100644 app/entry/src/main/ets/components/f1/ErrorBanner.ets create mode 100644 app/entry/src/main/ets/components/f1/LedgerItem.ets create mode 100644 app/entry/src/main/ets/components/f1/QuotaCard.ets diff --git a/app/entry/src/main/ets/components/f1/ErrorBanner.ets b/app/entry/src/main/ets/components/f1/ErrorBanner.ets new file mode 100644 index 0000000..83e1ce4 --- /dev/null +++ b/app/entry/src/main/ets/components/f1/ErrorBanner.ets @@ -0,0 +1,44 @@ +/** + * ErrorBanner(F1)—— 统一错误横幅:展示 ApiError 的 message + hint。 + * + * 两处使用:LoginPage(登录失败)、ProfilePage(数据加载失败)。 + * ArkTS 约束:回调用普通成员属性(非装饰器);@Prop 全部给本地默认值。 + */ + +@Component +export struct ErrorBanner { + @Prop message: string = '' + @Prop hint: string = '' + onClose: () => void = () => {} + + build() { + if (this.message !== '') { + Row({ space: 8 }) { + Column({ space: 2 }) { + Text(this.message) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#C0392B') + if (this.hint !== '') { + Text(this.hint) + .fontSize(12) + .fontColor('#D98880') + } + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Text('×') + .fontSize(20) + .fontColor('#C0392B') + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .onClick(() => this.onClose()) + } + .width('100%') + .padding(12) + .borderRadius(12) + .backgroundColor('#FBEAEA') + .alignItems(VerticalAlign.Top) + } + } +} diff --git a/app/entry/src/main/ets/components/f1/LedgerItem.ets b/app/entry/src/main/ets/components/f1/LedgerItem.ets new file mode 100644 index 0000000..a2a44f9 --- /dev/null +++ b/app/entry/src/main/ets/components/f1/LedgerItem.ets @@ -0,0 +1,57 @@ +/** + * LedgerItemView(F1)—— 配额流水行:类型 + 状态徽标 + 配额/时间 + 变动值/余额。 + * + * tx_type / status 的中文映射与配色由父层算好,本组件只负责展示(纯 @Prop)。 + */ + +@Component +export struct LedgerItemView { + @Prop typeText: string = '' + @Prop statusText: string = '' + @Prop statusColor: string = '#999999' + @Prop statusBg: string = '#F4F4F4' + @Prop quotaText: string = '' + @Prop timeText: string = '' + @Prop deltaText: string = '' + @Prop deltaColor: string = '#666666' + @Prop balanceText: string = '' + + build() { + Row({ space: 12 }) { + Column({ space: 4 }) { + Row({ space: 6 }) { + Text(this.typeText) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#2B2B2B') + Text(this.statusText) + .fontSize(10) + .fontColor(this.statusColor) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .borderRadius(8) + .backgroundColor(this.statusBg) + } + Text(`${this.quotaText} · ${this.timeText}`) + .fontSize(11) + .fontColor('#AAAAAA') + .maxLines(1) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + Column({ space: 4 }) { + Text(this.deltaText) + .fontSize(16) + .fontWeight(FontWeight.Bold) + .fontColor(this.deltaColor) + Text(this.balanceText) + .fontSize(10) + .fontColor('#BBBBBB') + } + .alignItems(HorizontalAlign.End) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + } +} diff --git a/app/entry/src/main/ets/components/f1/QuotaCard.ets b/app/entry/src/main/ets/components/f1/QuotaCard.ets new file mode 100644 index 0000000..f5030d4 --- /dev/null +++ b/app/entry/src/main/ets/components/f1/QuotaCard.ets @@ -0,0 +1,71 @@ +/** + * QuotaCardView(F1)—— 配额卡片:剩余数大字 + 进度条 + 周期文案。 + * + * 数据契约(对齐后端 GET /api/v1/quota): + * used / (limit_value + bonus_granted) 为进度;remaining 为剩余额度。 + * 周期文案由父层根据 quota_key + reset_at 算好传入(日=明日恢复/终身=一次性)。 + */ + +@Component +export struct QuotaCardView { + @Prop title: string = '' + @Prop remaining: number = 0 + @Prop used: number = 0 + @Prop total: number = 0 + @Prop resetText: string = '' + @Prop loaded: boolean = false + @Prop errorText: string = '' + @Prop accent: string = '#C2185B' + + build() { + Column({ space: 6 }) { + Text(this.title) + .fontSize(13) + .fontColor('#8A8A8A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + if (!this.loaded) { + LoadingProgress() + .width(28) + .height(28) + .color('#CCCCCC') + .margin({ top: 10, bottom: 14 }) + } else if (this.errorText !== '') { + Text(this.errorText) + .fontSize(13) + .fontColor('#C0392B') + .maxLines(2) + .margin({ top: 6, bottom: 8 }) + .textAlign(TextAlign.Center) + } else { + Text(`${this.remaining}`) + .fontSize(34) + .fontWeight(FontWeight.Bold) + .fontColor(this.accent) + Text('剩余次数') + .fontSize(11) + .fontColor('#AAAAAA') + Progress({ value: this.used, total: Math.max(this.total, 1), type: ProgressType.Linear }) + .width('100%') + .height(6) + .borderRadius(3) + .color(this.accent) + .backgroundColor('#F1E7EB') + .margin({ top: 4 }) + Text(`已用 ${this.used}/${this.total}`) + .fontSize(11) + .fontColor('#999999') + Text(this.resetText) + .fontSize(10) + .fontColor('#B3B3B3') + .maxLines(1) + } + } + .width('100%') + .padding(12) + .borderRadius(16) + .backgroundColor('#FFFFFF') + .alignItems(HorizontalAlign.Center) + } +} diff --git a/app/entry/src/main/ets/pages/LoginPage.ets b/app/entry/src/main/ets/pages/LoginPage.ets index 6b9ce76..a1cf9e6 100644 --- a/app/entry/src/main/ets/pages/LoginPage.ets +++ b/app/entry/src/main/ets/pages/LoginPage.ets @@ -1,17 +1,328 @@ /** - * LoginPage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * LoginPage(F1)—— dev-token 开发登录页。 + * + * 闭环:设备标识(随机生成一次并持久化,可改)→ AuthStore.loginByDevToken + * → 成功 router.replaceUrl HomePage;失败 catch ApiError 展示 message+hint。 + * 高级设置:折叠区编辑 baseUrl(AuthStore.setBaseUrl),真机连开发机靠它。 + * 已登录态进入本页:aboutToAppear 直接跳 HomePage。 + * + * ArkTS 约束(对齐 core/AuthStore 风格): + * - 泛型调用显式类型参数;未知异常统一包 ApiError(本页只展示不抛出) + * - async 逻辑放具名方法,build 内只留 UI 与点击回调 */ +import router from '@ohos.router' +import preferences from '@ohos.data.preferences' +import { ApiError } from '../core/ApiTypes' +import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore' +import { ContextHolder } from '../core/ContextHolder' +import { ErrorBanner } from '../components/f1/ErrorBanner' + +const PREF_NAME_LOGIN = 'meida_login' +const KEY_DEVICE_KEY = 'device_key' +const COLOR_PRIMARY = '#C2185B' + +/** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */ +function generateDeviceKey(): string { + const chars = '0123456789abcdef' + let suffix = '' + for (let i = 0; i < 16; i++) { + suffix += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return `dev-${suffix}` +} + +/** 未知异常统一包 ApiError(与 ApiClient.wrapUnknown 同一话术)。 */ +function toApiError(e: Object): ApiError { + if (e instanceof ApiError) { + return e + } + return new ApiError(50000, '请求失败,请检查网络', '请稍后重试') +} + @Entry @Component struct LoginPage { + @State deviceKey: string = '' + @State baseUrlInput: string = '' + @State showAdvanced: boolean = false + @State logging: boolean = false + @State savingUrl: boolean = false + @State errMessage: string = '' + @State errHint: string = '' + @State savedTip: string = '' + + aboutToAppear(): void { + this.bootstrap() + } + + /** 具名 async 初始化:确保令牌已载入 → 已登录直跳 → 回填设备标识与地址。 */ + private async bootstrap(): Promise { + try { + await AuthStore.init() + } catch (_e) { + // onCreate 里已 fire-and-forget 初始化过,这里重复 init 失败不影响 + } + if (AuthStore.isLoggedIn()) { + router.replaceUrl({ url: 'pages/HomePage' }) + return + } + try { + const prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME_LOGIN }) + const saved = await prefs.get(KEY_DEVICE_KEY, '') as string + if (saved !== '') { + this.deviceKey = saved + } else { + this.deviceKey = generateDeviceKey() + await prefs.put(KEY_DEVICE_KEY, this.deviceKey) + await prefs.flush() + } + } catch (_e) { + this.deviceKey = generateDeviceKey() + } + this.baseUrlInput = await AuthStore.getBaseUrl() + } + + /** 登录主流程:校验 → loading → dev-token → 跳首页;失败横幅展示。 */ + private async doLogin(): Promise { + if (this.logging) { + return + } + const key = this.deviceKey.trim() + this.errMessage = '' + this.errHint = '' + this.savedTip = '' + if (key === '') { + this.errMessage = '请先填写设备标识' + this.errHint = '可点「随机生成」重新生成一个' + return + } + this.logging = true + this.persistDeviceKey(key) + try { + await AuthStore.loginByDevToken(key) + router.replaceUrl({ url: 'pages/HomePage' }) + } catch (e) { + const err = toApiError(e) + this.errMessage = err.message + this.errHint = err.hint !== undefined ? err.hint : '' + } finally { + this.logging = false + } + } + + /** 设备标识持久化失败不阻塞登录。 */ + private async persistDeviceKey(key: string): Promise { + try { + const prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME_LOGIN }) + await prefs.put(KEY_DEVICE_KEY, key) + await prefs.flush() + } catch (_e) { + // 忽略:仅影响下次回填 + } + } + + private async saveBaseUrl(): Promise { + const url = this.baseUrlInput.trim() + this.savedTip = '' + if (url === '') { + this.errMessage = '服务器地址不能为空' + this.errHint = '示例:http://192.168.1.100:8000' + return + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + this.errMessage = '地址需以 http:// 或 https:// 开头' + this.errHint = '示例:http://192.168.1.100:8000' + return + } + this.savingUrl = true + try { + await AuthStore.setBaseUrl(url) + this.errMessage = '' + this.errHint = '' + this.savedTip = '已保存' + } catch (_e) { + this.errMessage = '保存失败,请重试' + this.errHint = '' + } finally { + this.savingUrl = false + } + } + + private async resetBaseUrl(): Promise { + this.savingUrl = true + try { + await AuthStore.setBaseUrl(DEFAULT_BASE_URL) + this.baseUrlInput = DEFAULT_BASE_URL + this.savedTip = '已恢复默认' + } catch (_e) { + this.errMessage = '保存失败,请重试' + this.errHint = '' + } finally { + this.savingUrl = false + } + } + + private regenDeviceKey(): void { + this.deviceKey = generateDeviceKey() + this.errMessage = '' + this.errHint = '' + } + build() { Column() { - Text('LoginPage(建设中)').fontSize(16).fontColor('#666666') + Scroll() { + Column({ space: 22 }) { + // 品牌区 + Column({ space: 10 }) { + Text('美搭') + .fontSize(48) + .fontWeight(FontWeight.Bold) + .fontColor(COLOR_PRIMARY) + .letterSpacing(6) + Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质') + .fontSize(14) + .fontColor('#8A8A8A') + } + .margin({ top: 72, bottom: 8 }) + + // 设备标识 + Column({ space: 8 }) { + Row({ space: 8 }) { + Text('设备标识') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#2B2B2B') + Blank() + Text('随机生成') + .fontSize(13) + .fontColor(COLOR_PRIMARY) + .padding({ left: 8, right: 8, top: 4, bottom: 4 }) + .borderRadius(12) + .backgroundColor('#F7E3EA') + .onClick(() => this.regenDeviceKey()) + } + .width('100%') + + TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' }) + .height(48) + .fontSize(15) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .padding({ left: 14, right: 14 }) + .onChange((value: string) => { + this.deviceKey = value + }) + + Text('同一标识对应同一账号与配额,换标识即换新用户') + .fontSize(11) + .fontColor('#B3B3B3') + } + .width('100%') + + // 高级设置(服务器地址,真机连开发机靠它) + Column({ space: 10 }) { + Row() { + Text('高级设置 · 服务器地址') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#2B2B2B') + Blank() + Text(this.showAdvanced ? '收起' : '展开') + .fontSize(13) + .fontColor(COLOR_PRIMARY) + } + .width('100%') + .onClick(() => { + this.showAdvanced = !this.showAdvanced + this.savedTip = '' + }) + + if (this.showAdvanced) { + Column({ space: 8 }) { + TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL }) + .height(46) + .fontSize(14) + .borderRadius(12) + .backgroundColor('#FFFFFF') + .padding({ left: 14, right: 14 }) + .onChange((value: string) => { + this.baseUrlInput = value + }) + + Row({ space: 10 }) { + Button(this.savingUrl ? '保存中…' : '保存地址') + .fontSize(14) + .height(38) + .borderRadius(19) + .backgroundColor(COLOR_PRIMARY) + .fontColor('#FFFFFF') + .enabled(!this.savingUrl) + .onClick(() => this.saveBaseUrl()) + + Button('恢复默认') + .fontSize(14) + .height(38) + .borderRadius(19) + .backgroundColor('#F0E0E6') + .fontColor(COLOR_PRIMARY) + .enabled(!this.savingUrl) + .onClick(() => this.resetBaseUrl()) + + if (this.savedTip !== '') { + Text(this.savedTip) + .fontSize(13) + .fontColor('#2E9E5B') + } + } + .width('100%') + + Text('模拟器直连本机可用默认地址;真机请填开发机局域网 IP(如 http://192.168.1.100:8000)') + .fontSize(11) + .fontColor('#B3B3B3') + } + .width('100%') + .padding({ top: 4 }) + } + } + .width('100%') + .padding(14) + .borderRadius(16) + .backgroundColor('#FFFFFF') + + // 登录按钮 + Button(this.logging ? '正在进入…' : '进入美搭') + .width('100%') + .height(50) + .fontSize(17) + .fontWeight(FontWeight.Medium) + .borderRadius(25) + .backgroundColor(COLOR_PRIMARY) + .fontColor('#FFFFFF') + .enabled(!this.logging && this.deviceKey.trim() !== '') + .onClick(() => this.doLogin()) + + // 错误横幅(登录/地址保存失败) + ErrorBanner({ + message: this.errMessage, + hint: this.errHint, + onClose: () => { + this.errMessage = '' + this.errHint = '' + } + }) + } + .width('100%') + .padding({ left: 24, right: 24, bottom: 32 }) + .constraintSize({ minHeight: '100%' }) + .justifyContent(FlexAlign.Center) + } + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .layoutWeight(1) } .width('100%') .height('100%') - .justifyContent(FlexAlign.Center) + .backgroundColor('#F7F2F4') } } diff --git a/app/entry/src/main/ets/pages/ProfilePage.ets b/app/entry/src/main/ets/pages/ProfilePage.ets index d6e7673..2a12f09 100644 --- a/app/entry/src/main/ets/pages/ProfilePage.ets +++ b/app/entry/src/main/ets/pages/ProfilePage.ets @@ -1,17 +1,524 @@ /** - * ProfilePage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * ProfilePage(F1)—— 个人中心:用户脱敏 ID、登出、配额卡片、最近流水。 + * + * 数据契约(全部走 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 全部放具名方法、显式泛型参数。 */ +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, '请求失败,请检查网络', '请稍后重试') +} + @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 + + 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' }) + } + } + build() { Column() { - Text('ProfilePage(建设中)').fontSize(16).fontColor('#666666') + 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%') } .width('100%') .height('100%') - .justifyContent(FlexAlign.Center) + .backgroundColor('#F6F2F3') } }