HarmonyOS购物商城——登录页面与路由守卫
购物商城加了登录功能。不是简单的跳转到登录页而是一套完整的登录体系AuthService管理登录状态、Index.ets做路由守卫、LoginPage处理登录交互。三个文件各司其职构成了一个最小可用的登录流程。完整效果AuthService最简登录状态管理let _loggedIn: boolean false export function isLoggedIn(): boolean { return _loggedIn } export function login(): void { _loggedIn true } export function logout(): void { _loggedIn false }三个函数一个模块级变量。没有Token、没有网络请求、没有持久化存储——就是一个内存里的布尔值。这是有意为之的。原型阶段的登录不需要真实认证只需要一个已登录/未登录的状态开关。AuthService的职责是提供状态查询和修改的接口而不是实现认证逻辑。以后对接真实后端时只需要改AuthService的内部实现比如把_loggedIn换成Token校验调用方不需要改。模块级变量_loggedIn的作用域是整个模块——所有导入AuthService的页面共享同一个变量。Index.ets调用login()修改了_loggedInProfilePage调用isLoggedIn()就能读到最新值。这是ES Module的单例特性——模块只加载一次变量只有一份。Index.ets路由守卫守卫逻辑aboutToAppear(): void { if (!isLoggedIn()) { router.pushUrl({ url: pages/LoginPage }); return } this.cartCount getCartCount() } onPageShow(): void { if (!isLoggedIn()) { router.pushUrl({ url: pages/LoginPage }); return } this.cartCount getCartCount() }两个生命周期钩子都检查登录状态。未登录时跳转到LoginPage不执行后续逻辑。为什么两个钩子都要检查aboutToAppear在页面首次创建时执行onPageShow在页面每次显示时执行。只检查aboutToAppear不够——用户登录后从ProfilePage返回首页aboutToAppear不会重新执行页面没有被销毁重建但onPageShow会执行。如果只在aboutToAppear检查用户登录后返回首页时不会重新验证状态。但实际上这个逻辑有问题用户登录后_loggedIn已经变成trueonPageShow检查isLoggedIn()会返回true不会再次跳转到LoginPage。所以两个钩子都检查是防御性编程——确保无论页面怎么创建/显示都会验证登录状态。return 的作用if (!isLoggedIn()) { router.pushUrl({ url: pages/LoginPage }); return } this.cartCount getCartCount()router.pushUrl之后加了return。这是因为pushUrl是异步的——调用后不会立即跳转代码会继续往下执行。如果不加returnthis.cartCount getCartCount()会在跳转之前执行。虽然执行了也没问题反正页面要跳走了但加return是更干净的写法——“不需要执行的代码就不执行”。LoginPage登录交互的完整实现页面结构Stack({ alignContent: Alignment.TopStart }) { // 背景渐变 Column() {} ... .linearGradient(...) // 装饰性光晕 Circle(...).animation(...) Circle(...).animation(...) Circle(...).animation(...) // 内容 Column() { Scroll() { Column() { // Logo区域 // 登录卡片 // 社交登录 } } } }三层结构最底层是渐变背景中间是三个装饰性光晕最上层是内容区域。Stack让三层叠在一起。背景渐变Column() {} .width(100%).height(100%) .linearGradient({ angle: 160, colors: [ [#FFF5F5, 0], [#FEF0F0, 0.3], [#FFF8F5, 0.6], [#FFFFFF, 1] ] })160°角度的暖色调渐变——从浅红#FFF5F5过渡到白色。四个颜色节点让渐变更平滑比两个节点的线性渐变更细腻。渐变从左上角到右下角和登录卡片的阴影方向一致。装饰性光晕Circle({ width: 200, height: 200 }) .fill(rgba(255,71,87,0.04)) .position({ x: -60, y: 40 }) .animation({ duration: 5000, curve: Curve.EaseInOut, iterations: -1, playMode: PlayMode.Alternate })三个不同大小、不同颜色、不同位置的圆形每个都有无限循环的交替动画。动画效果是呼吸感——圆形缓慢放大缩小颜色微微变化。光晕的颜色透明度很低0.03-0.05在白色背景上几乎看不出来但能给页面增加一点氛围感。这种装饰性元素不影响功能但能让页面看起来更有设计感。如果去掉光晕页面功能完全不受影响但视觉上会显得平。动画参数的选择duration: 3800-5000ms不同圆形用不同速度避免同步跳动curve: Curve.EaseInOut缓入缓出动画更自然iterations: -1无限循环playMode: PlayMode.Alternate正向播放后反向播放形成呼吸效果Logo区域Stack({ alignContent: Alignment.Center }) { Column() {} .width(80).height(80).borderRadius(24) .linearGradient({ angle: 135, colors: [[A, 0], [#FF9F43, 1]] }) .shadow({ radius: 20, color: A 30, offsetY: 8 }) Text(商).fontSize(34).fontWeight(FontWeight.Bold).fontColor(Color.White) } Text(购物商城).fontSize(26).fontWeight(FontWeight.Bold).fontColor(T1).margin({ top: 20 }) Text(好货抢先购 · 正品有保障).fontSize(14).fontColor(T2).margin({ top: 6 }) Text(新用户注册即享专属优惠).fontSize(12).fontColor(T3).margin({ top: 4 })Logo区域由四部分组成渐变圆角方形80×80borderRadius 24 白色商字应用名称购物商城26px加粗副标题好货抢先购 · 正品有保障14px灰色营销文案新用户注册即享专属优惠12px浅灰色阴影{ radius: 20, color: A 30, offsetY: 8 }让Logo有浮起来的感觉。A 30’是红色带18%透明度的阴影和Logo的渐变色呼应。三行文字的字号递减26→14→12颜色递浅T1→T2→T3形成清晰的信息层级应用名 副标题 营销文案。登录卡片毛玻璃效果Column() { // 手机号输入 // 验证码输入 // 错误提示 // 登录按钮 // 协议勾选 } .width(100%).padding(28) .backgroundColor(rgba(255,255,255,0.85)) .borderRadius(22) .border({ width: 1, color: rgba(255,71,87,0.06) }) .shadow({ radius: 24, color: rgba(255,71,87,0.06), offsetY: 4 })登录卡片的背景色是rgba(255,255,255,0.85)——85%不透明度的白色。这不是真正的毛玻璃需要BackdropBlur但视觉上接近——半透明白色叠加在渐变背景上能隐约看到背景的颜色变化。边框rgba(255,71,87,0.06)是极淡的红色几乎看不见但能让卡片边缘更清晰。阴影rgba(255,71,87,0.06)和边框同色形成统一的红色调阴影体系。手机号输入Row() { Text(86).fontSize(16).fontWeight(FontWeight.Medium).fontColor(T1) .padding({ right: 12 }) Row() {}.width(1).height(18).backgroundColor(#E8E4F0) TextInput({ placeholder: 请输入手机号, text: this.phone }) .type(InputType.Number).maxLength(11).fontSize(16) .backgroundColor(transparent).layoutWeight(1) .onChange((v: string) { this.phone v }) .onFocus(() { this.phoneFocus true }) .onBlur(() { this.phoneFocus false }) }.width(100%).padding({ top: 4, bottom: 12 }) .border({ width: { bottom: this.phoneFocus ? 2 : 1 }, color: this.phoneFocus ? A : #E8E4F0 })手机号输入由三部分组成86前缀、竖线分隔符、TextInput。底部边框的交互反馈输入框的底部边框会根据焦点状态变化——聚焦时是2px红色A失焦时是1px灰色#E8E4F0。这个微交互告诉用户当前正在编辑哪个字段。this.phoneFocus由onFocus/onBlur事件控制。竖线分隔符是Row() {}.width(1).height(18)——一个1px宽的空Row用backgroundColor画一条竖线。比用Divider更灵活——Divider的样式受全局主题影响空Row的样式完全可控。验证码输入Row() { TextInput({ placeholder: 请输入验证码, text: this.code }) .type(InputType.Number).maxLength(6).fontSize(16) .backgroundColor(transparent).layoutWeight(1) .onChange((v: string) { this.code v }) Text(发送验证码).fontSize(13).fontWeight(FontWeight.Medium) .fontColor(this.phoneOk() ? A : T3) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.phoneOk() ? #FFF0F0 : #F5F5F7) .borderRadius(10) }.width(100%).padding({ top: 4, bottom: 12 })验证码输入框右侧有一个发送验证码按钮。按钮的颜色根据手机号是否有效动态变化手机号有效11位红色文字浅红背景可点击手机号无效灰色文字灰色背景不可点击this.phoneOk()是一个计算方法return this.phone.length 11。只有输入了完整的11位手机号发送按钮才会激活。实际项目中点击发送验证码应该调用短信API发送真实验证码。这里没有实现——因为需要后端支持。但UI状态的切换已经做好了。错误提示if (this.errorMsg.length 0) { Row() { Text(⚠ this.errorMsg).fontSize(12).fontColor(#FF6B6B) }.width(100%).padding({ top: 12, bottom: 4 }) .backgroundColor(#FFF5F5).borderRadius(8).padding({ left: 12, right: 12 }) }错误提示在验证码输入框下方浅红背景红色文字。this.errorMsg在doLogin方法中设置——校验失败时写入错误信息校验通过时清空。错误提示用条件渲染if而不是始终占位。这样在没有错误时登录卡片更紧凑有错误时卡片自动撑开错误信息出现在对应的位置。登录按钮的状态Button() { Text(登录).fontSize(17).fontWeight(FontWeight.Bold).fontColor(Color.White) }.width(100%).height(52).borderRadius(14) .backgroundColor(this.formOk() ? A : #D0CcD4) .shadow({ radius: this.formOk() ? 14 : 0, color: this.formOk() ? A 35 : transparent, offsetY: 6 })登录按钮有两种状态表单有效手机号11位验证码4位以上已同意协议红色背景红色阴影可点击表单无效灰色背景#D0CcD4无阴影视觉上不可用this.formOk()是三个条件的ANDthis.phoneOk() this.codeOk() this.agreed。只有三个条件都满足按钮才激活。阴影的radius: this.formOk() ? 14 : 0——有效时有14px的红色阴影无效时阴影消失。这个变化让用户能直观感受到按钮能不能点。协议勾选Row() { Stack({ alignContent: Alignment.Center }) { Column() {} .width(18).height(18).borderRadius(5) .backgroundColor(this.agreed ? A : transparent) .border({ width: 1.5, color: this.agreed ? A : #D0CED8 }) if (this.agreed) { Text(✓).fontSize(11).fontWeight(FontWeight.Bold).fontColor(Color.White) } }.margin({ right: 8 }).onClick(() { this.agreed !this.agreed }) Text(我已阅读并同意).fontSize(12).fontColor(T2) Text(《用户协议》).fontSize(12).fontColor(A).fontWeight(FontWeight.Medium) Text(和).fontSize(12).fontColor(T2) Text(《隐私政策》).fontSize(12).fontColor(A).fontWeight(FontWeight.Medium) }.margin({ top: 20 }).justifyContent(FlexAlign.Center)协议勾选用自定义的Stack实现而不是系统Checkbox。原因是系统Checkbox的样式不可定制颜色和大小受限。自定义实现可以完全控制外观。勾选框的实现一个18×18的圆角方块未选中时透明背景灰色边框选中时红色背景白色对勾。点击整个Stack切换this.agreed状态。协议文字分四段灰色我已阅读并同意、红色《用户协议》“、灰色和”、红色《隐私政策》“。红色部分暗示可点击查看”虽然这里没有绑定点击事件。实际项目中应该跳转到对应的协议页面。社交登录Row() { Row() {}.width(50).height(1).backgroundColor(#E8E4F0) Text( 其他方式登录 ).fontSize(12).fontColor(T3) Row() {}.width(50).height(1).backgroundColor(#E8E4F0) }.justifyContent(FlexAlign.Center).margin({ top: 40, bottom: 20 }) Row({ space: 40 }) { Column({ space: 6 }) { Column() { Text().fontSize(26) } .width(52).height(52).borderRadius(26).backgroundColor(#F5F5F7) Text(微信).fontSize(11).fontColor(T2) }.alignItems(HorizontalAlign.Center).onClick(() { login(); router.back() }) // QQ, Apple 同理 }社交登录区域用分割线文字做标题三个圆形图标横向排列。每个图标点击后直接调用login()然后router.back()——没有真正的第三方登录逻辑只是模拟。分割线是两个50px宽的空Row中间夹着文字。这是分割线文字的经典实现——比用Divider更灵活因为Divider不能和文字在同一行。doLogin 的校验逻辑private doLogin(): void { this.errorMsg if (!this.phoneOk()) { this.errorMsg 请输入正确的11位手机号; return } if (!this.codeOk()) { this.errorMsg 请输入验证码; return } if (!this.agreed) { this.errorMsg 请阅读并同意用户协议; return } login() router.back() }四步校验手机号→验证码→协议→执行登录。每一步失败都设置对应的错误信息并return。全部通过后调用login()修改状态然后router.back()返回上一页。错误信息是逐条显示的——同一时间只显示一个错误。如果手机号不对显示请输入正确的11位手机号手机号对了但验证码不对显示请输入验证码。这种逐条提示比一次性显示所有错误更友好——用户不需要一次修复多个问题。踩坑记录aboutToAppear和onPageShow的双重检查。只在aboutToAppear检查不够——用户登录后返回首页aboutToAppear不会重新执行。只在onPageShow检查也不够——首页首次创建时onPageShow可能还没执行。两个都检查是最稳妥的方案虽然有冗余但能覆盖所有场景。pushUrl之后的return。router.pushUrl是异步的调用后代码继续执行。如果不加returnthis.cartCount getCartCount()会在跳转之前执行。虽然执行了也没问题页面要跳走了但加return是更干净的写法。更重要的是如果以后在getCartCount之后加了其他逻辑比如埋点上报不加return会导致未登录用户也触发上报。自定义勾选框的点击区域。协议勾选的Stack只有18×18像素点击区域很小。用户可能点不中。更好的做法是给整个Row绑定onClick而不是只给Stack绑定。但Row的onClick会和协议文字的点击冲突——如果以后要给《用户协议》加跳转就需要更精细的事件处理。社交登录的emoji图标。用代替真实的微信/QQ/Apple图标。emoji在不同设备上的渲染效果不同——有些设备上是彩色的有些是黑白的。如果要上线需要用真实的图标资源。但原型阶段用emoji够用省去了找图标资源的成本。登录按钮的阴影动画。按钮阴影从radius: 0变成radius: 14没有过渡动画——状态切换时阴影瞬间出现/消失。如果要更丝滑的效果可以用animation属性包一下。但登录按钮的切换频率很低用户输入时才会变动画的收益不大。