fix(app): 我的页 Tab 内嵌修复 + 身高体重改滚轮选择器——ProfileContent 组件化(占位+跳页方案把用户锁死在独立路由页,只能登出);身高 100-250/体重 25-300 滚轮选择(showTextPickerDialog),不再键盘输入
This commit is contained in:
@@ -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%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
*
|
*
|
||||||
* 协作铁律(FE 批次 README §2/§3):
|
* 协作铁律(FE 批次 README §2/§3):
|
||||||
* - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件;
|
* - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件;
|
||||||
* Tab2/3/4 本批用「占位 + 跳页」方案(目标页面是独立路由页,由对应任务实现)
|
* Tab2/3 用「占位 + 跳页」方案;Tab4(我的)内嵌 components/f1/ProfileContent 组件
|
||||||
|
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
|
||||||
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
||||||
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { router } from '@kit.ArkUI'
|
import { router } from '@kit.ArkUI'
|
||||||
import { ApiClient } from '../core/ApiClient'
|
import { ApiClient } from '../core/ApiClient'
|
||||||
|
import { ProfileContent } from '../components/f1/ProfileContent'
|
||||||
|
|
||||||
/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */
|
/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */
|
||||||
interface QuotaWireResp {
|
interface QuotaWireResp {
|
||||||
@@ -229,7 +231,9 @@ struct HomePage {
|
|||||||
.tabBar(this.TabBarItem('✦', 'AI', 2))
|
.tabBar(this.TabBarItem('✦', 'AI', 2))
|
||||||
|
|
||||||
TabContent() {
|
TabContent() {
|
||||||
this.PlaceholderTab('☺', '我的', '个人资料与设置(建设中)', '去我的资料', 'pages/ProfilePage')
|
// 个人中心内容直接内嵌(不再「占位+跳页」:独立路由页无底部 Tab,
|
||||||
|
// 进去后只能登出,用户被锁死在页面里)
|
||||||
|
ProfileContent()
|
||||||
}
|
}
|
||||||
.tabBar(this.TabBarItem('☺', '我的', 3))
|
.tabBar(this.TabBarItem('☺', '我的', 3))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,524 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* ProfilePage(F1)—— 个人中心:用户脱敏 ID、登出、配额卡片、最近流水。
|
* ProfilePage —— 个人中心路由壳。
|
||||||
*
|
*
|
||||||
* 数据契约(全部走 ApiClient/AuthStore,不直接碰 http):
|
* 内容已抽到 components/f1/ProfileContent(同时被 HomePage Tab4 内嵌),
|
||||||
* - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取)
|
* 本页仅保留独立路由入口(main_pages 已注册,供深链/外部跳转),
|
||||||
* - 流水: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 router from '@ohos.router'
|
||||||
import { ApiClient } from '../core/ApiClient'
|
|
||||||
import { ApiError } from '../core/ApiTypes'
|
|
||||||
import { AuthStore } from '../core/AuthStore'
|
import { AuthStore } from '../core/AuthStore'
|
||||||
import { ErrorBanner } from '../components/f1/ErrorBanner'
|
import { ProfileContent } from '../components/f1/ProfileContent'
|
||||||
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
|
@Entry
|
||||||
@Component
|
@Component
|
||||||
struct ProfilePage {
|
struct ProfilePage {
|
||||||
@State userIdMasked: string = ''
|
@State ready: boolean = false
|
||||||
@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 {
|
aboutToAppear(): void {
|
||||||
if (!AuthStore.isLoggedIn()) {
|
if (!AuthStore.isLoggedIn()) {
|
||||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const uid = AuthStore.getUserId()
|
this.ready = true
|
||||||
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() {
|
build() {
|
||||||
Column() {
|
if (this.ready) {
|
||||||
Refresh({ refreshing: $$this.refreshing }) {
|
ProfileContent()
|
||||||
Scroll() {
|
} else {
|
||||||
Column({ space: 14 }) {
|
Column()
|
||||||
// 顶部:用户信息 + 登出
|
|
||||||
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%')
|
.width('100%')
|
||||||
}
|
.height('100%')
|
||||||
.onRefreshing(() => {
|
.backgroundColor('#F6F2F3')
|
||||||
this.startLoadAll()
|
|
||||||
})
|
|
||||||
.layoutWeight(1)
|
|
||||||
.width('100%')
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
|
||||||
.height('100%')
|
|
||||||
.backgroundColor('#F6F2F3')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
|
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
|
||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验
|
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
|
||||||
|
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
|
||||||
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
|
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
|
||||||
* 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放)
|
* 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放)
|
||||||
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录),
|
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录),
|
||||||
@@ -24,6 +25,25 @@ const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮
|
|||||||
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
|
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
|
||||||
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
|
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 {
|
interface FieldLabelParams {
|
||||||
label: string
|
label: string
|
||||||
err: string
|
err: string
|
||||||
@@ -66,23 +86,18 @@ struct ReportCreatePage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNumeric(s: string): boolean {
|
|
||||||
return /^\d+(\.\d+)?$/.test(s.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateForm(): boolean {
|
private validateForm(): boolean {
|
||||||
let ok = true
|
let ok = true
|
||||||
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
||||||
if (this.genderErr !== '') {
|
if (this.genderErr !== '') {
|
||||||
ok = false
|
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 !== '') {
|
if (this.heightErr !== '') {
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
const w = Number(this.weightInput)
|
this.weightErr = this.weightInput === '' ? '请选择体重' : ''
|
||||||
this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : ''
|
|
||||||
if (this.weightErr !== '') {
|
if (this.weightErr !== '') {
|
||||||
ok = false
|
ok = false
|
||||||
}
|
}
|
||||||
@@ -111,6 +126,46 @@ struct ReportCreatePage {
|
|||||||
return this.coreGoals.indexOf(g) >= 0
|
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 {
|
private onSubmitTap(): void {
|
||||||
if (this.submitting) {
|
if (this.submitting) {
|
||||||
return
|
return
|
||||||
@@ -349,16 +404,23 @@ struct ReportCreatePage {
|
|||||||
Row({ space: 12 }) {
|
Row({ space: 12 }) {
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true })
|
this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true })
|
||||||
TextInput({ placeholder: '如 165', text: this.heightInput })
|
Row() {
|
||||||
.type(InputType.Number)
|
Text(this.heightInput === '' ? '请选择' : `${this.heightInput} cm`)
|
||||||
.height(44)
|
.fontSize(14)
|
||||||
.fontSize(14)
|
.fontColor(this.heightInput === '' ? '#AAAAAA' : '#333333')
|
||||||
.backgroundColor('#F5F6F8')
|
.layoutWeight(1)
|
||||||
.borderRadius(10)
|
Text('▼')
|
||||||
.onChange((v: string) => {
|
.fontSize(10)
|
||||||
this.heightInput = v
|
.fontColor('#999999')
|
||||||
this.heightErr = ''
|
}
|
||||||
})
|
.height(44)
|
||||||
|
.padding({ left: 12, right: 12 })
|
||||||
|
.backgroundColor('#F5F6F8')
|
||||||
|
.borderRadius(10)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openHeightPicker()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
@@ -366,16 +428,23 @@ struct ReportCreatePage {
|
|||||||
|
|
||||||
Column({ space: 8 }) {
|
Column({ space: 8 }) {
|
||||||
this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true })
|
this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true })
|
||||||
TextInput({ placeholder: '如 52', text: this.weightInput })
|
Row() {
|
||||||
.type(InputType.Number)
|
Text(this.weightInput === '' ? '请选择' : `${this.weightInput} kg`)
|
||||||
.height(44)
|
.fontSize(14)
|
||||||
.fontSize(14)
|
.fontColor(this.weightInput === '' ? '#AAAAAA' : '#333333')
|
||||||
.backgroundColor('#F5F6F8')
|
.layoutWeight(1)
|
||||||
.borderRadius(10)
|
Text('▼')
|
||||||
.onChange((v: string) => {
|
.fontSize(10)
|
||||||
this.weightInput = v
|
.fontColor('#999999')
|
||||||
this.weightErr = ''
|
}
|
||||||
})
|
.height(44)
|
||||||
|
.padding({ left: 12, right: 12 })
|
||||||
|
.backgroundColor('#F5F6F8')
|
||||||
|
.borderRadius(10)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openWeightPicker()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.layoutWeight(1)
|
.layoutWeight(1)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|||||||
Reference in New Issue
Block a user