登录模块


登录功能
个人页面
目标:准备个人中心的静态页面,通知栏颜色控制
pages/MinePage.ets
import { HcClockIn } from '../commons/components'
import { router } from '@kit.ArkUI'
interface Nav {
icon: ResourceStr
name: string
onClick?: () => void
other?: string
}
interface Tool {
icon: ResourceStr
name: string
value?: string
onClick?: () => void
}
@Component
export struct MinePage {
@StorageProp('topHeight')
topHeight: number = 0
@Builder
navBuilder(nav: Nav) {
GridCol() {
Column() {
Image(nav.icon)
.width(30)
.aspectRatio(1)
.margin({ bottom: 10 })
Text(nav.name)
.fontSize(14)
.fontColor($r('app.color.common_gray_03'))
.margin({ bottom: 4 })
if (nav.other) {
Row() {
Text(nav.other)
.fontSize(12)
.fontColor($r('app.color.common_gray_01'))
Image($r('sys.media.ohos_ic_public_arrow_right'))
.width(12)
.aspectRatio(1)
.fillColor($r('app.color.common_gray_01'))
}
}
}
.onClick(() => {
nav.onClick && nav.onClick()
})
}
}
@Builder
toolsBuilder(tool: Tool) {
Row() {
Image(tool.icon)
.width(16)
.aspectRatio(1)
.margin({ right: 12 })
Text(tool.name)
.fontSize(14)
Blank()
if (tool.value) {
Text(tool.value)
.fontSize(12)
.fontColor($r('app.color.common_gray_01'))
}
Image($r('sys.media.ohos_ic_public_arrow_right'))
.width(16)
.aspectRatio(1)
.fillColor($r('app.color.common_gray_01'))
}
.height(50)
.width('100%')
.padding({ left: 16, right: 10 })
.onClick(() => {
tool.onClick && tool.onClick()
})
}
build() {
Column({ space: 16 }) {
Row({ space: 12 }) {
Image($r('app.media.ic_mine_avatar'))
.alt($r('app.media.ic_mine_avatar'))
.width(55)
.aspectRatio(1)
.borderRadius(55)
// 是否登录
if (false) {
Column({ space: 4 }) {
Text('用户名')
.fontSize(18)
.fontWeight(500)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text('编辑信息')
.fontSize(12)
.fontColor($r('app.color.common_gray_01'))
Image($r('app.media.ic_mine_edit'))
.width(12)
.aspectRatio(1)
.fillColor($r('app.color.common_gray_01'))
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
} else {
Text('立即登录')
.fontSize(18)
.fontWeight(500)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
.onClick(() => {
router.pushUrl({ url: 'pages/LoginPage' })
})
}
HcClockIn({ clockInCount: 10 })
}
.width('100%')
.height(100)
.margin({ top: this.topHeight })
GridRow({ columns: 4 }) {
this.navBuilder({
icon: $r('app.media.ic_mine_history'), name: '历史记录', onClick: () => {
// TODO
}
})
this.navBuilder({
icon: $r('app.media.ic_mine_collect'), name: '我的收藏', onClick: () => {
// TODO
}
})
this.navBuilder({
icon: $r('app.media.ic_mine_like'), name: '我的点赞', onClick: () => {
// TODO
}
})
this.navBuilder({
icon: $r('app.media.ic_mine_study'),
name: '累计学时',
other: '10小时',
onClick: () => {
// TODO
}
})
}
.backgroundColor($r('app.color.white'))
.padding(16)
.borderRadius(8)
Column() {
this.toolsBuilder({
icon: $r('app.media.ic_mine_notes'), name: '开发常用词', onClick: () => {
// TODO
}
})
this.toolsBuilder({ icon: $r('app.media.ic_mine_ai'), name: '面通AI' })
this.toolsBuilder({ icon: $r('app.media.ic_mine_invite'), name: '推荐分享' })
this.toolsBuilder({ icon: $r('app.media.ic_mine_file'), name: '意见反馈' })
this.toolsBuilder({ icon: $r('app.media.ic_mine_info'), name: '关于我们' })
this.toolsBuilder({
icon: $r('app.media.ic_mine_setting'), name: '设置', onClick: () => {
// TODO
}
})
}
.backgroundColor($r('app.color.white'))
.borderRadius(8)
}
.padding(16)
.backgroundColor($r('app.color.common_gray_bg'))
.linearGradient({
colors: [['#FFB071', 0], [$r('app.color.common_gray_bg'), 0.3], [$r('app.color.common_gray_bg'), 1]]
})
.width('100%')
.height('100%')
}
} .onVisibleAreaChange([0,1], (isVisible) => {
if (isVisible) {
statusBar.setLightBar()
} else {
statusBar.setDarkBar()
}
})HcNavBar 组件
import { router } from '@kit.ArkUI'
@Component
export struct HcNavBar {
@StorageProp('topHeight')
topHeight: number = 0
// 标题文字
@Prop
title: string = ''
// 是否有下边框
@Prop
showBorder: boolean = false
// 左边图标
@Prop
leftIcon: ResourceStr = $r('app.media.ic_common_back')
// 右边图标
@Prop
rightIcon: ResourceStr = $r('sys.media.ohos_ic_public_more')
// 是否显示右边图标
@Prop
showRightIcon: boolean = true
build() {
Row({ space: 16 }) {
Image(this.leftIcon)
.size({ width: 24, height: 24 })
.fillColor($r('app.color.black'))
.onClick(() => router.back())
Row() {
if (this.title) {
Text(this.title)
.fontWeight(600)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.fontSize(18)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.height(56)
.layoutWeight(1)
if (this.showRightIcon) {
Image(this.rightIcon)
.size({ width: 24, height: 24 })
.objectFit(ImageFit.Contain)
} else {
Blank()
.width(24)
}
}
.padding({ left: 16, right: 16, top: this.topHeight })
.height(56 + this.topHeight)
.width('100%')
.border({
width: { bottom: this.showBorder ? 0.5 : 0 },
color: $r('app.color.common_gray_bg')
})
}
}登录页面
import { HcNavBar } from '../commons/components'
@Entry
@Component
struct LoginPage {
@StorageProp('topHeight')
topHeight: number = 0
@State
mobile: string = 'hmheima'
@State
code: string = 'Hmheima%123'
@State
isAgree: boolean = false
@State
loading: boolean = false
build() {
Column() {
HcNavBar({ showRightIcon: false })
Column() {
Image($r('app.media.icon'))
.width(120)
.aspectRatio(1)
Text('面试通')
.fontSize(28)
.margin({ bottom: 15 })
Text('搞定企业面试真题,就用面试通')
.fontSize(14)
.fontColor($r('app.color.common_gray_01'))
}
.padding(16)
Column({ space: 15 }) {
TextInput({ placeholder: '请输入用户名', text: $$this.mobile })
.customStyle()
TextInput({ placeholder: '请输入密码', text: $$this.code })
.type(InputType.Password)
.showPasswordIcon(false)
.customStyle()
Row() {
Checkbox()
.shape(CheckBoxShape.ROUNDED_SQUARE)
.selectedColor('#fa711d')
.size({ width: 14, height: 14 })
.select(this.isAgree)
.onChange((val) => {
this.isAgree = val
})
Text('已阅读并同意')
.fontSize(14)
.fontColor($r('app.color.common_gray_01'))
.padding({ right: 4 })
Text('用户协议')
.fontSize(14)
.padding({ right: 4 })
Text('和')
.fontSize(14)
.fontColor($r('app.color.common_gray_01'))
.padding({ right: 4 })
Text('隐私政策')
.fontSize(14)
}
.width('100%')
Button({ type: ButtonType.Normal }) {
Row() {
if (this.loading) {
LoadingProgress()
.color($r('app.color.white'))
.width(24)
.height(24)
.margin({ right: 10 })
}
Text('立即登录')
.fontColor($r('app.color.white'))
}
}
.width('100%')
.backgroundColor('none')
.borderRadius(4)
.height(44)
.linearGradient({
direction: GradientDirection.Right,
colors: [['#fc9c1c', 0], ['#fa711d', 1]]
})
.stateEffect(false)
.onClick(() => {
// TODO
})
}
.padding({ left: 36, right: 36, top: 30 })
Column() {
Text('其他登录方式')
.fontSize(14)
.fontColor($r('app.color.common_gray_01'))
}
.padding({ top: 70, bottom: 100 })
}
.width('100%')
.height('100%')
}
}
@Extend(TextInput)
function customStyle() {
.placeholderColor('#C3C3C5')
.caretColor('#fa711d')
.height(44)
.borderRadius(0)
.backgroundColor($r('app.color.white'))
.border({ width: { bottom: 0.5 }, color: $r('app.color.common_gray_border') })
}实现登录

实现步骤:
- 绑定事件,定义处理函数,进行校验
- 定义用户数据类型,发请求登录,加上loading效果,防止重复提交
- 成功,存储数据和返回个人页;失败,提示即可
落地代码:
pages/LoginPage.ets
async login() {
if (this.loading) {
return
}
if (!this.mobile) {
return promptAction.showToast({ message: '请输入用户名' })
}
if (!this.code) {
return promptAction.showToast({ message: '请输入密码' })
}
if (!this.isAgree) {
return promptAction.showToast({ message: '请勾选已阅读并同意' })
}
try {
this.loading = true
const user = await http.request<User, LoginParams>({
url: 'login', method: 'post', data: {
username: this.mobile,
password: this.code
}
})
this.loading = false
AppStorage.setOrCreate<User>('user', user)
router.back()
} catch (e) {
this.loading = false
promptAction.showToast({ message: e.message })
}
}models/index.ets
export interface User {
id: string
username: string
avatar: string
token: string
nickName?: string
// 学习时长
totalTime?: number
// 打卡次数
clockinNumbers?: number
}个人信息更新
目标:个人中心信息使用共享的用户信息来展示
前置知识:token 是用户是否登录的凭证,我们可以通过是否拥有它来判断是否登录。
实现步骤:
- 获取 AppStorage 中的用户数据,展示昵称和打卡天数
- 封装一个时间格式化的工具,显示 xx天 | xx小时 | xx分钟 数据
落地代码:
1)获取 AppStorage 中的用户数据,展示昵称和打卡天数
MinePage.ets
@StorageProp('user') user: User = {} as User Image(this.user.avatar || $r('app.media.ic_mine_avatar'))
.alt($r('app.media.ic_mine_avatar'))
.width(55)
.aspectRatio(1)
.borderRadius(55)
// 是否登录
if (this.user.token) {
Column({ space: 4 }) {
Text(this.user.nickName || this.user.username)
.fontSize(18)
.fontWeight(500)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text('编辑信息')
.fontSize(12)
.fontColor($r('app.color.common_gray_01'))
Image($r('app.media.ic_mine_edit'))
.width(12)
.aspectRatio(1)
.fillColor($r('app.color.common_gray_01'))
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
} else {
Text('立即登录')
.fontSize(18)
.fontWeight(500)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
.onClick(() => {
router.pushUrl({ url: 'pages/LoginPage' })
})
}
HcClockIn({ clockInCount: this.user.clockinNumbers })2)封装一个时间格式化的工具,显示 xx天 | xx小时 | xx分钟 数据
commons/utils/index.ets
const day = 60 * 60 * 24
const hour = 60 * 60
const min = 60
export const formatTime = (second: number = 0) => {
if (second > day) {
return (second / day).toFixed(1) + '天'
} else if (second > hour) {
return (second / hour).toFixed(1) + '小时'
} else {
return (second / min).toFixed(0) + '分钟'
}
}MinePage.ets
other: formatTime(this.user.totalTime),注意
- 此时的用户信息没有持久化,退出应用后登录状态丢失,下一章会统一处理
访问权限控制
auth工具-存储凭证
目标:封装一个 auth 工具,用于维护用户信息 key 和 持久化
实现步骤:
- 封装一个 auth 工具,提供初始化持久化用户信息方法,设置用户信息和获取用户信息的方案
- 首页初始化用户,登录后存储用户,使用导出的 key
落地代码:
1)auth 工具 commons/components/Auth.ets
import { User } from '../../models'
export const UserStoreKey = 'hc-user'
class Auth {
initUser() {
PersistentStorage.persistProp<User>(UserStoreKey, {} as User)
}
getUser () {
return AppStorage.get<User>(UserStoreKey) || {} as User
}
setUser(user: User) {
AppStorage.setOrCreate<User>(UserStoreKey, user)
}
}
export const auth = new Auth()2)首页初始化用户,登录后存储用户,使用导出的 key
pages/Index.ets
import { auth } from '../commons/utils'
auth.initUser()pages/LoginPage.ets
this.loading = false
+ auth.setUser(user)
router.back()pages/MinePage.ets
@StorageProp(UserStoreKey) user: User = {} as User携带请求凭证
目标:已登录状态,每次请求携带凭证

