HarmonyOS 购物商城:消息中心页面
前言
消息中心是 App 和用户沟通的窗口——订单状态变了通知一声,有优惠活动推送一条,物流更新提醒一下。用户不一定天天打开 App,但消息推送能把用户"拉回来"。
这篇文章拆解消息中心的实现:消息列表、未读状态、角标计数、促销卡片。
完整效果
数据模型
interfaceMsgItem{icon:string// emoji 图标title:string// 标题sub:string// 副标题/预览time:string// 时间unread:boolean// 是否未读color:string// 图标背景色count:number// 未读数量}每个消息项包含 7 个字段。color是图标背景色——不同类型的消息用不同颜色区分(系统通知红色、物流蓝色、促销黄色)。
消息数据
@Statemessages:MsgItem[]=[{icon:'🔔',title:'系统通知',sub:'您的订单已发货...',time:'10:32',unread:true,color:'#FF6B6B',count:3},{icon:'💰',title:'优惠促销',sub:'限时秒杀!...',time:'昨天',unread:true,color:'#FECA57',count:5},{icon:'📦',title:'物流助手',sub:'快递已签收...',time:'昨天 14:20',unread:false,color:'#5DADE2',count:0},// ...]7 条消息,3 条未读。数据是硬编码的——实际项目中从 API 加载。
消息类型的颜色编码:
- 🔔 系统通知:红色(#FF6B6B)——紧急
- 💰 优惠促销:黄色(#FECA57)——吸引
- 📦 物流助手:蓝色(#5DADE2)——中性
- ❤️ 活动通知:橙色(#E8734A)——温暖
- ⭐ 评价邀请:黄色(#FECA57)——轻量
- 💬 客服消息:绿色(#00B894)——友好
- 🏪 店铺动态:紫色(#8A5DFA)——品牌
头部
Row(){Row(){SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])}.width(34).height(34).borderRadius(17).backgroundColor('rgba(0,0,0,0.03)').justifyContent(FlexAlign.Center).onClick(()=>{router.back()})Text('消息中心').fontSize(22).fontWeight(FontWeight.Bold).fontColor(T1).margin({left:10}).layoutWeight(1)Text('全部已读').fontSize(13).fontColor(A).fontWeight(FontWeight.Medium)}头部三个元素:返回按钮 + 标题 + "全部已读"按钮。
"全部已读"的实现:
Text('全部已读').fontSize(13).fontColor(A).fontWeight(FontWeight.Medium)目前只是显示文字,没有绑定onClick。实际项目中需要遍历所有消息,把unread设为false,count设为0。
消息列表
ForEach(this.messages,(msg:MsgItem,idx:number)=>{Column(){Row(){// 图标角标// 消息信息// 箭头}if(idx<this.messages.length-1){Divider().strokeWidth(0.5).color('#F0EEF4').margin({left:78,right:16})}}.width('100%').backgroundColor(CW).onClick(()=>{this.openChat(msg)})})每条消息是一个 Row,点击后调用openChat。
分割线的左边距
Divider().strokeWidth(0.5).color('#F0EEF4').margin({left:78,right:16})分割线左边距 78px——跳过图标区域(50px 图标 + 12px 间距 + 16px 内边距 ≈ 78px)。这样分割线不会穿过图标,视觉更整洁。
图标角标
Stack({alignContent:Alignment.TopEnd}){Column(){Text(msg.icon).fontSize(22)}.width(50).height(50).borderRadius(15).backgroundColor(msg.color+'15').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)if(msg.count>0){Row(){Text(msg.count>99?'99+':msg.count.toString()).fontSize(9).fontWeight(FontWeight.Bold).fontColor(Color.White)}.width(18).height(18).borderRadius(9).backgroundColor(A).justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center).position({x:-2,y:-4})}}图标的结构:Stack 叠加 [彩色背景圆角方块 + emoji] 和 [红色圆形计数]。
背景色的透明度处理:
.backgroundColor(msg.color+'15')msg.color是'#FF6B6B',拼上'15'变成'#FF6B6B15'——这是 8 位 hex 颜色,后两位15是 alpha 通道(约 8% 不透明度)。效果是极淡的彩色背景。
计数的position:
.position({x:-2,y:-4})计数徽章在图标右上角,偏左 2px、偏上 4px。这个位置是手动调的——太右会超出容器,太下会挡住 emoji。
99+ 的处理:
msg.count>99?'99+':msg.count.toString()超过 99 显示"99+"——避免角标太宽撑坏布局。
消息信息
Column(){Row(){Text(msg.title).fontSize(15).fontWeight(msg.unread?FontWeight.Bold:FontWeight.Medium).fontColor(msg.unread?T1:T2).layoutWeight(1)Text(msg.time).fontSize(11).fontColor(T3)}Text(msg.sub).fontSize(12).fontColor(T2).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis}).margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)未读状态的视觉区分:
- 未读:标题粗体 + 深色(T1)
- 已读:标题中等字重 + 灰色(T2)
用户一眼就能看出哪些消息没看过。
openChat
privateopenChat(msg:MsgItem):void{msg.unread=falsemsg.count=0router.pushUrl({url:'pages/MessageDetail',params:{'title':msg.title,'sub':msg.sub,'time':msg.time,'icon':msg.icon,'color':msg.color}asRecord<string,Object>})}点击消息时:
- 把
unread设为false,count设为0——标记已读 - 跳转 MessageDetail 页面,传递消息信息
直接修改msg.unread有效吗?
msg是this.messages数组中的对象引用。直接修改msg.unread会修改原数组——但@State不会检测到对象内部的变化,UI 不会自动更新。
这是一个潜在的 bug——点击后标题不会从粗体变回普通字重。需要refresh()或者重新赋值this.messages = [...this.messages]。
促销卡片
Column(){Row(){Column(){}.width('100%').height(80).borderRadius(16).linearGradient({angle:135,colors:[[A,0],['#FF9F43',1]]})Column(){Text('🎉 618年中大促').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)Text('全场低至1折 · 领券再减').fontSize(11).fontColor('rgba(255,255,255,0.7)').margin({top:4})}.position({x:16,y:16})}.width('100%').height(80)}.onClick(()=>{router.pushUrl({url:'pages/ProductList',params:{'category':'全部分类'}asRecord<string,Object>})})消息列表底部加了一个促销卡片——渐变背景 + 文案,点击跳转商品列表。
促销卡片的位置:
它在ForEach之后、Scroll之内——用户滚到消息列表底部才能看到。这是一个"钩子"——用户看完所有消息后,看到促销卡片,可能点进去逛逛。
为什么用 Stack 结构的 Row?
渐变背景是一个空 Column,文字用position({ x: 16, y: 16 })定位在左上角。这样文字浮在渐变背景上方,视觉效果像一个完整的横幅。
踩坑记录
1. 消息已读的状态同步
msg.unread=falsemsg.count=0直接修改对象属性不会触发@State更新。需要:
privateopenChat(msg:MsgItem,idx:number):void{constupdated=[...this.messages]updated[idx]={...msg,unread:false,count:0}this.messages=updated router.pushUrl(...)}2. 分割线的左边距计算
Divider().margin({left:78,right:16})78px = 16px(Row padding-left)+ 50px(图标宽度)+ 12px(图标和文字间距)。如果图标宽度或间距变了,分割线的左边距也要跟着调。
3. 消息列表的性能
7 条消息用ForEach一次性渲染没问题。但如果消息数量多(100+),需要考虑懒加载。
4. "全部已读"按钮的实现
目前"全部已读"没有onClick。实现思路:
Text('全部已读').onClick(()=>{for(leti=0;i<this.messages.length;i++){this.messages[i].unread=falsethis.messages[i].count=0}this.messages=[...this.messages]// 触发 UI 更新})5. 促销卡片的点击跳转
.onClick(()=>{router.pushUrl({url:'pages/ProductList',params:{'category':'全部分类'}asRecord<string,Object>})})促销卡片跳转到商品列表页——和首页底部导航的"分类"按钮一样的目标。用户从消息中心进入购物场景。
写在最后
消息中心的设计核心是"信息分层"——未读消息用粗体和角标吸引注意力,已读消息退居二线。颜色编码帮助用户快速识别消息类型(红色=紧急,蓝色=物流,黄色=促销)。
促销卡片放在消息列表底部是一个巧妙的设计——用户看完所有消息后自然看到促销,从"查看通知"无缝切换到"浏览商品"。消息中心不只是通知的容器,也是流量的入口。