跳转到内容

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

本实验将基于 03_login 模板工程,完成点餐 APP 的账号登录功能。模板工程已经具备上一章搭建的页面框架、登录页入口、首页 Tab 框架、账号信息持久化工具和基础资源。本章的核心任务是把 LoginView 中的“模拟登录”改造为“真实账号登录”。

完成后,应用将具备以下能力:

  • 登录页展示完整的账号、密码、协议勾选、登录按钮和注册入口
  • 点击登录时先完成本地表单校验,错误输入立即给出 Toast 提示
  • 登录请求过程中显示通用 Loading 弹窗
  • 调用后端登录接口获取 token
  • 使用 token 继续获取用户信息
  • 将用户信息保存到 AccountManager,并跳转到首页

功能流程示意

登录功能流程示意图

本实验涉及的知识点

知识点使用场景
@State保存用户名、密码、协议勾选状态
TextInput构建账号和密码输入框
Checkbox构建协议勾选控件
promptAction.showToast()表单校验失败和登录失败提示
CustomDialogController控制 Loading 弹窗打开和关闭
Promise / async / await串联登录接口和用户信息接口
http.createHttp()发起 HTTP 请求
class-transformer将接口返回对象转换为数据类实例
AccountManager保存登录后的用户信息
router.replaceUrl()登录成功后替换跳转到首页

2.1 获取模板工程

模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/03_login

请从上面的 Git 仓库克隆或下载 03_login 模板工程,再导入 DevEco Studio。

2.2 认识模板工程的当前状态

03_login 模板不是空工程,它已经完成了上一章的页面框架。当前最重要的起点是:LoginView.ets 中只有一个简单的“模拟登录”按钮,点击后会手动创建一个 UserInfoData 对象并跳转首页。

这说明模板工程已经具备了以下基础:

已有能力对应文件本章是否需要重写
登录页路由入口entry/src/main/ets/pages/Login.ets不需要
首页路由入口entry/src/main/ets/pages/Index.ets不需要
首页 Tab 框架entry/src/main/ets/portal/PortalView.ets不需要
用户信息数据类entry/src/main/ets/model/UserInfoData.ets直接复用
账号信息保存工具entry/src/main/ets/account/AccountManager.ets直接复用
常量、颜色、字符串资源utils/Constants.etsresources直接复用
登录页 UI 和真实登录逻辑account/login/LoginView.ets本章重点开发

2.3 本实验需要修改和新建的文件

文件路径操作作用
entry/src/main/ets/account/login/LoginView.ets修改完成登录页 UI、校验、Loading 控制和真实登录流程
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/entryability/EntryAbility.ets修改开启全屏布局并保存状态栏高度
entry/src/main/ets/api/ServerApi.ets新建封装登录接口和用户信息接口

2.4 注册测试账号

注册测试账号

在真实登录接口对接前,请先注册一个测试账号:

https://food2.sziit.top#register

建议记录以下信息:

  • 用户名
  • 密码

后续测试时需要使用自己注册的账号登录。

如果注册链接打不开、访问较慢或被网络环境拦截,请按以下顺序处理:

  1. 先更换网络环境,例如切换到校园网、手机热点或浏览器无痕窗口后重试
  2. 如果仍无法注册,先继续完成本实验的 UI、表单校验、Loading 和接口封装步骤
  3. 到真实登录测试阶段时,使用课程提供的临时测试账号,或等待网络恢复后再完成最终登录验收

注册入口不可用不会阻止你完成前面的编码练习,但会影响最后“真实账号登录”的演示。

2.5 初始运行效果检查

运行模板工程后,进入登录页可以看到一个简化页面:

  • 页面中显示 LoginView
  • 页面中有一个“模拟登录”按钮
  • 点击按钮后可以跳转到首页

这一点很重要:后续每完成一小步,都可以和这个初始效果对比,确认本次修改是否生效。

初始运行效果

3.1 为什么不能直接在按钮里写请求?

登录功能看起来只是一个按钮点击事件,但真正的业务流程包含多个职责:

  1. 读取用户输入
  2. 判断输入是否有效
  3. 展示加载状态
  4. 调用登录接口
  5. 处理接口失败
  6. 获取完整用户资料
  7. 保存用户信息
  8. 跳转首页

如果把这些逻辑全部堆在按钮的 onClick 中,代码会很快变得难以阅读,也不方便排查问题。因此,本实验采用分层拆解的方式:

层次负责内容
UI 层输入框、按钮、协议勾选、Loading 弹窗
页面逻辑层表单校验、登录流程编排、跳转
接口层ServerApi.login()ServerApi.getUserInfo()
数据层LoginDataResponse<T>UserInfoData
账号状态层AccountManager.saveUserInfo()

3.2 先做 UI,再做真实接口

本章建议按以下顺序开发:

  1. 先把登录页面布局做出来,立即看到页面变化
  2. 再完成本地校验,立即看到 Toast 提示
  3. 再加入 Loading 弹窗,立即看到异步过程反馈
  4. 最后接入真实接口,把模拟登录替换掉

这样的顺序可以减少一次性改动太多导致的问题。每个阶段都有可观察的运行结果,方便定位错误。

目标:在应用启动时完成两件事——让页面内容延伸到状态栏下方(全屏模式),同时把状态栏的实际高度存入全局,供登录页等各个页面用作顶部留白,避免内容被系统区域遮挡。

4.1 适配思路

为什么要开全屏?

全屏适配示意图

