一、引言:社交聊天应用的时代背景与技术意义

在移动互联网时代,社交聊天应用已经成为人们日常生活中不可或缺的基础设施。从早期的文字短信到如今集即时通讯、社交动态、生活服务于一体的超级应用,社交软件的形态已经发生了翻天覆地的变化。用户对于社交应用的期待早已不再局限于简单的消息收发,而是希望在一个统一的平台中完成沟通、分享、消费、娱乐等多元化的需求。

在这里插入图片描述

HarmonyOS(鸿蒙操作系统)作为华为推出的分布式操作系统,其应用开发框架 ArkTS 提供了一套全新的声明式 UI 编程范式。与传统的前端开发模式相比,ArkTS 在类型安全、编译时检查、运行性能等方面都有显著的提升。通过 ArkTS,开发者可以用更接近自然思维的方式描述界面的结构和行为,从而大幅提升开发效率和代码可维护性。

本文将深入剖析一个基于 HarmonyOS ArkTS 构建的全功能社交聊天应用。该应用涵盖了聊天列表、通讯录、发现页、朋友圈动态流以及个人中心五大核心模块,并集成了消息统计图表、群聊管理、联系人分组、小程序入口、钱包展示等丰富的功能特性。通过对每一行关键代码的逐行解读,我们旨在帮助开发者全面理解 ArkTS 声明式 UI 的设计哲学、状态驱动机制以及组件化开发模式。

这个应用的技术价值不仅在于它复刻了主流社交软件的功能形态,更在于它展示了一个完整的 HarmonyOS 应用应当如何组织代码结构、管理应用状态、处理用户交互以及构建可复用的 UI 组件。无论你是刚刚接触 ArkTS 的新手,还是希望深入理解声明式 UI 最佳实践的资深开发者,都能从这份代码中获得有价值的参考。

二、技术架构总览

2.1 ArkTS 声明式 UI 核心理念

ArkTS 是华为为 HarmonyOS 应用开发量身打造的编程语言,它在 TypeScript 的基础上进行了扩展和优化。声明式 UI 的核心理念是:开发者只需要描述界面"是什么",而不需要关心"怎么做"。框架会自动追踪状态数据的变化,并在数据更新时智能地刷新对应的界面区域。

这种模式带来了几个显著的优势:

第一,代码更加简洁直观。开发者通过嵌套的组件调用就能描述出复杂的界面层级关系,无需手动操作 DOM 或进行命令式的界面更新。

第二,状态与视图保持高度同步。通过 @State 等装饰器标记的状态变量,一旦发生改变,框架会自动触发依赖该状态的 UI 组件重新渲染,杜绝了状态与界面不一致的问题。

第三,性能更优。框架内部实现了细粒度的差异化更新机制,只有真正发生变化的 UI 部分才会被重新绘制,避免了全局重绘带来的性能开销。

2.2 应用整体架构

本社交聊天应用采用了单组件、多 Builder 的架构模式。整个应用由一个入口组件 SocialChatApp 承载,内部通过 @Builder 装饰的方法将各个功能模块拆分为独立的 UI 构建单元。这种设计既保持了代码的集中管理,又通过 Builder 方法实现了逻辑上的模块化分离。

应用的顶层结构可以概括为三个层级:

  • 状态管理层:通过 @State 装饰器管理当前选中的标签页、搜索关键词、各类弹窗的显示状态、选中项的 ID 等核心状态数据。
  • 数据层:通过 private 属性定义了颜色配置、标签配置以及各类业务数据列表(聊天列表、联系人列表、朋友圈动态等)。
  • 视图层:通过 build() 方法和一系列 @Builder 方法构建完整的界面结构,包括顶部状态栏、标签页内容区、底部导航栏和弹窗覆盖层。

三、数据模型设计:类型安全的接口定义

在任何严肃的应用开发中,良好的数据模型设计都是项目成功的基石。ArkTS 作为 TypeScript 的超集,完整支持接口(Interface)类型系统,这让我们可以在编译阶段就捕获类型错误,避免运行时因数据结构不匹配而导致的问题。

本应用定义了一系列接口来描述各种业务数据的结构。让我们逐个分析。

3.1 聊天项数据结构

interface ChatItem {
  id: string
  name: string
  avatar: string
  lastMessage: string
  time: string
  unreadCount: number
  isPinned: boolean
  isMuted: boolean
  type: string
  online: boolean
}

在这里插入图片描述

逐行解析:

  • id: string —— 每个聊天会话的唯一标识符。使用字符串类型而非数字,便于在分布式环境中生成全局唯一的 ID,也方便后续扩展为服务端返回的 UUID。
  • name: string —— 聊天名称,对于单聊是对方的昵称,对于群聊是群名称。
  • avatar: string —— 头像资源。这里使用 Emoji 字符串作为头像,这是一种轻量级的方案,在实际项目中可以替换为图片资源路径或 Base64 编码。
  • lastMessage: string —— 最近一条消息的内容摘要,用于在聊天列表中预览展示。
  • time: string —— 最近消息的时间显示文本,可以是"14:32"这样的具体时间,也可以是"昨天"、"周二"这样的相对时间。
  • unreadCount: number —— 未读消息计数。这是一个关键的交互指标,决定了是否在列表项上显示红色未读徽标。
  • isPinned: boolean —— 是否置顶。置顶的聊天会显示在列表的最上方,并用特殊背景色区分。
  • isMuted: boolean —— 是否开启消息免打扰。免打扰的聊天不会发出提示音,未读徽标也会变为灰色。
  • type: string —— 聊天类型,取值为 'single'(单聊)或 'group'(群聊),用于控制头像的背景色等视觉差异。
  • online: boolean —— 对方是否在线。在线状态会在头像右下角显示一个绿色小圆点。

3.2 联系人数据结构

interface ContactItem {
  id: string
  name: string
  avatar: string
  pinyin: string
  phone: string
  region: string
  tag: string
  isStarred: boolean
}

在这里插入图片描述

逐行解析:

  • id: string —— 联系人的唯一标识。
  • name: string —— 联系人姓名。
  • avatar: string —— 联系人头像,同样使用 Emoji 字符。
  • pinyin: string —— 联系人姓名的拼音全拼。这个字段非常关键,它用于实现通讯录按字母分组的索引功能,例如将"阿明"归入"A"组,"陈强"归入"C"组。
  • phone: string —— 联系电话,这里使用了脱敏处理(中间四位用星号替代),体现了对用户隐私的保护意识。
  • region: string —— 所在地区,如"北京"、"上海"等,在联系人列表中作为辅助信息展示。
  • tag: string —— 联系人标签,如"同事"、“朋友”、"同学"等,便于对联系人进行分类管理。
  • isStarred: boolean —— 是否为星标好友。星标好友会在通讯录中优先展示,并带有星标图标。

3.3 朋友圈动态数据结构

interface MomentItem {
  id: string
  author: string
  avatar: string
  content: string
  images: number
  time: string
  likes: number
  comments: number
  location: string
}

在这里插入图片描述

逐行解析:

  • id: string —— 动态的唯一标识。
  • author: string —— 发布者姓名。
  • avatar: string —— 发布者头像。
  • content: string —— 动态的文本内容,支持多行展示。
  • images: number —— 图片数量。这里使用数字而非图片 URL 数组,是一种简化的设计。在实际应用中,这个字段可以扩展为字符串数组来存储真实的图片地址。
  • time: string —— 发布时间的显示文本,如"2小时前"、"昨天"等。
  • likes: number —— 点赞数。
  • comments: number —— 评论数。
  • location: string —— 发布时的地理位置信息,如"北京·中关村软件园"。

3.4 其他辅助数据结构

应用还定义了多个辅助接口来支撑不同功能模块的数据需求:

interface DiscoverItem {
  id: string
  title: string
  icon: string
  desc: string
  color: string
}

在这里插入图片描述

发现页的每个入口项包含标题、图标、描述和主题色。color 字段用于为每个入口设置不同的图标背景色,使界面更加丰富多彩。

interface MiniProgramItem {
  id: string
  name: string
  icon: string
  category: string
  color: string
}

在这里插入图片描述

小程序卡片项包含名称、图标、分类和主题色。分类字段(如"美食"、“出行”、“购物”)为后续的小程序分类筛选提供了基础。

interface ChartBarItem {
  day: string
  value: number
  label: string
}

在这里插入图片描述

图表数据项用于消息统计柱状图,包含星期标签、数值和显示文本。

interface ColorConfig {
  primary: string
  light: string
  bg: string
  card: string
  textPrimary: string
  textSecondary: string
  border: string
  unread: string
  online: string
}

在这里插入图片描述

颜色配置接口定义了应用的全局色彩体系,包括主色、浅色、背景色、卡片色、主要文字色、次要文字色、边框色、未读徽标色和在线状态色。这种集中式的颜色管理确保了整个应用视觉风格的一致性。

interface TabConfig {
  label: string
  icon: string
  color: string
}

interface ModalConfig {
  visible: boolean
  type: string
  title: string
}

interface ChatDetailConfig {
  name: string
  avatar: string
  region: string
  phone: string
  memberCount: number
  isMuted: boolean
  isPinned: boolean
}

在这里插入图片描述

此外还有标签配置、弹窗配置和聊天详情配置等接口,它们各自服务于特定的功能场景,为数据传递和状态管理提供了明确的类型约束。

四、应用状态管理

状态管理是声明式 UI 框架中最核心的概念之一。在 ArkTS 中,通过 @State 装饰器标记的变量会被框架自动追踪,当这些变量的值发生变化时,所有引用了这些变量的 UI 组件都会自动重新渲染。

4.1 核心状态变量

@Entry
@Component
struct SocialChatApp {
  @State currentTab: number = 0
  @State searchKeyword: string = ''
  @State showNewChatModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State showAddModal: boolean = false
  @State selectedChatId: string = ''
  @State editChatName: string = ''
  @State detailMuted: boolean = false
  @State detailPinned: boolean = false
  @State contactSearchKeyword: string = ''
  @State selectedContactId: string = ''
  @State momentLikedId: string = ''

在这里插入图片描述

逐行解析:

  • @Entry —— 标记此组件为应用的入口组件,HarmonyOS 框架会从此组件开始构建整个应用界面。
  • @Component —— 声明这是一个自定义组件,可以被复用和组合。
  • @State currentTab: number = 0 —— 当前激活的标签页索引。初始值为 0,对应"聊天"页。当用户点击底部导航栏时,此值会更新,从而触发标签页内容区的条件渲染切换。
  • @State searchKeyword: string = '' —— 聊天列表的搜索关键词。绑定到搜索输入框,用户输入时会实时更新,驱动聊天列表的过滤逻辑。
  • @State showNewChatModal 至 @State showAddModal —— 五个布尔类型状态变量,分别控制五个不同弹窗的显示与隐藏。这种多弹窗独立管理的方式虽然简单直接,但在弹窗数量较多时也可以考虑用枚举类型统一管理。
  • @State selectedChatId: string = '' —— 当前选中的聊天 ID。当用户点击某个聊天项时,此值会被设置,用于在详情弹窗中展示对应聊天的信息。
  • @State editChatName: string = '' —— 编辑聊天名称时使用的临时变量,绑定到编辑弹窗的输入框。
  • @State detailMuted: boolean 和 @State detailPinned: boolean —— 聊天详情弹窗中"消息免打扰"和"置顶聊天"两个开关的临时状态,点击时可即时切换。
  • @State contactSearchKeyword: string = '' —— 通讯录搜索关键词,与聊天搜索独立管理,互不干扰。
  • @State selectedContactId: string = '' —— 当前选中的联系人 ID。
  • @State momentLikedId: string = '' —— 当前点赞的朋友圈动态 ID。通过这个状态实现点赞/取消点赞的即时视觉反馈。

4.2 状态驱动的设计思想

上述状态变量的设计体现了几个重要的原则:

单一数据源原则。每个 UI 状态都有且只有一个对应的 @State 变量作为数据源。例如,当前显示哪个标签页完全由 currentTab 决定,不存在多个变量共同控制同一状态的情况,避免了状态冲突。

就近管理原则。所有状态都在入口组件中集中定义,便于全局管理和调试。虽然在实际大型项目中可能会使用状态管理框架或局部状态,但对于此规模的应用,集中管理是合理的选择。

即时反馈原则。例如 momentLikedId 的设计,当用户点击点赞按钮时,该变量立即更新为当前动态的 ID,界面上的心形图标会从空心变为实心,点赞数也会同步加一,为用户提供流畅的交互反馈。

五、配置与静态数据

5.1 全局颜色配置

private colors: Record<string, string> = {
  'primary': '#07C160',
  'light': '#95EC69',
  'bg': '#EDEDED',
  'card': '#FFFFFF',
  'textPrimary': '#1A1A1A',
  'textSecondary': '#999999',
  'border': '#E0E0E0',
  'unread': '#FA5151',
  'online': '#07C160'
}

在这里插入图片描述

逐行解析:

  • 'primary': '#07C160' —— 应用主色,一种鲜亮的绿色。这个颜色贯穿整个应用,出现在顶部状态栏、按钮、选中标签、图表高亮柱等多个位置,是应用品牌识别的核心色彩。
  • 'light': '#95EC69' —— 主色的浅色变体,用于图表中数值较低的柱子,与高数值的主色柱子形成视觉层次。
  • 'bg': '#EDEDED' —— 页面背景色,浅灰色。这种中性背景色能够突出白色卡片内容,营造出干净清爽的视觉感受。
  • 'card': '#FFFFFF' —— 卡片背景色,纯白色。聊天项、联系人项、设置项等都使用白色背景。
  • 'textPrimary': '#1A1A1A' —— 主要文字颜色,接近纯黑但略带柔和,避免纯黑带来的视觉刺激。
  • 'textSecondary': '#999999' —— 次要文字颜色,中灰色,用于时间、描述、辅助信息等。
  • 'border': '#E0E0E0' —— 边框颜色,浅灰色,用于列表项之间的分隔线。
  • 'unread': '#FA5151' —— 未读徽标颜色,红色。这个鲜艳的红色能够有效吸引用户注意力,提醒有未读消息。
  • 'online': '#07C160' —— 在线状态指示色,与主色相同,表示"可联络"的状态。

5.2 标签页配置

private tabs: Record<string, TabConfig> = {
  'tab0': { label: '聊天', icon: '💬', color: '#07C160' },
  'tab1': { label: '通讯录', icon: '👥', color: '#07C160' },
  'tab2': { label: '发现', icon: '🔍', color: '#07C160' },
  'tab3': { label: '朋友圈', icon: '🌟', color: '#07C160' },
  'tab4': { label: '我的', icon: '👤', color: '#07C160' }
}

在这里插入图片描述

这里使用 Record<string, TabConfig> 类型来存储标签配置,以 'tab0' 到 'tab4' 作为键。每个标签包含标签文字、图标和颜色三个属性。虽然在实际渲染中,底部导航栏通过辅助方法动态获取图标和文字(而非直接读取此配置),但这份配置数据为应用的扩展(如动态配置标签页)预留了空间。

5.3 聊天列表数据

private chatList: ChatItem[] = [
  { id: 'c1', name: '张伟', avatar: '👨', lastMessage: '明天下午开会,记得带上方案文件', time: '14:32', unreadCount: 3, isPinned: true, isMuted: false, type: 'single', online: true },
  { id: 'c2', name: '产品技术群', avatar: '🏢', lastMessage: '李娜: 新版本已经发布到测试环境了', time: '14:15', unreadCount: 12, isPinned: true, isMuted: false, type: 'group', online: false },
  { id: 'c3', name: '王芳', avatar: '👩', lastMessage: '好的,收到啦~', time: '13:50', unreadCount: 0, isPinned: true, isMuted: true, type: 'single', online: true },
  // ... 更多数据
]

聊天列表包含了 26 条模拟数据,涵盖了工作群聊、家人单聊、朋友群聊、服务通知等多种真实场景。每条数据都精心设置了不同的属性组合:有的置顶且未读、有的免打扰、有的在线、有的是群聊有的是单聊。这种丰富的数据组合确保了界面在各种状态下都能得到充分的测试和展示。

值得注意的几个设计细节:

  • 群聊的 lastMessage 前面会加上发送者名称(如"李娜: 新版本…"),这是群聊列表的标准展示方式。
  • 未读消息数从 0 到 20 不等,其中"旅行计划群"有 20 条未读,"设计师交流群"有 15 条未读,这些大数字会触发"99+"的显示逻辑(虽然本例中没有超过 99 的,但代码中已经做了此处理)。
  • 时间格式包括"14:32"(今天)、“昨天”、“周二”、"周一"等多种形态,模拟了真实聊天列表中时间的多样化展示。

5.4 联系人列表数据

private contactList: ContactItem[] = [
  { id: 'p1', name: '阿明', avatar: '🙂', pinyin: 'Aming', phone: '138****1234', region: '北京', tag: '同事', isStarred: true },
  { id: 'p2', name: '阿强', avatar: '😄', pinyin: 'Aqiang', phone: '139****5678', region: '上海', tag: '朋友', isStarred: true },
  // ... 更多数据
]

联系人列表包含 31 条数据,覆盖了从 A 到 Z 的多个字母开头。每条数据都有完整的拼音字段,用于字母分组索引。部分联系人被标记为星标好友(isStarred: true),这些联系人会在通讯录中优先展示。

标签字段涵盖了"同事"、“朋友”、“同学”、“游戏”、"育儿"等多种社交关系类型,反映了现代社交网络中人际关系的多样性。

5.5 其他业务数据

应用还定义了朋友圈动态列表(16 条)、发现页入口列表(8 项)、小程序列表(6 个)、图表数据(7 天)、个人配置、钱包配置、设置项列表(7 项)和收藏列表(5 项)。这些数据共同构成了一个完整的社交应用数据生态。

private chartData: ChartBarItem[] = [
  { day: '周一', value: 45, label: '45条' },
  { day: '周二', value: 62, label: '62条' },
  { day: '周三', value: 38, label: '38条' },
  { day: '周四', value: 78, label: '78条' },
  { day: '周五', value: 95, label: '95条' },
  { day: '周六', value: 52, label: '52条' },
  { day: '周日', value: 34, label: '34条' }
]

图表数据模拟了一周内每天的消息发送量。可以看到周五的消息量最高(95 条),周日最低(34 条),这符合工作日沟通频繁、周末相对清闲的真实使用规律。

private profileConfig: ProfileConfig = {
  name: '我的微信',
  avatar: '🧑‍💼',
  id: 'wxid_david_2024',
  region: '北京·海淀区'
}

private walletConfig: WalletConfig = {
  balance: '¥3,856.00',
  cards: 3,
  points: 12580
}

个人配置和钱包配置分别存储了当前用户的基本信息和钱包数据。钱包余额、卡券数量和积分点数构成了用户在应用内的"数字资产"概览。

六、整体布局架构:build 方法

build() 方法是每个 ArkTS 组件的核心入口,它定义了组件的视觉结构和布局层次。在这个社交聊天应用中,build() 方法构建了一个从顶到底的三段式布局。

build() {
  Stack() {
    Column() {
      // Status bar area
      Column() {
        Text('社交聊天')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.card)
          .margin({ top: 8, bottom: 8 })
      }
      .width('100%')
      .backgroundColor(this.colors.primary)
      .padding({ left: 16, right: 16 })

      // Tab content
      Column() {
        if (this.currentTab === 0) {
          this.chatTabContent()
        } else if (this.currentTab === 1) {
          this.contactsTabContent()
        } else if (this.currentTab === 2) {
          this.discoverTabContent()
        } else if (this.currentTab === 3) {
          this.momentsTabContent()
        } else {
          this.mineTabContent()
        }
      }
      .layoutWeight(1)
      .width('100%')
      .backgroundColor(this.colors.bg)

      // Bottom tab bar
      this.bottomTabBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(this.colors.bg)

    // Modal overlay layer
    this.modalOverlay()
  }
  .width('100%')
  .height('100%')
}

逐行解析:

