跳转到内容

编程实验:开发点餐 APP 账号登录功能

本实验将基于 03_login 模板工程,完成点餐 APP 的账号登录功能开发,主要包含以下任务:

  • 完成登录页 UI 布局
  • 完成用户名、密码与协议勾选校验
  • 封装通用 LoadingDialog 加载弹窗
  • 对接登录接口与用户信息接口
  • 登录成功后保存用户信息并跳转首页

本实验涉及的知识点

知识点使用场景
@State保存用户名、密码、协议勾选状态
TextInput用户名和密码输入
Checkbox用户协议勾选
CustomDialogController控制 Loading 弹窗打开与关闭
Promise / async / await登录流程中的异步请求处理
http.createHttp()发起 HTTP 登录与用户信息请求
class-transformer将接口返回数据转换为类实例
router.replaceUrl()登录成功后跳转首页
AccountManager保存登录后的用户信息

功能流程示意

输入用户名/密码
点击“登录”按钮
前端表单校验
显示 Loading 弹窗
调用登录接口获取 token
调用用户信息接口获取完整资料
保存到 AccountManager
跳转首页 pages/Index

2.1 获取模板工程

📦 模板工程仓库https://e.coding.net/g-rudz5555/hm-coding/03_login.git

请先导入模板工程并确认可以正常运行。

2.2 注册测试账号

在开始登录功能开发前,请先访问以下地址注册一个测试账号:

http://114.132.175.147:8080/#/register

建议记录自己注册的:

  • 用户名
  • 密码

后续测试登录接口时会直接使用。

2.3 本实验主要涉及的文件

文件路径作用
entry/src/main/ets/account/login/LoginView.ets登录页主组件,完成 UI、校验与登录逻辑
entry/src/main/ets/common/dialog/LoadingDialog.ets通用加载弹窗(新建)
entry/src/main/ets/api/Response.ets通用响应模型(新建)
entry/src/main/ets/model/LoginData.ets登录接口返回的 token 数据(新建)
entry/src/main/ets/api/ServerApi.ets封装登录与用户信息接口(新建)

💡 本实验默认你已经具备上一章节的基础工程能力,例如:AccountManagerUserInfoDataConstants 等文件已存在于模板工程中并可直接复用。

2.4 初始效果确认

运行模板工程后,登录页通常只包含基础结构或调试内容,尚未完成:

  • 完整登录表单 UI
  • 表单校验
  • 接口登录逻辑

✅ 预期结果:工程可启动,但登录功能尚未完整实现。

目标:完成登录页基础布局、输入框、协议区域与按钮区域。

3.1 梳理页面结构

根据课件要求,LoginView 页面主体建议使用 Column 作为根容器,内部依次放置:

  1. 关闭按钮
  2. 欢迎语
  3. 用户名输入框
  4. 密码输入框
  5. 用户隐私协议勾选区
  6. 登录按钮
  7. 注册按钮

可以先将页面骨架整理出来:

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
build() {
Column() {
// 1. 关闭按钮
// 2. 欢迎语
// 3. 用户名输入框
// 4. 密码输入框
// 5. 用户隐私协议
// 6. 登录按钮
// 7. 注册按钮
}
.padding({ top: this.statusBarPadding })
.alignItems(HorizontalAlign.Start)
.size({ width: '100%', height: '100%' })
}
}

3.2 添加状态变量

在实现输入框和协议勾选之前,先定义状态变量:

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
@State username: string = ''
@State password: string = ''
@State agreementChecked: boolean = false
build() {
Column() {
...
}
}
}

这样后续输入框和复选框的值就可以与页面状态联动。

3.3 实现关闭按钮与欢迎语

Column 容器中先放置顶部关闭按钮和欢迎语:

entry/src/main/ets/account/login/LoginView.ets
import router from '@ohos.router'
@Preview
@Component
export struct LoginView {
...
build() {
Column() {
Image($r('app.media.ic_close'))
.size({ width: 50, height: 50 })
.padding(5)
.fillColor($r('app.color.black_80'))
.margin({ top: 20, left: 20 })
.alignSelf(ItemAlign.Start)
.backgroundColor($r('app.color.black_5'))
.borderRadius(25)
.onClick(_ => {
router.back()
})
Text($r('app.string.login_welcome'))
.fontSize(34)
.fontColor($r('app.color.black_70'))
.margin({ left: 30, top: 60 })
...
}
...
}
}

3.4 实现用户名与密码输入框

继续在欢迎语下方添加两个输入框。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
@State username: string = ''
@State password: string = ''
...
build() {
Column() {
...
TextInput({ placeholder: $r('app.string.placeholder_username') })
.height(60)
.fontSize(20)
.defaultFocus(true)
.type(InputType.Normal)
.margin({ left: 20, right: 20, top: 40 })
.caretColor($r('app.color.black'))
.fontColor($r('app.color.black'))
.onChange(value => {
this.username = value
})
TextInput({ placeholder: $r('app.string.placeholder_password') })
.height(60)
.type(InputType.Password)
.fontSize(20)
.caretColor($r('app.color.black'))
.fontColor($r('app.color.black'))
.enterKeyType(EnterKeyType.Done)
.margin({ left: 20, right: 20, top: 10 })
.onChange(value => {
this.password = value
})
...
}
}
}

3.5 实现用户隐私协议区域

这一部分包含两项内容:

  1. 打开用户隐私协议页面的方法
  2. 勾选框 + 文本 UI

先添加打开协议页面的方法:

entry/src/main/ets/account/login/LoginView.ets
import common from '@ohos.app.ability.common'
import { Constants } from '../../utils/Constants'
@Preview
@Component
export struct LoginView {
...
private openUserAgreementPage() {
let want = {
action: 'ohos.want.action.viewData',
entities: ['entity.system.browsable'],
uri: Constants.USER_AGREEMENT_URL,
type: 'text/plain'
}
let context = getContext(this) as common.UIAbilityContext
context.startAbility(want)
}
build() {
Column() {
...
}
}
}

再添加协议勾选区:

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
@State agreementChecked: boolean = false
...
build() {
Column() {
...
Row() {
Checkbox()
.selectedColor($r('app.color.primary_color_light'))
.onChange(value => {
this.agreementChecked = value
})
Text($r('app.string.label_read_agreement_prefix'))
.margin({ left: 5 })
.fontColor($r('app.color.black_80'))
.fontSize(16)
Text($r('app.string.label_read_agreement_postfix'))
.fontColor($r('app.color.primary_color'))
.fontSize(16)
.onClick(_ => {
this.openUserAgreementPage()
})
}
.margin({ left: 20, right: 20, top: 20 })
...
}
}
}

⚠️ 在模拟器中,协议链接可能因为环境原因无法正常拉起浏览器,这不影响本实验的主体完成。

3.6 实现登录按钮与注册按钮

最后补齐页面底部两个入口:

entry/src/main/ets/account/login/LoginView.ets
import promptAction from '@ohos.promptAction'
@Preview
@Component
export struct LoginView {
...
build() {
Column() {
...
Row() {
Button($r('app.string.label_login'))
.fontSize(25)
.backgroundColor($r('app.color.primary_color_light'))
.borderRadius(5)
.type(ButtonType.Normal)
.size({ width: '100%', height: '100%' })
.onClick(_ => {
this.handleLogin()
})
}
.padding({ left: 30, right: 30 })
.margin({ top: 40 })
.size({ width: '100%', height: 50 })
Text($r('app.string.go_register'))
.fontSize(15)
.fontColor($r('app.color.primary_color_light'))
.alignSelf(ItemAlign.Center)
.margin({ top: 30 })
.onClick(_ => {
promptAction.showToast({ message: '暂未实现', bottom: 500 })
})
}
}
}

其中 handleLogin() 会在下一步中实现。

目标:完成表单校验、通用 Loading 弹窗,以及登录流程的异步交互框架。

4.1 定义 handleLogin() 方法骨架