默认情况下,HarmonyOS 会把页面内容限制在状态栏以下的安全区域,状态栏部分由系统管控。如果我们希望背景色或图片能铺满整个屏幕(包括状态栏区域),就需要主动调用 setWindowLayoutFullScreen(true) 开启全屏布局。

开全屏后为什么还要记高度?

开启全屏后,页面内容会从屏幕最顶端开始渲染,状态栏区域也属于页面范围。这意味着如果不做处理,登录页的关闭按钮等顶部组件会和状态栏中的时间、信号图标重叠。因此还需要读取状态栏的实际高度,用它作为顶部留白,把页面内容推到状态栏下方。

单位换算

系统接口 getWindowAvoidArea 返回的高度单位是 px(物理像素)。ArkTS 组件的 paddingmargin 等布局属性默认接受 vp(虚拟像素)。两者不是同一单位,直接把 px 值赋给 padding 会导致偏移量在不同屏幕密度的设备上表现不一致。

正确的做法是用 px2vp() 把物理像素转换为虚拟像素后再赋值:

// px → vp 转换示例(实际代码见 4.2)
const heightInVp = px2vp(avoidArea.topRect.height)
AppStorage.setOrCreate(APP_SCOPE_STATUS_BAR_HEIGHT, heightInVp)

这样存入 AppStorage 的值已经是 vp 单位,后续直接用作 padding 属性即可。

4.2 在 EntryAbility 中初始化全屏和状态栏高度

状态栏高度是全局配置,应该在应用窗口完全就绪后立即读取。HarmonyOS 提供的标准时机是 UIAbility.onWindowStageCreate()——此时窗口已创建完毕,可以安全读取窗口属性。这也是为什么应该把这段逻辑放在 EntryAbility 而不是某个具体页面里。

打开 entry/src/main/ets/entryability/EntryAbility.ets,在 onWindowStageCreate 中完成以下两件事:

entry/src/main/ets/entryability/EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
import { Logger } from '../utils/Logger';
import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../utils/Constants';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage) {
Logger.info('Ability onWindowStageCreate');
// TODO: 设置全屏并获取状态栏高度
// - 调用 windowStage.getMainWindow() 获取主窗口对象
// - 在 then 回调中:
// · 使用 win.setWindowLayoutFullScreen(true) 开启全屏布局
// · 调用 win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM) 获取系统避让区域
// · 读取 avoidArea.topRect.height,得到状态栏高度(单位:px)
// · 用 px2vp() 将其转换为 vp 单位
// · 通过 AppStorage.setOrCreate(APP_SCOPE_STATUS_BAR_HEIGHT, heightInVp) 写入全局存储
// · 调用 console.log('状态栏高度(vp): ' + heightInVp) 打印日志,用于验证
windowStage.loadContent('pages/Splash', (err, data) => {
if (err.code) {
Logger.error('Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
});
}
}

4.3 验证状态栏高度

验证状态栏高度日志

完成 4.2 的代码后,运行应用,在 DevEco Studio 的 Log 面板中搜索 状态栏高度,确认控制台输出了一个合理的正数。手机设备通常在 30–50 vp 之间,模拟器可能略有差异。

如果输出为 0,优先检查:getWindowAvoidArea 是否在 setWindowLayoutFullScreen 之后调用,以及 px2vp() 是否正确包裹了 topRect.height

✅ 阶段检查:日志中能看到正确的状态栏高度数值,说明 AppStorage 中的值已就绪,后续登录页可以直接通过 @StorageProp 读取使用。

登录页的状态栏适配(@StorageProp + padding)将在步骤 5 中一并完成。

目标:将模板中的简化 LoginView 改造成完整登录表单。本阶段完成后,页面应该能看到关闭按钮、欢迎语、账号输入框、密码输入框、协议区域、登录按钮和注册入口。

颜色资源说明:本步骤及后续所有 UI 开发中涉及的颜色均已在模板工程中定义,常用值如下,后文不再重复说明:

用途使用方式
主题浅红色(登录按钮、主题色、协议勾选色)'#F63440'
黑色 70% 透明度(正文字体颜色)$r('app.color.black_70')
页面背景色$r('app.color.page_background')

使用时直接把对应值赋给 .fontColor().backgroundColor() 等属性即可。如需查看完整颜色定义,可打开 entry/src/main/resources/base/element/color.json

5.1 清理模拟页面,搭建表单骨架

打开 entry/src/main/ets/account/login/LoginView.ets。本步骤有两个任务:清理模板中的模拟登录内容,同时完成状态栏适配——声明 statusBarPadding 变量并设置给根容器的顶部 padding。

状态栏适配说明:步骤 4 已经在 EntryAbility 中把状态栏高度(vp 单位)写入了 AppStorage。登录页这里只需要用 @StorageProp 绑定这个值,并把它设置给根容器的 .padding({ top: ... }),就能让页面内容从状态栏下方开始显示。

entry/src/main/ets/account/login/LoginView.ets
import router from '@ohos.router'
import { UserInfoData } from '../../model/UserInfoData'
import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
import { AccountManager } from '../AccountManager'
@Preview
@Component
export struct LoginView {
// TODO: 用 @StorageProp 声明一个属性,读取状态栏高度
build() {
Column({ space: 10 }) {
Text('LoginView') /* 模板中的占位标题,删除 */
Button('模拟登录').onClick(_ => { /* 模板中的模拟登录逻辑,整体删除 */ })
}
Column() {
// 后续步骤会在这里依次加入:
// 关闭按钮、欢迎语、输入框、协议区、登录按钮、注册入口
}
// TODO: 给根容器设置顶部内边距,值为上面声明的状态栏高度
.alignItems(HorizontalAlign.Center)
.size({ width: '100%', height: '100%' })
}
}

