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()修改了_loggedIn,ProfilePage调用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已经变成true,onPageShow检查isLoggedIn()会返回true,不会再次跳转到LoginPage。所以两个钩子都检查是防御性编程——确保无论页面怎么创建/显示,都会验证登录状态。
return 的作用
if (!isLoggedIn()) { router.pushUrl({ url: 'pages/LoginPage' }); return }
this.cartCount = getCartCount()
router.pushUrl之后加了return。这是因为pushUrl是异步的——调用后不会立即跳转,代码会继续往下执行。如果不加return,this.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×80,borderRadius 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()是三个条件的AND:this.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是异步的,调用后代码继续执行。如果不加return,this.cartCount = getCartCount()会在跳转之前执行。虽然执行了也没问题(页面要跳走了),但加return是更干净的写法。更重要的是:如果以后在getCartCount之后加了其他逻辑(比如埋点上报),不加return会导致未登录用户也触发上报。
自定义勾选框的点击区域。 协议勾选的Stack只有18×18像素,点击区域很小。用户可能点不中。更好的做法是给整个Row绑定onClick,而不是只给Stack绑定。但Row的onClick会和协议文字的点击冲突——如果以后要给"《用户协议》"加跳转,就需要更精细的事件处理。
社交登录的emoji图标。 用📱🐧🍎代替真实的微信/QQ/Apple图标。emoji在不同设备上的渲染效果不同——有些设备上是彩色的,有些是黑白的。如果要上线,需要用真实的图标资源。但原型阶段用emoji够用,省去了找图标资源的成本。
登录按钮的阴影动画。 按钮阴影从radius: 0变成radius: 14没有过渡动画——状态切换时阴影瞬间出现/消失。如果要更丝滑的效果,可以用animation属性包一下。但登录按钮的切换频率很低(用户输入时才会变),动画的收益不大。
更多推荐



所有评论(0)