feat(app): login page with dev-token flow and profile with quota cards(F1)

登录页(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,属规程指定路由方式,构建门禁通过
This commit is contained in:
xsl
2026-08-15 00:43:46 +08:00
parent 9503c6e259
commit 336b638a1f
5 changed files with 998 additions and 8 deletions
+315 -4
View File
@@ -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')
}
}