✅ 阶段检查:本步骤完成后,页面主体暂时没有可见内容是正常的,因为旧的”模拟登录”按钮已经移除,新的 UI 还没有添加。你需要重点确认两件事:

  • DevEco Studio 编译没有报错
  • 页面没有崩溃、白屏闪退或控制台异常

如果出现编译错误,优先检查 import 是否有多余或遗漏;如果出现运行后崩溃,优先检查 build() 中是否只保留了一个页面根容器。

5.2 添加页面状态变量

登录表单需要记录三类状态:

  • 用户名输入内容
  • 密码输入内容
  • 是否勾选协议

LoginView 中添加 @State 状态变量:

entry/src/main/ets/account/login/LoginView.ets
import router from '@ohos.router'
import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
@Preview
@Component
export struct LoginView {
@StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
// TODO: 添加三个 @State 状态变量
// - username:保存用户名输入框内容
// - password:保存密码输入框内容
// - agreementChecked:保存协议勾选状态
build() {
Column() {
// 4.1 已留下空骨架,本步骤不修改 build()
}
...
}
}

@State 的作用是让组件保存并响应自身状态变化。输入框内容变化后,登录按钮可以直接读取页面状态完成校验。

5.3 实现关闭按钮和欢迎语

关闭按钮使用模板已有的 ic_close 图片资源。欢迎语使用字符串资源 login_welcome,避免把展示文字写死在页面代码中。

entry/src/main/ets/account/login/LoginView.ets
import router from '@ohos.router'
import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
@Preview
@Component
export struct LoginView {
@StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
build() {
Column() {
// TODO: 使用 Image 实现关闭按钮
// - 图片资源使用 app.media.ic_close
// - 宽高设置为 50,内部留 5 的 padding,避免图标贴边
// - 填充色使用 $r('app.color.black_80')
// - 背景色使用 $r('app.color.black_5'),圆角设置为 25
// - 通过 alignSelf(ItemAlign.Start) 靠左,外边距设置为 { top: 20, left: 20 }
// - 点击时调用 router.back() 返回上一页
// TODO: 使用 Text 实现欢迎语
// - 文案使用 app.string.login_welcome
// - 字号设置为 34,作为登录页视觉焦点
// - 颜色使用 $r('app.color.black_70')
// - 外边距设置为 { top: 60 }
// 用户名/密码输入框、协议区、登录按钮、注册入口将在后续步骤实现
}
...
}
}

✅ 阶段检查:运行后应能看到关闭按钮和“欢迎登录点餐系统”文字。

登录页顶部区域效果占位

5.4 实现账号和密码输入框

账号输入框负责更新 username,密码输入框负责更新 password。两个输入框的结构类似,但密码输入框需要设置为密码类型。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
@StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
build() {
Column() {
...
// TODO: 实现用户名输入框
// - placeholder 使用 app.string.placeholder_username
// - 高度 60,字号 20,便于触摸和阅读
// - 输入类型使用普通文本
// - 左右外边距 20,顶部外边距 40,和欢迎语拉开距离
// - 输入光标 caretColor 和文字颜色 fontColor 均使用 $r('app.color.black')
// - onChange 中把输入值保存到 username 状态
// TODO: 实现密码输入框
// - placeholder 使用 app.string.placeholder_password
// - 类型设置为密码输入,隐藏密码明文
// - 键盘完成按钮设置为 Done
// - 和用户名输入框保持相同高度(60)、字号(20)和颜色
// - 左右外边距 20,顶部外边距 10(顶距小于用户名框,使两框形成一组)
// - onChange 中把输入值保存到 password 状态
// 协议区、登录按钮、注册入口将在后续步骤实现
}
...
}
}

✅ 阶段检查:运行后可以在页面输入账号和密码,密码框输入内容应以密码形式显示。

登录页输入框效果占位

5.5 实现用户隐私协议区域

协议区域包含一个 Checkbox 和两段文字。前半段是普通说明文字,后半段可点击打开协议链接。

entry/src/main/ets/account/login/LoginView.ets
import common from '@ohos.app.ability.common'
import router from '@ohos.router'
import { APP_SCOPE_STATUS_BAR_HEIGHT } from '../../utils/Constants'
import { APP_SCOPE_STATUS_BAR_HEIGHT, Constants } from '../../utils/Constants'
@Preview
@Component
export struct LoginView {
@StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
openUserAgreementPage() {
// TODO: 构造 want 对象并通过系统浏览器打开协议页面
// want 对象的字段固定格式如下:
// {
// action: 'ohos.want.action.viewData', // 声明"打开数据",系统浏览器识别此 action
// entities: ['entity.system.browsable'], // 声明目标是可浏览实体(网页)
// uri: Constants.USER_AGREEMENT_URL, // 协议页面地址,来自 Constants
// type: 'text/html' // 内容类型
// }
// 通过 getContext(this) as common.UIAbilityContext 获取 context,
// 调用 context.startAbility(want) 发起跳转
// 注意:Want 需要从 '@ohos.app.ability.Want' 导入
}
build() {
Column() {
...
// TODO: 实现用户隐私协议区域
// - Row 内从左到右放置 Checkbox、普通说明文字、协议链接文字
// - Checkbox selectedColor 使用 $r('app.color.primary_color_light')
// - Checkbox onChange 中同步 agreementChecked 状态
// - 普通说明文字 app.string.label_read_agreement_prefix,左外边距 5,字号 16,颜色 $r('app.color.black_80')
// - 协议链接文字使用 app.string.label_read_agreement_postfix
// - 协议链接文字颜色使用 $r('app.color.primary_color'),字号 16
// - 点击协议链接文字时调用 openUserAgreementPage
// - Row 设置左右外边距 20,顶部外边距 20
// 登录按钮、注册入口将在 4.6 实现
}
...
}
}

