merge: F1 登录页与个人中心(FE 第一批)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user