手把手教你实现鸿蒙App登录注册功能:表单验证+Token管理+自动登录完整方案
鸿蒙NEXT开发实战:登录注册系统实现 本文详细介绍了鸿蒙NEXT应用中登录注册系统的完整实现方案,涵盖从UI设计到后端集成的全流程开发要点。 核心功能架构 UI层:采用ArkUI声明式开发范式实现登录/注册页面 业务层:包含表单验证、登录逻辑处理 网络层:封装API请求,自动处理Token管理 存储层:使用Preferences实现Token持久化存储 关键技术实现 表单验证:手机号格式校验、密
📖 鸿蒙NEXT开发实战系列 | 第23篇 | 实战篇 🎯 适合人群:有鸿蒙基础的开发者 ⏰ 阅读时间:约20分钟 | 💻 开发环境:DevEco Studio 5.0+
登录注册是每个App的"门面担当",也是用户接触App的第一个交互场景。一个体验良好的登录注册模块,不仅能提升用户留存,还能为后续的功能扩展打下坚实基础。
本文将带你从零实现一个完整的鸿蒙登录注册系统,涵盖表单验证、网络请求封装、Token存储管理、自动登录等核心功能,所有代码均可直接复用到你的项目中。
📑 目录
一、功能架构设计
在开始编码之前,我们先梳理一下整体架构:
┌─────────────────────────────────────────────────────────┐
│ App启动流程 │
├─────────────────────────────────────────────────────────┤
│ EntryAbility → 检查本地Token → Token有效? → 首页 │
│ ↓ 否 │
│ 登录页面 │
├─────────────────────────────────────────────────────────┤
│ 登录流程 │
├─────────────────────────────────────────────────────────┤
│ 输入账号密码 → 表单验证 → 发送请求 → 保存Token → 首页 │
├─────────────────────────────────────────────────────────┤
│ 注册流程 │
├─────────────────────────────────────────────────────────┤
│ 输入信息 → 表单验证 → 获取验证码 → 提交注册 → 登录页面 │
└─────────────────────────────────────────────────────────┘
核心模块划分:
|
模块 |
职责 |
关键类 |
|---|---|---|
|
UI层 |
登录/注册页面展示 |
LoginPage.ets, RegisterPage.ets |
|
业务层 |
表单验证、登录逻辑 |
FormValidator, LoginViewModel |
|
网络层 |
API请求封装 |
HttpUtil, ApiConfig |
|
存储层 |
Token持久化 |
TokenManager |
|
启动层 |
自动登录检查 |
SplashAbility |
二、登录页面UI实现
登录页面包含手机号输入、密码输入、登录按钮、注册入口等元素。我们采用ArkUI声明式开发范式来实现。
2.1 登录页面完整代码
// pages/LoginPage.ets
import { router } from '@kit.ArkUI'
import { FormValidator } from '../utils/FormValidator'
import { HttpUtil } from '../utils/HttpUtil'
import { TokenManager } from '../utils/TokenManager'
import { promptAction } from '@kit.ArkUI'
@Entry
@Component
struct LoginPage {
// 表单数据
@State phoneNumber: string = ''
@State password: string = ''
@State showPassword: boolean = false
@State isLoading: boolean = false
// 验证错误提示
@State phoneError: string = ''
@State passwordError: string = ''
// 倒计时相关
@State countdown: number = 0
@State isCounting: boolean = false
// 登录模式:password-密码登录,sms-验证码登录
@State loginMode: string = 'password'
@State verifyCode: string = ''
@State codeError: string = ''
private timerInterval: number = -1
aboutToDisappear() {
// 页面销毁时清除定时器
if (this.timerInterval !== -1) {
clearInterval(this.timerInterval)
}
}
build() {
Column() {
// 顶部Logo区域
this.LogoSection()
// 表单区域
this.FormSection()
// 登录按钮
this.LoginButton()
// 底部操作
this.BottomActions()
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.padding({ left: 32, right: 32 })
}
// Logo区域组件
@Builder
LogoSection() {
Column() {
Image($r('app.media.app_icon'))
.width(80)
.height(80)
.borderRadius(20)
.margin({ top: 80 })
Text('欢迎登录')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 20 })
Text('登录后享受更多服务')
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.margin({ bottom: 40 })
}
// 表单区域组件
@Builder
FormSection() {
Column() {
// 登录模式切换
Tabs({ barPosition: BarPosition.Start }) {
TabContent() {
// 密码登录表单
this.PasswordLoginForm()
}
.tabBar(this.TabBarBuilder('密码登录', 'password'))
TabContent() {
// 验证码登录表单
this.SmsLoginForm()
}
.tabBar(this.TabBarBuilder('验证码登录', 'sms'))
}
.onChange((index: number) => {
this.loginMode = index === 0 ? 'password' : 'sms'
this.clearErrors()
})
.barWidth('100%')
.barMode(BarMode.Fixed)
.scrollable(false)
.animationDuration(0)
}
}
@Builder
TabBarBuilder(title: string, mode: string) {
Column() {
Text(title)
.fontSize(16)
.fontWeight(this.loginMode === mode ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.loginMode === mode ? '#007DFF' : '#666666')
}
.padding({ top: 12, bottom: 12 })
}
// 密码登录表单
@Builder
PasswordLoginForm() {
Column() {
// 手机号输入
Column() {
Row() {
Text('+86')
.fontSize(16)
.fontColor('#333333')
.margin({ right: 8 })
Divider()
.width(1)
.height(20)
.color('#E5E5E5')
.margin({ right: 8 })
TextInput({ placeholder: '请输入手机号', text: this.phoneNumber })
.type(InputType.PhoneNumber)
.maxLength(11)
.onChange((value: string) => {
this.phoneNumber = value
if (this.phoneError) {
this.phoneError = ''
}
})
.layoutWeight(1)
.backgroundColor('transparent')
}
.width('100%')
.height(48)
.padding({ left: 12, right: 12 })
.borderRadius(8)
.border({ width: 1, color: this.phoneError ? '#FF4D4F' : '#E5E5E5' })
.backgroundColor('#F8F8F8')
// 手机号错误提示
if (this.phoneError) {
Text(this.phoneError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ bottom: 16 })
// 密码输入
Column() {
Row() {
TextInput({ placeholder: '请输入密码', text: this.password })
.type(this.showPassword ? InputType.Normal : InputType.Password)
.maxLength(20)
.onChange((value: string) => {
this.password = value
if (this.passwordError) {
this.passwordError = ''
}
})
.layoutWeight(1)
.backgroundColor('transparent')
// 密码可见性切换
Image(this.showPassword ? $r('app.media.ic_eye_open') : $r('app.media.ic_eye_close'))
.width(20)
.height(20)
.fillColor('#999999')
.onClick(() => {
this.showPassword = !this.showPassword
})
}
.width('100%')
.height(48)
.padding({ left: 12, right: 12 })
.borderRadius(8)
.border({ width: 1, color: this.passwordError ? '#FF4D4F' : '#E5E5E5' })
.backgroundColor('#F8F8F8')
// 密码错误提示
if (this.passwordError) {
Text(this.passwordError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ bottom: 8 })
// 忘记密码
Text('忘记密码?')
.fontSize(14)
.fontColor('#007DFF')
.alignSelf(ItemAlign.End)
.onClick(() => {
// 跳转忘记密码页面
router.pushUrl({ url: 'pages/ForgotPasswordPage' })
})
}
.margin({ top: 20 })
}
// 验证码登录表单
@Builder
SmsLoginForm() {
Column() {
// 手机号输入(复用上面的样式)
Column() {
Row() {
Text('+86')
.fontSize(16)
.fontColor('#333333')
.margin({ right: 8 })
Divider()
.width(1)
.height(20)
.color('#E5E5E5')
.margin({ right: 8 })
TextInput({ placeholder: '请输入手机号', text: this.phoneNumber })
.type(InputType.PhoneNumber)
.maxLength(11)
.onChange((value: string) => {
this.phoneNumber = value
if (this.phoneError) {
this.phoneError = ''
}
})
.layoutWeight(1)
.backgroundColor('transparent')
}
.width('100%')
.height(48)
.padding({ left: 12, right: 12 })
.borderRadius(8)
.border({ width: 1, color: this.phoneError ? '#FF4D4F' : '#E5E5E5' })
.backgroundColor('#F8F8F8')
if (this.phoneError) {
Text(this.phoneError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ bottom: 16 })
// 验证码输入
Column() {
Row() {
TextInput({ placeholder: '请输入验证码', text: this.verifyCode })
.type(InputType.Number)
.maxLength(6)
.onChange((value: string) => {
this.verifyCode = value
if (this.codeError) {
this.codeError = ''
}
})
.layoutWeight(1)
.backgroundColor('transparent')
// 发送验证码按钮
Button(this.isCounting ? `${this.countdown}s后重新获取` : '获取验证码')
.fontSize(14)
.fontColor(this.isCounting ? '#999999' : '#007DFF')
.backgroundColor('transparent')
.enabled(!this.isCounting)
.onClick(() => {
this.sendVerifyCode()
})
}
.width('100%')
.height(48)
.padding({ left: 12, right: 12 })
.borderRadius(8)
.border({ width: 1, color: this.codeError ? '#FF4D4F' : '#E5E5E5' })
.backgroundColor('#F8F8F8')
if (this.codeError) {
Text(this.codeError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
}
.margin({ top: 20 })
}
// 登录按钮
@Builder
LoginButton() {
Button(this.isLoading ? '登录中...' : '登录')
.width('100%')
.height(48)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.backgroundColor('#007DFF')
.borderRadius(24)
.margin({ top: 32 })
.enabled(!this.isLoading)
.onClick(() => {
this.handleLogin()
})
}
// 底部操作区域
@Builder
BottomActions() {
Column() {
// 其他登录方式
Row() {
Divider().layoutWeight(1).color('#E5E5E5')
Text('其他登录方式')
.fontSize(12)
.fontColor('#999999')
.margin({ left: 16, right: 16 })
Divider().layoutWeight(1).color('#E5E5E5')
}
.width('100%')
.margin({ top: 40 })
// 第三方登录图标
Row() {
this.ThirdPartyButton($r('app.media.ic_wechat'), '微信')
this.ThirdPartyButton($r('app.media.ic_apple'), 'Apple')
this.ThirdPartyButton($r('app.media.ic_huawei'), '华为账号')
}
.margin({ top: 24 })
.justifyContent(FlexAlign.SpaceAround)
.width('60%')
// 注册入口
Row() {
Text('还没有账号?')
.fontSize(14)
.fontColor('#999999')
Text('立即注册')
.fontSize(14)
.fontColor('#007DFF')
.margin({ left: 4 })
.onClick(() => {
router.pushUrl({ url: 'pages/RegisterPage' })
})
}
.margin({ top: 32 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.End)
.margin({ bottom: 40 })
}
@Builder
ThirdPartyButton(icon: Resource, name: string) {
Column() {
Image(icon)
.width(44)
.height(44)
.borderRadius(22)
.border({ width: 1, color: '#E5E5E5' })
Text(name)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 6 })
}
.onClick(() => {
// 第三方登录逻辑
})
}
// ===================== 业务逻辑 =====================
// 发送验证码
async sendVerifyCode() {
// 验证手机号
const phoneResult = FormValidator.validatePhone(this.phoneNumber)
if (!phoneResult.valid) {
this.phoneError = phoneResult.message
return
}
try {
const result = await HttpUtil.post('/api/sms/send', {
phone: this.phoneNumber,
type: 'login'
})
if (result.code === 200) {
promptAction.showToast({ message: '验证码已发送' })
this.startCountdown()
} else {
promptAction.showToast({ message: result.message || '发送失败' })
}
} catch (error) {
promptAction.showToast({ message: '网络异常,请稍后重试' })
}
}
// 开始倒计时
startCountdown() {
this.isCounting = true
this.countdown = 60
this.timerInterval = setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
this.isCounting = false
clearInterval(this.timerInterval)
this.timerInterval = -1
}
}, 1000)
}
// 处理登录
async handleLogin() {
// 表单验证
if (!this.validateForm()) {
return
}
this.isLoading = true
try {
let params: Record<string, string> = {
phone: this.phoneNumber
}
if (this.loginMode === 'password') {
params.password = this.password
params.loginType = 'password'
} else {
params.verifyCode = this.verifyCode
params.loginType = 'sms'
}
const result = await HttpUtil.post('/api/user/login', params)
if (result.code === 200) {
// 保存Token信息
const tokenData = result.data
await TokenManager.saveToken({
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresIn: tokenData.expiresIn,
userId: tokenData.userId
})
// 保存用户信息
await TokenManager.saveUserInfo(tokenData.userInfo)
promptAction.showToast({ message: '登录成功' })
// 跳转首页
router.replaceUrl({ url: 'pages/Index' })
} else {
promptAction.showToast({ message: result.message || '登录失败' })
}
} catch (error) {
promptAction.showToast({ message: '网络异常,请稍后重试' })
} finally {
this.isLoading = false
}
}
// 表单验证
validateForm(): boolean {
let isValid = true
// 验证手机号
const phoneResult = FormValidator.validatePhone(this.phoneNumber)
if (!phoneResult.valid) {
this.phoneError = phoneResult.message
isValid = false
}
if (this.loginMode === 'password') {
// 验证密码
const passwordResult = FormValidator.validatePassword(this.password)
if (!passwordResult.valid) {
this.passwordError = passwordResult.message
isValid = false
}
} else {
// 验证验证码
const codeResult = FormValidator.validateVerifyCode(this.verifyCode)
if (!codeResult.valid) {
this.codeError = codeResult.message
isValid = false
}
}
return isValid
}
// 清除错误信息
clearErrors() {
this.phoneError = ''
this.passwordError = ''
this.codeError = ''
}
}
代码要点说明:
-
Tab切换登录模式:支持密码登录和验证码登录两种方式
-
输入框样式:使用圆角边框、聚焦状态颜色变化
-
倒计时功能:发送验证码后60秒倒计时,防止频繁发送
-
错误提示:输入框下方实时显示验证错误信息
三、注册页面UI实现
注册页面需要收集更多用户信息,包括手机号、验证码、密码、确认密码等。
// pages/RegisterPage.ets
import { router } from '@kit.ArkUI'
import { FormValidator } from '../utils/FormValidator'
import { HttpUtil } from '../utils/HttpUtil'
import { promptAction } from '@kit.ArkUI'
@Entry
@Component
struct RegisterPage {
@State phoneNumber: string = ''
@State verifyCode: string = ''
@State password: string = ''
@State confirmPassword: string = ''
@State isAgreed: boolean = false
@State isLoading: boolean = false
// 错误信息
@State phoneError: string = ''
@State codeError: string = ''
@State passwordError: string = ''
@State confirmError: string = ''
// 倒计时
@State countdown: number = 0
@State isCounting: boolean = false
// 密码强度
@State passwordStrength: number = 0 // 0:无 1:弱 2:中 3:强
private timerInterval: number = -1
aboutToDisappear() {
if (this.timerInterval !== -1) {
clearInterval(this.timerInterval)
}
}
build() {
Column() {
// 导航栏
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.onClick(() => router.back())
Text('注册账号')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin({ left: 12 })
Blank()
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
// 表单区域
Scroll() {
Column() {
// 手机号输入
this.InputField('手机号', '请输入手机号', this.phoneNumber, InputType.PhoneNumber, 11, (value) => {
this.phoneNumber = value
this.phoneError = ''
}, this.phoneError)
// 验证码输入
Column() {
Text('验证码')
.fontSize(14)
.fontColor('#333333')
.margin({ bottom: 8 })
.alignSelf(ItemAlign.Start)
Row() {
TextInput({ placeholder: '请输入验证码', text: this.verifyCode })
.type(InputType.Number)
.maxLength(6)
.height(48)
.layoutWeight(1)
.backgroundColor('#F8F8F8')
.borderRadius(8)
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.verifyCode = value
this.codeError = ''
})
Button(this.isCounting ? `${this.countdown}s` : '获取验证码')
.width(120)
.height(48)
.fontSize(14)
.fontColor(this.isCounting ? '#999999' : '#007DFF')
.backgroundColor(this.isCounting ? '#F0F0F0' : '#EBF5FF')
.borderRadius(8)
.enabled(!this.isCounting)
.onClick(() => this.sendVerifyCode())
}
.width('100%')
if (this.codeError) {
Text(this.codeError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ top: 20 })
// 密码输入
Column() {
Text('设置密码')
.fontSize(14)
.fontColor('#333333')
.margin({ bottom: 8 })
.alignSelf(ItemAlign.Start)
TextInput({ placeholder: '请设置密码(8-20位,含字母和数字)', text: this.password })
.type(InputType.Password)
.maxLength(20)
.height(48)
.width('100%')
.backgroundColor('#F8F8F8')
.borderRadius(8)
.padding({ left: 12, right: 12 })
.onChange((value: string) => {
this.password = value
this.passwordError = ''
this.updatePasswordStrength()
})
// 密码强度指示器
Row() {
ForEach([1, 2, 3], (level: number) => {
Row()
.width('30%')
.height(4)
.borderRadius(2)
.backgroundColor(this.passwordStrength >= level ?
(level === 1 ? '#FF4D4F' : level === 2 ? '#FAAD14' : '#52C41A') : '#E5E5E5')
.margin({ right: 4 })
})
Text(this.getPasswordStrengthText())
.fontSize(12)
.fontColor(this.getStrengthColor())
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 8 })
if (this.passwordError) {
Text(this.passwordError)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ top: 20 })
// 确认密码
this.InputField('确认密码', '请再次输入密码', this.confirmPassword, InputType.Password, 20, (value) => {
this.confirmPassword = value
this.confirmError = ''
}, this.confirmError)
// 用户协议
Row() {
Checkbox()
.select(this.isAgreed)
.selectedColor('#007DFF')
.onChange((value: boolean) => {
this.isAgreed = value
})
.width(18)
.height(18)
Text('我已阅读并同意')
.fontSize(12)
.fontColor('#666666')
.margin({ left: 8 })
Text('《用户协议》')
.fontSize(12)
.fontColor('#007DFF')
.onClick(() => {
router.pushUrl({ url: 'pages/UserAgreement' })
})
Text('和')
.fontSize(12)
.fontColor('#666666')
Text('《隐私政策》')
.fontSize(12)
.fontColor('#007DFF')
.onClick(() => {
router.pushUrl({ url: 'pages/PrivacyPolicy' })
})
}
.margin({ top: 24 })
}
.padding({ left: 32, right: 32, top: 20 })
}
.layoutWeight(1)
// 注册按钮
Button(this.isLoading ? '注册中...' : '注册')
.width('85%')
.height(48)
.fontSize(18)
.fontColor('#FFFFFF')
.backgroundColor('#007DFF')
.borderRadius(24)
.margin({ bottom: 40 })
.enabled(!this.isLoading)
.onClick(() => this.handleRegister())
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
@Builder
InputField(label: string, placeholder: string, value: string,
inputType: InputType, maxLength: number,
onChange: (value: string) => void, error: string) {
Column() {
Text(label)
.fontSize(14)
.fontColor('#333333')
.margin({ bottom: 8 })
.alignSelf(ItemAlign.Start)
TextInput({ placeholder: placeholder, text: value })
.type(inputType)
.maxLength(maxLength)
.height(48)
.width('100%')
.backgroundColor('#F8F8F8')
.borderRadius(8)
.padding({ left: 12, right: 12 })
.border({ width: 1, color: error ? '#FF4D4F' : 'transparent' })
.onChange(onChange)
if (error) {
Text(error)
.fontSize(12)
.fontColor('#FF4D4F')
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
}
.margin({ top: 20 })
}
// 更新密码强度
updatePasswordStrength() {
this.passwordStrength = FormValidator.getPasswordStrength(this.password)
}
getPasswordStrengthText(): string {
const texts = ['', '弱', '中', '强']
return texts[this.passwordStrength]
}
getStrengthColor(): string {
const colors = ['', '#FF4D4F', '#FAAD14', '#52C41A']
return colors[this.passwordStrength]
}
// 发送验证码
async sendVerifyCode() {
const phoneResult = FormValidator.validatePhone(this.phoneNumber)
if (!phoneResult.valid) {
this.phoneError = phoneResult.message
return
}
try {
const result = await HttpUtil.post('/api/sms/send', {
phone: this.phoneNumber,
type: 'register'
})
if (result.code === 200) {
promptAction.showToast({ message: '验证码已发送' })
this.startCountdown()
} else {
promptAction.showToast({ message: result.message || '发送失败' })
}
} catch (error) {
promptAction.showToast({ message: '网络异常' })
}
}
startCountdown() {
this.isCounting = true
this.countdown = 60
this.timerInterval = setInterval(() => {
this.countdown--
if (this.countdown <= 0) {
this.isCounting = false
clearInterval(this.timerInterval)
}
}, 1000)
}
// 处理注册
async handleRegister() {
if (!this.validateForm()) {
return
}
if (!this.isAgreed) {
promptAction.showToast({ message: '请先同意用户协议' })
return
}
this.isLoading = true
try {
const result = await HttpUtil.post('/api/user/register', {
phone: this.phoneNumber,
verifyCode: this.verifyCode,
password: this.password
})
if (result.code === 200) {
promptAction.showToast({ message: '注册成功,请登录' })
router.back()
} else {
promptAction.showToast({ message: result.message || '注册失败' })
}
} catch (error) {
promptAction.showToast({ message: '网络异常' })
} finally {
this.isLoading = false
}
}
validateForm(): boolean {
let isValid = true
const phoneResult = FormValidator.validatePhone(this.phoneNumber)
if (!phoneResult.valid) {
this.phoneError = phoneResult.message
isValid = false
}
const codeResult = FormValidator.validateVerifyCode(this.verifyCode)
if (!codeResult.valid) {
this.codeError = codeResult.message
isValid = false
}
const passwordResult = FormValidator.validatePassword(this.password)
if (!passwordResult.valid) {
this.passwordError = passwordResult.message
isValid = false
}
if (this.password !== this.confirmPassword) {
this.confirmError = '两次输入的密码不一致'
isValid = false
}
return isValid
}
}
四、表单验证工具类
封装通用的表单验证逻辑,方便在多个页面复用。
// utils/FormValidator.ets
/**
* 表单验证工具类
* 提供手机号、密码、验证码等常用验证功能
*/
export class FormValidator {
/**
* 验证手机号格式
* @param phone 手机号
* @returns 验证结果
*/
static validatePhone(phone: string): ValidationResult {
if (!phone || phone.trim().length === 0) {
return { valid: false, message: '请输入手机号' }
}
// 去除空格
phone = phone.replace(/\s/g, '')
// 验证是否为11位数字
if (!/^1\d{10}$/.test(phone)) {
return { valid: false, message: '请输入正确的手机号' }
}
return { valid: true, message: '' }
}
/**
* 验证密码强度
* @param password 密码
* @returns 验证结果
*/
static validatePassword(password: string): ValidationResult {
if (!password || password.length === 0) {
return { valid: false, message: '请输入密码' }
}
if (password.length < 8) {
return { valid: false, message: '密码长度不能少于8位' }
}
if (password.length > 20) {
return { valid: false, message: '密码长度不能超过20位' }
}
// 检查是否包含字母和数字
const hasLetter = /[a-zA-Z]/.test(password)
const hasNumber = /[0-9]/.test(password)
if (!hasLetter || !hasNumber) {
return { valid: false, message: '密码需包含字母和数字' }
}
// 检查是否包含特殊字符(可选要求)
// const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password)
// if (!hasSpecial) {
// return { valid: false, message: '密码需包含特殊字符' }
// }
return { valid: true, message: '' }
}
/**
* 获取密码强度等级
* @param password 密码
* @returns 强度等级 0-3
*/
static getPasswordStrength(password: string): number {
if (!password || password.length < 6) {
return 0
}
let strength = 0
// 长度检查
if (password.length >= 8) strength++
if (password.length >= 12) strength++
// 包含小写字母
if (/[a-z]/.test(password)) strength++
// 包含大写字母
if (/[A-Z]/.test(password)) strength++
// 包含数字
if (/[0-9]/.test(password)) strength++
// 包含特殊字符
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength++
// 映射到1-3等级
if (strength <= 2) return 1 // 弱
if (strength <= 4) return 2 // 中
return 3 // 强
}
/**
* 验证验证码
* @param code 验证码
* @returns 验证结果
*/
static validateVerifyCode(code: string): ValidationResult {
if (!code || code.trim().length === 0) {
return { valid: false, message: '请输入验证码' }
}
if (!/^\d{4,6}$/.test(code)) {
return { valid: false, message: '请输入正确的验证码' }
}
return { valid: true, message: '' }
}
/**
* 验证邮箱格式
* @param email 邮箱
* @returns 验证结果
*/
static validateEmail(email: string): ValidationResult {
if (!email || email.trim().length === 0) {
return { valid: false, message: '请输入邮箱' }
}
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) {
return { valid: false, message: '请输入正确的邮箱地址' }
}
return { valid: true, message: '' }
}
/**
* 验证用户名
* @param username 用户名
* @returns 验证结果
*/
static validateUsername(username: string): ValidationResult {
if (!username || username.trim().length === 0) {
return { valid: false, message: '请输入用户名' }
}
if (username.length < 2 || username.length > 20) {
return { valid: false, message: '用户名长度为2-20个字符' }
}
// 只允许字母、数字、下划线、中文
const usernameRegex = /^[\u4e00-\u9fa5a-zA-Z0-9_]+$/
if (!usernameRegex.test(username)) {
return { valid: false, message: '用户名只能包含字母、数字、下划线和中文' }
}
return { valid: true, message: '' }
}
}
/**
* 验证结果接口
*/
export interface ValidationResult {
valid: boolean
message: string
}
验证规则说明:
|
字段 |
规则 |
示例 |
|---|---|---|
|
手机号 |
1开头的11位数字 |
13812345678 |
|
密码 |
8-20位,包含字母和数字 |
Abc123456 |
|
验证码 |
4-6位纯数字 |
123456 |
|
邮箱 |
标准邮箱格式 |
五、网络请求封装
封装统一的网络请求工具类,处理请求拦截、响应拦截、错误处理等。
// utils/HttpUtil.ets
import { http } from '@kit.NetworkKit'
import { TokenManager } from './TokenManager'
import { router } from '@kit.ArkUI'
// API基础配置
const BASE_URL = 'https://api.your-app.com'
const TIMEOUT = 15000 // 15秒超时
/**
* 网络请求工具类
*/
export class HttpUtil {
/**
* 发送GET请求
*/
static async get(url: string, params?: Record<string, string>): Promise<ApiResponse> {
const fullUrl = params ? `${url}?${HttpUtil.buildQueryString(params)}` : url
return HttpUtil.request(fullUrl, 'GET')
}
/**
* 发送POST请求
*/
static async post(url: string, data?: Record<string, string | number | boolean>): Promise<ApiResponse> {
return HttpUtil.request(url, 'POST', data)
}
/**
* 发送PUT请求
*/
static async put(url: string, data?: Record<string, string | number | boolean>): Promise<ApiResponse> {
return HttpUtil.request(url, 'PUT', data)
}
/**
* 发送DELETE请求
*/
static async delete(url: string): Promise<ApiResponse> {
return HttpUtil.request(url, 'DELETE')
}
/**
* 核心请求方法
*/
private static async request(
url: string,
method: string,
data?: Record<string, string | number | boolean>
): Promise<ApiResponse> {
// 创建HTTP请求实例
const httpRequest = http.createHttp()
try {
// 构建完整URL
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`
// 获取Token并添加到请求头
const token = await TokenManager.getAccessToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 发送请求
const response = await httpRequest.request(fullUrl, {
method: method as http.RequestMethod,
header: headers,
extraData: data ? JSON.stringify(data) : undefined,
connectTimeout: TIMEOUT,
readTimeout: TIMEOUT
})
// 解析响应
const result: ApiResponse = JSON.parse(response.result as string)
// 处理Token过期
if (result.code === 401) {
// 尝试刷新Token
const refreshSuccess = await HttpUtil.refreshToken()
if (refreshSuccess) {
// 重新发起原请求
return HttpUtil.request(url, method, data)
} else {
// Token刷新失败,跳转登录页
HttpUtil.handleUnauthorized()
}
}
return result
} catch (error) {
console.error('HTTP请求错误:', error)
return {
code: -1,
message: '网络请求失败,请检查网络连接',
data: null
}
} finally {
// 释放请求资源
httpRequest.destroy()
}
}
/**
* 刷新Token
*/
private static async refreshToken(): Promise<boolean> {
try {
const refreshToken = await TokenManager.getRefreshToken()
if (!refreshToken) {
return false
}
const httpRequest = http.createHttp()
const response = await httpRequest.request(`${BASE_URL}/api/token/refresh`, {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json'
},
extraData: JSON.stringify({ refreshToken }),
connectTimeout: TIMEOUT,
readTimeout: TIMEOUT
})
httpRequest.destroy()
const result: ApiResponse = JSON.parse(response.result as string)
if (result.code === 200 && result.data) {
// 保存新的Token
await TokenManager.saveToken({
accessToken: result.data.accessToken,
refreshToken: result.data.refreshToken,
expiresIn: result.data.expiresIn,
userId: result.data.userId
})
return true
}
return false
} catch (error) {
console.error('Token刷新失败:', error)
return false
}
}
/**
* 处理未授权(401)情况
*/
private static handleUnauthorized() {
// 清除本地Token
TokenManager.clearToken()
// 跳转到登录页
router.replaceUrl({ url: 'pages/LoginPage' })
}
/**
* 构建查询字符串
*/
private static buildQueryString(params: Record<string, string>): string {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
}
}
/**
* API响应接口
*/
export interface ApiResponse {
code: number
message: string
data: any
}
/**
* API接口配置
*/
export class ApiConfig {
// 用户相关
static readonly USER_LOGIN = '/api/user/login'
static readonly USER_REGISTER = '/api/user/register'
static readonly USER_LOGOUT = '/api/user/logout'
static readonly USER_INFO = '/api/user/info'
static readonly USER_UPDATE = '/api/user/update'
// 短信相关
static readonly SMS_SEND = '/api/sms/send'
// Token相关
static readonly TOKEN_REFRESH = '/api/token/refresh'
}
网络请求封装要点:
-
统一请求头:自动添加Token到请求头
-
Token自动刷新:401时自动尝试刷新Token
-
错误处理:统一的错误返回格式
-
资源释放:请求完成后销毁HTTP实例
六、Token存储管理
使用Preferences实现Token的本地持久化存储。
// utils/TokenManager.ets
import { preferences } from '@kit.ArkData'
// 存储Key常量
const PREF_NAME = 'user_prefs'
const KEY_ACCESS_TOKEN = 'access_token'
const KEY_REFRESH_TOKEN = 'refresh_token'
const KEY_EXPIRES_IN = 'expires_in'
const KEY_TOKEN_SAVE_TIME = 'token_save_time'
const KEY_USER_ID = 'user_id'
const KEY_USER_INFO = 'user_info'
/**
* Token存储管理类
*/
export class TokenManager {
/**
* 获取Preferences实例
*/
private static async getPreferences(): Promise<preferences.Preferences> {
const context = getContext(this)
return preferences.getPreferences(context, PREF_NAME)
}
/**
* 保存Token信息
* @param tokenData Token数据
*/
static async saveToken(tokenData: TokenData): Promise<void> {
try {
const prefs = await TokenManager.getPreferences()
await prefs.put(KEY_ACCESS_TOKEN, tokenData.accessToken)
await prefs.put(KEY_REFRESH_TOKEN, tokenData.refreshToken)
await prefs.put(KEY_EXPIRES_IN, tokenData.expiresIn.toString())
await prefs.put(KEY_TOKEN_SAVE_TIME, Date.now().toString())
await prefs.put(KEY_USER_ID, tokenData.userId)
await prefs.flush()
} catch (error) {
console.error('保存Token失败:', error)
}
}
/**
* 获取AccessToken
*/
static async getAccessToken(): Promise<string> {
try {
const prefs = await TokenManager.getPreferences()
const token = await prefs.get(KEY_ACCESS_TOKEN, '') as string
return token
} catch (error) {
console.error('获取Token失败:', error)
return ''
}
}
/**
* 获取RefreshToken
*/
static async getRefreshToken(): Promise<string> {
try {
const prefs = await TokenManager.getPreferences()
return await prefs.get(KEY_REFRESH_TOKEN, '') as string
} catch (error) {
console.error('获取RefreshToken失败:', error)
return ''
}
}
/**
* 获取用户ID
*/
static async getUserId(): Promise<string> {
try {
const prefs = await TokenManager.getPreferences()
return await prefs.get(KEY_USER_ID, '') as string
} catch (error) {
console.error('获取用户ID失败:', error)
return ''
}
}
/**
* 检查Token是否有效
* @returns Token是否有效
*/
static async isTokenValid(): Promise<boolean> {
try {
const prefs = await TokenManager.getPreferences()
const accessToken = await prefs.get(KEY_ACCESS_TOKEN, '') as string
if (!accessToken) {
return false
}
const expiresIn = Number(await prefs.get(KEY_EXPIRES_IN, '0'))
const saveTime = Number(await prefs.get(KEY_TOKEN_SAVE_TIME, '0'))
if (saveTime === 0) {
return false
}
// 计算Token是否过期
// 提前5分钟判定过期,留出刷新时间
const now = Date.now()
const expireTime = saveTime + (expiresIn * 1000) - (5 * 60 * 1000)
return now < expireTime
} catch (error) {
console.error('检查Token有效性失败:', error)
return false
}
}
/**
* 保存用户信息
* @param userInfo 用户信息
*/
static async saveUserInfo(userInfo: object): Promise<void> {
try {
const prefs = await TokenManager.getPreferences()
await prefs.put(KEY_USER_INFO, JSON.stringify(userInfo))
await prefs.flush()
} catch (error) {
console.error('保存用户信息失败:', error)
}
}
/**
* 获取用户信息
* @returns 用户信息对象
*/
static async getUserInfo<T>(): Promise<T | null> {
try {
const prefs = await TokenManager.getPreferences()
const userInfoStr = await prefs.get(KEY_USER_INFO, '') as string
return userInfoStr ? JSON.parse(userInfoStr) as T : null
} catch (error) {
console.error('获取用户信息失败:', error)
return null
}
}
/**
* 清除所有Token和用户信息
*/
static async clearToken(): Promise<void> {
try {
const prefs = await TokenManager.getPreferences()
await prefs.delete(KEY_ACCESS_TOKEN)
await prefs.delete(KEY_REFRESH_TOKEN)
await prefs.delete(KEY_EXPIRES_IN)
await prefs.delete(KEY_TOKEN_SAVE_TIME)
await prefs.delete(KEY_USER_ID)
await prefs.delete(KEY_USER_INFO)
await prefs.flush()
} catch (error) {
console.error('清除Token失败:', error)
}
}
/**
* 检查是否已登录
* @returns 是否已登录
*/
static async isLoggedIn(): Promise<boolean> {
const accessToken = await TokenManager.getAccessToken()
return !!accessToken
}
}
/**
* Token数据接口
*/
export interface TokenData {
accessToken: string
refreshToken: string
expiresIn: number // Token有效期(秒)
userId: string
}
/**
* 用户信息接口(示例)
*/
export interface UserInfo {
userId: string
nickname: string
avatar: string
phone: string
}
Token管理要点:
|
功能 |
实现方式 |
说明 |
|---|---|---|
|
存储 |
Preferences |
轻量级键值对存储,适合存储Token |
|
过期判断 |
时间戳计算 |
保存获取时间,与expiresIn对比 |
|
提前刷新 |
5分钟缓冲 |
Token过期前5分钟判定为过期 |
|
清除 |
删除所有相关Key |
退出登录时调用 |
七、自动登录实现
在App启动时自动检查Token有效性,实现无感登录。
7.1 启动页面实现
// pages/SplashPage.ets
import { router } from '@kit.ArkUI'
import { TokenManager } from '../utils/TokenManager'
import { HttpUtil } from '../utils/HttpUtil'
import { window } from '@kit.ArkUI'
@Entry
@Component
struct SplashPage {
@State showLogo: boolean = false
@State progressText: string = '正在启动...'
async aboutToAppear() {
// 设置沉浸式状态栏
this.setImmersiveStatusBar()
// 显示Logo动画
setTimeout(() => {
this.showLogo = true
}, 100)
}
async onPageShow() {
// 延迟一下,让用户看到启动页
await this.delay(1500)
// 检查登录状态
await this.checkLoginStatus()
}
build() {
Stack() {
// 背景
Column()
.width('100%')
.height('100%')
.backgroundColor('#007DFF')
// Logo和文字
Column() {
Image($r('app.media.app_icon'))
.width(100)
.height(100)
.borderRadius(24)
.opacity(this.showLogo ? 1 : 0)
.scale({ x: this.showLogo ? 1 : 0.5, y: this.showLogo ? 1 : 0.5 })
.animation({
duration: 500,
curve: Curve.EaseOut
})
Text('Your App Name')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 16 })
.opacity(this.showLogo ? 1 : 0)
.animation({
duration: 500,
curve: Curve.EaseOut,
delay: 200
})
Text(this.progressText)
.fontSize(14)
.fontColor('#FFFFFF99')
.margin({ top: 40 })
}
}
.width('100%')
.height('100%')
}
/**
* 检查登录状态
*/
async checkLoginStatus() {
try {
this.progressText = '检查登录状态...'
// 检查本地是否有Token
const hasToken = await TokenManager.isLoggedIn()
if (!hasToken) {
// 未登录,跳转登录页
this.navigateToLogin()
return
}
// 检查Token是否过期
const isValid = await TokenManager.isTokenValid()
if (isValid) {
// Token有效,尝试获取用户信息验证
this.progressText = '验证用户信息...'
const userInfoValid = await this.verifyUserInfo()
if (userInfoValid) {
// 登录有效,跳转首页
this.navigateToHome()
} else {
// 用户信息验证失败,跳转登录页
this.navigateToLogin()
}
} else {
// Token已过期,尝试刷新
this.progressText = '正在刷新登录状态...'
const refreshSuccess = await this.refreshToken()
if (refreshSuccess) {
this.navigateToHome()
} else {
// 刷新失败,需要重新登录
await TokenManager.clearToken()
this.navigateToLogin()
}
}
} catch (error) {
console.error('检查登录状态失败:', error)
this.navigateToLogin()
}
}
/**
* 验证用户信息是否有效
*/
async verifyUserInfo(): Promise<boolean> {
try {
const result = await HttpUtil.get('/api/user/info')
return result.code === 200
} catch (error) {
return false
}
}
/**
* 刷新Token
*/
async refreshToken(): Promise<boolean> {
try {
const refreshToken = await TokenManager.getRefreshToken()
if (!refreshToken) {
return false
}
const result = await HttpUtil.post('/api/token/refresh', {
refreshToken
})
if (result.code === 200 && result.data) {
// 保存新的Token
await TokenManager.saveToken({
accessToken: result.data.accessToken,
refreshToken: result.data.refreshToken,
expiresIn: result.data.expiresIn,
userId: result.data.userId
})
return true
}
return false
} catch (error) {
console.error('刷新Token失败:', error)
return false
}
}
/**
* 跳转到登录页
*/
navigateToLogin() {
router.replaceUrl({ url: 'pages/LoginPage' })
}
/**
* 跳转到首页
*/
navigateToHome() {
router.replaceUrl({ url: 'pages/Index' })
}
/**
* 设置沉浸式状态栏
*/
setImmersiveStatusBar() {
const windowClass = window.getMainWindowSync(getContext(this))
windowClass.setWindowLayoutFullScreen(true)
}
/**
* 延迟函数
*/
delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
7.2 EntryAbility配置
// entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { window } from '@kit.ArkUI'
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate')
}
onDestroy() {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy')
}
onWindowStageCreate(windowStage: window.WindowStage) {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate')
// 设置启动页
windowStage.loadContent('pages/SplashPage', (err) => {
if (err.code) {
hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err))
return
}
hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.')
})
}
onWindowStageDestroy() {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy')
}
onForeground() {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground')
}
onBackground() {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground')
}
}
7.3 自动登录流程图
App启动
│
▼
┌───────────────┐
│ 检查本地Token │
└───────┬───────┘
│
▼
有Token? ──否──→ 跳转登录页
│
是
▼
┌───────────────┐
│ 检查Token是否 │
│ 过期 │
└───────┬───────┘
│
▼
未过期? ──否──→ 尝试刷新Token ──失败──→ 跳转登录页
│ │
是 成功
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 验证用户信息 │ │ 跳转首页 │
└───────┬───────┘ └───────────────┘
│
▼
有效? ──否──→ 跳转登录页
│
是
▼
跳转首页
八、完整登录流程串联
8.1 登录成功后的数据流
// 示例:登录成功后的完整处理流程
// 1. 登录请求
const loginResult = await HttpUtil.post(ApiConfig.USER_LOGIN, {
phone: phoneNumber,
password: password
})
if (loginResult.code === 200) {
// 2. 保存Token
await TokenManager.saveToken({
accessToken: loginResult.data.accessToken,
refreshToken: loginResult.data.refreshToken,
expiresIn: loginResult.data.expiresIn,
userId: loginResult.data.userId
})
// 3. 保存用户信息
await TokenManager.saveUserInfo(loginResult.data.userInfo)
// 4. 跳转首页
router.replaceUrl({ url: 'pages/Index' })
}
8.2 退出登录实现
// 示例:退出登录
async logout() {
try {
// 1. 调用后端退出接口(可选)
await HttpUtil.post(ApiConfig.USER_LOGOUT)
} catch (error) {
console.error('退出登录接口调用失败:', error)
} finally {
// 2. 清除本地Token和用户信息
await TokenManager.clearToken()
// 3. 跳转登录页
router.replaceUrl({ url: 'pages/LoginPage' })
}
}
8.3 网络请求中携带Token示例
// 在HttpUtil中,Token会自动添加到请求头
// 开发者只需这样调用:
// 获取用户信息
const userInfo = await HttpUtil.get(ApiConfig.USER_INFO)
// 更新用户资料
const updateResult = await HttpUtil.put(ApiConfig.USER_UPDATE, {
nickname: '新昵称',
avatar: 'https://example.com/avatar.jpg'
})
// 所有请求都会自动携带Token,无需手动添加
九、总结与最佳实践
9.1 本文实现的功能清单
|
功能模块 |
实现内容 |
状态 |
|---|---|---|
|
登录页面 |
密码登录、验证码登录 |
已完成 |
|
注册页面 |
手机号注册、密码强度指示 |
已完成 |
|
表单验证 |
手机号、密码、验证码验证 |
已完成 |
|
网络请求 |
统一封装、Token自动携带 |
已完成 |
|
Token管理 |
存储、过期判断、自动刷新 |
已完成 |
|
自动登录 |
启动检查、无感登录 |
已完成 |
9.2 安全最佳实践
-
密码安全
-
前端仅做格式验证,不做加密
-
建议后端使用bcrypt等算法存储密码
-
传输时使用HTTPS加密
-
-
Token安全
-
AccessToken有效期建议设为2小时
-
RefreshToken有效期建议设为30天
-
存储在Preferences中,不建议存储在内存
-
-
接口安全
-
所有接口使用HTTPS
-
敏感接口添加验证码校验
-
实现接口防重放攻击
-
9.3 性能优化建议
-
减少网络请求
-
用户信息本地缓存
-
Token有效期内不重复验证
-
-
优化启动速度
-
SplashPage使用预加载
-
非关键资源延迟加载
-
-
内存优化
-
及时销毁HTTP实例
-
页面退出时清除定时器
-
9.4 常见问题及解决方案
|
问题 |
原因 |
解决方案 |
|---|---|---|
|
Token频繁失效 |
服务器时间不同步 |
增加时间容错,提前刷新 |
|
验证码发送失败 |
手机号格式错误 |
前端严格验证 |
|
自动登录失败 |
网络超时 |
增加重试机制 |
|
密码强度不够 |
规则不明确 |
添加强度指示器 |
9.5 后续扩展方向
-
生物识别登录(指纹、人脸)
-
第三方登录(微信、华为账号)
-
多账号切换
-
账号注销功能
-
登录设备管理
参考资料
📝 下篇预告:第24篇将讲解鸿蒙App的推送通知实现,包括本地通知和远程推送的完整方案。
标签:登录注册 Token管理 表单验证 鸿蒙实战 网络请求 ArkUI HarmonyOS NEXT
📖 鸿蒙NEXT开发实战系列 持续更新中,欢迎点赞收藏关注!
更多推荐



所有评论(0)