/** * 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 { 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 { 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 { 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 { 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 { 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') } }