  • Stack() —— 最外层使用 Stack(堆叠)容器。Stack 的特点是子元素会层叠排列,后添加的子元素覆盖在先添加的子元素之上。这里使用 Stack 是为了让弹窗覆盖层能够浮在主界面之上。
  • Column() —— 主内容区使用 Column(纵向排列)容器,从上到下依次放置状态栏、标签内容区和底部导航栏。
  • Text('社交聊天') —— 顶部状态栏的标题文字。
  • .fontSize(18) —— 标题字号为 18,属于中等偏大的标题字号。
  • .fontWeight(FontWeight.Bold) —— 字重为粗体,增强视觉层级。
  • .fontColor(this.colors.card) —— 文字颜色为白色(this.colors.card 的值为 #FFFFFF),与绿色背景形成高对比度。
  • .margin({ top: 8, bottom: 8 }) —— 上下各留 8vp 的间距,使标题在状态栏中垂直居中。
  • .width('100%') —— 状态栏宽度占满整个屏幕宽度。
  • .backgroundColor(this.colors.primary) —— 背景色为应用主色(绿色),这是社交应用标志性的顶部栏配色。
  • .padding({ left: 16, right: 16 }) —— 左右各留 16vp 的内边距,确保标题文字不紧贴屏幕边缘。
  • if (this.currentTab === 0) —— 条件渲染的核心逻辑。根据 currentTab 的值决定显示哪个标签页的内容。这是 ArkTS 声明式 UI 的典型用法:状态驱动视图,状态变化时界面自动更新。
  • this.chatTabContent() —— 调用 @Builder 方法构建聊天页内容。通过这种方式,每个标签页的内容被封装在独立的方法中,保持代码的清晰和模块化。
  • .layoutWeight(1) —— 标签内容区占据剩余的所有空间。layoutWeight 是 ArkTS 中弹性布局的关键属性,值为 1 表示该元素会尽可能多地占据可用空间。
  • this.bottomTabBar() —— 底部导航栏,同样通过 Builder 方法构建。
  • this.modalOverlay() —— 弹窗覆盖层。由于它被添加在 Stack 的最外层 Column 之后,所以会覆盖在整个主界面上方。

这个布局结构清晰地体现了移动应用的经典三段式布局:顶部栏(Header)、内容区(Content)和底部导航栏(Tab Bar)。内容区通过 layoutWeight(1) 自适应占据中间区域,确保在不同屏幕尺寸下都能正确显示。

七、聊天模块深度解析

聊天模块是整个应用最核心的功能区域,它包含了搜索栏、消息统计图表、置顶聊天区和全部聊天区四个部分。

7.1 聊天页整体结构

@Builder
chatTabContent() {
  Column() {
    // Search bar
    Row() {
      Row() {
        Text('🔍')
          .fontSize(14)
        TextInput({ text: this.searchKeyword, placeholder: '搜索聊天' })
          .fontSize(14)
          .backgroundColor(Color.Transparent)
          .layoutWeight(1)
          .margin({ left: 8 })
          .onChange((value: string) => {
            this.searchKeyword = value
          })
      }
      .width('100%')
      .height(36)
      .borderRadius(8)
      .backgroundColor('#F5F5F5')
      .padding({ left: 12, right: 12 })
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(this.colors.card)

逐行解析:

  • @Builder —— 装饰器,标记此方法为 UI 构建器。Builder 方法可以被 this. 调用,将返回的 UI 结构嵌入到调用位置。它是 ArkTS 实现组件化复用的重要手段。
  • chatTabContent() —— 方法名,表示聊天标签页的内容构建器。
  • 外层 Row() —— 搜索栏的容器行。
  • 内层 Row() —— 搜索框的实际容器,包含搜索图标和输入框。
  • Text('🔍') —— 搜索放大镜图标,使用 Emoji 字符。
  • .fontSize(14) —— 图标字号 14,与输入框文字大小一致。
  • TextInput({ text: this.searchKeyword, placeholder: '搜索聊天' }) —— 文本输入组件。text 参数绑定到状态变量 searchKeyword,实现双向数据绑定;placeholder 是占位提示文字。
  • .backgroundColor(Color.Transparent) —— 输入框背景设为透明,使其与外层容器的背景色融为一体。
  • .layoutWeight(1) —— 输入框占据搜索图标右侧的所有剩余空间。
  • .margin({ left: 8 }) —— 左侧留 8vp 间距,与搜索图标保持距离。
  • .onChange((value: string) => { this.searchKeyword = value }) —— 输入内容变化时的回调函数。每次用户输入或删除字符时,都会更新 searchKeyword 状态变量,触发依赖此变量的 UI 更新。
  • .height(36) —— 搜索框高度 36vp,这是一个舒适的触摸目标高度。
  • .borderRadius(8) —— 圆角半径 8vp,营造柔和的视觉感受。
  • .backgroundColor('#F5F5F5') —— 浅灰色背景,与白色卡片背景形成微妙的层次区分。
  • .alignItems(VerticalAlign.Center) —— 子元素垂直居中对齐。

7.2 聊天列表与分组逻辑

    // Chat list
    Scroll() {
      Column() {
        // Message statistics chart
        this.messageStatsChart()

        // Pinned chats section header
        Row() {
          Text('📌 置顶聊天')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, top: 10, bottom: 6 })

        // Pinned chats
        ForEach(this.chatList.filter((item: ChatItem) => item.isPinned), (item: ChatItem) => {
          this.chatItemRow(item)
        }, (item: ChatItem) => item.id)

        // Divider
        Row()
          .width('100%')
          .height(8)
          .backgroundColor(this.colors.bg)

        // All chats header
        Row() {
          Text('💬 全部聊天')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, top: 10, bottom: 6 })

        // All chats
        ForEach(this.chatList.filter((item: ChatItem) => !item.isPinned), (item: ChatItem) => {
          this.chatItemRow(item)
        }, (item: ChatItem) => item.id)

        // Bottom spacer
        Column().layoutWeight(1).height(20)
      }
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
  }
  .width('100%')
  .height('100%')
}

逐行解析:

  • Scroll() —— 可滚动容器。由于聊天列表可能很长,使用 Scroll 包裹使其可以上下滑动浏览。
  • this.messageStatsChart() —— 在聊天列表顶部嵌入消息统计图表,这是一个创新的设计,让用户在查看聊天列表的同时了解自己的消息使用情况。
  • Text('📌 置顶聊天') —— 置顶聊天的分组标题。使用图钉 Emoji 直观表达"置顶"的含义。
  • .fontColor(this.colors.textSecondary) —— 标题使用次要文字颜色(灰色),不抢眼但起到分组引导作用。
  • ForEach(this.chatList.filter((item: ChatItem) => item.isPinned), ...) —— 使用 filter 方法从聊天列表中筛选出所有置顶的聊天项。ForEach 是 ArkTS 中用于渲染列表的核心组件。
  • ForEach 的三个参数:第一个是数据数组,第二个是项渲染函数(为每个数据项构建 UI),第三个是键值生成函数(用于框架的差异化更新优化)。
  • (item: ChatItem) => item.id —— 键值生成函数,以聊天 ID 作为唯一键。这确保了当列表数据变化时,框架能准确识别哪些项发生了增删改,从而进行最小化的 UI 更新。
  • Row().width('100%').height(8).backgroundColor(this.colors.bg) —— 分组分隔条。一个高度为 8vp 的灰色条带,在置顶聊天和全部聊天之间起到视觉分隔作用。
  • ForEach(this.chatList.filter((item: ChatItem) => !item.isPinned), ...) —— 渲染非置顶的聊天项。通过取反 !item.isPinned 筛选出普通聊天。
  • Column().layoutWeight(1).height(20) —— 底部留白占位符,确保列表最后一项不会被底部导航栏遮挡。
  • .scrollBar(BarState.Off) —— 隐藏滚动条,使界面更加简洁。在移动端,通常通过手势滑动而非滚动条来浏览内容。
  • .edgeEffect(EdgeEffect.Spring) —— 设置滚动边缘效果为弹簧效果。当用户滑动到列表顶部或底部时,会有一个弹性回弹的动画,增强了交互的物理真实感。

7.3 消息统计图表

消息统计图表是聊天模块中一个颇具特色的功能,它在聊天列表的顶部展示了一周消息发送量的柱状图。

@Builder
messageStatsChart() {
  Column() {
    Row() {
      Column() {
        Text(this.statConfig.title)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
          .alignSelf(ItemAlign.Start)
        Row() {
          Column() {
            Text(this.statConfig.totalValue)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.colors.primary)
              .alignSelf(ItemAlign.Start)
            Text(this.statConfig.totalLabel)
              .fontSize(11)
              .fontColor(this.colors.textSecondary)
              .alignSelf(ItemAlign.Start)
          }
          .margin({ right: 24 })

          Column() {
            Text(this.statConfig.avgValue)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3B7CFF')
              .alignSelf(ItemAlign.Start)
            Text(this.statConfig.avgLabel)
              .fontColor(this.colors.textSecondary)
              .alignSelf(ItemAlign.Start)
          }
        }
        .margin({ top: 8 })
        .alignItems(VerticalAlign.Center)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      // Add button
      Column() {
        Text('➕')
          .fontSize(24)
      }
      .width(44)
      .height(44)
      .borderRadius(22)
      .backgroundColor(this.colors.primary)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.showAddModal = true
      })
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)

逐行解析:

  • Text(this.statConfig.title) —— 显示图表标题"本周消息统计"。
  • .alignSelf(ItemAlign.Start) —— 在 Column 中将自身对齐到起始位置(左侧),而非默认的居中。
  • Text(this.statConfig.totalValue) —— 显示总消息数"404"。字号 24,加粗,使用主色(绿色),使关键数据突出醒目。
  • Text(this.statConfig.totalLabel) —— 显示"总消息"标签,字号 11,灰色,作为数据的说明文字。
  • Text(this.statConfig.avgValue) —— 显示日均消息数"57.7"。使用蓝色 #3B7CFF 而非绿色,通过颜色区分两个统计维度。
  • Column() 加号按钮 —— 右上角的圆形按钮,宽高均为 44vp,圆角半径 22vp(正好是宽度的一半,形成正圆)。
  • .backgroundColor(this.colors.primary) —— 按钮背景为绿色,与主色调一致。
  • .onClick(() => { this.showAddModal = true }) —— 点击按钮时设置 showAddModal 为 true,触发新建聊天弹窗的显示。这就是状态驱动弹窗的典型实现方式。
    // Bar chart
    Row() {
      ForEach(this.chartData, (item: ChartBarItem) => {
        Column() {
          // Bar
          Column()
            .width(20)
            .height(item.value * 1.8)
            .borderRadius(4)
            .backgroundColor(item.value > 70 ? this.colors.primary : this.colors.light)

          Text(item.day)
            .fontSize(10)
            .fontColor(this.colors.textSecondary)
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }, (item: ChartBarItem) => item.day)
    }
    .width('100%')
    .margin({ top: 16 })
    .alignItems(VerticalAlign.Bottom)
  }
  .width('100%')
  .padding(16)
  .backgroundColor(this.colors.card)
  .borderRadius(0)
}

逐行解析:

  • Row() —— 柱状图容器,使用横向排列来放置每天的柱子。
  • ForEach(this.chartData, ...) —— 遍历 7 天的图表数据,为每一天生成一个柱子。
  • Column().width(20).height(item.value * 1.8) —— 柱子本身。宽度固定为 20vp,高度为数值乘以 1.8 的缩放系数。例如周五值为 95,高度为 171vp;周日值为 34,高度为 61.2vp。通过乘以系数将数据值映射为视觉高度。
  • .borderRadius(4) —— 柱子顶部圆角,使柱子看起来更加精致。
  • .backgroundColor(item.value > 70 ? this.colors.primary : this.colors.light) —— 条件颜色设置。当消息量超过 70 条时,柱子使用主色(深绿);否则使用浅色(浅绿)。这种条件着色让用户一眼就能识别出哪些天的消息量较高。
  • Text(item.day) —— 柱子下方的星期标签。
  • .layoutWeight(1) —— 每个柱子项平均分配横向空间,确保 7 根柱子均匀分布。
  • .alignItems(VerticalAlign.Bottom) —— 整个图表行底部对齐,使所有柱子都从底部开始向上生长,符合柱状图的标准视觉规范。

7.4 聊天项行构建

聊天项行是聊天列表中每一行聊天会话的视觉呈现,它需要在一个有限的行高内展示头像、名称、最后消息、时间、未读徽标等多种信息。

@Builder
chatItemRow(item: ChatItem) {
  Row() {
    // Avatar with online status
    Stack({ alignContent: Alignment.BottomEnd }) {
      Text(item.avatar)
        .fontSize(32)
        .width(48)
        .height(48)
        .textAlign(TextAlign.Center)
        .borderRadius(8)
        .backgroundColor(item.type === 'group' ? '#E8F5E9' : '#F0F0F0')

      if (item.online) {
        Row()
          .width(12)
          .height(12)
          .borderRadius(6)
          .backgroundColor(this.colors.online)
          .border({ width: 2, color: this.colors.card })
      }
    }
    .width(48)
    .height(48)
    .margin({ right: 12 })

逐行解析:

  • chatItemRow(item: ChatItem) —— Builder 方法接收一个 ChatItem 参数,为每个聊天项构建独立的行 UI。这种参数化的 Builder 是实现列表项复用的标准模式。
  • Stack({ alignContent: Alignment.BottomEnd }) —— 头像容器使用 Stack,设置内容对齐方式为右下角(BottomEnd),这样在线状态指示点会出现在头像的右下角。
  • Text(item.avatar) —— 头像 Emoji 字符。
  • .fontSize(32) —— Emoji 字号 32,在 48x48 的容器中占据较大比例,确保头像清晰可见。
  • .width(48).height(48) —— 头像固定尺寸 48x48vp。
  • .borderRadius(8) —— 圆角矩形头像,半径 8vp。现代社交应用普遍采用圆角矩形而非圆形头像,更加规整。
  • .backgroundColor(item.type === 'group' ? '#E8F5E9' : '#F0F0F0') —— 根据聊天类型设置不同的头像背景色。群聊使用浅绿色背景(#E8F5E9),单聊使用浅灰色背景(#F0F0F0),通过颜色微妙地区分两种聊天类型。
  • if (item.online) —— 条件渲染在线状态点。只有在线的聊天才显示此指示器。
  • Row().width(12).height(12).borderRadius(6) —— 在线状态指示点。宽高 12vp,圆角 6vp(正圆)。使用 Row 组件而非 Circle 是因为 ArkTS 中 Row 可以方便地设置背景色和边框。
  • .backgroundColor(this.colors.online) —— 绿色圆点,表示在线。
  • .border({ width: 2, color: this.colors.card }) —— 白色 2vp 边框。这个白色描边使绿色圆点在任意背景色上都能清晰可见,是一种常见的视觉处理技巧。
    // Chat info
    Column() {
      Row() {
        Text(item.name)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.colors.textPrimary)
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(item.time)
          .fontSize(12)
          .fontColor(this.colors.textSecondary)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

逐行解析:

  • Column() —— 聊天信息容器,纵向排列名称行和消息行。
  • 第一行 Row() —— 包含聊天名称和时间。
  • Text(item.name) —— 聊天名称文字。
  • .fontSize(16).fontWeight(FontWeight.Medium) —— 字号 16,中等粗细。这是列表项主标题的标准字号。
  • .layoutWeight(1) —— 名称占据行内除时间外的所有空间。
  • .maxLines(1) —— 限制最多显示 1 行。当名称过长时不会换行。
  • .textOverflow({ overflow: TextOverflow.Ellipsis }) —— 文本溢出时显示省略号(…)。与 maxLines(1) 配合使用,确保长名称不会破坏布局。
  • Text(item.time) —— 时间文字,字号 12,灰色,紧跟在名称右侧。
      Row() {
        Text(item.lastMessage)
          .fontSize(13)
          .fontColor(this.colors.textSecondary)
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        if (item.isMuted) {
          Text('🔕')
            .fontSize(12)
            .margin({ right: 6 })
        }

        if (item.unreadCount > 0) {
          Text(item.unreadCount > 99 ? '99+' : item.unreadCount.toString())
            .fontSize(11)
            .fontColor(Color.White)
            .backgroundColor(item.isMuted ? this.colors.textSecondary : this.colors.unread)
            .borderRadius(10)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .textAlign(TextAlign.Center)
        }
      }
      .width('100%')
      .margin({ top: 4 })
      .alignItems(VerticalAlign.Center)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 10, bottom: 10 })
  .backgroundColor(item.isPinned ? '#F5FFF7' : this.colors.card)
  .onClick(() => {
    this.selectedChatId = item.id
    this.editChatName = item.name
    this.showDetailModal = true
    this.detailMuted = item.isMuted
    this.detailPinned = item.isPinned
  })
}

逐行解析:

  • 第二行 Row() —— 包含最后消息、免打扰图标和未读徽标。
  • Text(item.lastMessage) —— 最后消息预览。字号 13,灰色,单行显示并溢出省略。
  • if (item.isMuted) —— 如果开启了消息免打扰,显示静音图标。
  • if (item.unreadCount > 0) —— 只有存在未读消息时才显示未读徽标。
  • Text(item.unreadCount > 99 ? '99+' : item.unreadCount.toString()) —— 未读数量文本。当超过 99 时显示"99+",否则显示实际数字。这是社交应用的通用约定,防止超大数字撑破徽标的圆形形状。
  • .fontColor(Color.White) —— 徽标文字为白色。
  • .backgroundColor(item.isMuted ? this.colors.textSecondary : this.colors.unread) —— 徽标背景色根据免打扰状态变化。免打扰的聊天使用灰色徽标,普通聊天使用红色徽标。这种颜色差异让用户一眼就能区分哪些未读消息会触发提醒。
  • .borderRadius(10) —— 圆角半径 10vp,使徽标呈现药丸形状。
  • .padding({ left: 6, right: 6, top: 2, bottom: 2 }) —— 内边距设置,确保数字在徽标中有适当的留白。对于个位数和两位数,徽标会自动调整宽度;对于"99+",左右各 6vp 的内边距保证了良好的视觉效果。
  • .backgroundColor(item.isPinned ? '#F5FFF7' : this.colors.card) —— 整行的背景色。置顶聊天使用极浅的绿色背景(#F5FFF7),普通聊天使用白色背景。这种微妙的色调差异让用户能够快速识别置顶项。
  • .onClick(...) —— 点击聊天行时的处理逻辑。将当前聊天的 ID、名称、免打扰状态和置顶状态分别赋值给对应的状态变量,然后设置 showDetailModal 为 true,打开聊天详情弹窗。这种模式体现了状态驱动的设计思路:用户交互修改状态,状态变化驱动弹窗显示。

八、通讯录模块深度解析

通讯录模块负责管理和展示所有联系人,提供了搜索、快捷入口、星标好友和按字母分组浏览等功能。

8.1 通讯录整体结构

@Builder
contactsTabContent() {
  Column() {
    // Search bar
    Row() {
      Row() {
        Text('🔍')
          .fontSize(14)
        TextInput({ text: this.contactSearchKeyword, placeholder: '搜索联系人' })
          .fontSize(14)
          .backgroundColor(Color.Transparent)
          .layoutWeight(1)
          .margin({ left: 8 })
          .onChange((value: string) => {
            this.contactSearchKeyword = value
          })
      }
      .width('100%')
      .height(36)
      .borderRadius(8)
      .backgroundColor('#F5F5F5')
      .padding({ left: 12, right: 12 })
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(this.colors.card)

通讯录的搜索栏与聊天页的搜索栏结构完全一致,但绑定的是独立的状态变量 contactSearchKeyword。这种设计确保了不同页面的搜索状态互不干扰,用户在聊天页输入的搜索词不会影响通讯录页。

    Scroll() {
      Column() {
        // Quick entries
        Row() {
          Column() {
            Text('👥')
              .fontSize(28)
            Text('新的朋友')
              .fontSize(12)
              .fontColor(this.colors.textPrimary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('📈')
              .fontSize(28)
            Text('群聊')
              .fontSize(12)
              .fontColor(this.colors.textPrimary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('🏷️')
              .fontSize(28)
            Text('标签')
              .fontSize(12)
              .fontColor(this.colors.textPrimary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('🏢')
              .fontSize(28)
            Text('公众号')
              .fontSize(12)
              .fontColor(this.colors.textPrimary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 16, bottom: 16 })
        .backgroundColor(this.colors.card)

逐行解析:

  • 快捷入口区域使用 Row 容器横向排列四个功能入口:“新的朋友”、“群聊”、“标签"和"公众号”。
  • 每个入口是一个 Column,包含一个大图标(字号 28)和一个小标签(字号 12)。
  • .layoutWeight(1) —— 四个入口平均分配横向空间,等宽排列。
  • .alignItems(HorizontalAlign.Center) —— 图标和文字水平居中对齐。
  • .margin({ top: 4 }) —— 文字与图标之间留 4vp 间距。

8.2 星标好友与字母分组

        // Starred contacts header
        Row() {
          Text('⭐ 星标好友')
            .fontSize(13)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, top: 10, bottom: 6 })

        // Starred contacts
        ForEach(this.contactList.filter((item: ContactItem) => item.isStarred), (item: ContactItem) => {
          this.contactRow(item)
        }, (item: ContactItem) => item.id)

逐行解析:

  • 星标好友区域使用与置顶聊天相同的分组模式:先显示分组标题,再用 ForEach 渲染筛选后的联系人列表。
  • filter((item: ContactItem) => item.isStarred) —— 筛选所有星标好友。这种基于属性过滤的分组方式简洁高效,无需维护额外的分组数据结构。
        // Grouped contacts
        ForEach(this.getGroupedContactLetters(), (letter: string) => {
          Column() {
            Text(letter)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.colors.textSecondary)
              .alignSelf(ItemAlign.Start)
              .padding({ left: 16, top: 8, bottom: 4 })

            ForEach(this.getContactsByLetter(letter), (item: ContactItem) => {
              this.contactRow(item)
            }, (item: ContactItem) => item.id)
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
        }, (letter: string) => letter)

逐行解析:

  • 这是通讯录中最复杂的部分——按字母分组渲染联系人。
  • this.getGroupedContactLetters() —— 调用辅助方法获取所有需要显示的字母列表,如 ['A', 'C', 'D', 'F', ...]。
  • 外层 ForEach 遍历字母列表,为每个字母创建一个分组。
  • Text(letter) —— 显示字母索引标题,加粗显示。
  • 内层 ForEach(this.getContactsByLetter(letter), ...) —— 调用辅助方法获取该字母下的所有联系人,并为每个联系人渲染一行。
  • 这种嵌套 ForEach 的模式是处理分组列表的标准做法:外层遍历分组,内层遍历组内项。

8.3 联系人行构建

@Builder
contactRow(item: ContactItem) {
  Row() {
    Text(item.avatar)
      .fontSize(32)
      .width(44)
      .height(44)
      .textAlign(TextAlign.Center)
      .borderRadius(8)
      .backgroundColor('#F5F5F5')
      .margin({ right: 12 })

    Column() {
      Row() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.colors.textPrimary)
          .layoutWeight(1)

        if (item.isStarred) {
          Text('⭐')
            .fontSize(12)
        }
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Text(item.tag)
          .fontSize(11)
          .fontColor(this.colors.primary)
          .backgroundColor('#E8F5E9')
          .borderRadius(4)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })

        Text('· ' + item.region + ' · ' + item.phone)
          .fontSize(11)
          .fontColor(this.colors.textSecondary)
          .margin({ left: 6 })
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%')
      .margin({ top: 4 })
      .alignItems(VerticalAlign.Center)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 8, bottom: 8 })
  .backgroundColor(this.colors.card)
  .onClick(() => {
    this.selectedContactId = item.id
  })
}

逐行解析:

  • 联系人行的结构与聊天行类似,但信息侧重点不同。
  • Text(item.avatar) —— 联系人头像,尺寸 44x44vp,略小于聊天头像(48x48),因为联系人行的信息密度更高。
  • 第一行 Row() —— 包含联系人名称和星标图标。
  • if (item.isStarred) { Text('⭐') } —— 星标好友在名称右侧显示星形图标,这是视觉上的优先级标识。
  • 第二行 Row() —— 包含标签徽章和辅助信息。
  • Text(item.tag) —— 标签徽章(如"同事"、“朋友”)。使用绿色文字配浅绿色背景,形成药丸形状的小标签。
  • Text('· ' + item.region + ' · ' + item.phone) —— 地区和电话信息,用中圆点分隔,字号 11,灰色。这些信息单行显示并溢出省略。
  • .onClick(() => { this.selectedContactId = item.id }) —— 点击联系人时记录其 ID,可用于后续的联系人详情展示。

九、发现模块深度解析

发现模块是社交应用中聚合各类服务入口和功能扩展的区域。

9.1 发现页整体结构

@Builder
discoverTabContent() {
  Scroll() {
    Column() {
      // Discover items
      ForEach(this.discoverList, (item: DiscoverItem) => {
        Row() {
          Row() {
            Text(item.icon)
              .fontSize(22)
              .width(36)
              .height(36)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor(item.color + '20')
          }
          .margin({ right: 12 })

          Column() {
            Text(item.title)
              .fontSize(15)
              .fontWeight(FontWeight.Medium)
              .fontColor(this.colors.textPrimary)
              .alignSelf(ItemAlign.Start)
            Text(item.desc)
              .fontSize(12)
              .fontColor(this.colors.textSecondary)
              .margin({ top: 2 })
              .alignSelf(ItemAlign.Start)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Text('›')
            .fontSize(20)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(this.colors.card)
        .border({ width: 0.5, color: this.colors.border })
      }, (item: DiscoverItem) => item.id)

逐行解析:

  • ForEach(this.discoverList, ...) —— 遍历发现页的 8 个入口项(朋友圈、扫一扫、摇一摇、看一看、搜一搜、附近、购物、游戏)。
  • Text(item.icon) —— 入口图标,尺寸 36x36vp,字号 22。
  • .backgroundColor(item.color + '20') —— 图标背景色使用入口项的主题色加上 '20' 后缀。'20' 是十六进制的透明度值(约 12.5%),这样每个入口的图标背景都是其主题色的极浅版本,既保持了色彩识别度又不会过于刺眼。
  • Column() 包含标题和描述 —— 标题字号 15,中等粗细;描述字号 12,灰色。两者左对齐排列。
  • Text('›') —— 右侧的箭头符号,表示该项可以点击进入。这是 iOS 风格列表项的经典设计语言。
  • .border({ width: 0.5, color: this.colors.border }) —— 每个入口项之间用 0.5vp 的细线分隔,形成连续的列表视觉效果。

9.2 小程序卡片网格

        // Mini programs header
        Row() {
          Text('🧩 小程序')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ left: 16, top: 20, bottom: 8 })

        // Mini programs grid
        Row() {
          ForEach(this.miniProgramList.slice(0, 3), (item: MiniProgramItem) => {
            this.miniProgramCard(item)
          }, (item: MiniProgramItem) => item.id)
        }
        .width('100%')
        .padding({ left: 8, right: 8 })
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          ForEach(this.miniProgramList.slice(3, 6), (item: MiniProgramItem) => {
            this.miniProgramCard(item)
          }, (item: MiniProgramItem) => item.id)
        }
        .width('100%')
        .padding({ left: 8, right: 8, top: 8 })
        .justifyContent(FlexAlign.SpaceBetween)

逐行解析:

  • 小程序区域使用两行三列的网格布局。
  • this.miniProgramList.slice(0, 3) —— 取前 3 个小程序作为第一行。
  • this.miniProgramList.slice(3, 6) —— 取后 3 个小程序作为第二行。
  • .justifyContent(FlexAlign.SpaceBetween) —— 三个卡片在行内均匀分布,两端贴边,中间等距。这是网格布局中最常用的对齐方式。
  • 使用 slice 分割数组而非嵌套循环的方式来实现网格,代码简洁直观。
@Builder
miniProgramCard(item: MiniProgramItem) {
  Column() {
    Text(item.icon)
      .fontSize(32)
      .width(48)
      .height(48)
      .textAlign(TextAlign.Center)
      .borderRadius(12)
      .backgroundColor(item.color + '20')

    Text(item.name)
      .fontSize(12)
      .fontColor(this.colors.textPrimary)
      .margin({ top: 6 })
      .maxLines(1)

    Text(item.category)
      .fontSize(10)
      .fontColor(this.colors.textSecondary)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .backgroundColor(this.colors.card)
  .borderRadius(12)
  .margin({ left: 4, right: 4 })
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • 小程序卡片是一个 Column,从上到下依次放置图标、名称和分类。
  • Text(item.icon) —— 图标尺寸 48x48vp,圆角 12vp,背景色为主题色加 12.5% 透明度。
  • Text(item.name) —— 小程序名称,字号 12,单行显示。
  • Text(item.category) —— 分类标签,字号 10,灰色。
  • .layoutWeight(1) —— 卡片在行内平均分配宽度。
  • .borderRadius(12) —— 卡片圆角 12vp,比图标圆角更大,形成层次感。
  • .margin({ left: 4, right: 4 }) —— 卡片左右各留 4vp 间距,避免卡片紧贴在一起。

十、朋友圈模块深度解析

朋友圈模块是社交应用中内容消费的核心区域,它展示了一个类似时间线的动态信息流,让用户可以浏览好友分享的生活点滴。

10.1 朋友圈头部横幅

@Builder
momentsTabContent() {
  Scroll() {
    Column() {
      // Header banner
      Column() {
        Row() {
          Text('🌅')
            .fontSize(40)
            .width('100%')
            .height(120)
            .textAlign(TextAlign.Center)
            .backgroundColor('#B3E5FC')
        }
        .width('100%')

        Row() {
          Column() {
            Text(this.profileConfig.name)
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.colors.textPrimary)
              .alignSelf(ItemAlign.End)
          }
          .margin({ right: 12, bottom: 8 })

          Text(this.profileConfig.avatar)
            .fontSize(36)
            .width(60)
            .height(60)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .border({ width: 3, color: this.colors.card })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .padding({ right: 16 })
        .margin({ top: -30 })
      }
      .width('100%')
      .backgroundColor(this.colors.card)

逐行解析:

  • Text('🌅') —— 朋友圈顶部的横幅背景,使用日出 Emoji 模拟一张封面图。字号 40,高度 120vp,浅蓝色背景(#B3E5FC)模拟天空效果。
  • 第二个 Row() —— 用户信息行,包含用户名和头像。
  • .justifyContent(FlexAlign.End) —— 行内内容右对齐。这是朋友圈封面的经典布局:用户名在头像左侧,整体靠右排列。
  • .margin({ top: -30 }) —— 负的顶部边距,让头像部分向上"骑"在横幅图片上,形成重叠的视觉效果。这种设计在社交应用的朋友圈封面中非常常见,营造出空间层次感。
  • Text(this.profileConfig.avatar) —— 用户头像,尺寸 60x60vp,圆角 8vp。
  • .border({ width: 3, color: this.colors.card }) —— 3vp 白色边框,使头像在横幅背景上更加突出。
  • .alignSelf(ItemAlign.End) —— 用户名在 Column 中底部对齐,与头像底部齐平。

10.2 朋友圈动态卡片

      // Moments list
      ForEach(this.momentList, (item: MomentItem) => {
        this.momentCard(item)
      }, (item: MomentItem) => item.id)

      Column().layoutWeight(1).height(20)
    }
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.Spring)
  .backgroundColor(this.colors.bg)
}

朋友圈列表使用 ForEach 遍历 16 条动态数据,每条动态通过 momentCard Builder 方法构建卡片 UI。列表整体包裹在 Scroll 中,支持上下滑动浏览。

10.3 动态卡片详细构建

@Builder
momentCard(item: MomentItem) {
  Column() {
    Row() {
      Text(item.author.charAt(0) + item.author.charAt(1))
        .fontSize(20)
        .width(44)
        .height(44)
        .textAlign(TextAlign.Center)
        .borderRadius(8)
        .backgroundColor(this.colors.light)
        .margin({ right: 12 })
      Column() {
        Text(item.author)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#576B95')
          .alignSelf(ItemAlign.Start)
        Row() {
          Text(item.location)
            .fontSize(11)
            .fontColor(this.colors.textSecondary)
          Text(' · ' + item.time)
            .fontSize(11)
            .fontColor(this.colors.textSecondary)
        }
        .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)

逐行解析:

  • Text(item.author.charAt(0) + item.author.charAt(1)) —— 取作者姓名的前两个字符作为头像文字。这是一种文字头像的方案,当没有真实头像图片时,用姓名缩写代替,简洁而有效。
  • .backgroundColor(this.colors.light) —— 文字头像使用浅绿色背景,与主色调呼应。
  • Text(item.author) —— 作者姓名,使用特殊颜色 #576B95(一种蓝灰色)。这是社交应用中常见的"链接蓝"配色,暗示点击作者名可以跳转到对方的主页。
  • Row() 包含位置和时间 —— 位置和时间用中圆点连接,字号 11,灰色,排列在作者名下方。
    // Content
    Text(item.content)
      .fontSize(14)
      .fontColor(this.colors.textPrimary)
      .width('100%')
      .margin({ top: 8 })
      .lineHeight(22)

    // Images
    if (item.images > 0) {
      this.momentImages(item.images)
    }

逐行解析:

  • Text(item.content) —— 动态正文内容。字号 14,主要文字色,宽度占满。
  • .lineHeight(22) —— 行高 22vp,比默认行高稍大,提升多行文本的阅读舒适度。
  • if (item.images > 0) —— 条件渲染图片区域。只有动态包含图片时才显示。
  • this.momentImages(item.images) —— 调用图片网格 Builder,根据图片数量展示不同布局的图片网格。
    // Actions
    Row() {
      Text('📍 ' + item.location)
        .fontSize(11)
        .fontColor(this.colors.textSecondary)
        .layoutWeight(1)

      Row() {
        Text(this.momentLikedId === item.id ? '❤️' : '🤍')
          .fontSize(16)
        Text(' ' + (this.momentLikedId === item.id ? item.likes + 1 : item.likes).toString())
          .fontSize(13)
          .fontColor(this.colors.textSecondary)
      }
      .padding({ left: 12, right: 12, top: 4, bottom: 4 })
      .borderRadius(4)
      .backgroundColor('#F7F7F7')
      .onClick(() => {
        this.momentLikedId = item.id
      })

      Row() {
        Text('💬')
          .fontSize(16)
        Text(' ' + item.comments.toString())
          .fontSize(13)
          .fontColor(this.colors.textSecondary)
      }
      .padding({ left: 12, right: 12, top: 4, bottom: 4 })
      .borderRadius(4)
      .backgroundColor('#F7F7F7')
      .margin({ left: 8 })
    }
    .width('100%')
    .margin({ top: 12 })
    .alignItems(VerticalAlign.Center)
  }
  .width('100%')
  .padding(16)
  .backgroundColor(this.colors.card)
  .border({ width: 0.5, color: this.colors.border })
}

逐行解析:

  • Text('📍 ' + item.location) —— 左侧显示位置信息,带定位图标。
  • .layoutWeight(1) —— 位置占据左侧所有空间,将点赞和评论按钮推到右侧。
  • 点赞按钮 Row():
    • Text(this.momentLikedId === item.id ? '❤️' : '🤍') —— 三元表达式控制图标显示。如果当前动态已被点赞(momentLikedId 等于当前项 ID),显示实心红心;否则显示空心白心。
    • Text(' ' + (this.momentLikedId === item.id ? item.likes + 1 : item.likes).toString()) —— 点赞数显示。已点赞时在原基数上加 1,未点赞时显示原始数字。这种即时计算保证了点赞数与视觉状态的同步。
    • .backgroundColor('#F7F7F7') —— 按钮浅灰色背景,与卡片白色背景形成微妙的区分。
    • .onClick(() => { this.momentLikedId = item.id }) —— 点击点赞按钮时,将当前动态 ID 赋值给 momentLikedId。由于这是一个 @State 变量,赋值后会自动触发界面重新渲染,心形图标和点赞数都会即时更新。
  • 评论按钮 Row() —— 结构与点赞按钮类似,显示评论图标和评论数,但不可交互(仅展示)。
  • .margin({ left: 8 }) —— 评论按钮在点赞按钮右侧,间隔 8vp。

10.4 图片网格自适应布局

朋友圈的图片展示是一个经典的布局难题:不同数量的图片需要不同的网格排列方式。本应用通过条件分支实现了 1 到 4+ 张图片的自适应布局。

@Builder
momentImages(count: number) {
  Column() {
    if (count === 1) {
      Row() {
        Text('🖼️')
          .fontSize(40)
          .width(120)
          .height(120)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
      }
      .margin({ top: 8 })

逐行解析(单张图片):

  • 当图片数量为 1 时,显示一个大尺寸的图片占位符。
  • 尺寸 120x120vp,字号 40,是一个中等大小的正方形图片。
  • 单张图片使用较大尺寸,避免被缩得太小而看不清细节。
    } else if (count === 2) {
      Row() {
        Text('🖼️')
          .fontSize(24)
          .layoutWeight(1)
          .height(100)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
          .margin({ right: 4 })
        Text('🖼️')
          .fontSize(24)
          .layoutWeight(1)
          .height(100)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
          .margin({ left: 4 })
      }
      .margin({ top: 8 })

逐行解析(两张图片):

  • 两张图片横向并排排列,各占一半宽度(layoutWeight(1))。
  • 高度 100vp,比单张图片矮一些,因为两张并排时总宽度更大。
  • .margin({ right: 4 }) 和 .margin({ left: 4 }) —— 两张图片之间留 8vp 间距(4+4)。
    } else if (count === 3) {
      Row() {
        Text('🖼️')
          .fontSize(20)
          .layoutWeight(1)
          .height(90)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
          .margin({ right: 4 })
        Text('🖼️')
          .fontSize(20)
          .layoutWeight(1)
          .height(90)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
          .margin({ left: 4, right: 4 })
        Text('🖼️')
          .fontSize(20)
          .layoutWeight(1)
          .height(90)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor('#F0F0F0')
          .margin({ left: 4 })
      }
      .margin({ top: 8 })

逐行解析(三张图片):

  • 三张图片横向并排,各占三分之一宽度。
  • 高度进一步缩小到 90vp,字号 20。
  • 中间图片的 margin 为 { left: 4, right: 4 },与左右两张各保持 4vp 间距。
    } else {
      Column() {
        Row() {
          Text('🖼️')
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ right: 2 })
          Text('🖼️')
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 2, right: 2 })
          Text('🖼️')
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 2 })
        }
        .width('100%')
        Row() {
          Text('🖼️')
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ right: 2, top: 4 })
          Text('🖼️')
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 2, right: 2, top: 4 })
          Text('+' + (count - 5).toString())
            .fontSize(18)
            .layoutWeight(1)
            .height(80)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#E0E0E0')
            .fontColor(this.colors.textSecondary)
            .margin({ left: 2, top: 4 })
        }
        .width('100%')
      }
      .margin({ top: 8 })
    }
  }
  .width('100%')
}

逐行解析(四张及以上图片):

  • 当图片数量达到 4 张或更多时,使用 3x2 的网格布局(类似九宫格)。
  • 第一行显示前 3 张图片,第二行显示第 4、5 张以及一个"+N"的计数占位符。
  • 每张图片高度 80vp,字号 18,比前三张布局更小,以适应更多的图片。
  • Text('+' + (count - 5).toString()) —— 第六个位置显示剩余图片数量。例如如果有 7 张图片,则显示"+2",表示还有 2 张未展示。这是社交应用中处理多图的标准做法,用九宫格展示前几张,剩余的用计数提示。
  • .backgroundColor('#E0E0E0') —— 计数占位符使用灰色背景,与图片占位符的浅灰色(#F0F0F0)略有区别,暗示这里不是图片而是"更多"入口。
  • .margin({ top: 4 }) —— 第二行图片与第一行之间留 4vp 间距。

这种根据图片数量动态切换布局的设计,是社交应用朋友圈中最经典也是最复杂的 UI 逻辑之一。通过条件分支而非循环来处理,虽然代码量较大,但每种布局的细节控制非常精确。

十一、个人中心模块深度解析

个人中心模块是用户管理个人信息和访问设置功能的入口,包含了个人资料卡、钱包信息、设置列表和收藏列表四个部分。

11.1 个人资料卡

@Builder
mineTabContent() {
  Scroll() {
    Column() {
      // Profile card
      Column() {
        Row() {
          Column() {
            Text(this.profileConfig.name)
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(Color.White)
              .alignSelf(ItemAlign.Start)

            Row() {
              Text('ID: ' + this.profileConfig.id)
                .fontSize(12)
                .fontColor('#E0FFE0')
            }
            .margin({ top: 4 })

            Row() {
              Text('📍 ' + this.profileConfig.region)
                .fontSize(12)
                .fontColor('#E0FFE0')
            }
            .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 20 })

          Text(this.profileConfig.avatar)
            .fontSize(40)
            .width(64)
            .height(64)
            .textAlign(TextAlign.Center)
            .borderRadius(12)
            .backgroundColor('#FFFFFF40')
            .margin({ right: 20 })
        }
        .width('100%')
        .padding({ top: 40, bottom: 20 })
        .backgroundColor(this.colors.primary)
        .alignItems(VerticalAlign.Center)

逐行解析:

  • Text(this.profileConfig.name) —— 用户名,字号 20,粗体,白色文字。
  • Text('ID: ' + this.profileConfig.id) —— 用户 ID,字号 12,颜色为 #E0FFE0(极浅绿色)。在绿色背景上使用浅绿色文字,既保持了可读性又营造出层次感。
  • Text('📍 ' + this.profileConfig.region) —— 用户所在地区,带定位图标,同样使用浅绿色。
  • .padding({ top: 40, bottom: 20 }) —— 顶部内边距 40vp(为状态栏留出空间),底部 20vp。
  • .backgroundColor(this.colors.primary) —— 整个资料卡头部使用主色(绿色)背景,是个人中心的标志性视觉元素。
  • Text(this.profileConfig.avatar) —— 用户头像,尺寸 64x64vp,字号 40。
  • .backgroundColor('#FFFFFF40') —— 头像背景使用半透明白色(40 表示约 25% 不透明度),在绿色背景上呈现出一种磨砂玻璃的质感。
  • .borderRadius(12) —— 头像圆角 12vp,比其他模块的头像圆角略大,因为这里的头像尺寸更大。

11.2 钱包信息卡

        // Wallet card
        Row() {
          Column() {
            Text('💰 钱包余额')
              .fontSize(12)
              .fontColor(this.colors.textSecondary)
              .alignSelf(ItemAlign.Start)
            Text(this.walletConfig.balance)
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.colors.textPrimary)
              .margin({ top: 4 })
              .alignSelf(ItemAlign.Start)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Row() {
            Column() {
              Text(this.walletConfig.cards.toString())
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.textPrimary)
              Text('卡券')
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .margin({ right: 20 })

            Column() {
              Text(this.walletConfig.points.toString())
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.textPrimary)
              Text('积分')
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
          }
        }
        .width('100%')
        .padding(16)
        .backgroundColor(this.colors.card)
      }
      .width('100%')

逐行解析:

  • 钱包卡紧接在绿色资料卡下方,使用白色背景,形成鲜明的色彩对比。
  • 左侧显示钱包余额:标签"💰 钱包余额"(字号 12,灰色)和余额数值"¥3,856.00"(字号 22,粗体,黑色)。
  • 右侧显示卡券和积分两个统计项,每个都是一个小 Column,上面是数字(字号 18,粗体),下面是标签(字号 11,灰色)。
  • .margin({ right: 20 }) —— 卡券和积分之间留 20vp 间距。
  • 这种"余额 + 卡券 + 积分"的三栏布局是移动支付应用的经典设计,让用户一眼就能了解自己的数字资产全貌。

11.3 设置列表

        // Settings list
        ForEach(this.settingsList, (item: SettingsItem) => {
          Row() {
            Row() {
              Text(item.icon)
                .fontSize(22)
                .width(36)
                .height(36)
                .textAlign(TextAlign.Center)
                .borderRadius(8)
                .backgroundColor(item.color + '20')
            }
            .margin({ right: 12 })

            Text(item.title)
              .fontSize(15)
              .fontColor(this.colors.textPrimary)
              .layoutWeight(1)

            Text(item.desc)
              .fontSize(12)
              .fontColor(this.colors.textSecondary)

            Text(' ›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
              .margin({ left: 8 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14, bottom: 14 })
          .backgroundColor(this.colors.card)
          .border({ width: 0.5, color: this.colors.border })
        }, (item: SettingsItem) => item.id)

逐行解析:

  • ForEach(this.settingsList, ...) —— 遍历 7 个设置项(服务、收藏、朋友圈、视频号、卡包、表情、设置)。
  • 每个设置项是一个 Row,从左到右依次是:图标、标题、描述、箭头。
  • Text(item.icon) —— 设置项图标,36x36vp,背景色为主题色加 12.5% 透明度。
  • Text(item.title) —— 设置项标题,字号 15,占据中间空间。
  • Text(item.desc) —— 设置项描述,字号 12,灰色。例如"钱包、支付"、"3张卡券"等。
  • Text(' ›') —— 右侧箭头,与发现页入口项相同的设计语言。
  • .border({ width: 0.5, color: this.colors.border }) —— 列表项之间的细分隔线。

11.4 收藏列表

        // Favorites section
        Row() {
          Text('⭐ 我的收藏')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ left: 16, top: 20, bottom: 8 })

        ForEach(this.favoriteList, (item: FavoriteItem) => {
          Row() {
            Text(item.icon)
              .fontSize(22)
              .width(36)
              .height(36)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .margin({ right: 12 })

            Column() {
              Text(item.title)
                .fontSize(14)
                .fontColor(this.colors.textPrimary)
                .alignSelf(ItemAlign.Start)
              Text(item.category)
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .margin({ top: 2 })
                .alignSelf(ItemAlign.Start)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text('›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 10 })
          .backgroundColor(this.colors.card)
          .border({ width: 0.5, color: this.colors.border })
        }, (item: FavoriteItem) => item.id)

逐行解析:

  • 收藏列表的结构与设置列表类似,但每项包含标题和分类两个文本(而非标题和描述)。
  • 收藏项包括文件、图片、视频、语音和笔记五种类型,每种类型有不同的图标。
  • 图标背景统一使用浅灰色(#F5F5F5),而非设置项的主题色透明度背景,视觉上更加简洁。
  • 分类标签(如"文件"、“图片”)字号 11,灰色,排列在标题下方。

十二、底部导航栏

底部导航栏是应用的核心导航组件,它让用户可以在五个功能模块之间快速切换。

@Builder
bottomTabBar() {
  Row() {
    ForEach([0, 1, 2, 3, 4], (idx: number) => {
      Column() {
        Text(this.getTabIcon(idx))
          .fontSize(24)
        Text(this.getTabLabel(idx))
          .fontSize(11)
          .fontColor(this.currentTab === idx ? this.colors.primary : this.colors.textSecondary)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 6, bottom: 6 })
      .onClick(() => {
        this.currentTab = idx
      })
    }, (idx: number) => idx.toString())
  }
  .width('100%')
  .backgroundColor(this.colors.card)
  .border({ width: { top: 0.5 }, color: this.colors.border })
}

逐行解析:

  • ForEach([0, 1, 2, 3, 4], ...) —— 遍历 0 到 4 的索引数组,为每个标签生成一个按钮。
  • Text(this.getTabIcon(idx)) —— 通过辅助方法获取当前索引对应的图标 Emoji,字号 24。
  • Text(this.getTabLabel(idx)) —— 通过辅助方法获取当前索引对应的标签文字,字号 11。
  • .fontColor(this.currentTab === idx ? this.colors.primary : this.colors.textSecondary) —— 关键的颜色切换逻辑。当前选中的标签文字显示为绿色(主色),未选中的显示为灰色。这种视觉反馈让用户清楚知道自己当前在哪个页面。
  • .layoutWeight(1) —— 五个标签按钮平均分配底部栏的宽度。
  • .padding({ top: 6, bottom: 6 }) —— 每个按钮上下各留 6vp 内边距,确保足够的触摸区域。
  • .onClick(() => { this.currentTab = idx }) —— 点击标签时更新 currentTab 状态变量。由于 currentTab 是 @State 变量,赋值后会自动触发整个应用界面的重新渲染:底部栏的高亮标签会切换,同时内容区会根据新的 currentTab 值显示对应的标签页内容。
  • .border({ width: { top: 0.5 }, color: this.colors.border }) —— 只在顶部设置 0.5vp 的边框线,将导航栏与内容区分隔开。width: { top: 0.5 } 是 ArkTS 中设置单边边框的语法。

十三、弹窗系统深度解析

弹窗系统是社交应用中处理用户交互确认和信息展示的重要机制。本应用实现了一套基于状态驱动的弹窗管理系统,包含五种不同类型的弹窗。

13.1 弹窗调度器

@Builder
modalOverlay() {
  if (this.showAddModal) {
    this.addChatModal()
  } else if (this.showEditModal) {
    this.editChatModal()
  } else if (this.showDeleteModal) {
    this.deleteConfirmModal()
  } else if (this.showDetailModal) {
    this.chatDetailModal()
  } else if (this.showNewChatModal) {
    this.newChatModal()
  }
}

逐行解析:

  • modalOverlay() 是弹窗系统的统一入口,被放置在 build() 方法的 Stack 最外层,因此能够覆盖在所有主界面内容之上。
  • 通过一系列 if-else if 条件判断,根据各弹窗状态变量的值决定显示哪个弹窗。
  • 这种设计确保了同一时间只有一个弹窗处于显示状态,避免了多个弹窗层叠导致的视觉混乱。
  • 当所有弹窗状态变量都为 false 时,不渲染任何弹窗,主界面正常显示。

13.2 新建聊天弹窗

@Builder
addChatModal() {
  Column() {
    // Overlay background
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#80000000')
      .onClick(() => {
        this.showAddModal = false
      })

    // Modal content
    Column() {
      Row() {
        Text('新建聊天')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
      }
      .width('100%')
      .padding({ top: 16, bottom: 12 })
      .justifyContent(FlexAlign.Center)

      Column() {
        this.newChatOption('👤', '发起单聊', '与好友一对一聊天')
        this.newChatOption('👥', '发起群聊', '创建群组,邀请好友加入')
        this.newChatOption('📞', '语音通话', '发起语音/视频通话')
        this.newChatOption('📄', '添加联系人', '搜索或扫码添加好友')
      }
      .width('100%')
      .backgroundColor('#F5F5F5')
      .borderRadius(8)

      Row() {
        Text('取消')
          .fontSize(16)
          .fontColor(this.colors.textPrimary)
          .padding({ top: 12, bottom: 12 })
      }
      .width('100%')
      .backgroundColor(this.colors.card)
      .borderRadius(8)
      .margin({ top: 8 })
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.showAddModal = false
      })
    }
    .width('80%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(this.colors.card)
    .borderRadius(12)
    .padding(12)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • 弹窗的整体结构是一个 Column,包含两个部分:半透明遮罩层和弹窗内容。
  • Column().width('100%').height('100%').backgroundColor('#80000000') —— 全屏半透明遮罩层。#80000000 中的 80 是十六进制的透明度值(约 50% 不透明度),黑色半透明背景使背后的主界面变暗,将用户注意力集中到弹窗上。
  • .onClick(() => { this.showAddModal = false }) —— 点击遮罩层关闭弹窗。这是移动端弹窗的标准交互模式:点击弹窗外部区域即可关闭。
  • 弹窗内容 Column():
    • .width('80%') —— 弹窗宽度为屏幕的 80%,居中显示。
    • .constraintSize({ maxHeight: '80%' }) —— 最大高度不超过屏幕的 80%,防止内容过多时溢出屏幕。
    • .borderRadius(12) —— 圆角 12vp,使弹窗看起来更加柔和。
    • .padding(12) —— 内边距 12vp,确保内容不紧贴弹窗边缘。
  • 标题行 Text('新建聊天') —— 字号 17,粗体,居中显示。
  • 选项列表使用 Column 包裹四个 newChatOption 调用,浅灰色背景(#F5F5F5)与弹窗白色背景区分。
  • 取消按钮 Row() —— 单独一行,白色背景,居中文字,点击关闭弹窗。
  • .justifyContent(FlexAlign.Center) 和 .alignItems(HorizontalAlign.Center) —— 弹窗在屏幕中水平和垂直双居中。

13.3 新建聊天选项行

@Builder
newChatOption(icon: string, title: string, desc: string) {
  Row() {
    Text(icon)
      .fontSize(22)
      .width(36)
      .height(36)
      .textAlign(TextAlign.Center)
      .margin({ right: 12 })

    Column() {
      Text(title)
        .fontSize(14)
        .fontColor(this.colors.textPrimary)
        .alignSelf(ItemAlign.Start)
      Text(desc)
        .fontSize(11)
        .fontColor(this.colors.textSecondary)
        .margin({ top: 2 })
        .alignSelf(ItemAlign.Start)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    Text('›')
      .fontSize(18)
      .fontColor(this.colors.textSecondary)
  }
  .width('100%')
  .padding({ top: 12, bottom: 12, left: 12, right: 12 })
  .backgroundColor(this.colors.card)
  .borderRadius(8)
  .margin({ bottom: 4 })
  .onClick(() => {
    this.showAddModal = false
    this.showNewChatModal = true
  })
}

逐行解析:

  • newChatOption(icon: string, title: string, desc: string) —— 参数化 Builder,接收图标、标题和描述三个参数。这种设计使得四个选项可以复用同一个 Builder,只需传入不同的参数。
  • 每个选项行包含图标、标题描述列和右箭头,结构与发现页入口项类似。
  • .onClick(() => { this.showAddModal = false; this.showNewChatModal = true }) —— 点击选项时,先关闭新建聊天弹窗,再打开联系人选择弹窗。这种弹窗链式跳转体现了应用中多个功能步骤的串联逻辑。

13.4 联系人选择弹窗

@Builder
newChatModal() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#80000000')
      .onClick(() => {
        this.showNewChatModal = false
      })

    Column() {
      Row() {
        Text('选择联系人')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
          .layoutWeight(1)
        Text('✕')
          .fontSize(18)
          .fontColor(this.colors.textSecondary)
          .onClick(() => {
            this.showNewChatModal = false
          })
      }
      .width('100%')
      .padding({ top: 16, bottom: 12, left: 16, right: 16 })

      Scroll() {
        Column() {
          ForEach(this.contactList.slice(0, 10), (item: ContactItem) => {
            Row() {
              Text(item.avatar)
                .fontSize(28)
                .width(40)
                .height(40)
                .textAlign(TextAlign.Center)
                .borderRadius(8)
                .backgroundColor('#F5F5F5')
                .margin({ right: 12 })

              Column() {
                Text(item.name)
                  .fontSize(14)
                  .fontColor(this.colors.textPrimary)
                  .alignSelf(ItemAlign.Start)
                Text(item.region)
                  .fontSize(11)
                  .fontColor(this.colors.textSecondary)
                  .margin({ top: 2 })
                  .alignSelf(ItemAlign.Start)
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)

              Text('○')
                .fontSize(18)
                .fontColor(this.colors.border)
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 10, bottom: 10 })
            .border({ width: 0.5, color: this.colors.border })
            .onClick(() => {
              this.showNewChatModal = false
            })
          }, (item: ContactItem) => item.id)
        }
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)

      Row() {
        Text('确定')
          .fontSize(16)
          .fontColor(Color.White)
          .padding({ top: 12, bottom: 12 })
      }
      .width('100%')
      .backgroundColor(this.colors.primary)
      .borderRadius(8)
      .margin({ top: 8, left: 16, right: 16, bottom: 16 })
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.showNewChatModal = false
      })
    }
    .width('90%')
    .height('60%')
    .backgroundColor(this.colors.card)
    .borderRadius(12)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • 这个弹窗比新建聊天弹窗更大,宽度 90%,高度 60%,因为需要展示联系人列表。
  • 头部行包含标题和关闭按钮(Text('✕')),关闭按钮独立绑定点击事件。
  • Scroll() —— 联系人列表可滚动,因为联系人可能很多。
  • this.contactList.slice(0, 10) —— 只取前 10 个联系人展示。在实际应用中,这里应该实现完整的搜索和分页逻辑。
  • 每个联系人行右侧有一个 Text('○') 空心圆圈,表示可选中的单选按钮。点击联系人行后弹窗关闭,模拟选中联系人的行为。
  • 底部确定按钮使用主色(绿色)背景,白色文字,是弹窗中的主要操作按钮(CTA)。

13.5 编辑聊天名称弹窗

@Builder
editChatModal() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#80000000')
      .onClick(() => {
        this.showEditModal = false
      })

    Column() {
      Row() {
        Text('编辑聊天名称')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
      }
      .width('100%')
      .padding({ top: 20, bottom: 16 })
      .justifyContent(FlexAlign.Center)

      Column() {
        TextInput({ text: this.editChatName, placeholder: '请输入聊天名称' })
          .fontSize(15)
          .borderRadius(8)
          .backgroundColor('#F5F5F5')
          .padding({ left: 12, right: 12, top: 10, bottom: 10 })
          .onChange((value: string) => {
            this.editChatName = value
          })
      }
      .width('100%')
      .padding({ left: 20, right: 20 })

      Row() {
        Text('取消')
          .fontSize(15)
          .fontColor(this.colors.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .onClick(() => {
            this.showEditModal = false
          })

        Column()
          .width(0.5)
          .height(40)
          .backgroundColor(this.colors.border)

        Text('确定')
          .fontSize(15)
          .fontColor(this.colors.primary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .border({ width: { top: 0.5 }, color: this.colors.border })
    }
    .width('80%')
    .backgroundColor(this.colors.card)
    .borderRadius(12)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • 这是一个标准的输入弹窗,宽度 80%,包含标题、输入框和操作按钮。
  • TextInput({ text: this.editChatName, placeholder: '请输入聊天名称' }) —— 文本输入框,绑定到 editChatName 状态变量。弹窗打开时此变量已经被设置为当前聊天的名称,因此输入框会预填充已有名称。
  • .onChange((value: string) => { this.editChatName = value }) —— 输入变化时更新状态变量。
  • 底部按钮行采用"取消 | 分隔线 | 确定"的三段式布局:
    • Text('取消') —— 左侧取消按钮,灰色文字,占据一半宽度。
    • Column().width(0.5).height(40).backgroundColor(this.colors.border) —— 中间的垂直分隔线,宽度 0.5vp,高度 40vp。
    • Text('确定') —— 右侧确定按钮,绿色文字(主色),占据一半宽度。
  • .border({ width: { top: 0.5 }, color: this.colors.border }) —— 按钮行上方有 0.5vp 的分隔线,与输入区域分隔。

13.6 删除确认弹窗

@Builder
deleteConfirmModal() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#80000000')
      .onClick(() => {
        this.showDeleteModal = false
      })

    Column() {
      Row() {
        Text('确认删除')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
      }
      .width('100%')
      .padding({ top: 20, bottom: 16 })
      .justifyContent(FlexAlign.Center)

      Text('删除后聊天记录将无法恢复,确定删除该聊天吗?')
        .fontSize(14)
        .fontColor(this.colors.textSecondary)
        .textAlign(TextAlign.Center)
        .padding({ left: 20, right: 20, bottom: 16 })
        .lineHeight(22)

      Row() {
        Text('取消')
          .fontSize(15)
          .fontColor(this.colors.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .onClick(() => {
            this.showDeleteModal = false
          })

        Column()
          .width(0.5)
          .height(40)
          .backgroundColor(this.colors.border)

        Text('删除')
          .fontSize(15)
          .fontColor(this.colors.unread)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .onClick(() => {
            this.showDeleteModal = false
          })
      }
      .width('100%')
      .border({ width: { top: 0.5 }, color: this.colors.border })
    }
    .width('75%')
    .backgroundColor(this.colors.card)
    .borderRadius(12)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • 删除确认弹窗是最窄的弹窗(宽度 75%),因为内容简单——只有标题、提示文本和两个按钮。
  • Text('删除后聊天记录将无法恢复,确定删除该聊天吗?') —— 风险提示文本。明确告知用户删除操作的不可逆性,这是破坏性操作的标准设计原则。
  • .lineHeight(22) —— 行高 22vp,提升多行提示文本的阅读舒适度。
  • 底部按钮行结构与编辑弹窗一致,但"删除"按钮使用红色文字(this.colors.unread,即 #FA5151),而非绿色。在 UI 设计中,红色通常用于表示破坏性或危险操作,与确认操作的绿色形成对比,提醒用户谨慎操作。

13.7 聊天详情弹窗

聊天详情弹窗是功能最丰富的弹窗,它展示了聊天的详细信息并提供了多个管理操作。

@Builder
chatDetailModal() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#80000000')
      .onClick(() => {
        this.showDetailModal = false
      })

    Column() {
      // Header
      Row() {
        Text('聊天信息')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
          .layoutWeight(1)

        Text('✕')
          .fontSize(18)
          .fontColor(this.colors.textSecondary)
          .onClick(() => {
            this.showDetailModal = false
          })
      }
      .width('100%')
      .padding({ top: 16, bottom: 12, left: 16, right: 16 })

逐行解析:

  • 弹窗头部包含标题"聊天信息"和右上角的关闭按钮(✕)。标题使用 layoutWeight(1) 占据左侧所有空间,将关闭按钮推到右侧。
      // Avatar and name
      Column() {
        Text('👨')
          .fontSize(48)
          .width(72)
          .height(72)
          .textAlign(TextAlign.Center)
          .borderRadius(16)

        Text(this.editChatName)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.colors.textPrimary)
          .margin({ top: 8 })

        Text('ID: ' + this.selectedChatId)
          .fontSize(12)
          .fontColor(this.colors.textSecondary)
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 20, bottom: 20 })
      .alignItems(HorizontalAlign.Center)

逐行解析:

  • 头像和名称区域居中排列。
  • Text('👨') —— 大尺寸头像,字号 48,尺寸 72x72vp,圆角 16vp。
  • Text(this.editChatName) —— 聊天名称,字号 18,粗体。此变量在用户点击聊天行时已被赋值。
  • Text('ID: ' + this.selectedChatId) —— 聊天 ID,字号 12,灰色。
      // Settings
      Column() {
        Row() {
          Text('消息免打扰')
            .fontSize(14)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)

          Text(this.detailMuted ? '已开启 🔕' : '已关闭')
            .fontSize(13)
            .fontColor(this.detailMuted ? this.colors.primary : this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .border({ width: 0.5, color: this.colors.border })
        .onClick(() => {
          this.detailMuted = !this.detailMuted
        })

逐行解析:

  • "消息免打扰"设置行,点击可切换开关状态。
  • Text(this.detailMuted ? '已开启 🔕' : '已关闭') —— 根据状态显示不同文本。开启时显示"已开启"加静音图标,关闭时显示"已关闭"。
  • .fontColor(this.detailMuted ? this.colors.primary : this.colors.textSecondary) —— 开启时文字为绿色,关闭时为灰色,通过颜色强化状态视觉反馈。
  • .onClick(() => { this.detailMuted = !this.detailMuted }) —— 点击切换 detailMuted 的布尔值。由于是 @State 变量,切换后界面会自动更新。
        Row() {
          Text('置顶聊天')
            .fontSize(14)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)

          Text(this.detailPinned ? '已置顶 📌' : '未置顶')
            .fontSize(13)
            .fontColor(this.detailPinned ? this.colors.primary : this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .border({ width: 0.5, color: this.colors.border })
        .onClick(() => {
          this.detailPinned = !this.detailPinned
        })

        Row() {
          Text('修改名称')
            .fontSize(14)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)
          Text('›')
            .fontSize(18)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .border({ width: 0.5, color: this.colors.border })
        .onClick(() => {
          this.showDetailModal = false
          this.showEditModal = true
        })

        Row() {
          Text('清空聊天记录')
            .fontSize(14)
            .fontColor(this.colors.unread)
            .layoutWeight(1)
          Text('›')
            .fontSize(18)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .onClick(() => {
          this.showDetailModal = false
          this.showDeleteModal = true
        })
      }
      .width('100%')
      .backgroundColor('#F5F5F5')
      .borderRadius(8)
      .margin({ left: 16, right: 16, bottom: 16 })
    }
    .width('85%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(this.colors.card)
    .borderRadius(12)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

逐行解析:

  • "置顶聊天"行 —— 与"消息免打扰"相同的切换逻辑,点击切换 detailPinned 状态。
  • "修改名称"行 —— 点击后关闭详情弹窗(showDetailModal = false),打开编辑弹窗(showEditModal = true)。这是弹窗间跳转的又一个实例。
  • "清空聊天记录"行 —— 文字使用红色(this.colors.unread),表示这是一个危险操作。点击后关闭详情弹窗,打开删除确认弹窗。
  • 设置区域整体使用浅灰色背景(#F5F5F5),圆角 8vp,与弹窗白色背景区分。
  • .margin({ left: 16, right: 16, bottom: 16 }) —— 设置区域左右各留 16vp 边距,底部 16vp。
  • .constraintSize({ maxHeight: '80%' }) —— 弹窗最大高度不超过屏幕的 80%,确保在各种屏幕尺寸下都能完整显示。

十四、辅助方法

辅助方法是组件中定义的普通方法(非 Builder),用于处理数据逻辑和返回计算结果。

14.1 标签图标和文字获取

getTabIcon(idx: number): string {
  if (idx === 0) return '💬'
  if (idx === 1) return '👥'
  if (idx === 2) return '🔍'
  if (idx === 3) return '🌟'
  return '👤'
}

getTabLabel(idx: number): string {
  if (idx === 0) return '聊天'
  if (idx === 1) return '通讯录'
  if (idx === 2) return '发现'
  if (idx === 3) return '朋友圈'
  return '我的'
}

逐行解析:

  • getTabIcon(idx: number): string —— 根据标签索引返回对应的 Emoji 图标。0 到 4 分别对应聊天、通讯录、发现、朋友圈和我的。
  • getTabLabel(idx: number): string —— 根据标签索引返回对应的中文标签文字。
  • 这两个方法被底部导航栏的 ForEach 调用,为每个标签按钮动态提供图标和文字。
  • 使用 if 语句而非 switch 或对象查找,虽然代码略显冗长,但逻辑清晰,易于理解和修改。

14.2 联系人字母分组

getGroupedContactLetters(): string[] {
  let letters: string[] = ['A', 'C', 'D', 'F', 'H', 'J', 'L', 'M', 'N', 'O', 'P', 'Q', 'S', 'T', 'W', 'X', 'Y', 'Z']
  return letters
}

getContactsByLetter(letter: string): ContactItem[] {
  return this.contactList.filter((item: ContactItem) => {
    return item.pinyin.charAt(0).toUpperCase() === letter
  })
}

逐行解析:

  • getGroupedContactLetters(): string[] —— 返回通讯录中需要显示的字母列表。这个列表是硬编码的,基于预设的联系人数据。在实际应用中,这个方法应该动态计算:遍历所有联系人,收集拼音首字母,去重后按字母顺序排序。
  • getContactsByLetter(letter: string): ContactItem[] —— 根据给定字母筛选联系人。使用 filter 方法,对每个联系人取其 pinyin 字段的首字母,转换为大写后与目标字母比较。
  • item.pinyin.charAt(0).toUpperCase() —— charAt(0) 取拼音字符串的第一个字符,toUpperCase() 转为大写以确保大小写不敏感的匹配。
  • 这两个方法配合使用,实现了通讯录的字母分组功能:外层 ForEach 遍历字母列表,内层为每个字母调用 getContactsByLetter 获取该组联系人。

十五、关键技术特性对比

下表总结了本应用中各个核心模块的关键技术特性,便于快速对比和理解各模块的设计差异:

模块核心功能关键技术点状态变量依赖数据驱动方式交互复杂度
聊天列表会话浏览与管理ForEach + filter 分组渲染currentTab, searchKeyword, showDetailModal 等属性过滤分组(isPinned)高(点击、搜索、弹窗联动)
消息统计数据可视化条件着色柱状图无(纯静态数据)ForEach 遍历 chartData低(仅展示)
通讯录联系人管理嵌套 ForEach 字母分组contactSearchKeyword, selectedContactIdpinyin 首字母分组中(搜索、点击选择)
发现页服务入口聚合透明度色值拼接(color + ‘20’)无ForEach 遍历 discoverList低(点击跳转)
小程序应用网格展示slice 分割 + SpaceBetween 布局无ForEach + slice 分行低(点击跳转)
朋友圈动态信息流条件分支图片网格momentLikedIdForEach 遍历 momentList中(点赞交互)
个人中心用户信息与设置Stack 层叠 + 负边距重叠无ForEach 遍历 settingsList低(点击跳转)
底部导航页面切换layoutWeight 等分 + 条件着色currentTabForEach 遍历索引数组高(核心导航)
弹窗系统交互确认与信息展示Stack 覆盖 + 状态驱动显示showAddModal 等 5 个布尔变量if-else 条件分支高(弹窗链式跳转)
聊天详情会话管理操作状态切换 + 弹窗跳转detailMuted, detailPinned, selectedChatId三元表达式条件渲染高(开关切换 + 跳转)

十六、总结

本文对一个基于 HarmonyOS ArkTS 的全功能社交聊天应用进行了全面而深入的技术解析。从数据模型设计到状态管理,从布局架构到各功能模块的逐行代码解读,我们系统地梳理了这个应用的技术实现细节。

16.1 架构设计亮点

单组件多 Builder 的架构模式是这个应用最显著的架构特征。整个应用由一个入口组件承载,通过 15 个以上的 @Builder 方法将功能模块拆分为独立的 UI 构建单元。这种模式的优势在于代码集中管理、状态共享方便,同时通过 Builder 方法实现了逻辑上的模块化。对于中等复杂度的应用,这是一种平衡了开发效率和代码可维护性的合理选择。

状态驱动的交互模型贯穿了整个应用。13 个 @State 变量分别管理着标签切换、搜索输入、弹窗显示、选中项、开关状态等核心交互状态。用户的每一次操作——无论是点击标签、输入搜索词、点赞动态还是切换开关——都通过修改状态变量来触发界面更新。这种"状态即真相"的设计哲学,使得应用的交互逻辑清晰可追踪,便于调试和维护。

条件渲染的广泛运用是声明式 UI 的核心能力之一。从标签页切换(if (this.currentTab === 0))到在线状态显示(if (item.online)),从未读徽标(if (item.unreadCount > 0))到朋友圈图片网格(if (count === 1)),条件渲染在应用中无处不在。这种模式让 UI 始终与数据状态保持精确同步,避免了命令式 UI 中常见的状态与界面不一致问题。

16.2 技术实现亮点

数据过滤与分组在聊天列表和通讯录中得到了充分体现。聊天列表通过 filter(item => item.isPinned) 将聊天分为置顶和非置顶两组;通讯录通过 getContactsByLetter 方法按拼音首字母分组。这种基于属性的数据分组方式简洁高效,无需维护额外的分组数据结构。

透明度色值拼接技巧(item.color + '20')是应用中一个精妙的视觉设计手法。通过在十六进制颜色值后追加两位透明度代码,实现了图标背景色为主题色极浅版本的效果。这种技术让每个功能入口都能有独特的色彩标识,同时保持整体的视觉和谐。

弹窗链式跳转机制使得多个弹窗之间可以串联调用。例如:点击加号按钮打开新建聊天弹窗 -> 点击选项打开联系人选择弹窗 -> 点击联系人关闭弹窗。又如:点击聊天行打开详情弹窗 -> 点击"修改名称"打开编辑弹窗 -> 点击"清空聊天记录"打开删除确认弹窗。这种链式跳转通过状态变量的切换实现,逻辑清晰且用户体验流畅。

朋友圈图片网格自适应布局是应用中最复杂的 UI 逻辑之一。通过四个条件分支(1 张、2 张、3 张、4 张以上),为不同数量的图片提供了最优的视觉布局。特别是 4 张以上时使用九宫格加"+N"计数的设计,是社交应用中处理多图展示的标准方案。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// 社交聊天 - HarmonyOS ArkTS
// Primary #07C160 | Light #95EC69 | BG #EDEDED | Card #FFFFFF

// ===================== Interfaces =====================

interface ChatItem {
  id: string
  name: string
  avatar: string
  lastMessage: string
  time: string
  unreadCount: number
  isPinned: boolean
  isMuted: boolean
  type: string
  online: boolean
}

interface ContactItem {
  id: string
  name: string
  avatar: string
  pinyin: string
  phone: string
  region: string
  tag: string
  isStarred: boolean
}

interface MomentItem {
  id: string
  author: string
  avatar: string
  content: string
  images: number
  time: string
  likes: number
  comments: number
  location: string
}

interface DiscoverItem {
  id: string
  title: string
  icon: string
  desc: string
  color: string
}

interface MiniProgramItem {
  id: string
  name: string
  icon: string
  category: string
  color: string
}

interface ProfileConfig {
  name: string
  avatar: string
  id: string
  region: string
}

interface WalletConfig {
  balance: string
  cards: number
  points: number
}

interface ChartBarItem {
  day: string
  value: number
  label: string
}

interface SettingsItem {
  id: string
  title: string
  icon: string
  desc: string
  color: string
}

interface FavoriteItem {
  id: string
  title: string
  icon: string
  category: string
}

interface ChatStatConfig {
  title: string
  totalLabel: string
  totalValue: string
  avgLabel: string
  avgValue: string
}

interface TabConfig {
  label: string
  icon: string
  color: string
}

interface ModalConfig {
  visible: boolean
  type: string
  title: string
}

interface ChatDetailConfig {
  name: string
  avatar: string
  region: string
  phone: string
  memberCount: number
  isMuted: boolean
  isPinned: boolean
}

interface ColorConfig {
  primary: string
  light: string
  bg: string
  card: string
  textPrimary: string
  textSecondary: string
  border: string
  unread: string
  online: string
}

// ===================== Entry Component =====================

@Entry
@Component
struct SocialChatApp {
  @State currentTab: number = 0
  @State searchKeyword: string = ''
  @State showNewChatModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State showAddModal: boolean = false
  @State selectedChatId: string = ''
  @State editChatName: string = ''
  @State detailMuted: boolean = false
  @State detailPinned: boolean = false
  @State contactSearchKeyword: string = ''
  @State selectedContactId: string = ''
  @State momentLikedId: string = ''

  private colors: Record<string, string> = {
    'primary': '#07C160',
    'light': '#95EC69',
    'bg': '#EDEDED',
    'card': '#FFFFFF',
    'textPrimary': '#1A1A1A',
    'textSecondary': '#999999',
    'border': '#E0E0E0',
    'unread': '#FA5151',
    'online': '#07C160'
  }

  private tabs: Record<string, TabConfig> = {
    'tab0': { label: '聊天', icon: '💬', color: '#07C160' },
    'tab1': { label: '通讯录', icon: '👥', color: '#07C160' },
    'tab2': { label: '发现', icon: '🔍', color: '#07C160' },
    'tab3': { label: '朋友圈', icon: '🌟', color: '#07C160' },
    'tab4': { label: '我的', icon: '👤', color: '#07C160' }
  }

  private chatList: ChatItem[] = [
    { id: 'c1', name: '张伟', avatar: '👨', lastMessage: '明天下午开会,记得带上方案文件', time: '14:32', unreadCount: 3, isPinned: true, isMuted: false, type: 'single', online: true },
    { id: 'c2', name: '产品技术群', avatar: '🏢', lastMessage: '李娜: 新版本已经发布到测试环境了', time: '14:15', unreadCount: 12, isPinned: true, isMuted: false, type: 'group', online: false },
    { id: 'c3', name: '王芳', avatar: '👩', lastMessage: '好的,收到啦~', time: '13:50', unreadCount: 0, isPinned: true, isMuted: true, type: 'single', online: true },
    { id: 'c4', name: '大学同学群', avatar: '🎓', lastMessage: '陈强: 周末同学聚会安排一下', time: '13:20', unreadCount: 5, isPinned: false, isMuted: false, type: 'group', online: false },
    { id: 'c5', name: '妈妈', avatar: '👵', lastMessage: '记得吃饭,别老吃外卖', time: '12:45', unreadCount: 2, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c6', name: '刘洋', avatar: '🧑', lastMessage: '[图片]', time: '12:10', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c7', name: '健身打卡群', avatar: '💪', lastMessage: '赵敏: 今天跑步5公里完成!', time: '11:30', unreadCount: 8, isPinned: false, isMuted: true, type: 'group', online: false },
    { id: 'c8', name: '赵敏', avatar: '👧', lastMessage: '晚上一起吃饭吗?', time: '11:00', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c9', name: '工作汇报群', avatar: '📊', lastMessage: '领导: 本周汇报已收到,辛苦大家', time: '10:45', unreadCount: 0, isPinned: false, isMuted: true, type: 'group', online: false },
    { id: 'c10', name: '陈强', avatar: '🧔', lastMessage: '文件已发你邮箱,查收', time: '10:20', unreadCount: 1, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c11', name: '外卖拼单群', avatar: '🍱', lastMessage: '孙丽: 今天中午吃什么?', time: '10:00', unreadCount: 3, isPinned: false, isMuted: false, type: 'group', online: false },
    { id: 'c12', name: '李娜', avatar: '👩‍💼', lastMessage: '会议纪要已整理好', time: '09:50', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c13', name: '设计师交流群', avatar: '🎨', lastMessage: '周杰: 新的UI规范文档已上传', time: '09:30', unreadCount: 15, isPinned: false, isMuted: true, type: 'group', online: false },
    { id: 'c14', name: '周杰', avatar: '👨‍💻', lastMessage: '[语音] 00:32', time: '09:15', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c15', name: '房东-老吴', avatar: '🧓', lastMessage: '水电费账单发你了', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c16', name: '读书分享群', avatar: '📚', lastMessage: '吴静: 《人类简史》第三章读后感', time: '昨天', unreadCount: 6, isPinned: false, isMuted: false, type: 'group', online: false },
    { id: 'c17', name: '吴静', avatar: '👱‍♀️', lastMessage: '周末一起去图书馆?', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c18', name: '郑浩', avatar: '🧑‍🦰', lastMessage: '收到,明天见', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c19', name: '旅行计划群', avatar: '✈️', lastMessage: '孙丽: 机票已订好,三亚五日游', time: '昨天', unreadCount: 20, isPinned: false, isMuted: false, type: 'group', online: false },
    { id: 'c20', name: '孙丽', avatar: '👩‍🦰', lastMessage: '酒店也定好了,海边别墅', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c21', name: '马涛', avatar: '👨‍🦱', lastMessage: '代码已经提交了,帮忙review一下', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: false },
    { id: 'c22', name: '王者荣耀战队', avatar: '🎮', lastMessage: '冯雷: 今晚8点开黑!', time: '昨天', unreadCount: 9, isPinned: false, isMuted: true, type: 'group', online: false },
    { id: 'c23', name: '冯雷', avatar: '🧑‍🔧', lastMessage: '上分上分!', time: '昨天', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: true },
    { id: 'c24', name: '快递通知', avatar: '📦', lastMessage: '您的快递已签收', time: '周二', unreadCount: 0, isPinned: false, isMuted: true, type: 'single', online: false },
    { id: 'c25', name: '育儿交流群', avatar: '👶', lastMessage: '韩雪: 宝宝辅食食谱分享', time: '周一', unreadCount: 4, isPinned: false, isMuted: false, type: 'group', online: false },
    { id: 'c26', name: '韩雪', avatar: '👩‍🍼', lastMessage: '谢谢分享!马上试试', time: '周一', unreadCount: 0, isPinned: false, isMuted: false, type: 'single', online: false }
  ]

  private contactList: ContactItem[] = [
    { id: 'p1', name: '阿明', avatar: '🙂', pinyin: 'Aming', phone: '138****1234', region: '北京', tag: '同事', isStarred: true },
    { id: 'p2', name: '阿强', avatar: '😄', pinyin: 'Aqiang', phone: '139****5678', region: '上海', tag: '朋友', isStarred: true },
    { id: 'p3', name: '陈强', avatar: '🧔', pinyin: 'ChenQiang', phone: '137****8888', region: '深圳', tag: '同学', isStarred: false },
    { id: 'p4', name: '陈静', avatar: '😊', pinyin: 'ChenJing', phone: '136****6666', region: '杭州', tag: '同事', isStarred: false },
    { id: 'p5', name: '邓超', avatar: '😎', pinyin: 'DengChao', phone: '135****3333', region: '广州', tag: '朋友', isStarred: false },
    { id: 'p6', name: '冯雷', avatar: '🧑‍🔧', pinyin: 'FengLei', phone: '134****7777', region: '成都', tag: '游戏', isStarred: false },
    { id: 'p7', name: '韩雪', avatar: '👩‍🍼', pinyin: 'HanXue', phone: '133****2222', region: '武汉', tag: '育儿', isStarred: true },
    { id: 'p8', name: '黄磊', avatar: '🧑‍🏫', pinyin: 'HuangLei', phone: '132****1111', region: '南京', tag: '同事', isStarred: false },
    { id: 'p9', name: '蒋欣', avatar: '🤗', pinyin: 'JiangXin', phone: '131****9999', region: '西安', tag: '朋友', isStarred: false },
    { id: 'p10', name: '李娜', avatar: '👩‍💼', pinyin: 'LiNa', phone: '130****4444', region: '北京', tag: '同事', isStarred: true },
    { id: 'p11', name: '李明', avatar: '👨', pinyin: 'LiMing', phone: '159****5555', region: '上海', tag: '同学', isStarred: false },
    { id: 'p12', name: '刘洋', avatar: '🧑', pinyin: 'LiuYang', phone: '158****6666', region: '深圳', tag: '朋友', isStarred: false },
    { id: 'p13', name: '马涛', avatar: '👨‍🦱', pinyin: 'MaTao', phone: '157****7777', region: '杭州', tag: '同事', isStarred: false },
    { id: 'p14', name: '倪妮', avatar: '💃', pinyin: 'NiNi', phone: '156****8888', region: '广州', tag: '朋友', isStarred: false },
    { id: 'p15', name: '欧阳明', avatar: '🤓', pinyin: 'OuyangMing', phone: '155****1212', region: '成都', tag: '同事', isStarred: false },
    { id: 'p16', name: '彭于晏', avatar: '🤩', pinyin: 'PengYuYan', phone: '154****3434', region: '武汉', tag: '朋友', isStarred: true },
    { id: 'p17', name: '钱多多', avatar: '🤑', pinyin: 'QianDuoDuo', phone: '153****5656', region: '南京', tag: '同事', isStarred: false },
    { id: 'p18', name: '孙丽', avatar: '👩‍🦰', pinyin: 'SunLi', phone: '152****7878', region: '西安', tag: '朋友', isStarred: false },
    { id: 'p19', name: '宋佳', avatar: '👩‍🎤', pinyin: 'SongJia', phone: '151****9090', region: '北京', tag: '同事', isStarred: false },
    { id: 'p20', name: '唐嫣', avatar: '👸', pinyin: 'TangYan', phone: '150****1212', region: '上海', tag: '朋友', isStarred: false },
    { id: 'p21', name: '王芳', avatar: '👩', pinyin: 'WangFang', phone: '189****3434', region: '深圳', tag: '同事', isStarred: true },
    { id: 'p22', name: '王伟', avatar: '👨‍💼', pinyin: 'WangWei', phone: '188****5656', region: '杭州', tag: '同事', isStarred: false },
    { id: 'p23', name: '吴静', avatar: '👱‍♀️', pinyin: 'WuJing', phone: '187****7878', region: '广州', tag: '朋友', isStarred: false },
    { id: 'p24', name: '谢娜', avatar: '😂', pinyin: 'XieNa', phone: '186****9090', region: '成都', tag: '朋友', isStarred: false },
    { id: 'p25', name: '徐峥', avatar: '🤣', pinyin: 'XuZheng', phone: '185****1212', region: '武汉', tag: '同事', isStarred: false },
    { id: 'p26', name: '杨颖', avatar: '😍', pinyin: 'YangYing', phone: '184****3434', region: '南京', tag: '朋友', isStarred: false },
    { id: 'p27', name: '赵敏', avatar: '👧', pinyin: 'ZhaoMin', phone: '183****5656', region: '西安', tag: '同事', isStarred: true },
    { id: 'p28', name: '郑浩', avatar: '🧑‍🦰', pinyin: 'ZhengHao', phone: '182****7878', region: '北京', tag: '同学', isStarred: false },
    { id: 'p29', name: '周杰', avatar: '👨‍💻', pinyin: 'ZhouJie', phone: '181****9090', region: '上海', tag: '同事', isStarred: false },
    { id: 'p30', name: '张伟', avatar: '👨', pinyin: 'ZhangWei', phone: '180****1212', region: '深圳', tag: '同事', isStarred: false },
    { id: 'p31', name: '朱莉', avatar: '🧑‍🎨', pinyin: 'ZhuLi', phone: '179****3434', region: '杭州', tag: '朋友', isStarred: false }
  ]

  private momentList: MomentItem[] = [
    { id: 'm1', author: '李娜', avatar: '👩‍💼', content: '今天完成了一个大项目,团队合影留念!感谢每一位伙伴的付出💪', images: 4, time: '2小时前', likes: 32, comments: 8, location: '北京·中关村软件园' },
    { id: 'm2', author: '王芳', avatar: '👩', content: '周末去爬山了,山顶的风景真的太美了!推荐大家去凤凰岭走一走🍃', images: 3, time: '4小时前', likes: 56, comments: 12, location: '北京·凤凰岭' },
    { id: 'm3', author: '陈强', avatar: '🧔', content: '今天读完了《原则》这本书,受益匪浅。分享一句话:痛苦+反思=进步。', images: 1, time: '6小时前', likes: 28, comments: 5, location: '上海·徐汇区图书馆' },
    { id: 'm4', author: '赵敏', avatar: '👧', content: '今天跑步5公里打卡!坚持运动的感觉真好🏃‍♀️', images: 2, time: '8小时前', likes: 45, comments: 7, location: '杭州·西湖断桥' },
    { id: 'm5', author: '刘洋', avatar: '🧑', content: '新买的车到手了!第一次自己开车上班,虽然紧张但很兴奋🚗', images: 3, time: '12小时前', likes: 78, comments: 20, location: '深圳·南山科技园' },
    { id: 'm6', author: '周杰', avatar: '👨‍💻', content: '加班到凌晨终于把bug修好了,代码人的日常😅', images: 0, time: '昨天', likes: 15, comments: 3, location: '上海·浦东张江' },
    { id: 'm7', author: '孙丽', avatar: '👩‍🦰', content: '三亚的机票定了!期待海边度假之旅✈️🌊 有一起的吗?', images: 1, time: '昨天', likes: 92, comments: 25, location: '三亚·海棠湾' },
    { id: 'm8', author: '吴静', avatar: '👱‍♀️', content: '图书馆借了几本好书,周末泡在书里📖 知识使人快乐!', images: 2, time: '昨天', likes: 38, comments: 9, location: '北京·国家图书馆' },
    { id: 'm9', author: '马涛', avatar: '👨‍🦱', content: '今天提交了一个大PR,终于可以放松一下了。代码review通过的感觉太好了!', images: 0, time: '2天前', likes: 22, comments: 4, location: '杭州·阿里巴巴' },
    { id: 'm10', author: '冯雷', avatar: '🧑‍🔧', content: '今晚开黑连胜10把!队友太给力了🎮', images: 1, time: '2天前', likes: 67, comments: 18, location: '成都·春熙路' },
    { id: 'm11', author: '韩雪', avatar: '👩‍🍼', content: '宝宝今天第一次自己吃饭啦!虽然弄得满身都是,但是好可爱❤️', images: 4, time: '3天前', likes: 120, comments: 35, location: '武汉·光谷' },
    { id: 'm12', author: '郑浩', avatar: '🧑‍🦰', content: '今天和女朋友一起做了顿饭,厨艺又进步了一点🍝', images: 3, time: '3天前', likes: 54, comments: 11, location: '北京·望京' },
    { id: 'm13', author: '唐嫣', avatar: '👸', content: '新上映的电影太好看了,强烈推荐大家去看!🍿', images: 1, time: '4天前', likes: 41, comments: 14, location: '上海·人民广场' },
    { id: 'm14', author: '彭于晏', avatar: '🤩', content: '健身房打卡第100天!自律给我自由💪', images: 2, time: '5天前', likes: 189, comments: 42, location: '广州·天河城' },
    { id: 'm15', author: '谢娜', avatar: '😂', content: '今天笑了一整天,因为看了一个超搞笑的综艺。快乐就是这么简单!', images: 0, time: '5天前', likes: 33, comments: 6, location: '成都·太古里' },
    { id: 'm16', author: '朱莉', avatar: '🧑‍🎨', content: '完成了一幅水彩画,最近迷上了画画🎨 用色彩表达心情', images: 3, time: '1周前', likes: 76, comments: 19, location: '南京·秦淮河' }
  ]

  private discoverList: DiscoverItem[] = [
    { id: 'd1', title: '朋友圈', icon: '🌟', desc: '分享你的生活', color: '#07C160' },
    { id: 'd2', title: '扫一扫', icon: '📷', desc: '扫二维码/条码', color: '#3B7CFF' },
    { id: 'd3', title: '摇一摇', icon: '📳', desc: '发现身边的人', color: '#FF6B6B' },
    { id: 'd4', title: '看一看', icon: '📰', desc: '热点资讯', color: '#FF9500' },
    { id: 'd5', title: '搜一搜', icon: '🔍', desc: '搜索内容', color: '#5856D6' },
    { id: 'd6', title: '附近', icon: '📍', desc: '附近的人和事', color: '#FF2D55' },
    { id: 'd7', title: '购物', icon: '🛒', desc: '精选好物', color: '#FF3B30' },
    { id: 'd8', title: '游戏', icon: '🎮', desc: '好友在玩', color: '#AF52DE' }
  ]

  private miniProgramList: MiniProgramItem[] = [
    { id: 'mp1', name: '美团外卖', icon: '🍔', category: '美食', color: '#FFD700' },
    { id: 'mp2', name: '滴滴出行', icon: '🚕', category: '出行', color: '#FF6B35' },
    { id: 'mp3', name: '猫眼电影', icon: '🎬', category: '娱乐', color: '#FF2D92' },
    { id: 'mp4', name: '京东购物', icon: '📦', category: '购物', color: '#E1251B' },
    { id: 'mp5', name: '大众点评', icon: '⭐', category: '生活', color: '#FFB02E' },
    { id: 'mp6', name: '12306', icon: '🚄', category: '出行', color: '#2196F3' }
  ]

  private chartData: ChartBarItem[] = [
    { day: '周一', value: 45, label: '45条' },
    { day: '周二', value: 62, label: '62条' },
    { day: '周三', value: 38, label: '38条' },
    { day: '周四', value: 78, label: '78条' },
    { day: '周五', value: 95, label: '95条' },
    { day: '周六', value: 52, label: '52条' },
    { day: '周日', value: 34, label: '34条' }
  ]

  private profileConfig: ProfileConfig = {
    name: '我的微信',
    avatar: '🧑‍💼',
    id: 'wxid_david_2024',
    region: '北京·海淀区'
  }

  private walletConfig: WalletConfig = {
    balance: '¥3,856.00',
    cards: 3,
    points: 12580
  }

  private settingsList: SettingsItem[] = [
    { id: 's1', title: '服务', icon: '🛍️', desc: '钱包、支付', color: '#07C160' },
    { id: 's2', title: '收藏', icon: '⭐', desc: '收藏的内容', color: '#FF9500' },
    { id: 's3', title: '朋友圈', icon: '🌟', desc: '我的动态', color: '#FF2D55' },
    { id: 's4', title: '视频号', icon: '📹', desc: '我的视频', color: '#5856D6' },
    { id: 's5', title: '卡包', icon: '💳', desc: '3张卡券', color: '#FF6B35' },
    { id: 's6', title: '表情', icon: '😀', desc: '我的表情包', color: '#AF52DE' },
    { id: 's7', title: '设置', icon: '⚙️', desc: '通用、隐私', color: '#8E8E93' }
  ]

  private favoriteList: FavoriteItem[] = [
    { id: 'f1', title: '项目方案文档', icon: '📄', category: '文件' },
    { id: 'f2', title: '海边度假照片', icon: '🏖️', category: '图片' },
    { id: 'f3', title: '生日聚会视频', icon: '🎥', category: '视频' },
    { id: 'f4', title: '团队会议录音', icon: '🎙️', category: '语音' },
    { id: 'f5', title: '旅游攻略笔记', icon: '📝', category: '笔记' }
  ]

  private statConfig: ChatStatConfig = {
    title: '本周消息统计',
    totalLabel: '总消息',
    totalValue: '404',
    avgLabel: '日均',
    avgValue: '57.7'
  }

  // ===================== Build =====================

  build() {
    Stack() {
      Column() {
        // Status bar area
        Column() {
          Text('社交聊天')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.card)
            .margin({ top: 8, bottom: 8 })
        }
        .width('100%')
        .backgroundColor(this.colors.primary)
        .padding({ left: 16, right: 16 })

        // Tab content
        Column() {
          if (this.currentTab === 0) {
            this.chatTabContent()
          } else if (this.currentTab === 1) {
            this.contactsTabContent()
          } else if (this.currentTab === 2) {
            this.discoverTabContent()
          } else if (this.currentTab === 3) {
            this.momentsTabContent()
          } else {
            this.mineTabContent()
          }
        }
        .layoutWeight(1)
        .width('100%')
        .backgroundColor(this.colors.bg)

        // Bottom tab bar
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(this.colors.bg)

      // Modal overlay layer
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }

  // ===================== Chat Tab =====================

  @Builder
  chatTabContent() {
    Column() {
      // Search bar
      Row() {
        Row() {
          Text('🔍')
            .fontSize(14)
          TextInput({ text: this.searchKeyword, placeholder: '搜索聊天' })
            .fontSize(14)
            .backgroundColor(Color.Transparent)
            .layoutWeight(1)
            .margin({ left: 8 })
            .onChange((value: string) => {
              this.searchKeyword = value
            })
        }
        .width('100%')
        .height(36)
        .borderRadius(8)
        .backgroundColor('#F5F5F5')
        .padding({ left: 12, right: 12 })
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor(this.colors.card)

      // Chat list
      Scroll() {
        Column() {
          // Message statistics chart
          this.messageStatsChart()

          // Pinned chats section header
          Row() {
            Text('📌 置顶聊天')
              .fontSize(13)
              .fontWeight(FontWeight.Medium)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, top: 10, bottom: 6 })

          // Pinned chats
          ForEach(this.chatList.filter((item: ChatItem) => item.isPinned), (item: ChatItem) => {
            this.chatItemRow(item)
          }, (item: ChatItem) => item.id)

          // Divider
          Row()
            .width('100%')
            .height(8)
            .backgroundColor(this.colors.bg)

          // All chats header
          Row() {
            Text('💬 全部聊天')
              .fontSize(13)
              .fontWeight(FontWeight.Medium)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, top: 10, bottom: 6 })

          // All chats
          ForEach(this.chatList.filter((item: ChatItem) => !item.isPinned), (item: ChatItem) => {
            this.chatItemRow(item)
          }, (item: ChatItem) => item.id)

          // Bottom spacer
          Column().layoutWeight(1).height(20)
        }
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  messageStatsChart() {
    Column() {
      Row() {
        Column() {
          Text(this.statConfig.title)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
            .alignSelf(ItemAlign.Start)
          Row() {
            Column() {
              Text(this.statConfig.totalValue)
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.primary)
                .alignSelf(ItemAlign.Start)
              Text(this.statConfig.totalLabel)
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .alignSelf(ItemAlign.Start)
            }
            .margin({ right: 24 })

            Column() {
              Text(this.statConfig.avgValue)
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3B7CFF')
                .alignSelf(ItemAlign.Start)
              Text(this.statConfig.avgLabel)
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .alignSelf(ItemAlign.Start)
            }
          }
          .margin({ top: 8 })
          .alignItems(VerticalAlign.Center)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)

        // Add button
        Column() {
          Text('➕')
            .fontSize(24)
        }
        .width(44)
        .height(44)
        .borderRadius(22)
        .backgroundColor(this.colors.primary)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          this.showAddModal = true
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)

      // Bar chart
      Row() {
        ForEach(this.chartData, (item: ChartBarItem) => {
          Column() {
            // Bar
            Column()
              .width(20)
              .height(item.value * 1.8)
              .borderRadius(4)
              .backgroundColor(item.value > 70 ? this.colors.primary : this.colors.light)

            Text(item.day)
              .fontSize(10)
              .fontColor(this.colors.textSecondary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }, (item: ChartBarItem) => item.day)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(VerticalAlign.Bottom)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(this.colors.card)
    .borderRadius(0)
  }

  @Builder
  chatItemRow(item: ChatItem) {
    Row() {
      // Avatar with online status
      Stack({ alignContent: Alignment.BottomEnd }) {
        Text(item.avatar)
          .fontSize(32)
          .width(48)
          .height(48)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor(item.type === 'group' ? '#E8F5E9' : '#F0F0F0')

        if (item.online) {
          Row()
            .width(12)
            .height(12)
            .borderRadius(6)
            .backgroundColor(this.colors.online)
            .border({ width: 2, color: this.colors.card })
        }
      }
      .width(48)
      .height(48)
      .margin({ right: 12 })

      // Chat info
      Column() {
        Row() {
          Text(item.name)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(item.time)
            .fontSize(12)
            .fontColor(this.colors.textSecondary)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Row() {
          Text(item.lastMessage)
            .fontSize(13)
            .fontColor(this.colors.textSecondary)
            .layoutWeight(1)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          if (item.isMuted) {
            Text('🔕')
              .fontSize(12)
              .margin({ right: 6 })
          }

          if (item.unreadCount > 0) {
            Text(item.unreadCount > 99 ? '99+' : item.unreadCount.toString())
              .fontSize(11)
              .fontColor(Color.White)
              .backgroundColor(item.isMuted ? this.colors.textSecondary : this.colors.unread)
              .borderRadius(10)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .textAlign(TextAlign.Center)
          }
        }
        .width('100%')
        .margin({ top: 4 })
        .alignItems(VerticalAlign.Center)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor(item.isPinned ? '#F5FFF7' : this.colors.card)
    .onClick(() => {
      this.selectedChatId = item.id
      this.editChatName = item.name
      this.showDetailModal = true
      this.detailMuted = item.isMuted
      this.detailPinned = item.isPinned
    })
  }

  // ===================== Contacts Tab =====================

  @Builder
  contactsTabContent() {
    Column() {
      // Search bar
      Row() {
        Row() {
          Text('🔍')
            .fontSize(14)
          TextInput({ text: this.contactSearchKeyword, placeholder: '搜索联系人' })
            .fontSize(14)
            .backgroundColor(Color.Transparent)
            .layoutWeight(1)
            .margin({ left: 8 })
            .onChange((value: string) => {
              this.contactSearchKeyword = value
            })
        }
        .width('100%')
        .height(36)
        .borderRadius(8)
        .backgroundColor('#F5F5F5')
        .padding({ left: 12, right: 12 })
        .alignItems(VerticalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor(this.colors.card)

      Scroll() {
        Column() {
          // Quick entries
          Row() {
            Column() {
              Text('👥')
                .fontSize(28)
              Text('新的朋友')
                .fontSize(12)
                .fontColor(this.colors.textPrimary)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column() {
              Text('📈')
                .fontSize(28)
              Text('群聊')
                .fontSize(12)
                .fontColor(this.colors.textPrimary)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column() {
              Text('🏷️')
                .fontSize(28)
              Text('标签')
                .fontSize(12)
                .fontColor(this.colors.textPrimary)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column() {
              Text('🏢')
                .fontSize(28)
              Text('公众号')
                .fontSize(12)
                .fontColor(this.colors.textPrimary)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 16, bottom: 16 })
          .backgroundColor(this.colors.card)

          // Starred contacts header
          Row() {
            Text('⭐ 星标好友')
              .fontSize(13)
              .fontWeight(FontWeight.Medium)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, top: 10, bottom: 6 })

          // Starred contacts
          ForEach(this.contactList.filter((item: ContactItem) => item.isStarred), (item: ContactItem) => {
            this.contactRow(item)
          }, (item: ContactItem) => item.id)

          // All contacts header
          Row() {
            Text('📋 全部联系人')
              .fontSize(13)
              .fontWeight(FontWeight.Medium)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, top: 10, bottom: 6 })

          // Grouped contacts
          ForEach(this.getGroupedContactLetters(), (letter: string) => {
            Column() {
              Text(letter)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.textSecondary)
                .alignSelf(ItemAlign.Start)
                .padding({ left: 16, top: 8, bottom: 4 })

              ForEach(this.getContactsByLetter(letter), (item: ContactItem) => {
                this.contactRow(item)
              }, (item: ContactItem) => item.id)
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
          }, (letter: string) => letter)

          Column().layoutWeight(1).height(20)
        }
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  contactRow(item: ContactItem) {
    Row() {
      Text(item.avatar)
        .fontSize(32)
        .width(44)
        .height(44)
        .textAlign(TextAlign.Center)
        .borderRadius(8)
        .backgroundColor('#F5F5F5')
        .margin({ right: 12 })

      Column() {
        Row() {
          Text(item.name)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)

          if (item.isStarred) {
            Text('⭐')
              .fontSize(12)
          }
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Row() {
          Text(item.tag)
            .fontSize(11)
            .fontColor(this.colors.primary)
            .backgroundColor('#E8F5E9')
            .borderRadius(4)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })

          Text('· ' + item.region + ' · ' + item.phone)
            .fontSize(11)
            .fontColor(this.colors.textSecondary)
            .margin({ left: 6 })
            .layoutWeight(1)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .width('100%')
        .margin({ top: 4 })
        .alignItems(VerticalAlign.Center)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor(this.colors.card)
    .onClick(() => {
      this.selectedContactId = item.id
    })
  }

  // ===================== Discover Tab =====================

  @Builder
  discoverTabContent() {
    Scroll() {
      Column() {
        // Discover items
        ForEach(this.discoverList, (item: DiscoverItem) => {
          Row() {
            Row() {
              Text(item.icon)
                .fontSize(22)
                .width(36)
                .height(36)
                .textAlign(TextAlign.Center)
                .borderRadius(8)
                .backgroundColor(item.color + '20')
            }
            .margin({ right: 12 })

            Column() {
              Text(item.title)
                .fontSize(15)
                .fontWeight(FontWeight.Medium)
                .fontColor(this.colors.textPrimary)
                .alignSelf(ItemAlign.Start)
              Text(item.desc)
                .fontSize(12)
                .fontColor(this.colors.textSecondary)
                .margin({ top: 2 })
                .alignSelf(ItemAlign.Start)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text('›')
              .fontSize(20)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .backgroundColor(this.colors.card)
          .border({ width: 0.5, color: this.colors.border })
        }, (item: DiscoverItem) => item.id)

        // Mini programs header
        Row() {
          Text('🧩 小程序')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ left: 16, top: 20, bottom: 8 })

        // Mini programs grid
        Row() {
          ForEach(this.miniProgramList.slice(0, 3), (item: MiniProgramItem) => {
            this.miniProgramCard(item)
          }, (item: MiniProgramItem) => item.id)
        }
        .width('100%')
        .padding({ left: 8, right: 8 })
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          ForEach(this.miniProgramList.slice(3, 6), (item: MiniProgramItem) => {
            this.miniProgramCard(item)
          }, (item: MiniProgramItem) => item.id)
        }
        .width('100%')
        .padding({ left: 8, right: 8, top: 8 })
        .justifyContent(FlexAlign.SpaceBetween)

        Column().layoutWeight(1).height(20)
      }
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .backgroundColor(this.colors.bg)
  }

  @Builder
  miniProgramCard(item: MiniProgramItem) {
    Column() {
      Text(item.icon)
        .fontSize(32)
        .width(48)
        .height(48)
        .textAlign(TextAlign.Center)
        .borderRadius(12)
        .backgroundColor(item.color + '20')

      Text(item.name)
        .fontSize(12)
        .fontColor(this.colors.textPrimary)
        .margin({ top: 6 })
        .maxLines(1)

      Text(item.category)
        .fontSize(10)
        .fontColor(this.colors.textSecondary)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .padding({ top: 12, bottom: 12 })
    .backgroundColor(this.colors.card)
    .borderRadius(12)
    .margin({ left: 4, right: 4 })
    .alignItems(HorizontalAlign.Center)
  }

  // ===================== Moments Tab =====================

  @Builder
  momentsTabContent() {
    Scroll() {
      Column() {
        // Header banner
        Column() {
          Row() {
            Text('🌅')
              .fontSize(40)
              .width('100%')
              .height(120)
              .textAlign(TextAlign.Center)
              .backgroundColor('#B3E5FC')
          }
          .width('100%')

          Row() {
            Column() {
              Text(this.profileConfig.name)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.textPrimary)
                .alignSelf(ItemAlign.End)
            }
            .margin({ right: 12, bottom: 8 })

            Text(this.profileConfig.avatar)
              .fontSize(36)
              .width(60)
              .height(60)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .border({ width: 3, color: this.colors.card })
          }
          .width('100%')
          .justifyContent(FlexAlign.End)
          .padding({ right: 16 })
          .margin({ top: -30 })
        }
        .width('100%')
        .backgroundColor(this.colors.card)

        // Moments list
        ForEach(this.momentList, (item: MomentItem) => {
          this.momentCard(item)
        }, (item: MomentItem) => item.id)

        Column().layoutWeight(1).height(20)
      }
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .backgroundColor(this.colors.bg)
  }

  @Builder
  momentCard(item: MomentItem) {
    Column() {
      Row() {
        Text(item.author.charAt(0) + item.author.charAt(1))
          .fontSize(20)
          .width(44)
          .height(44)
          .textAlign(TextAlign.Center)
          .borderRadius(8)
          .backgroundColor(this.colors.light)
          .margin({ right: 12 })
        Column() {
          Text(item.author)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor('#576B95')
            .alignSelf(ItemAlign.Start)
          Row() {
            Text(item.location)
              .fontSize(11)
              .fontColor(this.colors.textSecondary)
            Text(' · ' + item.time)
              .fontSize(11)
              .fontColor(this.colors.textSecondary)
          }
          .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)

      // Content
      Text(item.content)
        .fontSize(14)
        .fontColor(this.colors.textPrimary)
        .width('100%')
        .margin({ top: 8 })
        .lineHeight(22)

      // Images
      if (item.images > 0) {
        this.momentImages(item.images)
      }

      // Actions
      Row() {
        Text('📍 ' + item.location)
          .fontSize(11)
          .fontColor(this.colors.textSecondary)
          .layoutWeight(1)

        Row() {
          Text(this.momentLikedId === item.id ? '❤️' : '🤍')
            .fontSize(16)
          Text(' ' + (this.momentLikedId === item.id ? item.likes + 1 : item.likes).toString())
            .fontSize(13)
            .fontColor(this.colors.textSecondary)
        }
        .padding({ left: 12, right: 12, top: 4, bottom: 4 })
        .borderRadius(4)
        .backgroundColor('#F7F7F7')
        .onClick(() => {
          this.momentLikedId = item.id
        })

        Row() {
          Text('💬')
            .fontSize(16)
          Text(' ' + item.comments.toString())
            .fontSize(13)
            .fontColor(this.colors.textSecondary)
        }
        .padding({ left: 12, right: 12, top: 4, bottom: 4 })
        .borderRadius(4)
        .backgroundColor('#F7F7F7')
        .margin({ left: 8 })
      }
      .width('100%')
      .margin({ top: 12 })
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(this.colors.card)
    .border({ width: 0.5, color: this.colors.border })
  }

  @Builder
  momentImages(count: number) {
    Column() {
      if (count === 1) {
        Row() {
          Text('🖼️')
            .fontSize(40)
            .width(120)
            .height(120)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
        }
        .margin({ top: 8 })
      } else if (count === 2) {
        Row() {
          Text('🖼️')
            .fontSize(24)
            .layoutWeight(1)
            .height(100)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ right: 4 })
          Text('🖼️')
            .fontSize(24)
            .layoutWeight(1)
            .height(100)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 4 })
        }
        .margin({ top: 8 })
      } else if (count === 3) {
        Row() {
          Text('🖼️')
            .fontSize(20)
            .layoutWeight(1)
            .height(90)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ right: 4 })
          Text('🖼️')
            .fontSize(20)
            .layoutWeight(1)
            .height(90)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 4, right: 4 })
          Text('🖼️')
            .fontSize(20)
            .layoutWeight(1)
            .height(90)
            .textAlign(TextAlign.Center)
            .borderRadius(8)
            .backgroundColor('#F0F0F0')
            .margin({ left: 4 })
        }
        .margin({ top: 8 })
      } else {
        Column() {
          Row() {
            Text('🖼️')
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .margin({ right: 2 })
            Text('🖼️')
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .margin({ left: 2, right: 2 })
            Text('🖼️')
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .margin({ left: 2 })
          }
          .width('100%')
          Row() {
            Text('🖼️')
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .margin({ right: 2, top: 4 })
            Text('🖼️')
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F0F0F0')
              .margin({ left: 2, right: 2, top: 4 })
            Text('+' + (count - 5).toString())
              .fontSize(18)
              .layoutWeight(1)
              .height(80)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#E0E0E0')
              .fontColor(this.colors.textSecondary)
              .margin({ left: 2, top: 4 })
          }
          .width('100%')
        }
        .margin({ top: 8 })
      }
    }
    .width('100%')
  }

  // ===================== Mine Tab =====================

  @Builder
  mineTabContent() {
    Scroll() {
      Column() {
        // Profile card
        Column() {
          Row() {
            Column() {
              Text(this.profileConfig.name)
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White)
                .alignSelf(ItemAlign.Start)

              Row() {
                Text('ID: ' + this.profileConfig.id)
                  .fontSize(12)
                  .fontColor('#E0FFE0')
              }
              .margin({ top: 4 })

              Row() {
                Text('📍 ' + this.profileConfig.region)
                  .fontSize(12)
                  .fontColor('#E0FFE0')
              }
              .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 20 })

            Text(this.profileConfig.avatar)
              .fontSize(40)
              .width(64)
              .height(64)
              .textAlign(TextAlign.Center)
              .borderRadius(12)
              .backgroundColor('#FFFFFF40')
              .margin({ right: 20 })
          }
          .width('100%')
          .padding({ top: 40, bottom: 20 })
          .backgroundColor(this.colors.primary)
          .alignItems(VerticalAlign.Center)

          // Wallet card
          Row() {
            Column() {
              Text('💰 钱包余额')
                .fontSize(12)
                .fontColor(this.colors.textSecondary)
                .alignSelf(ItemAlign.Start)
              Text(this.walletConfig.balance)
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor(this.colors.textPrimary)
                .margin({ top: 4 })
                .alignSelf(ItemAlign.Start)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Row() {
              Column() {
                Text(this.walletConfig.cards.toString())
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.colors.textPrimary)
                Text('卡券')
                  .fontSize(11)
                  .fontColor(this.colors.textSecondary)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center)
              .margin({ right: 20 })

              Column() {
                Text(this.walletConfig.points.toString())
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.colors.textPrimary)
                Text('积分')
                  .fontSize(11)
                  .fontColor(this.colors.textSecondary)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center)
            }
          }
          .width('100%')
          .padding(16)
          .backgroundColor(this.colors.card)
        }
        .width('100%')

        // Settings list
        ForEach(this.settingsList, (item: SettingsItem) => {
          Row() {
            Row() {
              Text(item.icon)
                .fontSize(22)
                .width(36)
                .height(36)
                .textAlign(TextAlign.Center)
                .borderRadius(8)
                .backgroundColor(item.color + '20')
            }
            .margin({ right: 12 })

            Text(item.title)
              .fontSize(15)
              .fontColor(this.colors.textPrimary)
              .layoutWeight(1)

            Text(item.desc)
              .fontSize(12)
              .fontColor(this.colors.textSecondary)

            Text(' ›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
              .margin({ left: 8 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14, bottom: 14 })
          .backgroundColor(this.colors.card)
          .border({ width: 0.5, color: this.colors.border })
        }, (item: SettingsItem) => item.id)

        // Favorites section
        Row() {
          Text('⭐ 我的收藏')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ left: 16, top: 20, bottom: 8 })

        ForEach(this.favoriteList, (item: FavoriteItem) => {
          Row() {
            Text(item.icon)
              .fontSize(22)
              .width(36)
              .height(36)
              .textAlign(TextAlign.Center)
              .borderRadius(8)
              .backgroundColor('#F5F5F5')
              .margin({ right: 12 })

            Column() {
              Text(item.title)
                .fontSize(14)
                .fontColor(this.colors.textPrimary)
                .alignSelf(ItemAlign.Start)
              Text(item.category)
                .fontSize(11)
                .fontColor(this.colors.textSecondary)
                .margin({ top: 2 })
                .alignSelf(ItemAlign.Start)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text('›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 10 })
          .backgroundColor(this.colors.card)
          .border({ width: 0.5, color: this.colors.border })
        }, (item: FavoriteItem) => item.id)

        Column().layoutWeight(1).height(20)
      }
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .backgroundColor(this.colors.bg)
  }

  // ===================== Bottom Tab Bar =====================

  @Builder
  bottomTabBar() {
    Row() {
      ForEach([0, 1, 2, 3, 4], (idx: number) => {
        Column() {
          Text(this.getTabIcon(idx))
            .fontSize(24)
          Text(this.getTabLabel(idx))
            .fontSize(11)
            .fontColor(this.currentTab === idx ? this.colors.primary : this.colors.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 6, bottom: 6 })
        .onClick(() => {
          this.currentTab = idx
        })
      }, (idx: number) => idx.toString())
    }
    .width('100%')
    .backgroundColor(this.colors.card)
    .border({ width: { top: 0.5 }, color: this.colors.border })
  }

  // ===================== Modal Overlay =====================

  @Builder
  modalOverlay() {
    if (this.showAddModal) {
      this.addChatModal()
    } else if (this.showEditModal) {
      this.editChatModal()
    } else if (this.showDeleteModal) {
      this.deleteConfirmModal()
    } else if (this.showDetailModal) {
      this.chatDetailModal()
    } else if (this.showNewChatModal) {
      this.newChatModal()
    }
  }

  @Builder
  addChatModal() {
    Column() {
      // Overlay background
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#80000000')
        .onClick(() => {
          this.showAddModal = false
        })

      // Modal content
      Column() {
        Row() {
          Text('新建聊天')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ top: 16, bottom: 12 })
        .justifyContent(FlexAlign.Center)

        Column() {
          this.newChatOption('👤', '发起单聊', '与好友一对一聊天')
          this.newChatOption('👥', '发起群聊', '创建群组,邀请好友加入')
          this.newChatOption('📞', '语音通话', '发起语音/视频通话')
          this.newChatOption('📄', '添加联系人', '搜索或扫码添加好友')
        }
        .width('100%')
        .backgroundColor('#F5F5F5')
        .borderRadius(8)

        Row() {
          Text('取消')
            .fontSize(16)
            .fontColor(this.colors.textPrimary)
            .padding({ top: 12, bottom: 12 })
        }
        .width('100%')
        .backgroundColor(this.colors.card)
        .borderRadius(8)
        .margin({ top: 8 })
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showAddModal = false
        })
      }
      .width('80%')
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor(this.colors.card)
      .borderRadius(12)
      .padding(12)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  newChatOption(icon: string, title: string, desc: string) {
    Row() {
      Text(icon)
        .fontSize(22)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .margin({ right: 12 })

      Column() {
        Text(title)
          .fontSize(14)
          .fontColor(this.colors.textPrimary)
          .alignSelf(ItemAlign.Start)
        Text(desc)
          .fontSize(11)
          .fontColor(this.colors.textSecondary)
          .margin({ top: 2 })
          .alignSelf(ItemAlign.Start)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Text('›')
        .fontSize(18)
        .fontColor(this.colors.textSecondary)
    }
    .width('100%')
    .padding({ top: 12, bottom: 12, left: 12, right: 12 })
    .backgroundColor(this.colors.card)
    .borderRadius(8)
    .margin({ bottom: 4 })
    .onClick(() => {
      this.showAddModal = false
      this.showNewChatModal = true
    })
  }

  @Builder
  newChatModal() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#80000000')
        .onClick(() => {
          this.showNewChatModal = false
        })

      Column() {
        Row() {
          Text('选择联系人')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor(this.colors.textSecondary)
            .onClick(() => {
              this.showNewChatModal = false
            })
        }
        .width('100%')
        .padding({ top: 16, bottom: 12, left: 16, right: 16 })

        Scroll() {
          Column() {
            ForEach(this.contactList.slice(0, 10), (item: ContactItem) => {
              Row() {
                Text(item.avatar)
                  .fontSize(28)
                  .width(40)
                  .height(40)
                  .textAlign(TextAlign.Center)
                  .borderRadius(8)
                  .backgroundColor('#F5F5F5')
                  .margin({ right: 12 })

                Column() {
                  Text(item.name)
                    .fontSize(14)
                    .fontColor(this.colors.textPrimary)
                    .alignSelf(ItemAlign.Start)
                  Text(item.region)
                    .fontSize(11)
                    .fontColor(this.colors.textSecondary)
                    .margin({ top: 2 })
                    .alignSelf(ItemAlign.Start)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)

                Text('○')
                  .fontSize(18)
                  .fontColor(this.colors.border)
              }
              .width('100%')
              .padding({ left: 16, right: 16, top: 10, bottom: 10 })
              .border({ width: 0.5, color: this.colors.border })
              .onClick(() => {
                this.showNewChatModal = false
              })
            }, (item: ContactItem) => item.id)
          }
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)

        Row() {
          Text('确定')
            .fontSize(16)
            .fontColor(Color.White)
            .padding({ top: 12, bottom: 12 })
        }
        .width('100%')
        .backgroundColor(this.colors.primary)
        .borderRadius(8)
        .margin({ top: 8, left: 16, right: 16, bottom: 16 })
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showNewChatModal = false
        })
      }
      .width('90%')
      .height('60%')
      .backgroundColor(this.colors.card)
      .borderRadius(12)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  editChatModal() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#80000000')
        .onClick(() => {
          this.showEditModal = false
        })

      Column() {
        Row() {
          Text('编辑聊天名称')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ top: 20, bottom: 16 })
        .justifyContent(FlexAlign.Center)

        Column() {
          TextInput({ text: this.editChatName, placeholder: '请输入聊天名称' })
            .fontSize(15)
            .borderRadius(8)
            .backgroundColor('#F5F5F5')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .onChange((value: string) => {
              this.editChatName = value
            })
        }
        .width('100%')
        .padding({ left: 20, right: 20 })

        Row() {
          Text('取消')
            .fontSize(15)
            .fontColor(this.colors.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.showEditModal = false
            })

          Column()
            .width(0.5)
            .height(40)
            .backgroundColor(this.colors.border)

          Text('确定')
            .fontSize(15)
            .fontColor(this.colors.primary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.showEditModal = false
            })
        }
        .width('100%')
        .margin({ top: 16 })
        .border({ width: { top: 0.5 }, color: this.colors.border })
      }
      .width('80%')
      .backgroundColor(this.colors.card)
      .borderRadius(12)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  deleteConfirmModal() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#80000000')
        .onClick(() => {
          this.showDeleteModal = false
        })

      Column() {
        Row() {
          Text('确认删除')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
        }
        .width('100%')
        .padding({ top: 20, bottom: 16 })
        .justifyContent(FlexAlign.Center)

        Text('删除后聊天记录将无法恢复,确定删除该聊天吗?')
          .fontSize(14)
          .fontColor(this.colors.textSecondary)
          .textAlign(TextAlign.Center)
          .padding({ left: 20, right: 20, bottom: 16 })
          .lineHeight(22)

        Row() {
          Text('取消')
            .fontSize(15)
            .fontColor(this.colors.textSecondary)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.showDeleteModal = false
            })

          Column()
            .width(0.5)
            .height(40)
            .backgroundColor(this.colors.border)

          Text('删除')
            .fontSize(15)
            .fontColor(this.colors.unread)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .onClick(() => {
              this.showDeleteModal = false
            })
        }
        .width('100%')
        .border({ width: { top: 0.5 }, color: this.colors.border })
      }
      .width('75%')
      .backgroundColor(this.colors.card)
      .borderRadius(12)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  chatDetailModal() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#80000000')
        .onClick(() => {
          this.showDetailModal = false
        })

      Column() {
        // Header
        Row() {
          Text('聊天信息')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
            .layoutWeight(1)

          Text('✕')
            .fontSize(18)
            .fontColor(this.colors.textSecondary)
            .onClick(() => {
              this.showDetailModal = false
            })
        }
        .width('100%')
        .padding({ top: 16, bottom: 12, left: 16, right: 16 })

        // Avatar and name
        Column() {
          Text('👨')
            .fontSize(48)
            .width(72)
            .height(72)
            .textAlign(TextAlign.Center)
            .borderRadius(16)
            .backgroundColor('#F0F0F0')

          Text(this.editChatName)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.colors.textPrimary)
            .margin({ top: 8 })

          Text('ID: ' + this.selectedChatId)
            .fontSize(12)
            .fontColor(this.colors.textSecondary)
            .margin({ top: 4 })
        }
        .width('100%')
        .padding({ top: 20, bottom: 20 })
        .alignItems(HorizontalAlign.Center)

        // Settings
        Column() {
          Row() {
            Text('消息免打扰')
              .fontSize(14)
              .fontColor(this.colors.textPrimary)
              .layoutWeight(1)

            Text(this.detailMuted ? '已开启 🔕' : '已关闭')
              .fontSize(13)
              .fontColor(this.detailMuted ? this.colors.primary : this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .border({ width: 0.5, color: this.colors.border })
          .onClick(() => {
            this.detailMuted = !this.detailMuted
          })

          Row() {
            Text('置顶聊天')
              .fontSize(14)
              .fontColor(this.colors.textPrimary)
              .layoutWeight(1)

            Text(this.detailPinned ? '已置顶 📌' : '未置顶')
              .fontSize(13)
              .fontColor(this.detailPinned ? this.colors.primary : this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .border({ width: 0.5, color: this.colors.border })
          .onClick(() => {
            this.detailPinned = !this.detailPinned
          })

          Row() {
            Text('修改名称')
              .fontSize(14)
              .fontColor(this.colors.textPrimary)
              .layoutWeight(1)
            Text('›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .border({ width: 0.5, color: this.colors.border })
          .onClick(() => {
            this.showDetailModal = false
            this.showEditModal = true
          })

          Row() {
            Text('清空聊天记录')
              .fontSize(14)
              .fontColor(this.colors.unread)
              .layoutWeight(1)
            Text('›')
              .fontSize(18)
              .fontColor(this.colors.textSecondary)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          .onClick(() => {
            this.showDetailModal = false
            this.showDeleteModal = true
          })
        }
        .width('100%')
        .backgroundColor('#F5F5F5')
        .borderRadius(8)
        .margin({ left: 16, right: 16, bottom: 16 })
      }
      .width('85%')
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor(this.colors.card)
      .borderRadius(12)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  // ===================== Helper Methods =====================

  getTabIcon(idx: number): string {
    if (idx === 0) return '💬'
    if (idx === 1) return '👥'
    if (idx === 2) return '🔍'
    if (idx === 3) return '🌟'
    return '👤'
  }

  getTabLabel(idx: number): string {
    if (idx === 0) return '聊天'
    if (idx === 1) return '通讯录'
    if (idx === 2) return '发现'
    if (idx === 3) return '朋友圈'
    return '我的'
  }

  getGroupedContactLetters(): string[] {
    let letters: string[] = ['A', 'C', 'D', 'F', 'H', 'J', 'L', 'M', 'N', 'O', 'P', 'Q', 'S', 'T', 'W', 'X', 'Y', 'Z']
    return letters
  }

  getContactsByLetter(letter: string): ContactItem[] {
    return this.contactList.filter((item: ContactItem) => {
      return item.pinyin.charAt(0).toUpperCase() === letter
    })
  }
}

16.3 可改进方向

虽然这个应用在功能完整性和代码质量上都达到了较高水平,但仍有几个方向可以进一步优化:

在这里插入图片描述

数据层与视图层的分离。目前所有数据都以 private 属性硬编码在组件中,这在实际项目中是不可持续的。应该将数据获取逻辑抽取到独立的数据服务层,通过网络请求或本地数据库异步加载。

组件的进一步拆分。虽然 Builder 方法实现了逻辑上的模块化,但所有 UI 代码仍集中在一个组件中。对于更大规模的应用,应该将各功能模块拆分为独立的子组件,通过 @Prop、@Link 等装饰器实现组件间的数据传递。

搜索功能的完整实现。目前搜索框虽然绑定了状态变量,但列表渲染并未实际使用搜索关键词进行过滤。应该在 ForEach 的数据源中加入基于 searchKeyword 的过滤逻辑。

动画与过渡效果的增强。当前应用缺少页面切换动画、弹窗出现/消失动画等过渡效果。可以通过 ArkTS 的 animateTo 和 transition 等 API 来增强用户体验。

更多推荐