
前面把首页、列表、详情、购物车、消息中心都拆了一遍这次轮到两个收尾型页面——客服对话和个人中心。它们不像商品页那样有复杂的数据流但各有各的结构逻辑客服页面是一个完整的即时通讯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白色文字无边框文字右对齐对方的消息白色背景深色文字#1E1B2E1px#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)隐藏滚动条。个人中心的滚动条通常不显示因为用户能通过内容感知到下面还有东西。HeaderRow() { 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颜色属性而ColumnbackgroundColorborderRadius的组合代码更简洁。在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 }) }OrderIconColumn布局emoji和文字垂直排列space: 6控制间距。layoutWeight(1)让每个图标均分父Row的宽度——五个图标各占20%视觉上整齐对称。MenuRowRow布局左边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不是大方向对就行是每个像素都到位。