登录页(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,属规程指定路由方式,构建门禁通过
329 lines
10 KiB
Plaintext
329 lines
10 KiB
Plaintext
/**
|
||
* 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')
|
||
}
|
||
}
|