《新闻资讯》六、个人中心模块实现指南
HarmonyOS NEXT 新闻资讯应用 · 个人中心模块 features/personal 实现指南
开发环境:DevEco Studio 6.1.0 Release
SDK版本:HarmonyOS SDK 6.1.0(23) / API 23
开发语言:ArkTS
状态管理:V2(@ComponentV2系列装饰器)
前置阅读:common 公共能力层指南 | 新闻模块指南 | 整体架构指南
本篇详细讲解个人中心模块 features/personal 的实现。作为应用最后一个 Tab 页,个人中心是逻辑最复杂的 feature 模块——包含 3 个组件/页面、404 行源码,涵盖用户头像展示、功能网格、登录验证码倒计时、评论列表、退出登录、AppStorage 跨页面状态共享 等核心功能。
效果
一、模块定位与架构角色
个人中心模块在三层架构中处于基础特性层(features),编译为 HAR 包:
产品定制层 product/phone (HAP)
↓ 依赖
基础特性层 features/news | features/video | features/live | features/personal ← 本模块 | features/service (HAR)
↓ 依赖
公共能力层 common (HAR)
模块职责:提供用户中心、登录和评论管理功能,包含 3 个组件/页面:
| 组件 | 文件 | 行数 | 类型 | 功能 |
|---|---|---|---|---|
PersonalHome |
components/PersonalHome.ets |
208 | @ComponentV2 | 个人中心主页,List顶部对齐+差异化图标+更多列表+退出登录 |
LoginPage |
pages/LoginPage.ets |
139 | @ComponentV2 | 登录页,手机号+验证码,NavDestination |
MyComments |
pages/MyComments.ets |
89 | @ComponentV2 | 我的评论页,评论列表/空状态,NavDestination |
被依赖关系:product/phone 的 MainPage 将 PersonalHome 嵌入第四个 HdsTab(“我的”),LoginPage 和 MyComments 通过 pageMap 路由注册。
二、模块配置详解
2.1 oh-package.json5
{
"name": "personal",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"dependencies": {
"common": "file:../../common"
}
}
2.2 Index.ets 入口文件
export { PersonalHome } from './src/main/ets/components/PersonalHome';
export { LoginPage } from './src/main/ets/pages/LoginPage';
export { MyComments } from './src/main/ets/pages/MyComments';
导出 3 个组件。MainPage 通过 import { PersonalHome, LoginPage, MyComments } from 'personal' 使用。
三、完整文件结构树
features/personal/
├── oh-package.json5 (12行) 模块配置
├── Index.ets (4行) 统一导出
├── src/main/
│ ├── ets/
│ │ ├── components/
│ │ │ └── PersonalHome.ets (208行) 个人中心主页,List+差异化图标
│ │ └── pages/
│ │ ├── LoginPage.ets (139行) 登录页
│ │ └── MyComments.ets (89行) 我的评论页
│ └── resources/ (图片等静态资源)
四、PersonalHome 个人中心主页
PersonalHome 是个人中心模块的核心组件,包含头像区、功能网格、更多功能列表和退出登录按钮。
4.1 完整源码
重要变更:PersonalHome 做了以下关键调整:
- 顶部对齐:
Scroll改为List+.alignListItem(ListItemAlign.Start)- 差异化图标:功能网格 4 项各自使用不同 SVG 图标(ic_add/ic_history/ic_comment/ic_collect)
- 前置图标:更多功能列表每项前添加对应 SVG 图标(ic_activity/service/ic_fankui 等)
import { UserInfo, StyleConstants } from 'common';
@ComponentV2
export struct PersonalHome {
@Local user: UserInfo = new UserInfo();
@Consumer('pageStack') pageStack!: NavPathStack;
private functionItems: string[] = ['订阅', '历史', '评论', '收藏'];
private moreItems: string[] = ['活动', '服务', '反馈', '消息', '客服', '关于', '设置'];
// 功能网格图标映射
getFunctionIcon(item: string): Resource {
if (item === '订阅') return $r('app.media.ic_add');
else if (item === '历史') return $r('app.media.ic_history');
else if (item === '评论') return $r('app.media.ic_comment');
else return $r('app.media.ic_collect');
}
// 更多功能图标映射
getMoreIcon(item: string): Resource {
if (item === '活动') return $r('app.media.ic_activity');
else if (item === '服务') return $r('app.media.service');
else if (item === '反馈') return $r('app.media.ic_fankui');
else if (item === '消息') return $r('app.media.ic_message');
else if (item === '客服') return $r('app.media.ic_kefu');
else if (item === '关于') return $r('app.media.ic_about');
else return $r('app.media.ic_set');
}
@Builder AvatarSection() { /* 头像区,同之前 */ }
build() {
List() { // List 替代 Scroll,实现顶部对齐
ListItem() { // 红色顶部区域
Column() { this.AvatarSection() }
.width('100%').backgroundColor('#E84026').padding({ top: 8 })
}
ListItem() { // 功能网格 - 差异化图标
Row() {
ForEach(this.functionItems, (item: string) => {
Column({ space: 8 }) {
Image(this.getFunctionIcon(item)) // 各自不同图标
.width(28).height(28).fillColor('#E84026')
Text(item).fontSize(13)
}.layoutWeight(1).height(80).justifyContent(FlexAlign.Center)
})
}.backgroundColor(Color.White)
.borderRadius({ topLeft: 16, topRight: 16 }).padding({ top: 16, bottom: 8 })
}
ListItem() { // 更多功能 - 带前置图标
Column() {
ForEach(this.moreItems, (item: string) => {
Row() {
Image(this.getMoreIcon(item)) // 前置图标 20×20
.width(20).height(20).margin({ right: 12 })
Text(item).fontSize(15)
Blank()
Text('>').fontSize(16).fontColor('#CCCCCC')
}.width('100%').height(48).padding({ left: 16, right: 16 })
})
}.backgroundColor(Color.White).borderRadius(8)
.margin({ top: 12, left: 16, right: 16 })
}
if (this.user.isLoggedIn) { // 退出登录按钮
ListItem() {
Row() {
Button('退出登录', { type: ButtonType.Capsule })
.width('60%').height(40)
.onClick(() => { /* 清除登录态 */ })
}.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 24, bottom: 24 })
}
}
}
.alignListItem(ListItemAlign.Start) // 关键:顶部对齐
.scrollBar(BarState.Off)
.width('100%').height('100%')
.backgroundColor(StyleConstants.BG_COLOR)
}
}
4.2 分段深度讲解
段落1 — 状态变量(第10-13行)
@Local user: UserInfo = new UserInfo();
@Consumer('pageStack') pageStack!: NavPathStack;
private functionItems: string[] = ['订阅', '历史', '评论', '收藏'];
private moreItems: string[] = ['活动', '服务', '反馈', '消息', '客服', '关于', '设置'];
| 变量 | 装饰器 | 类型 | 说明 |
|---|---|---|---|
user |
@Local |
UserInfo |
用户信息模型(@ObservedV2 类) |
pageStack |
@Consumer('pageStack') |
NavPathStack |
路由栈,用于跳转 LoginPage 和 MyComments |
functionItems |
无(private) | string[] |
功能网格 4 项 |
moreItems |
无(private) | string[] |
更多功能 7 项 |
UserInfo 模型回顾:
@ObservedV2
export class UserInfo {
@Trace account: string = '';
@Trace nickname: string = '';
@Trace isLoggedIn: boolean = false;
@Trace score: number = 0;
}
isLoggedIn 默认为 false,在 aboutToAppear 中根据 AppStorage 是否有 userAccount 来决定。
段落2 — aboutToAppear 登录态恢复(第15-23行)
aboutToAppear() {
let account = AppStorage.get<string>('userAccount') ?? '';
if (account !== '') {
this.user.account = account;
this.user.nickname = `用户${account.slice(-4)}`;
this.user.isLoggedIn = true;
this.user.score = 100;
}
}
AppStorage.get 读取登录态:
AppStorage.get<string>('userAccount')— 从全局存储读取手机号?? ''— 空值合并,如果不存在则返回空字符串account !== ''— 有手机号说明已登录(由 LoginPage 写入)account.slice(-4)— 取手机号后 4 位,生成昵称如"用户1234"- 设置
isLoggedIn = true和score = 100
数据流:LoginPage → AppStorage.setOrCreate('userAccount', phone) → PersonalHome.aboutToAppear() → AppStorage.get('userAccount')
段落3 — @Builder AvatarSection 头像区(第25-68行)
布局结构:
Column (居中对齐)
├── Stack (头像)
│ ├── Circle (64×64, 灰色背景 #F5F5F5)
│ └── Text (首字母/问号, 28号加粗)
├── Text (用户名/登录注册, 18号白色)
│ └── onClick → pushPathByName('LoginPage')
└── if/else
├── 已登录:Text('积分:100')
└── 未登录:Text('登录后查看积分')
条件渲染设计:
| 状态 | 头像文字 | 文字颜色 | 用户名 | 积分 |
|---|---|---|---|---|
| 已登录 | nickname.charAt(0) 首字母 |
#E84026 红色 |
用户1234 |
积分:100 |
| 未登录 | '?' |
#999999 灰色 |
登录/注册 |
登录后查看积分 |
头像实现技巧:使用 Stack 叠加 Circle(灰色背景圆)和 Text(首字母),模拟头像效果。charAt(0) 取昵称第一个字符作为头像文字。
点击跳转登录:用户名 Text 的 onClick 判断 !this.user.isLoggedIn,仅在未登录时跳转到 LoginPage。
段落4 — 功能网格 + 差异化图标
功能网格现在为每个功能项使用独立的 SVG 图标,通过 getFunctionIcon() 方法映射:
| 功能项 | 图标资源 | 语义 |
|---|---|---|
| 订阅 | ic_add.svg |
添加/订阅 |
| 历史 | ic_history.svg |
历史记录 |
| 评论 | ic_comment.svg |
评论/消息 |
| 收藏 | ic_collect.svg |
收藏/星形 |
Image(this.getFunctionIcon(item)) // 差异化图标
.width(28).height(28)
.fillColor('#E84026')
所有 SVG 图标从源代码目录复制到 features/personal/src/main/resources/base/media/。
段落5 — 更多功能列表 + 前置图标
更多功能列表现在为每个列表项添加前置 SVG 图标,通过 getMoreIcon() 方法映射:
| 列表项 | 图标资源 |
|---|---|
| 活动 | ic_activity.svg |
| 服务 | service.svg |
| 反馈 | ic_fankui.svg |
| 消息 | ic_message.svg |
| 客服 | ic_kefu.svg |
| 关于 | ic_about.svg |
| 设置 | ic_set.svg |
Row() {
Image(this.getMoreIcon(item)) // 前置图标 20×20
.width(20).height(20).margin({ right: 12 })
Text(item).fontSize(15)
Blank()
Text('>').fontSize(16).fontColor('#CCCCCC')
}
段落6 — build() 主布局 + 顶部对齐(List 替代 Scroll)
关键变更:使用 List 替代 Scroll,配合 .alignListItem(ListItemAlign.Start) 实现内容顶部对齐:
build() {
List() {
ListItem() { /* 头像区 */ }
ListItem() { /* 功能网格 */ }
ListItem() { /* 更多功能 */ }
if (isLoggedIn) { ListItem() { /* 退出登录 */ } }
}
.alignListItem(ListItemAlign.Start) // 关键:顶部对齐
.scrollBar(BarState.Off)
}
为什么用 List 而非 Scroll?
| 对比 | Scroll + Column | List + ListItem |
|---|---|---|
| 顶部对齐 | 内容可能垂直居中 | ListItemAlign.Start 确保顶部对齐 |
| 性能 | 一次性创建所有子组件 | ListItem 懒加载渲染 |
| 可扩展性 | 需手动管理滚动 | 支持 .scrollTo()、scrollBar 等 |
退出登录按钮:
Button('退出登录', { type: ButtonType.Capsule })
.width('60%')
.height(40)
.fontColor('#E84026') // 红色文字
.backgroundColor(Color.White) // 白色背景
.border({ width: 1, color: '#E84026' }) // 红色边框
.onClick(() => {
AppStorage.setOrCreate('userAccount', ''); // 清除全局存储
this.user.account = ''; // 重置所有属性
this.user.nickname = '';
this.user.isLoggedIn = false;
this.user.score = 0;
})
ButtonType.Capsule:胶囊形按钮(两端半圆形),比默认的 ButtonType.Normal(圆角矩形)更适合"退出登录"这类操作按钮。
退出登录逻辑:
AppStorage.setOrCreate('userAccount', '')— 清除全局手机号- 重置
user对象所有属性 isLoggedIn = false触发条件渲染更新:头像变"?“、用户名变"登录/注册”、退出按钮隐藏
条件渲染:if (this.user.isLoggedIn) 只在已登录时显示退出按钮,未登录时不显示。
五、LoginPage 登录页
LoginPage 是从 PersonalHome 点击"登录/注册"跳转的二级页面,支持手机号 + 验证码登录。
5.1 完整源码
import { StyleConstants, Logger } from 'common';
/**
* 登录页
* 使用 @ComponentV2 + NavDestination 实现
* 支持手机号+验证码登录
*/
@ComponentV2
export struct LoginPage {
@Consumer('pageStack') pageStack!: NavPathStack;
@Local phone: string = '';
@Local verifyCode: string = '';
@Local isCodeSent: boolean = false;
@Local countdown: number = 0;
private verifyCodes: string[] = ['1234', '5678', '9012', '3456'];
@Builder
InputRow(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row() {
Text(label)
.fontSize(14)
.fontColor(StyleConstants.TEXT_PRIMARY)
.width(80)
TextInput({ placeholder: placeholder, text: value })
.layoutWeight(1)
.height(40)
.type(label === '手机号' ? InputType.Number : InputType.Normal)
.onChange(onChange)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR })
}
build() {
NavDestination() {
Column() {
// 顶部返回栏
Row() {
Image($r('app.media.back'))
.width(24).height(24)
.margin({ left: 16 })
.onClick(() => { this.pageStack.pop(); })
}
.width('100%').height(56).padding({ top: 8 })
// 标题
Text('手机号登录')
.fontSize(24).fontWeight(FontWeight.Bold)
.fontColor(StyleConstants.TEXT_PRIMARY)
.margin({ left: 24, top: 24 }).width('100%')
Text('未注册的手机号验证后将自动创建账号')
.fontSize(13).fontColor(StyleConstants.TEXT_SECONDARY)
.margin({ left: 24, top: 8 }).width('100%')
// 输入区域
Column() {
this.InputRow('手机号', '请输入手机号', this.phone, (v: string) => {
this.phone = v;
})
Row() {
Text('验证码')
.fontSize(14).fontColor(StyleConstants.TEXT_PRIMARY).width(80)
TextInput({ placeholder: '请输入验证码', text: this.verifyCode })
.layoutWeight(1).height(40)
.type(InputType.Number)
.onChange((v: string) => { this.verifyCode = v; })
Button(this.countdown > 0 ? `${this.countdown}s` : '获取验证码')
.height(32).fontSize(12)
.fontColor(this.isCodeSent && this.countdown > 0 ? '#999999' : '#E84026')
.backgroundColor(Color.Transparent)
.enabled(!this.isCodeSent || this.countdown <= 0)
.onClick(() => {
if (this.phone.length === 11) {
this.isCodeSent = true;
this.countdown = 60;
let code = this.verifyCodes[Math.floor(Math.random() * this.verifyCodes.length)];
Logger.info(`验证码为:${code}`);
let timer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
clearInterval(timer);
}
}, 1000);
}
})
}
.width('100%').height(56)
.padding({ left: 16, right: 16 })
.border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR })
}
.margin({ top: 24, left: 8, right: 8 })
// 登录按钮
Button('登录', { type: ButtonType.Capsule })
.width('80%').height(44).fontSize(16)
.fontColor(Color.White)
.backgroundColor('#E84026')
.margin({ top: 40 })
.enabled(this.phone.length === 11 && this.verifyCode.length >= 4)
.onClick(() => {
AppStorage.setOrCreate('userAccount', this.phone);
this.pageStack.pop();
})
}
.width('100%').height('100%')
.backgroundColor(Color.White)
}
.hideTitleBar(true)
}
}
5.2 分段深度讲解
段落1 — 状态变量(第11-16行)
@Local phone: string = ''; // 手机号
@Local verifyCode: string = ''; // 验证码
@Local isCodeSent: boolean = false; // 是否已发送验证码
@Local countdown: number = 0; // 倒计时秒数
private verifyCodes: string[] = ['1234', '5678', '9012', '3456']; // Mock验证码池
4 个 @Local 状态变量控制整个登录页的交互逻辑。
段落2 — @Builder InputRow 参数化输入行(第18-36行)
@Builder
InputRow(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Row() {
Text(label) // 标签:80宽固定
.width(80)
TextInput({ placeholder: placeholder, text: value }) // 输入框
.layoutWeight(1) // 占满剩余宽度
.type(label === '手机号' ? InputType.Number : InputType.Normal)
.onChange(onChange) // 回调
}
.border({ width: { bottom: 0.5 }, color: StyleConstants.DIVIDER_COLOR }) // 底部边框
}
4 个参数:
label:标签文字(“手机号"或"验证码”)placeholder:输入框占位文字value:当前输入值onChange:值变化回调函数(v: string) => void
动态键盘类型:label === '手机号' 时使用 InputType.Number(数字键盘),否则使用 InputType.Normal(全键盘)。
调用示例:
this.InputRow('手机号', '请输入手机号', this.phone, (v: string) => {
this.phone = v; // 回调中更新状态
})
段落3 — 验证码倒计时(第95-109行)
.onClick(() => {
if (this.phone.length === 11) { // 1. 验证手机号长度
this.isCodeSent = true; // 2. 标记已发送
this.countdown = 60; // 3. 开始60秒倒计时
let code = this.verifyCodes[ // 4. 随机选一个验证码
Math.floor(Math.random() * this.verifyCodes.length)
];
Logger.info(`验证码为:${code}`); // 5. 输出到日志
let timer = setInterval(() => { // 6. 每秒递减
this.countdown--;
if (this.countdown <= 0) {
clearInterval(timer); // 7. 到0时停止
}
}, 1000);
}
})
倒计时完整流程:
用户点击"获取验证码"
→ 验证手机号 11 位
→ isCodeSent = true
→ countdown = 60
→ 按钮文字变为 "60s"
→ setInterval 每秒执行:
countdown--
按钮文字变为 "59s" → "58s" → ... → "1s" → "0s"
countdown <= 0 时:
clearInterval(timer)
按钮文字恢复为 "获取验证码"
按钮 enabled 恢复为 true
按钮 enabled 条件:!this.isCodeSent || this.countdown <= 0
- 未发送过验证码时:
!false = true→ 可点击 - 已发送且倒计时中:
!true = false→ 不可点击 - 倒计时结束:
countdown <= 0 = true→ 可再次点击
段落4 — 登录按钮(第118-130行)
Button('登录', { type: ButtonType.Capsule })
.enabled(this.phone.length === 11 && this.verifyCode.length >= 4)
.onClick(() => {
AppStorage.setOrCreate('userAccount', this.phone); // 写入全局存储
this.pageStack.pop(); // 返回上一页
})
enabled 条件:手机号 11 位 + 验证码至少 4 位,两个条件同时满足才可点击。
AppStorage.setOrCreate:如果 'userAccount' key 已存在则更新值,不存在则创建。写入手机号后,PersonalHome 的 aboutToAppear 可以读取到。
六、MyComments 我的评论页
MyComments 展示用户发表的所有评论,从 AppStorage 读取评论数据。
6.1 完整源码
import { StyleConstants } from 'common';
/**
* 我的评论页
* 使用 @ComponentV2 + NavDestination 实现
* 展示用户发表的所有评论
*/
@ComponentV2
export struct MyComments {
@Consumer('pageStack') pageStack!: NavPathStack;
@Local comments: string[] = [];
aboutToAppear() {
this.comments = (AppStorage.get<string[]>('newsData') ?? []);
}
build() {
NavDestination() {
Column() {
// 顶部标题栏
Row() {
Image($r('app.media.back'))
.width(24).height(24).margin({ left: 16 })
.onClick(() => { this.pageStack.pop(); })
Text('我的评论')
.fontSize(18).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.margin({ right: 40 })
}
.width('100%').height(56).padding({ top: 8 })
if (this.comments.length === 0) {
// 空状态
Column({ space: 12 }) {
Text('暂无评论')
.fontSize(16).fontColor(StyleConstants.TEXT_SECONDARY)
Text('去新闻详情页发表评论吧')
.fontSize(13).fontColor('#CCCCCC')
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
// 评论列表
List() {
ForEach(this.comments, (item: string, index?: number) => {
ListItem() {
Column() {
Text(item)
.fontSize(15).fontColor(StyleConstants.TEXT_PRIMARY).width('100%')
Text('刚刚')
.fontSize(12).fontColor(StyleConstants.TEXT_SECONDARY).margin({ top: 6 })
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
Divider()
.backgroundColor(StyleConstants.DIVIDER_COLOR)
.margin({ left: 16, right: 16 })
}
}, (item: string, index?: number) => item + index)
}
.width('100%').layoutWeight(1)
}
}
.width('100%').height('100%')
.backgroundColor(Color.White)
}
.hideTitleBar(true)
}
}
6.2 分段深度讲解
段落1 — 数据加载(第13-15行)
aboutToAppear() {
this.comments = (AppStorage.get<string[]>('newsData') ?? []);
}
从 AppStorage 读取 key 为 'newsData' 的评论数组。如果不存在(用户从未评论过),返回空数组 []。newsData 由 NewsDetail 页面的评论功能写入。
段落2 — 条件渲染:空状态 vs 列表态(第41-80行)
if (this.comments.length === 0) {
// 空状态:居中显示提示
Column({ space: 12 }) {
Text('暂无评论')
Text('去新闻详情页发表评论吧')
}
.layoutWeight(1) // 占满剩余空间
.justifyContent(FlexAlign.Center) // 垂直居中
} else {
// 评论列表
List() {
ForEach(this.comments, ...)
}
.layoutWeight(1)
}
条件渲染模式:ArkUI 的 if/else 控制组件的创建和销毁:
- 空状态:Column 居中显示两行提示文字
- 列表态:List + ForEach 渲染评论列表
ForEach key 生成器:item + index 将评论内容和索引拼接,确保唯一性(同一条评论可能发表多次)。
七、AppStorage 跨页面状态共享模式详解
个人中心模块大量使用 AppStorage 实现跨页面数据传递,是本应用中最集中的 AppStorage 使用场景。
7.1 数据流图
┌──────────────────────────────────────────────────────┐
│ AppStorage │
│ │
│ 'userAccount': string ← LoginPage 写入手机号 │
│ → PersonalHome 读取恢复登录态 │
│ │
│ 'newsData': string[] ← NewsDetail 写入评论内容 │
│ → MyComments 读取展示列表 │
└──────────────────────────────────────────────────────┘
7.2 AppStorage API 说明
| API | 用途 | 示例 |
|---|---|---|
setOrCreate(key, value) |
存在则更新,不存在则创建 | AppStorage.setOrCreate('userAccount', phone) |
get<T>(key) |
读取值,不存在返回 undefined | AppStorage.get<string>('userAccount') |
?? 空值合并 |
undefined 时使用默认值 | AppStorage.get<string>('userAccount') ?? '' |
7.3 为什么用 AppStorage 而非 @Provider/@Consumer?
| 对比维度 | AppStorage | @Provider/@Consumer |
|---|---|---|
| 作用范围 | 全局(跨 Navigation 边界) | 组件树内(父子关系) |
| 数据类型 | 基本类型 + 数组 | 任意类型 |
| 适用场景 | 登录态、用户设置等全局状态 | NavPathStack 等组件树共享状态 |
| 生命周期 | 应用全局 | 组件树范围 |
| 本应用使用 | userAccount、newsData | pageStack |
关键区别:LoginPage 和 PersonalHome 不在同一个父子组件链中——它们通过 NavPathStack 路由跳转,处于 Navigation 容器的不同层级。AppStorage 可以跨越这种边界,而 @Provider/@Consumer 需要严格的父子关系。
八、V2 装饰器使用汇总
| 装饰器 | 变量/位置 | 作用 |
|---|---|---|
@ComponentV2 |
PersonalHome、LoginPage、MyComments |
V2 组件声明 |
@Local |
user、phone、verifyCode、isCodeSent、countdown、comments |
组件内状态 |
@Consumer('pageStack') |
PersonalHome(组件内) |
消费 NavPathStack |
@Param |
LoginPage.pageStack、MyComments.pageStack |
MainPage 显式传参 |
@Builder |
AvatarSection、InputRow |
可复用 UI 模板 |
九、常见问题 Q&A
Q1: LoginPage 的验证码是真的发送了吗?
A: 不是。验证码从本地数组 ['1234', '5678', '9012', '3456'] 中随机选取,通过 Logger.info 输出到日志。实际项目中应调用短信 API 发送。
Q2: 退出登录后再次进入个人中心,状态如何?
A: 退出登录将 'userAccount' 设为空字符串。下次进入个人中心时,aboutToAppear 读取到空字符串,isLoggedIn 保持 false,显示未登录状态。
Q3: 为什么 PersonalHome 用 List 而非 Scroll?
A: Scroll 包裹 Column 时内容可能垂直居中而非顶部对齐。改用 List + .alignListItem(ListItemAlign.Start) 确保内容始终从顶部开始渲染。
Q4: MyComments 页面的评论数据何时更新?
A: 仅在 aboutToAppear 时读取一次。如果用户在 NewsDetail 新增评论后返回 MyComments,需要重新触发页面创建才能看到新数据。实际项目中可以使用 AppStorage.link 实现实时同步。
Q5: 如何添加更多功能项的 onClick 跳转?
A: 在 FunctionGrid 的 onClick 中添加条件分支:
.onClick(() => {
if (item === '评论') { this.pageStack.pushPathByName('MyComments', null); }
else if (item === '收藏') { this.pageStack.pushPathByName('MyFavorites', null); }
// ...
})
十、小结
个人中心模块以 208 行源码实现了完整的用户中心功能,核心知识点包括:
- List + alignListItem 顶部对齐:替代 Scroll,确保页面内容始终顶部对齐
- 差异化图标映射:
getFunctionIcon()/getMoreIcon()为每个功能项返回独立 SVG 图标 - AppStorage 跨页面状态共享:LoginPage 写入 → PersonalHome 读取
- 验证码倒计时:
setInterval+countdown递减 +clearInterval停止 - @Builder 参数化模板:
InputRow接受 4 个参数(含回调函数),实现输入行复用 - 条件渲染:
if/else控制头像区、退出按钮、空状态/列表态的显示
更多推荐


所有评论(0)