实现步骤:
- 理解 token 在客户端身份认证中的作用,和使用方案
- 在每次请求的时候,携带本地存储的 token 信息,作为登录凭证
落地代码:commons/components/Http.ets
// 请求拦截器
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const user = auth.getUser()
if (user.token) {
config.headers.Authorization = `Bearer ${user.token}`
}
return config
}, (err: AxiosError) => {
return Promise.reject(err)
})处理凭证失效
目标:处理 token 失效情况,跳转到登录,需要回跳

实现步骤:
- 理解状态码 401 的含义,以及判断登录失效的条件
- 实现登录失效拦截到登录页,考虑多个接口同时401的情况
落地代码:commons/components/Http.ets
(err: AxiosError) => {
logger.error('Req Error 2', JSON.stringify(err))
if (err.response?.status === 401) {
auth.setUser({} as User )
router.pushUrl({
url: 'pages/LoginPage'
}, router.RouterMode.Single)
promptAction.showToast({ message: '登录失效' })
}
return Promise.reject(err)
}辅助测试代码:
Text('修改token模拟失效')
.onClick(() => {
this.user.token = 'x'
auth.setUser(this.user)
})
Text('清空token')
.onClick(() => {
this.user.token = ''
auth.setUser({} as User)
})首页列表更新
目标:登录成功的时候,更新首页试题数据
前置内容:
- 思考:首页如何知道登录成功?
@Watch触发情况太大不好控制。 - 方案:使用 emitter 可以通知
// 绑定事件
emitter.on("eventId", () => {
console.info('callback');
});
// 触发事件
emitter.emit("eventId", eventData);实现步骤:
- 在首页 HomeCategory 组件,绑定事件,触发的时候更新数据
- 在登录成功后,触发事件
落地代码:
1)绑定时间,触发更新 constants/index.ets
// 统一维护事件ID
export const LOGIN_EVENT = 'LOGIN_EVENT'HomeCategory.ets
aboutToAppear(): void {
this.getQuestionTypeList()
// 触发 LOGIN_EVENT 更新数据
emitter.on(LOGIN_EVENT, () => {
this.getQuestionTypeList()
})
}2)登录成功,触发事件
this.loading = false
auth.setUser(user)
+ emitter.emit(LOGIN_EVENT)
router.back()auth工具-访问控制
目标:未登录状态下,跳转访问控制的页面拦截到登录需要回跳,访问控制的逻辑需要鉴权