在部分模拟器环境中,外部浏览器可能无法正常拉起。只要点击逻辑没有导致应用崩溃即可继续后续实验。

✅ 阶段检查:运行后可以勾选协议;点击《用户隐私协议》文字时,满足以下任一情况都算本阶段通过:

  • 能拉起浏览器或系统选择器,并尝试打开协议链接
  • 模拟器没有可用浏览器,但应用不崩溃,控制台没有因 want 字段错误或类型转换错误导致的异常

也就是说,本阶段验收重点是“点击事件、want 结构和 startAbility 调用逻辑正确”,不是强制要求每台模拟器都能成功打开外部网页。

登录页协议区域效果

5.6 实现登录按钮和注册入口

登录按钮只负责调用 handleLogin(),校验逻辑统一放在该方法中。注册入口本章不实现真实注册,点击后给出“暂未实现”提示即可。

entry/src/main/ets/account/login/LoginView.ets
import common from '@ohos.app.ability.common'
import promptAction from '@ohos.promptAction'
import router from '@ohos.router'
import { APP_SCOPE_STATUS_BAR_HEIGHT, Constants } from '../../utils/Constants'
@Preview
@Component
export struct LoginView {
@StorageProp(APP_SCOPE_STATUS_BAR_HEIGHT) statusBarPadding: number = 0
...
handleLogin() {
// TODO: 在此实现表单校验和登录流程(后续步骤逐步完善)
}
build() {
Column() {
...
// TODO: 实现登录按钮区域
// - 外层 Row:padding 左右各 30
// - margin 顶部 40
// - size { width: '100%', height: 50 }
// - Button 文案使用 app.string.label_login
// - 按钮占满 Row 的宽高(size { width: '100%', height: '100%' })
// - 字号 25,背景色 $r('app.color.primary_color_light'),圆角 5,type 设为 ButtonType.Normal
// - 点击时调用 handleLogin,让登录流程集中在方法中维护
// TODO: 实现注册入口
// - 文案使用 app.string.go_register
// - 字号 15,颜色使用 $r('app.color.primary_color_light')
// - 顶部外边距 30
// - 本章暂不实现注册页,点击时使用 Toast 提示暂未实现
}
...
}
}

✅ 阶段检查:运行后应看到完整登录页外观。点击登录暂时没有实际效果,点击注册应显示“此功能暂未实现”。

完整登录页 UI 效果占位

目标:让登录按钮先完成本地校验。这样不需要访问网络,也能立即验证页面状态是否正确。

6.1 分析需要校验什么

登录前至少需要检查三件事:

校验项为什么需要
用户名不能为空后端无法识别空账号
密码不能为空后端无法完成密码校验
必须勾选协议登录前需要用户确认协议

校验逻辑应该放在 handleLogin() 的开头。只要某一项不满足,就显示 Toast 并 return,阻止继续执行后续登录流程。

6.2 校验用户名不能为空

先完成第一项校验:读取 username 状态,如果用户名为空,就提示“用户名不能为空”,并立即结束 handleLogin()

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
// TODO: 校验用户名不能为空
// - 读取 username 状态
// - 为空时使用 app.string.toast_empty_username 在页面中下部提示用户
// - 命中校验失败后使用 return 阻止继续执行后续登录逻辑
}
build() {
...
}
}

✅ 阶段检查:不输入用户名,直接点击登录,应看到“用户名不能为空”提示。

用户名为空校验效果占位

6.3 校验密码不能为空

用户名校验通过后,再检查 password 状态。如果密码为空,提示“密码不能为空”,同样立即结束登录流程。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
...
// TODO: 校验密码不能为空
// - 读取 password 状态
// - 为空时使用 app.string.toast_empty_password 提示用户,Toast 位置与用户名校验保持一致
// - 命中校验失败后使用 return 阻止继续执行后续登录逻辑
}
build() {
...
}
}

✅ 阶段检查:输入用户名,不输入密码,点击登录,应看到“密码不能为空”提示。

密码为空校验效果占位

6.4 校验用户隐私协议

用户名和密码都不为空后,再检查 agreementChecked。如果用户没有勾选协议,需要提示用户先阅读并同意协议。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
...
// TODO: 校验用户隐私协议必须勾选
// - 判断 agreementChecked 是否为 false
// - 未勾选时使用 app.string.toast_agreement_not_checked 提示用户,Toast 位置与前两项校验保持一致
// - 命中校验失败后使用 return 阻止继续执行后续登录逻辑
}
build() {
...
}
}

✅ 阶段检查:输入用户名和密码,但不勾选协议,点击登录,应看到“请先阅读并同意《用户隐私协议》”提示。

协议未勾选校验效果占位

目标:实现 Loading 弹窗,在登录流程中给用户明确的等待反馈;使用模拟异步任务验证弹窗的打开和关闭。

7.1 自定义弹窗的开发范式