先在 LoginView 中定义登录按钮点击后的处理函数:

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
let username = this.username
if (username == '') {
promptAction.showToast({ message: $r('app.string.toast_empty_username'), bottom: 500 })
return
}
let password = this.password
if (password == '') {
promptAction.showToast({ message: $r('app.string.toast_empty_password'), bottom: 500 })
return
}
if (!this.agreementChecked) {
promptAction.showToast({ message: $r('app.string.toast_agreement_not_checked'), bottom: 500 })
return
}
}
}

这样可以先完成三项基础校验:

  • 用户名不能为空
  • 密码不能为空
  • 必须勾选《用户隐私协议》

4.2 创建通用 LoadingDialog

entry/src/main/ets/common/dialog 目录下新建 LoadingDialog.ets

entry/src/main/ets/common/dialog/LoadingDialog.ets
@CustomDialog export struct LoadingDialog {
private controller: CustomDialogController
message: ResourceStr = $r('app.string.default_loading_message')
build() {
Column() {
LoadingProgress()
.size({ width: 70, height: 70 })
Text(this.message)
.fontSize(15)
.fontColor($r('app.color.black_80'))
.margin({ top: 4 })
}
.padding({ left: 30, right: 30, top: 10, bottom: 20 })
.borderRadius(5)
.backgroundColor(Color.White)
}
}

该弹窗可复用于后续其他耗时场景,例如:提交订单、加载详情页、上传图片等。

4.3 在 LoginView 中接入弹窗控制器

entry/src/main/ets/account/login/LoginView.ets
import { LoadingDialog } from '../../common/dialog/LoadingDialog'
@Preview
@Component
export struct LoginView {
private loadingDialogController = new CustomDialogController({
builder: LoadingDialog({ message: $r('app.string.message_logging') }),
autoCancel: false,
customStyle: true,
})
...
}

4.4 先实现一个模拟异步登录方法

在真正对接后端接口之前,先把异步流程框架搭起来:

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private async doLogin(username: string, password: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
setTimeout(() => {
reject(new Error('模拟登录异常'))
}, 3000)
})
}
}

4.5 在 handleLogin() 中补齐异步流程

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
...
this.loadingDialogController.open()
this.doLogin(username, password).then(success => {
router.replaceUrl({ url: 'pages/Index' })
}).catch((e: Error) => {
promptAction.showToast({ message: e.message })
}).finally(() => {
this.loadingDialogController.close()
})
}
}

到这里,页面已经具备完整的“校验 → 打开 Loading → 异步处理 → 关闭 Loading”的登录流程骨架。

目标:对接真实登录接口和用户信息接口,完成最终登录功能。

5.1 创建通用响应模型 Response

entry/src/main/ets/api 目录下新建 Response.ets

entry/src/main/ets/api/Response.ets
import { Exclude, Expose, Type } from 'class-transformer'
import 'reflect-metadata'
export class Response<T> {
static CODE_SUCCESS = 200
code: number = 200
@Expose({ name: 'msg' })
message: string = ''
@Type((options) => {
return (options?.newObject as Response<T>).type
})
data: T | null = null
@Exclude() private type: Function
constructor(type: Function) {
this.type = type
}
isSuccess(): boolean {
return this.code == Response.CODE_SUCCESS
}
}

5.2 创建登录结果模型 LoginData

entry/src/main/ets/model 目录下新建 LoginData.ets

entry/src/main/ets/model/LoginData.ets
export class LoginData {
token: string = ''
}

5.3 创建 ServerApi 并封装接口

先创建基础骨架:

entry/src/main/ets/api/ServerApi.ets
import { plainToClassFromExist } from 'class-transformer'
import 'reflect-metadata'
import http from '@ohos.net.http'
import { Response } from './Response'
import { LoginData } from '../model/LoginData'
import { UserInfoData } from '../model/UserInfoData'
import { Constants } from '../utils/Constants'
export class ServerApi {}

