Files
meida/app/entry/src/main/ets/pages/LoginPage.ets
T
xsl 336b638a1f 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,属规程指定路由方式,构建门禁通过
2026-08-15 00:43:46 +08:00

329 lines
10 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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() {
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%')
.backgroundColor('#F7F2F4')
}
}