HarmonyOS 的自定义弹窗由两部分组成:

  • 弹窗组件:用 @CustomDialog 装饰的 struct,负责弹窗的 UI 和内容。它通过 controller: CustomDialogController 成员与外部通信。
  • 控制器:在调用方组件中用 new CustomDialogController({ builder: XxxDialog }) 创建,调用 .open().close() 控制弹窗的显示与隐藏。

使用时的基本模式如下:

// 弹窗组件
@CustomDialog
struct MyDialog {
controller: CustomDialogController // 必须声明,弹窗内部可以调用它来关闭自身
message: string = ''
build() { ... }
}
// 调用方
@Component
struct SomePage {
dialogController = new CustomDialogController({
builder: MyDialog({ message: '提示内容' }),
autoCancel: false, // 点击弹窗外部不关闭
customStyle: true, // 使用弹窗组件自定义的样式
})
build() {
Button('打开').onClick(() => this.dialogController.open())
}
}

7.2 创建 LoadingDialog.ets

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

entry/src/main/ets/common/dialog/LoadingDialog.ets
@CustomDialog
export struct LoadingDialog {
// TODO: 声明 CustomDialogController 成员
// TODO: 声明 message 参数,默认值使用 app.string.default_loading_message
build() {
Column() {
// TODO: 添加 LoadingProgress
// - 宽高设置为 70,保证加载状态明显
// TODO: 添加 Text 显示 message
// - 放在 LoadingProgress 下方
// - 字号 15,颜色使用 $r('app.color.black_80')
// - 顶部外边距 4
}
// TODO: 设置弹窗容器样式
// - 左右留白约 30,上方留白约 10,下方留白约 20
// - 圆角设置为 5,和登录按钮风格一致
// - 背景使用白色,保证在不同页面背景下都清晰
}
}

✅ 阶段检查:新建文件后先编译一次,确认 @CustomDialog 组件没有语法错误。

7.3 在 LoginView 中创建弹窗控制器

回到 LoginView.ets,导入 Loading 弹窗,并在组件中添加控制器。

请务必注意此处导入的LoadingDialog组件为 common/dialog/LoadingDialog 目录下的自定义组件,而不是 HarmonyOS 内置的 LoadingDialog 组件。

entry/src/main/ets/account/login/LoginView.ets
import { LoadingDialog } from '../../common/dialog/LoadingDialog'
@Preview
@Component
export struct LoginView {
...
// TODO: 定义 loadingDialogController
// - 作为成员变量,建议放在三个 @State 状态变量之后
// - builder 使用 LoadingDialog
// - message 传入 app.string.message_logging
// - autoCancel 设置为 false,避免登录期间点击外部取消弹窗
// - customStyle 设置为 true,使用 LoadingDialog 自己定义的白底圆角样式
...
}

7.4 先用模拟异步任务验证流程

真实接口还没有接入前,可以先写一个模拟登录方法。这个方法只负责延迟一段时间后结束,用于检查 Loading 是否能打开和关闭。

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((resolve) => {
// TODO: 使用 setTimeout 模拟约 1.5 秒的网络请求,延迟后 resolve(true)
})
}
private handleLogin() { ... }
...
}

7.5 在 handleLogin() 中串联 Loading

在三项表单校验通过后,补充“打开 Loading → 调用异步登录 → 处理结果 → 关闭 Loading”的流程。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
...
private handleLogin() {
... // 三项表单校验
// TODO: 打开 Loading 弹窗
// TODO: 调用 doLogin(username, password)
// TODO: then 中使用 Toast 或日志提示“模拟登录成功”,用于验证异步流程
// TODO: catch 中读取 Error.message,并使用 Toast 提示失败原因
// TODO: finally 中关闭 Loading 弹窗
// TODO: 注意:关闭 Loading 应放在 finally 中,保证成功失败都会关闭
}
...
}

✅ 阶段检查:输入账号密码并勾选协议后,点击登录,应能看到“正在登录…”弹窗,约 1.5 秒后弹窗关闭。

登录 Loading 弹窗效果

目标:先设计接口返回数据的模型,再写请求代码。这样可以让网络层代码更清晰。

8.1 接口说明

真实登录需要两个接口串联完成:

登录接口

  • 地址POST /order-account/login
  • 请求体application/x-www-form-urlencoded):
参数类型说明
namestring用户名
passwordstring密码
rolenumber固定传 1,表示点餐用户端
  • 响应示例
{
"code": 200,
"msg": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9..."
}
}

用户信息接口

  • 地址GET /order-account/info

  • 请求头:需携带登录接口返回的 token

  • 响应示例

{
"code": 200,
"msg": "success",
"data": {
"accountId": 101,
"name": "testuser",
"imagePath": "https://example.com/avatar.jpg"
}
}

为什么不只保存 token?因为首页和个人中心需要展示用户名、头像等信息。登录成功后应该保存完整用户资料,而不仅是登录凭证。

8.2 引入 class-transformer

对比 8.1 中两个接口的响应字段,会发现后端字段名和本地类的属性名并不完全对应:

后端字段本地属性说明
msgmessage响应消息
accountIdid用户 id
nameusername用户名
imagePathavatarUrl头像地址

如果手动写字段映射(localObj.id = rawObj.accountId),每个模型都要重复这类代码,可维护性很差。

本项目引入了 class-transformer 解决这两个问题:

问题一:字段名不一致

它提供 @Expose({ name: '后端字段名' }) 装饰器,可以在类定义中直接声明字段映射关系,不需要手写 obj.id = raw.accountId 这类代码。

问题二:原始对象没有方法