然后添加登录接口:

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
static async login(username: string, password: string): Promise<Response<LoginData>> {
let rsp = await http.createHttp().request(
`${Constants.SERVER_HOST}/order-account/login`,
{
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
extraData: `name=${username}&password=${password}&role=1`,
expectDataType: http.HttpDataType.OBJECT
}
)
return plainToClassFromExist(new Response<LoginData>(LoginData), rsp.result)
}
}

再添加用户信息接口:

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
...
static async getUserInfo(token: string): Promise<Response<UserInfoData>> {
let rsp = await http.createHttp().request(
`${Constants.SERVER_HOST}/order-account/info`,
{
method: http.RequestMethod.GET,
header: { token: token },
expectDataType: http.HttpDataType.OBJECT
}
)
return plainToClassFromExist(new Response<UserInfoData>(UserInfoData), rsp.result)
}
}

5.4 用真实接口替换模拟登录逻辑

回到 LoginView.ets,先删除之前 doLogin() 中的模拟异常代码,再改为真实登录流程。

entry/src/main/ets/account/login/LoginView.ets
import { ServerApi } from '../../api/ServerApi'
import { AccountManager } from '../AccountManager'
@Preview
@Component
export struct LoginView {
...
private async doLogin(username: string, password: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
let defMsg = getContext(this).resourceManager.getStringSync($r('app.string.toast_login_failed'))
const loginRsp = await ServerApi.login(username, password)
if (!loginRsp.isSuccess() || loginRsp.data == null) {
reject(new Error(loginRsp.message != '' ? loginRsp.message : defMsg))
return
}
const userInfoRsp = await ServerApi.getUserInfo(loginRsp.data.token)
if (!userInfoRsp.isSuccess() || userInfoRsp.data == null) {
reject(new Error(userInfoRsp.message != '' ? userInfoRsp.message : defMsg))
return
}
let userInfo = userInfoRsp.data
userInfo.token = loginRsp.data.token
await AccountManager.saveUserInfo(userInfo)
resolve(true)
})
}
}

登录逻辑说明

这段代码完成了三件事:

  1. 调用 /order-account/login 获取 token
  2. 使用 token 再调用 /order-account/info 获取完整用户信息
  3. 将用户信息保存到 AccountManager,供首页、个人中心等页面复用
点击查看本阶段关键流程总结
private handleLogin() {
let username = this.username
if (username == '') {
promptAction.showToast({ message: $r('app.string.toast_empty_username'), bottom: 500 })
return
}
let password = this.password
if (password == '') {
promptAction.showToast({ message: $r('app.string.toast_empty_password'), bottom: 500 })
return
}
if (!this.agreementChecked) {
promptAction.showToast({ message: $r('app.string.toast_agreement_not_checked'), bottom: 500 })
return
}
this.loadingDialogController.open()
this.doLogin(username, password).then(success => {
router.replaceUrl({ url: 'pages/Index' })
}).catch((e: Error) => {
promptAction.showToast({ message: e.message })
}).finally(() => {
this.loadingDialogController.close()
})
}

✅ 预期效果:输入正确账号密码并勾选协议后,可以完成登录并跳转到首页;输入错误或接口异常时,会弹出失败提示。

6.1 本实验完成内容回顾

完成本实验后,你应已经掌握:

  • 基于 Column / Row 实现登录页表单布局
  • 使用 TextInputCheckbox@State 管理表单数据
  • 使用 promptAction.showToast() 进行表单校验提示
  • 封装并复用 LoadingDialog
  • 使用 http.createHttp() 对接登录接口
  • 使用 class-transformer 解析接口响应数据
  • 使用 AccountManager 保存登录后的用户信息

6.2 拓展任务

如需继续提升,可尝试完成以下扩展:

  1. 实现“注册按钮”点击后跳转到注册页
  2. 使用 Web 组件在应用内打开《用户隐私协议》页面
  3. 为登录按钮增加防重复点击处理
  4. 在网络异常时增加更友好的错误提示

6.3 课后作业

完成点餐 APP 账号登录功能开发,并录制演示视频,建议至少包含:

  • 正常登录
  • 用户名为空校验
  • 密码为空校验
  • 未勾选协议校验
  • 登录失败提示
  • 登录成功后的页面跳转