实现步骤:
- 在 auth 中添加一个方法,实现跳转路由和执行逻辑,执行前进行鉴权
- 在 LoginPage 中,如果发现参数中有 return_url 进行 replace 回调
落地代码:
1)封装鉴权方法
Auth.ets
/**
* @param options 是路由跳转参数 或 是一个回调函数
*/
checkAuth(options: router.RouterOptions | Function) {
const user = this.getUser()
if (user.token) {
// 已登录,路由跳转和函数调用正常放行
if (typeof options === 'function') {
options()
} else {
router.pushUrl(options)
}
} else {
// 未登录,拦截到登录页
if (typeof options === 'function') {
router.pushUrl({ url: 'pages/LoginPage' })
} else {
// 带上 return_url 用户登录回跳
const params = options.params as Record<string, string> || {}
params.return_url = options.url
router.pushUrl({ url: 'pages/LoginPage', params })
}
}
}2)处理回跳
LoginPage.ets
this.loading = false
auth.setUser(user)
emitter.emit(LOGIN_EVENT)
// 有 return_url 就回跳
const params = router.getParams() as Record<string, string> || {}
if (params?.return_url) {
router.replaceUrl({ url: params.return_url, params })
} else {
router.back()
}隐私协议&用户协议
目标:提供一个根据 url 显示不同网页的鸿蒙原生页面 ⭐️
前置知识:
- 提供具有网页显示能力的Web组件,@ohos.web.webview提供web控制能力。
- 访问在线网页时需添加网络权限:ohos.permission.INTERNET
// web 组件基本使用
webViewController:webview.WebviewController = new webview.WebviewController()
Web({
src: $rawfile('index.html'),
//src:"http://www.xxx.com/index.html",
controller:this.webViewController
})实现步骤:
- 定义一个页面,可以根据路由参数,来显示页面标题和加载的页面
落地代码:
WebPage.ets
import { HcNavBar } from '../commons/components/HcNavBar'
import { webview } from '@kit.ArkWeb'
import { router } from '@kit.ArkUI'
interface WebPageParams {
title: string
src: string
}
@Entry
@Component
struct WebPage {
@State title: string = ''
@State src: string = ''
controller = new webview.WebviewController()
aboutToAppear(): void {
const params = router.getParams() as WebPageParams
this.title = params.title
this.src = params.src
}
build() {
Column() {
HcNavBar({ title: this.title, showRightIcon: false })
Web({ src: this.src, controller: this.controller })
.layoutWeight(1)
.width('100%')
}
.height('100%')
.width('100%')
}
}LoginPage.ets
Text('用户协议')
.fontSize(14)
.padding({ right: 4 })
.onClick(() => {
router.pushUrl({
url: 'pages/WebPage',
params: { title: '用户协议', src: 'http://110.41.143.89/user' }
})
})
Text('和')
.fontSize(14)
.fontColor($r('app.color.common_gray_01'))
.padding({ right: 4 })
Text('隐私政策')
.fontSize(14)
.onClick(() => {
router.pushUrl({
url: 'pages/WebPage',
params: { title: '隐私政策', src: 'http://110.41.143.89/privacy' }
})
})