后端返回的是普通 JSON 对象,不是类实例,无法调用类上定义的方法(如 isSuccess())。调用 plainToClassFromExist(new MyClass(), rawData) 可以把原始对象”升级”为真正的类实例,同时完成字段映射。

依赖关系

reflect-metadataclass-transformer 的运行时依赖,用于在程序运行时读取装饰器声明的元信息。只需在文件顶部 import 'reflect-metadata' 即可启用,通常不需要深入了解它的原理。

// 示例:声明后端字段 accountId 对应本地属性 id
@Expose({ name: 'accountId' })
id: number = 0

模板工程的 oh-package.json5 已经包含这两个依赖,不需要手动安装。如果导入时报”找不到模块”,请在 DevEco Studio 中同步工程依赖(File → Sync and Refresh Project)。

plainToClassFromExist 调用方式

有了上面的背景,下面展示在本实验中完整的转换写法。步骤 9 的 ServerApi 中会用到这个模式:

// 假设 data.result 是 HTTP 响应体(字符串)
const raw: Object = JSON.parse(String(data.result)) as Object // 先解析成原始对象
// 第一步:把原始响应转换为 Response<LoginData> 实例
// - 第一个参数:new Response<LoginData>(LoginData) —— 提供目标实例,构造函数传入 data 字段的类型
// - 第二个参数:raw —— 原始 JSON 对象
const response = plainToClassFromExist(
new Response<LoginData>(LoginData),
raw
)
// 此时 response.isSuccess()、response.code、response.message 均可正常调用
// 第二步:response.data 目前还是普通对象,需要再转换一次才能变成 LoginData 实例
if (response.data) {
response.data = plainToClassFromExist(
new LoginData(),
response.data as Object
)
}
// 此时 response.data.token 才能正确读取 @Expose 字段映射后的值

两层 plainToClassFromExist 是必要的:外层处理 Response 字段映射(msg→message),内层处理 data 字段里业务数据的字段映射(accountId→id 等)。

8.3 创建通用响应模型 Response

后端接口通常会返回统一结构,例如:

{
"code": 200,
"msg": "success",
"data": {}
}

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

entry/src/main/ets/api/Response.ets
import 'reflect-metadata'
import { Exclude, Expose, Type } from 'class-transformer'
export class Response<T> {
static CODE_SUCCESS = 200
code: number = 200
// 后端字段名是 msg,本地用 message
@Expose({ name: 'msg' })
message: string = ''
// @Type 根据构造函数传入的类型,自动将 data 字段转换为对应的类实例
@Type((options) => {
return (options?.newObject as Response<T>).type
})
data: T | null = null
@Exclude()
private type: Function
// 构造函数接收 data 字段对应的类型,供 @Type 使用
constructor(type: Function) {
this.type = type
}
isSuccess(): boolean {
return this.code === Response.CODE_SUCCESS
}
}

这里的关键思路是:Response<T> 不绑定具体业务数据。登录接口可以是 Response<LoginData>,用户信息接口可以是 Response<UserInfoData>

8.4 创建登录结果模型 LoginData

登录接口的核心返回值是 token。在 entry/src/main/ets/model 目录下新建 LoginData.ets

entry/src/main/ets/model/LoginData.ets
import { Expose } from 'class-transformer'
export class LoginData {
@Expose()
token: string = ''
}

8.5 复用已有 UserInfoData

模板工程已经提供 UserInfoData.ets本步骤不需要新建也不需要修改,但需要打开该文件阅读字段映射,确保你理解各字段的含义,因为后续步骤会用到它们。

entry/src/main/ets/model/UserInfoData.ets
import { Expose } from 'class-transformer'
export class UserInfoData {
@Expose({ name: 'accountId' })
id: number = 0
@Expose({ name: 'name' })
username: string = ''
@Expose({ name: 'imagePath' })
avatarUrl: string = ''
// token 不来自用户信息接口,而是在登录成功后由登录流程手动赋值
token: string = ''
}

确认字段映射与 8.2 表格一致后,继续下一步。

✅ 阶段检查:完成 Response.etsLoginData.ets 后先编译一次。此时还没有接口请求代码,编译通过即可。

目标:创建 ServerApi,把网络请求集中封装起来。登录页后续只调用方法,不直接拼接请求细节。

9.1 创建 ServerApi.ets

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

如果导入 class-transformer@ohos.net.http 时报”找不到模块”,请参考 8.2 末尾的排查步骤。

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 {
}

9.2 实现 login 方法

登录接口需要发送 POST 请求,提交用户名、密码和角色。role=1 是后端约定的点餐用户端角色值。

HTTP 请求的写法模式

@ohos.net.http 模块使用回调方式,本实验将其包装成 Promise,方便后续用 async/await 串联多个接口。以下是 POST 和 GET 请求的通用骨架:

POST 请求 Promise 封装模式
static async somePostMethod(param: string): Promise<Response<SomeData>> {
const req = http.createHttp()
return new Promise((resolve, reject) => {
req.request(
Constants.SERVER_HOST + '/some-path', // 完整 URL = 服务器地址 + 接口路径
{
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
extraData: `key=${encodeURIComponent(param)}&role=1`, // 表单格式,用 & 拼接字段
connectTimeout: 10000,
readTimeout: 10000,
},
(err, data) => {
req.destroy() // 无论成功失败都必须释放请求实例
if (err || data.responseCode !== 200) {
reject(new Error('网络请求失败'))
return
}
// 解析响应,转换为数据类实例(参考 8.2 的两层转换说明)
const raw: Object = JSON.parse(String(data.result)) as Object
const response = plainToClassFromExist(new Response<SomeData>(SomeData), raw)
if (response.data) {
response.data = plainToClassFromExist(new SomeData(), response.data as Object)
}
resolve(response)
}
)
})
}
GET 请求 Promise 封装模式(携带 token)
static async someGetMethod(token: string): Promise<Response<SomeData>> {
const req = http.createHttp()
return new Promise((resolve, reject) => {
req.request(
Constants.SERVER_HOST + '/some-path',
{
method: http.RequestMethod.GET,
header: { token: token }, // 后端约定使用 token 作为 Header Key
connectTimeout: 10000,
readTimeout: 10000,
},
(err, data) => {
req.destroy()
if (err || data.responseCode !== 200) {
reject(new Error('网络请求失败'))
return
}
const raw: Object = JSON.parse(String(data.result)) as Object
const response = plainToClassFromExist(new Response<SomeData>(SomeData), raw)
if (response.data) {
response.data = plainToClassFromExist(new SomeData(), response.data as Object)
}
resolve(response)
}
)
})
}

参考以上模式,完成 login 方法:

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
static async login(username: string, password: string): Promise<Response<LoginData>> {
// TODO: 参考上方 POST 请求模式:
// - 请求路径:Constants.SERVER_HOST + '/order-account/login'
// - header: { 'Content-Type': 'application/x-www-form-urlencoded' },
// - extraData 格式:`name=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&role=1`
// - 数据转换:Response<LoginData>(LoginData) → response.data → LoginData 实例
}
}

9.3 实现 getUserInfo 方法

用户信息接口需要携带登录后获得的 token,否则后端无法判断当前请求属于哪个用户。在 ServerApi 中追加:

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
// ... login 方法省略
static async getUserInfo(token: string): Promise<Response<UserInfoData>> {
// TODO: 参考上方 GET 请求模式:
// - 请求路径:Constants.SERVER_HOST + '/order-account/info'
// - header 中携带:
// 'token': token
// 'Content-Type': 'application/x-www-form-urlencoded',
// - 数据转换:Response<UserInfoData>(UserInfoData) → response.data → UserInfoData 实例
}
}

9.4 确认网络权限

模板工程的 entry/src/main/module.json5 已经声明网络权限:

entry/src/main/module.json5
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:reason_for_internet"
}
]

因此本章不需要再额外添加网络权限。

✅ 阶段检查:完成 ServerApi.ets 后编译工程。此时如果出现 class-transformerreflect-metadata 找不到,检查根目录 oh-package.json5 中是否已有依赖,并在 DevEco Studio 中同步工程依赖。

目标:把之前的模拟异步登录替换为真实接口登录,并在成功后保存账号信息、跳转首页。

10.1 回到 LoginView 添加导入

真实登录流程需要调用 ServerApiAccountManager

entry/src/main/ets/account/login/LoginView.ets
import { ServerApi } from '../../api/ServerApi'
import { AccountManager } from '../AccountManager'

如果文件顶部还保留了模板中用于模拟登录的 UserInfoData 导入,可以移除。真实登录流程中,用户信息来自接口。

10.2 用真实接口替换模拟 doLogin()

真实登录流程要完成五件事:

  1. 调用登录接口获取 token
  2. 判断登录接口是否成功
  3. 使用 token 调用用户信息接口
  4. 判断用户信息接口是否成功
  5. 给用户信息补充 token,并保存到 AccountManager
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((resolve) => { // 删除原来的模式实现
// TODO: 使用 setTimeout 模拟约 1.5 秒的网络请求,延迟后 resolve(true)
})
// TODO: await ServerApi.login(username, password),检查响应是否成功,取出 token
// TODO: await ServerApi.getUserInfo(token),检查响应是否成功,取出用户信息
// TODO: 将 token 补充到用户信息对象,调用 AccountManager.saveUserInfo 保存
// TODO: 成功后 return true;失败时 throw new Error(失败原因)
}
private handleLogin() {
}
}

10.3 登录成功后跳转首页

前面已经完成了校验、打开 Loading、调用 doLogin() 和关闭 Loading。现在需要把模拟成功 Toast 改成首页跳转。

entry/src/main/ets/account/login/LoginView.ets
@Preview
@Component
export struct LoginView {
private handleLogin() {
// 7.5 中 doLogin().then 里写的“模拟登录成功”提示要改为路由跳转:
// TODO: 把 then 回调中的 Toast 提示替换为 router.replaceUrl({ url: 'pages/Index' })
// - 登录成功进入首页时不使用 pushUrl
// - 原因:登录成功后不希望返回键回到登录页
}
}

页面跳转方式的选择规则可以这样记:

场景建议方式原因
启动页进入登录页或首页replaceUrl启动页不应该留在返回栈
登录成功进入首页replaceUrl不希望返回键回到登录页
从登录页进入注册页pushUrl注册页通常需要允许返回登录页
注册成功回到登录页replaceUrl 或事件通知后返回不希望用户反复回到已完成的注册流程

✅ 阶段检查:使用注册好的账号密码登录。

  • 输入正确账号密码并勾选协议,应显示 Loading,然后跳转首页
  • 切换到“我的”Tab,应能看到接口返回的头像和用户名
  • 点击退出后,应回到登录页
  • 输入错误账号或密码,应显示失败提示,并停留在登录页

