编程实验:开发点餐 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/Index2.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 | 封装登录与用户信息接口(新建) |
💡 本实验默认你已经具备上一章节的基础工程能力,例如:
AccountManager、UserInfoData、Constants等文件已存在于模板工程中并可直接复用。
2.4 初始效果确认
运行模板工程后,登录页通常只包含基础结构或调试内容,尚未完成:
- 完整登录表单 UI
- 表单校验
- 接口登录逻辑
✅ 预期结果:工程可启动,但登录功能尚未完整实现。
目标:完成登录页基础布局、输入框、协议区域与按钮区域。
3.1 梳理页面结构
根据课件要求,LoginView 页面主体建议使用 Column 作为根容器,内部依次放置:
- 关闭按钮
- 欢迎语
- 用户名输入框
- 密码输入框
- 用户隐私协议勾选区
- 登录按钮
- 注册按钮
可以先将页面骨架整理出来:
@Preview@Componentexport 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 添加状态变量
在实现输入框和协议勾选之前,先定义状态变量:
@Preview@Componentexport struct LoginView { @State username: string = '' @State password: string = '' @State agreementChecked: boolean = false
build() { Column() { ... } }}这样后续输入框和复选框的值就可以与页面状态联动。
3.3 实现关闭按钮与欢迎语
在 Column 容器中先放置顶部关闭按钮和欢迎语:
import router from '@ohos.router'
@Preview@Componentexport 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 实现用户名与密码输入框
继续在欢迎语下方添加两个输入框。
@Preview@Componentexport 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 实现用户隐私协议区域
这一部分包含两项内容:
- 打开用户隐私协议页面的方法
- 勾选框 + 文本 UI
先添加打开协议页面的方法:
import common from '@ohos.app.ability.common'import { Constants } from '../../utils/Constants'
@Preview@Componentexport 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() { ... } }}再添加协议勾选区:
@Preview@Componentexport 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 实现登录按钮与注册按钮
最后补齐页面底部两个入口:
import promptAction from '@ohos.promptAction'
@Preview@Componentexport 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 中定义登录按钮点击后的处理函数:
@Preview@Componentexport 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:
@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 中接入弹窗控制器
import { LoadingDialog } from '../../common/dialog/LoadingDialog'
@Preview@Componentexport struct LoginView { private loadingDialogController = new CustomDialogController({ builder: LoadingDialog({ message: $r('app.string.message_logging') }), autoCancel: false, customStyle: true, })
...}4.4 先实现一个模拟异步登录方法
在真正对接后端接口之前,先把异步流程框架搭起来:
@Preview@Componentexport 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() 中补齐异步流程
@Preview@Componentexport 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:
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:
export class LoginData { token: string = ''}5.3 创建 ServerApi 并封装接口
先创建基础骨架:
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 {}然后添加登录接口:
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) }}再添加用户信息接口:
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() 中的模拟异常代码,再改为真实登录流程。
import { ServerApi } from '../../api/ServerApi'import { AccountManager } from '../AccountManager'
@Preview@Componentexport 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) }) }}登录逻辑说明
这段代码完成了三件事:
- 调用
/order-account/login获取token - 使用
token再调用/order-account/info获取完整用户信息 - 将用户信息保存到
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实现登录页表单布局 - 使用
TextInput、Checkbox与@State管理表单数据 - 使用
promptAction.showToast()进行表单校验提示 - 封装并复用
LoadingDialog - 使用
http.createHttp()对接登录接口 - 使用
class-transformer解析接口响应数据 - 使用
AccountManager保存登录后的用户信息
6.2 拓展任务
如需继续提升,可尝试完成以下扩展:
- 实现“注册按钮”点击后跳转到注册页
- 使用
Web组件在应用内打开《用户隐私协议》页面 - 为登录按钮增加防重复点击处理
- 在网络异常时增加更友好的错误提示
6.3 课后作业
完成点餐 APP 账号登录功能开发,并录制演示视频,建议至少包含:
- 正常登录
- 用户名为空校验
- 密码为空校验
- 未勾选协议校验
- 登录失败提示
- 登录成功后的页面跳转