HarmonyOS购物商城——客服对话与个人中心页面
前面把首页、列表、详情、购物车、消息中心都拆了一遍,这次轮到两个"收尾型"页面——客服对话和个人中心。它们不像商品页那样有复杂的数据流,但各有各的结构逻辑:客服页面是一个完整的即时通讯UI骨架,个人中心是一个多区块卡片堆叠的典型范式。
完整效果
ServiceChat —— 聊天界面的完整拆解
整体布局:Stack钉底 + Column承载
Stack({ alignContent: Alignment.Bottom }) {
Column() {
// Header(标题栏)
// Scroll() { Column { ForEach(消息列表) } }
}
// Input bar(被Stack的alignContent: Bottom钉在底部)
}

Stack的作用是让输入栏脱离文档流,始终固定在页面底部。Column负责Header和消息列表的垂直排列。如果用纯Column布局,输入栏会跟着Scroll一起滚动,聊天体验就废了。
Stack的alignContent: Alignment.Bottom控制子元素的对齐方式——把输入栏"贴"到最底部。Stack的默认对齐是左上角(Alignment.TopStart),如果不写alignContent,输入栏会跑到左上角去。
Header 的三段式结构
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() })
// 中:标题 + 在线状态
Column() {
Text('客服中心').fontSize(18).fontWeight(FontWeight.Bold).fontColor(T1)
Row() {
Row() {}.width(6).height(6).borderRadius(3).backgroundColor('#00B894')
Text(' 在线').fontSize(11).fontColor(T3)
}
}.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
// 右:更多菜单
Text('···').fontSize(18).fontColor(T2)
}

Header是标准的三段式:左返回、中标题、右操作。中标题区域用了layoutWeight(1)占据剩余宽度,这是Row布局中"中间内容撑满"的标准写法。
返回按钮是一个34×34的圆角方块,背景色rgba(0,0,0,0.03)几乎透明,但有微弱的灰色感,让用户知道"这里可以点"。SymbolGlyph是鸿蒙系统图标,比自定义图片更轻量。
标题下方的在线状态由两个元素组成:一个6px的绿色圆点和"在线"文字。圆点的实现方式是空Row加borderRadius——这是ArkUI中做纯色圆形的最轻量方案。用Circle()组件也可以,但Circle需要声明fill颜色和尺寸,代码量更多。6px的圆点在视觉上刚好能引起注意又不抢戏,这是经过考量的尺寸。
右侧的"···"用三个点代替文字,是移动端常见的"更多操作"入口。这里没有绑定点击事件,实际项目中通常会弹出清除聊天记录、举报等选项。
消息列表的渲染逻辑
Scroll() {
Column({ space: 12 }) {
// 系统消息
Text('—— 客服已接入 · 请描述您的问题 ——')
.fontSize(11).fontColor(T3).width('100%')
.textAlign(TextAlign.Center).margin({ top: 8, bottom: 8 })
// 消息列表
ForEach(this.messages, (msg: ChatMsg) => {
Row() {
if (msg.fromMe) { Blank() }
Column() {
Text(msg.text).fontSize(14).fontColor(msg.fromMe ? Color.White : T1)
.lineHeight(22).padding(12).borderRadius(14)
.backgroundColor(msg.fromMe ? A : CW)
.border({ width: msg.fromMe ? 0 : 1, color: '#F0EEF4' })
Text(msg.time).fontSize(10).fontColor(T3).margin({ top: 4 })
}.width('75%').alignItems(msg.fromMe ? HorizontalAlign.End : HorizontalAlign.Start)
if (!msg.fromMe) { Blank() }
}.width('100%')
})
Blank().height(60)
}.width('100%').padding({ left: 16, right: 16 })
}