“我的”Tab 中显示的用户信息来自首页 Tab 组件。模板工程中可重点查看 entry/src/main/ets/portal/PortalView.ets:该组件通过全局账号状态读取当前用户信息,退出登录时也会清除 AccountManager 中保存的数据。如果登录成功但“我的”Tab 没有显示用户名或头像,优先排查 AccountManager.saveUserInfo() 是否保存了完整用户信息,以及 PortalView 是否正确绑定了用户信息状态。

登录成功进入首页效果

当前模板工程中的部分 API 在新 SDK 下会出现 deprecated 警告(如 showToastreplaceUrlgetContextrouter.backpx2vp 等)。本章以功能完成和构建通过为准,暂不要求处理这些警告。

11.1 常见问题排查

现象优先检查
点击登录没有任何反应onClick 是否调用了 handleLogin()
一直提示用户名为空TextInput.onChange 是否把 value 赋值给 username 状态
一直提示未勾选协议Checkbox.onChange 是否更新了 agreementChecked 状态
Loading 不显示CustomDialogController 是否正确创建,open() 是否执行
Loading 不关闭finally() 中是否调用了 close()
接口请求失败账号密码是否正确,网络权限是否存在,服务器地址是否为 Constants.SERVER_HOST
用户名头像不显示是否调用了 AccountManager.saveUserInfo(userInfo),并给用户信息补充了 token

11.2 基础验收标准

完成基础实验后,请按以下顺序验证:

  1. 打开应用进入登录页,展示完整登录 UI
  2. 不输入用户名点击登录,出现用户名为空提示
  3. 只输入用户名点击登录,出现密码为空提示
  4. 输入用户名和密码但不勾选协议,出现协议提示
  5. 输入错误账号或密码,显示登录失败提示
  6. 输入正确账号密码并勾选协议,显示 Loading 后进入首页
  7. 切换到“我的”Tab,展示当前登录用户信息
  8. 点击退出,返回登录页

本步骤为可选拓展,不影响基础实验评分。建议在完成基础登录功能并确认稳定后再尝试。

12.1 记住最近一次登录用户名

要实现的功能:用户登录成功后保存用户名,下次打开登录页时自动填充最近一次登录的用户名。

实现思路

  1. 定义一个用于持久化最近登录用户名的 key
  2. 在登录成功后,把当前用户名写入 AppStorage / PersistentStorage
  3. LoginView 初始化时读取该值,并赋值给 username
  4. TextInput 需要显示初始用户名
entry/src/main/ets/account/login/LoginView.ets
// TODO: 定义最近登录用户名的持久化 key
// TODO: 使用 PersistentStorage 注册该 key
@Preview
@Component
export struct LoginView {
// TODO: username 初始值改为从 AppStorage 中读取最近登录用户名
private handleLogin() {
// TODO: 登录成功后,把当前用户名写入 AppStorage
}
}

✅ 拓展验收:登录成功后退出账号,再次进入登录页时,用户名输入框中能看到上次登录的用户名。

12.2 输入框校验失败抖动动画

要实现的效果:用户点击登录时,如果用户名或密码输入框未通过校验,该输入框执行一次水平抖动动画(类似摇头效果),视觉上强调”这里有问题”,比单纯文字提示更直观。

效果参考:输入框在约 400ms 内左右快速震荡 3 次后恢复原位,过程流畅不生硬。

实现思路

  1. 为用户名输入框和密码输入框各维护一个 @State 偏移量状态(如 usernameOffset: number = 0
  2. 校验失败时,调用 ArkUI 的 animateTo 依次设置偏移量:+10 → -10 → +10 → -10 → 0,每次切换间隔约 80ms
  3. 在输入框组件上通过 .translate({ x: this.usernameOffset }) 应用偏移
  4. 注意:抖动结束后偏移量需恢复为 0,避免输入框残留位移

可能用到的 API

  • animateTo(value: AnimateParam, event: () => void):执行帧动画,curve 建议使用 Curve.Linear 保证抖动节奏均匀
  • .translate({ x: offsetVp }):按虚拟像素偏移组件位置

✅ 拓展验收:点击登录时输入框为空,可以看到对应输入框发生抖动效果;动画结束后输入框恢复原始位置,页面布局不发生变化。

完成本实验后,请录制一段不超过 3 分钟的演示视频并提交。

12.1 必须涵盖的演示内容

请在视频中按顺序完整演示以下流程:

序号演示内容说明
登录页展示打开 APP,进入登录页,可以看到完整账号登录 UI
用户名为空校验不输入用户名点击登录,出现“用户名不能为空”提示
密码为空校验只输入用户名点击登录,出现“密码不能为空”提示
协议未勾选校验输入用户名和密码但不勾选协议,出现协议提示
登录失败提示输入错误账号或密码,页面显示登录失败原因
真实账号登录输入正确账号密码并勾选协议,显示 Loading 后进入首页
用户信息展示切换到「我的」Tab,可以看到当前登录用户头像和用户名
退出登录点击退出后返回登录页

12.2 加分项(可选)

若完成了 Step 12 中的拓展任务,请额外演示对应效果:

  • 记住最近一次登录用户名:退出后再次进入登录页,用户名自动填充
  • 输入框校验失败抖动动画:点击登录时未通过校验的输入框发生抖动,动画结束后恢复原位

12.3 提交方式

请将以下内容提交到雨课堂课后作业

  1. 演示视频:录制上述演示流程的屏幕录像,视频中需能看出你的个人信息(如设备上显示的学号/姓名,或口头说明均可)
  2. 视频格式不限,文件大小建议控制在合理范围内