系统消息: 顶部的"客服已接入"是居中的灰色小字,用全角破折号装饰。这条消息不是从messages数组里读的,而是硬编码在布局里。实际项目中,这种系统消息应该放在messages数组的最前面,用一个特殊的type字段标记。
ForEach渲染: 每条消息是一个Row,内部根据fromMe决定左右布局。核心思路是用Blank()做弹性空间——自己说的消息,左边放Blank把气泡推到右边;对方说的消息,右边放Blank把气泡推到左边。这是Flexbox布局中经典的"两端对齐"技巧。
气泡样式细节:
- 自己的消息:红色背景(
#FF4757),白色文字,无边框,文字右对齐 - 对方的消息:白色背景,深色文字(
#1E1B2E),1px#F0EEF4边框,文字左对齐 - 气泡圆角:
borderRadius(14)——14px的圆角让气泡看起来柔和,但不会太圆(太圆会像药丸) - 内边距:
padding(12)——12px的内边距让文字不贴边,呼吸感刚好 - 行高:
lineHeight(22)——14px字号配22px行高,比值约1.57,比默认行高稍宽松
75%宽度的取舍: 气泡固定75%宽度,没有做弹性宽度(根据文字长度自适应)。弹性宽度需要测量文字渲染后的实际宽度,ArkUI没有直接的API做这件事,实现成本高。75%在大多数场景下够用——短消息会留白,长消息不会溢出。
底部留白: Blank().height(60)在消息列表底部留出60px空间。这个数值要大于输入栏的高度,否则最后一条消息会被输入栏遮挡。输入栏大约50px高(40px TextInput + 8px padding + 阴影),60px的留白刚好够。
消息数据模型
interface ChatMsg { text: string; fromMe: boolean; time: string }
三个字段:消息内容、是否自己发的、时间戳。这是聊天系统最精简的数据模型。实际项目中还需要:
id:消息唯一标识,用于滚动定位和消息状态追踪type:消息类型(text/image/file/system),用于区分不同渲染方式status:发送状态(sending/sent/delivered/read),用于显示消息状态replyTo:引用某条消息,用于"回复"功能
发送逻辑与自动回复
private send(): void {
if (this.input.trim().length === 0) { return }
this.messages.push({ text: this.input, fromMe: true, time: '刚刚' })
this.input = ''
// Simulate auto-reply
this.messages.push({ text: '收到您的消息,客服会尽快回复您~', fromMe: false, time: '刚刚' })
}
发送逻辑分三步:校验输入非空、把消息推入数组、清空输入框。自动回复是mock的,直接push一条固定文本。
这里有个ArkUI的关键点:直接修改@State数组(push新元素)会触发UI重新渲染。这和React不同——React需要setState返回新数组,ArkUI的@State装饰器会自动追踪数组变化。但这个"自动追踪"有局限性:如果修改的是数组元素的属性(比如this.messages[0].text = 'xxx'),不会触发更新。需要手动重新赋值整个数组。
输入栏的完整结构
Row() {
TextInput({ placeholder: '输入您的问题...', text: this.input })
.layoutWeight(1).height(40).fontSize(14).backgroundColor('#F5F5F7')
.borderRadius(20).padding({ left: 14, right: 14 })
.onChange((v: string) => { this.input = v })
Row() {
Text('发送').fontSize(13).fontWeight(FontWeight.Bold).fontColor(Color.White)
}.padding({ left: 16, right: 16 }).height(36).borderRadius(18).backgroundColor(A)
.margin({ left: 8 }).onClick(() => { this.send() })
}.width('100%').padding({ left: 14, right: 14, top: 8, bottom: 26 })
.backgroundColor(CW).shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: -2 })
输入栏是一个Row,左侧TextInput占据剩余空间(layoutWeight(1)),右侧发送按钮固定宽度。
TextInput样式:
- 高度40px,圆角20px(半圆胶囊形)
- 背景色
#F5F5F7——比白色稍暗,让用户知道"这里是输入区" - 内边距
padding({ left: 14, right: 14 })——文字不贴边 onChange事件实时同步输入内容到@State input
发送按钮:
- 固定尺寸:水平padding 16px + 内部文字,高度36px,圆角18px(半圆)
- 红色背景(
#FF4757),白色加粗文字 margin({ left: 8 })和TextInput之间留8px间距
输入栏的阴影和间距:
padding({ bottom: 26 })——底部26px的安全距离,防止被系统导航栏遮挡backgroundColor(CW)——白色背景,和消息区域的灰色背景形成对比shadow({ offsetY: -2 })——阴影往上飘,模拟输入栏浮在消息列表上方的层次感
踩坑记录
聊天记录自动滚动问题。 Scroll组件默认不会自动滚到最新消息。发送新消息后,@State更新会触发重新渲染,但滚动条位置不变。如果用户在看历史消息,新消息会出现在底部但用户看不到。解决方案有两种:
- 给每条消息加
id,发送后调用scrollTo({ id: lastId })精确滚动 - 简化方案:在列表底部放一个
Blank().height(60),利用渲染触发的自然滚动
这里用了方案2,成本低但不精确。消息数量多了之后,最后一条消息可能不在可视区域的最底部。
键盘弹出时的布局冲突。 ArkUI的TextInput弹出键盘时会自动上推布局,但Stack的对齐方式可能导致输入栏被键盘盖住。这里用padding({ bottom: 26 })预留了安全距离,在大多数机型上能正常工作。但不同设备的键盘高度差异很大(普通键盘约300px,带工具栏的键盘约400px),严格适配需要监听键盘高度变化事件。
时间戳的简化处理。 所有消息的时间都用硬编码的字符串(‘10:30’、‘刚刚’)。实际项目中需要时间格式化函数:1分钟内显示"刚刚",1小时内显示"X分钟前",当天显示"HH:mm",昨天显示"昨天 HH:mm",更早显示完整日期。
ProfilePage —— 个人中心的卡片堆叠
整体结构:Scroll + 多区块卡片
Column() {
Scroll() {
Column() {
// Header
// User card
// Orders section
// Menu group 1(收藏夹/浏览记录/地址管理/优惠券)
// Menu group 2(客服中心/设置)
}.width('100%').alignItems(HorizontalAlign.Start)
}.width('100%').layoutWeight(1).backgroundColor(BG).scrollBar(BarState.Off)
}
个人中心是典型的"卡片列表"布局:每个功能区块是一个白色圆角卡片,卡片之间有16px的间距,整体放在灰色背景(#F8F7FC)上。
alignItems(HorizontalAlign.Start)让所有卡片左对齐——如果不写这行,Column默认会把子元素拉伸到和Column等宽,导致卡片内部的padding计算出错。
scrollBar(BarState.Off)隐藏滚动条。个人中心的滚动条通常不显示,因为用户能通过内容感知到"下面还有东西"。
Header
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(20).fontWeight(FontWeight.Bold).fontColor(T1).margin({ left: 10 })
}.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
和客服页面的Header结构一样:左返回按钮 + 右标题。但个人中心的标题更大(20px vs 18px),因为"我的"是页面主标题,而"客服中心"是功能页标题。字号的差异体现了信息层级——主标题>功能标题>正文。
用户卡片
Row() {
// 头像
Stack() {
Column() {}.width(60).height(60).borderRadius(30)
.linearGradient({ angle: 135, colors: [[A, 0], ['#FF9F43', 1]] })
Text('U').fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
}
// 文字信息
Column() {
Text('Hi, 用户').fontSize(18).fontWeight(FontWeight.Bold).fontColor(T1)
Text('查看个人主页 ›').fontSize(12).fontColor(T3).margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).margin({ left: 14 }).layoutWeight(1)
// 右箭头
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}
用户卡片由三部分组成:头像、文字信息、右箭头。
渐变头像的实现: Stack里先放一个60×60的圆形Column,用linearGradient填充135°的红橙渐变(从#FF4757到#FF9F43),然后在上面叠一个白色字母"U"。Stack的默认对齐是居中,所以文字自然居中在圆形上。
为什么用Column而不是Circle做底层?因为Circle需要额外声明fill颜色属性,而Column+backgroundColor+borderRadius的组合代码更简洁。在ArkUI中,用矩形元素加borderRadius做圆形是常见模式。
135°的渐变角度让颜色从左上到右下过渡,视觉上比0°(水平)或90°(垂直)更有动感。颜色从红色过渡到橙色,和品牌色(#FF4757)保持一致。
文字层次: "Hi, 用户"用18px加粗深色,"查看个人主页 ›“用12px浅灰色,中间留2px间距。大小+颜色+粗细三重对比,让用户一眼就能区分主信息和副信息。右箭头›用浅灰色(#DDD),暗示"可点击但不紧急”。
订单区域
Column() {
Row() {
Text('我的订单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)
Blank()
Text('全部 ›').fontSize(12).fontColor(T3)
}.width('100%').margin({ bottom: 14 })
Row() {
this.OrderIcon('待付款', '💳')
this.OrderIcon('待发货', '📦')
this.OrderIcon('待收货', '🚚')
this.OrderIcon('待评价', '⭐')
this.OrderIcon('售后', '🔧')
}.width('100%').justifyContent(FlexAlign.SpaceAround)
}
.width('100%').padding(18).backgroundColor(CW).borderRadius(16)
.margin({ left: 16, right: 16, bottom: 16 })
订单区域分为标题行和图标行两部分。
标题行: 左边"我的订单"加粗16px,右边"全部 ›"12px灰色,用Blank()把两边撑开。"全部 ›"是一个导航入口,点击后跳转到全部订单列表页。这里的›和用户卡片的›用法一致,是"可点击"的视觉暗示。
图标行: 五个订单状态图标用@Builder OrderIcon渲染,父Row用justifyContent(FlexAlign.SpaceAround)均匀分布。每个图标由emoji和文字组成,emoji用24px字号(比正文大),文字用11px灰色。
这五个状态覆盖了订单的完整生命周期:待付款→待发货→待收货→待评价→售后。顺序是固定的,不能调整——用户的心智模型是"从左到右,从新到旧"。
功能菜单的分组逻辑
// 第一组:交易相关
Column({ space: 0 }) {
this.MenuRow('收藏夹', '❤️')
Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
this.MenuRow('浏览记录', '🕐')
Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
this.MenuRow('地址管理', '📍')
Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
this.MenuRow('优惠券', '🎫')
}.width('100%').backgroundColor(CW).borderRadius(16)
.margin({ left: 16, right: 16, bottom: 16 })
// 第二组:服务相关
Column({ space: 0 }) {
this.MenuRow('客服中心', '💬')
Divider().strokeWidth(0.5).color('#F0EEF4').margin({ left: 52 })
this.MenuRow('设置', '⚙️')
}.width('100%').backgroundColor(CW).borderRadius(16)
.margin({ left: 16, right: 16, bottom: 30 })
菜单被分成两组,不是随便分的。第一组(收藏夹/浏览记录/地址管理/优惠券)是和购物交易直接相关的功能,第二组(客服中心/设置)是辅助性功能。分组的依据是用户的使用频率和操作目的——买完东西后用户更可能去看收藏或优惠券,而不是去设置页。
两组之间用16px的间距隔开,视觉上形成"两堆"的感觉。如果合成一组,虽然功能上没问题,但视觉上会显得太长,用户需要滚动更多才能找到"设置"。
分割线的左边距: margin({ left: 52 })让分割线从图标右侧开始,不和图标重叠。这个52px的计算过程是:菜单行有padding({ left: 18 }),图标是18px字号的emoji(实际渲染宽度约11px),图标和文字之间有margin({ right: 14 }),所以分割线需要从左边偏移18 + 11 + 14 ≈ 43px。但实际测量中发现52px更合适,因为emoji的渲染宽度在不同设备上有微小差异。这个数值是调试出来的,不是精确公式。
最后一组的底部间距: 第二组的margin({ bottom: 30 })比其他卡片多14px(其他是16px)。多出来的14px是给底部安全区域预留的空间——不同设备的底部导航栏高度不同,30px的底部间距能确保最后一个菜单行不被遮挡。
@Builder 的复用模式
@Builder OrderIcon(label: string, icon: string) {
Column({ space: 6 }) {
Text(icon).fontSize(24)
Text(label).fontSize(11).fontColor(T2)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
@Builder MenuRow(label: string, icon: string) {
Row() {
Text(icon).fontSize(18).margin({ right: 14 })
Text(label).fontSize(14).fontColor(T1).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(12).fontColor(['#DDD'])
}.width('100%').padding({ left: 18, right: 18, top: 14, bottom: 14 })
}
OrderIcon: Column布局,emoji和文字垂直排列,space: 6控制间距。layoutWeight(1)让每个图标均分父Row的宽度——五个图标各占20%,视觉上整齐对称。
MenuRow: Row布局,左边emoji + 中间文字 + 右边箭头,三段式。文字用layoutWeight(1)占据剩余空间,箭头固定在右边。padding({ left: 18, right: 18, top: 14, bottom: 14 })统一内边距,让每个菜单行的高度一致。
@Builder的局限性在于不支持可选参数。如果某个菜单行不需要分割线,不能通过参数控制,只能在外面单独写一个不带Divider的版本。这是ArkUI的限制——@Builder本质上是UI模板,不是完整的函数。
踩坑记录
emoji图标宽度不一致。 订单区域的五个emoji(💳📦🚚⭐🔧)在不同设备上渲染宽度不同。💳(信用卡)通常比📦(包裹)窄。用layoutWeight(1)强制等分能掩盖这个问题,但如果宽度差异超过10%,视觉上会明显不齐。更稳妥的方案是用SymbolGlyph系统图标替代emoji,但需要逐个查找对应的图标名称。
Divider左边距的调试过程。 52px这个数值不是一次写对的。初始值是40px,分割线和图标重叠了;改成50px,还是差一点;最后调到52px才对齐。ArkUI没有可视化的边距调试工具,只能靠肉眼观察+反复微调。如果项目中有多处类似的边距对齐,建议写一个常量统一管理,比如const DIVIDER_LEFT_MARGIN = 52。
卡片间距的一致性。 除了最后一组(bottom: 30),其他卡片的间距都是16px。这个间距不是随意选的——16px是移动端设计中常见的"中等间距",比8px宽松、比24px紧凑。保持间距一致能让页面节奏感更好。如果某个卡片用了不同的间距,用户会下意识觉得"这里不对劲"。
scrollBar(BarState.Off)的必要性。 个人中心的滚动条通常隐藏,因为用户能通过内容感知到"下面还有东西"。但隐藏滚动条会导致一个问题:用户不知道页面有多长,可能会过度滚动。更好的方案是只在滚动时显示滚动条,停止后自动隐藏——但这需要额外的事件监听,这里为了简洁省略了。
这两个页面看似简单,但细节密度很高。客服页面的聊天气泡对齐、输入栏阴影方向、键盘安全距离,个人中心的卡片间距、分割线对齐、emoji宽度处理,每个细节都值得花时间打磨。好的UI不是大方向对就行,是每个像素都到位。
更多推荐

所有评论(0)