一、引言:为什么我们需要关注二手交易市场应用

在移动互联网已经深度渗透到人们生活每一个角落的今天,"闲置经济"正在以前所未有的速度崛起。随着消费观念的升级和环保意识的觉醒,越来越多的人开始接受"把不用的东西转给需要它的人"这一理念。从大学生毕业季的课本流转,到都市白领搬家时的家具转让,再到年轻父母处理孩子长大后的母婴用品,二手交易已经成为一种兼具经济价值与社会价值的日常行为。

在这里插入图片描述

然而,市面上的二手交易平台虽然不少,但真正能做到"轻量、好看、好用"的应用却并不多见。许多应用要么功能臃肿、要么界面陈旧、要么交互僵硬。正是在这样的背景下,本文要拆解的这个"跳蚤橙风"智慧二手交易市场应用就显得格外有研究价值。它以一套完整的、声明式的、组件化的代码,向我们展示了如何用现代移动端 UI 框架构建一个既有颜值又有逻辑的二手交易平台。

1.1 这个应用解决什么问题

这个应用面向的是典型的 C2C(个人对个人)二手交易场景。它覆盖了用户在一个完整交易闭环中几乎所有的核心诉求:

  • 浏览与发现:用户可以在首页通过双列瀑布流快速扫视大量闲置商品,也可以通过分类网格精准定位某一品类的商品。
  • 检索与筛选:顶部提供了搜索栏和横向滚动的分类筛选标签,让用户在海量商品中快速找到目标。
  • 沟通与议价:消息模块模拟了买卖双方的聊天场景,并且贴心地展示了出价信息,让议价过程一目了然。
  • 个人管理:在"我的"页面,用户可以查看自己的统计数据、价格区间分布、快捷操作入口以及各类设置项。
  • 商品发布与编辑:通过弹窗形式提供发布、编辑、下架等完整的商品生命周期管理能力。
  • 商品详情查看:点击任意商品卡片即可弹出详情面板,展示价格、成色、卖家、描述、标签等完整信息。

1.2 为什么选择这套技术栈

本应用采用 HarmonyOS(鸿蒙)的 ArkUI 声明式开发范式,使用基于 TypeScript 扩展而来的 ArkTS 语言。选择这套技术栈有几方面考量:

第一,声明式 UI 是当前前端开发的主流范式。无论是 Flutter、SwiftUI 还是 Jetpack Compose,都在拥抱"状态驱动视图"的理念。ArkUI 的 build() 方法、@State@Builder@Component 等装饰器,与这些现代框架的设计哲学高度一致,学习曲线平滑,迁移成本低。

第二,ArkUI 提供了丰富的原生能力,如 linearGradient 渐变、shadow 阴影、animation 动画、Scroll 滚动容器等,能够轻松实现高质量的视觉效果,无需引入额外的样式库。

第三,@Observed 装饰器为数据类提供了可观测能力,结合 @State 可以实现数据和视图的自动联动,开发者只需关注"状态长什么样",而不需要手动操作 DOM 或调用刷新方法,大大降低了出错概率。

1.3 本文的阅读方式

接下来,本文将按照代码从上到下的顺序,把整个应用拆解为若干个层次:接口定义层、可观察数据类层、配置层、纯函数工具层、枚举层、模拟数据层、主入口组件层、四个 Tab 子组件层以及若干个补充展示组件层。每一层都会先给出设计动机,再贴出代表性代码片段,然后逐行解释其含义和设计意图,最后点出值得借鉴的工程实践。文末会以一张关键特性对比表格进行横向总结,并给出整体性的技术反思。


二、整体架构概览

在深入细节之前,我们先用一张"分层地图"来建立全局认知。整个应用代码可以被清晰地划分为以下几层:

  • 接口定义层:用 interface 定义各种"元数据"结构,描述分类、状态、成色、价格区间、消息、快捷操作、统计项等的形状。
  • 可观察数据类层:用 @Observed class 定义 ProductItemMessageItemCategoryItem 三个核心业务实体,它们是整个应用的数据骨架。
  • 配置层:用 Record<string, XXX> 把"分类、状态、成色"等枚举值与对应的视觉元数据(颜色、图标、背景)绑定起来,形成一张可查表。
  • 纯函数工具层:把"根据分类取颜色"“格式化价格”"计算折扣率"这类无副作用的逻辑抽成全局函数,做到一处定义、处处复用。
  • 枚举层:用 enum MarketTab 把底部四个 Tab 的索引收敛为有语义的常量,避免魔法数字。
  • 模拟数据层:用硬编码的方式构造 25 条商品、12 条消息、8 个分类等,用于在没有后端的情况下驱动整个界面。
  • 主入口组件层@Entry @Component struct SecondHandMarketApp,承载底部 Tab 切换和四个弹窗的调度。
  • Tab 子组件层:首页、分类、消息、我的四个独立 @Component,各自负责自己的视图与交互。
  • 补充展示组件层:分类占比条、热门推荐滑块、议价进度条、交易概览卡片、发布提示横幅等,是可复用的"展示型"组件。

这种分层方式的好处在于"关注点分离":数据形状、视觉配置、业务逻辑、视图渲染各自独立,修改某一层不会牵连其他层。下面我们逐层展开。


三、接口定义层:用 interface 描述元数据的形状

3.1 为什么需要这么多 interface

在一个真实的应用里,"分类"不仅仅是一个字符串,它还关联着图标、主色、背景色;"状态"不仅是一个词,它还关联着颜色和图标;"成色"不仅是一个等级,它还关联着颜色和一段描述性文字。如果把这些属性散落在各处用 any 或裸对象传递,代码很快就会变成一团乱麻。

因此,作者在代码最顶部一口气定义了七个 interface,它们的作用是为每一类"元数据"约定一个统一的类型契约。这样无论是配置层写数据,还是工具层取数据,都有类型检查兜底,IDE 也能提供智能提示。

3.2 CategoryMeta:分类元数据

interface CategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

在这里插入图片描述

这一段定义了"商品分类"的元数据结构。

  • label 是分类的显示名称,比如"数码电子"。
  • icon 是该分类对应的 emoji 图标,比如"📱",用 emoji 的好处是无需引入图片资源,跨平台兼容性极好。
  • color 是该分类的主题色,用于文字、边框等强调元素,比如"#1565C0"是一种深蓝色。
  • bg 是该分类的浅色背景,用于卡片底色或标签底色,比如"#E3F2FD"是极浅的蓝色。

这种"主色 + 浅背景"的配对是 Material Design 的经典做法,既能保证视觉对比度,又能让界面看起来柔和舒适。

3.3 StatusMeta:状态元数据

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

在这里插入图片描述

这一段定义了"商品状态"的元数据结构。

  • label 是状态名称,比如"在售"“已售”“已下架”。
  • color 是状态对应的颜色,"在售"用橙色表示活跃,"已售"用绿色表示完成,"已下架"用灰色表示沉寂。
  • icon 是状态对应的小圆点图标,用不同颜色的圆 emoji 直观传达状态。

注意它比 CategoryMeta 少了一个 bg 字段,这是因为状态标签通常只做小范围高亮,不需要大面积背景色,体现了"按需定义字段"的克制。

3.4 ConditionMeta:成色元数据

interface ConditionMeta {
  label: string
  color: string
  desc: string
}

在这里插入图片描述

这一段定义了"商品成色"的元数据结构。

  • label 是成色名称,如"几乎全新"“轻微使用”“明显使用”。
  • color 是成色等级对应的颜色,从绿色到橙色渐变,暗示新旧程度。
  • desc 是一段补充说明,比如"仅拆封未使用"“正常使用痕迹”“有可见磨损”,帮助买家更准确地理解成色含义。

desc 字段是这个 interface 最有价值的设计,它把"成色"从一个抽象等级变成了可读的描述,极大降低了买卖双方的理解偏差。

3.5 PriceRangeMeta:价格区间元数据

interface PriceRangeMeta {
  label: string
  min: number
  max: number
  color: string
}

在这里插入图片描述

这一段定义了"价格区间"的元数据结构。

  • label 是区间的显示文本,如"0-50元"“1000以上”。
  • minmax 是区间的数值边界,用于后续可能的筛选逻辑。
  • color 是该区间柱状图的颜色,从浅绿到深红渐变,暗示价格从低到高。

这里把 min/max 作为数值字段单独抽出,而不是从 label 字符串里解析,是一个很务实的设计——将来如果要做"按价格区间筛选商品"的功能,直接比较数值即可,无需正则解析字符串。

3.6 MessageMeta、QuickActionMeta、StatMeta

interface MessageMeta {
  label: string
  unread: number
}

interface QuickActionMeta {
  icon: string
  label: string
  color: string
  bg: string
}

interface StatMeta {
  label: string
  value: string
  icon: string
}

在这里插入图片描述

这三个 interface 分别描述了消息摘要、快捷操作、统计项的形状。

  • MessageMeta 很简洁,只有标签和未读数,用于消息列表的顶部摘要。
  • QuickActionMetaCategoryMeta 结构几乎一致,都有 icon/label/color/bg 四件套,说明快捷操作也走"图标 + 主色 + 浅背景"的视觉范式。
  • StatMeta 描述的是"我的"页面里的统计卡片,包含图标、数值和标签,注意 valuestring 类型而非 number,因为统计值可能包含"12.6k"这类已格式化的文本。

四、可观察数据类层:用 @Observed 让数据"活"起来

4.1 @Observed 装饰器的作用

在 ArkUI 中,普通的 class 实例如果被赋值给 @State 变量,当其内部字段变化时,视图并不会自动刷新。而加上 @Observed 装饰器后,该类的实例就变成了"可观察对象",其字段被修改时会自动触发依赖该字段的视图更新。

这一层定义了三个核心业务实体:商品、消息、分类。它们是整个应用的数据骨架,所有视图都是围绕这三个实体展开的。

4.2 ProductItem:商品实体

@Observed
class ProductItem {
  id: number = 0
  title: string = ''
  category: string = ''
  price: number = 0
  originalPrice: number = 0
  condition: string = '几乎全新'
  status: string = '在售'
  seller: string = ''
  sellerAvatar: string = ''
  location: string = ''
  description: string = ''
  tags: string[] = []
  images: string[] = []
  views: number = 0
  likes: number = 0
  messages: number = 0
  publishDate: string = ''
  isFavorite: boolean = false
  barter: boolean = false

  constructor(
    id: number, title: string, category: string, price: number,
    originalPrice: number, condition: string, status: string,
    seller: string, sellerAvatar: string, location: string,
    description: string, tags: string[], images: string[],
    views: number, likes: number, messages: number,
    publishDate: string, isFavorite: boolean, barter: boolean
  ) {
    this.id = id; this.title = title; this.category = category
    this.price = price; this.originalPrice = originalPrice
    this.condition = condition; this.status = status
    this.seller = seller; this.sellerAvatar = sellerAvatar
    this.location = location; this.description = description
    this.tags = tags; this.images = images; this.views = views
    this.likes = likes; this.messages = messages
    this.publishDate = publishDate; this.isFavorite = isFavorite
    this.barter = barter
  }
}

在这里插入图片描述

这是整个应用最重要的数据类。让我们逐字段分析它的设计:

  • id 是商品的唯一标识,用数字表示,便于排序和定位。
  • title 是商品标题,是用户最先看到的信息,所以放在前面。
  • category 是商品所属分类,值为"数码电子""家居生活"等,与配置层的 key 对应。
  • price 是当前售价,originalPrice 是原价,两者配合可以计算折扣率,这是二手交易中最吸引买家的信息。
  • condition 是成色,默认值设为"几乎全新",这是一种"乐观默认值"——大部分卖家发布时商品成色都不错。
  • status 是状态,默认值设为"在售",因为新建商品自然是上架状态。
  • sellersellerAvatar 是卖家信息,sellerAvatar 用 emoji 表示,避免了真实头像图片资源的依赖。
  • location 是交易地点,格式为"城市·区域",如"深圳·南山区",符合国内二手交易的地域习惯。
  • description 是详细描述,tags 是标签数组(如 ['iPhone', 'Apple', '5G']),images 是图片数组(这里用 emoji 占位)。
  • viewslikesmessages 分别是浏览数、收藏数、消息数,用于体现商品热度。
  • publishDate 是发布日期,isFavorite 是当前用户是否收藏,barter 是是否支持以物换物。

值得注意的是,每个字段都给了默认值(如 ''0false[]),这是非常严谨的防御性编程习惯,可以避免在数据不完整时出现 undefined 导致的渲染异常。

构造函数接收全部 19 个参数并逐一赋值。虽然参数较多,但这是为了在构造 mock 数据时能一行写完一个商品,保证了数据定义的紧凑性。

4.3 MessageItem:消息实体

@Observed
class MessageItem {
  id: number = 0
  userName: string = ''
  userAvatar: string = ''
  productTitle: string = ''
  lastMessage: string = ''
  time: string = ''
  unread: number = 0
  isOnline: boolean = false
  priceOffer: number = 0

  constructor(
    id: number, userName: string, userAvatar: string,
    productTitle: string, lastMessage: string, time: string,
    unread: number, isOnline: boolean, priceOffer: number
  ) {
    this.id = id; this.userName = userName; this.userAvatar = userAvatar
    this.productTitle = productTitle; this.lastMessage = lastMessage
    this.time = time; this.unread = unread; this.isOnline = isOnline
    this.priceOffer = priceOffer
  }
}

在这里插入图片描述

消息实体描述的是买卖双方的一次对话会话。

  • userNameuserAvatar 是对方的信息。
  • productTitle 是这次对话围绕的商品标题,这是二手交易场景的特色——每条消息都关联一个具体商品。
  • lastMessage 是最后一条消息内容,time 是时间戳文本(如"10分钟前")。
  • unread 是未读消息数,用于在列表项右侧显示红色徽标。
  • isOnline 是对方是否在线,用于在头像旁显示绿色小圆点。
  • priceOffer 是对方的出价金额,这是这个应用的一个亮点设计——把议价信息直接外显在消息列表里,买家不用点进去就知道对方还价多少。

priceOffer 为 0 时表示对方还没有出价,这是一种隐式约定。

4.4 CategoryItem:分类实体

@Observed
class CategoryItem {
  name: string = ''
  icon: string = ''
  count: number = 0
  color: string = ''
  bg: string = ''

  constructor(name: string, icon: string, count: number, color: string, bg: string) {
    this.name = name; this.icon = icon; this.count = count
    this.color = color; this.bg = bg
  }
}

在这里插入图片描述

分类实体比 CategoryMeta 多了一个 count 字段,表示该分类下有多少件商品。这说明同一个"分类"概念在不同上下文有不同的字段需求——元数据描述视觉属性,实体描述业务数据,两者通过分类名称关联。


五、配置层:用 Record 建立"枚举值—视觉元数据"映射表

5.1 Record 类型的妙用

Record<string, T> 是 TypeScript 中描述"以字符串为 key、以 T 为 value 的映射表"的工具类型。作者用它把分类名、状态名、成色名作为 key,把对应的视觉元数据作为 value,构建出几张可查表。

这种做法的好处是"数据驱动渲染":视图代码不需要写一堆 if (cat === '数码电子') color = '#1565C0' 的分支,而是直接 CATEGORY_CONFIG[cat].color 一行搞定。新增一个分类时,只需在表里加一行,视图代码完全不用改。

5.2 CATEGORY_CONFIG:分类配置表

const CATEGORY_CONFIG: Record<string, CategoryMeta> = {
  '数码电子': { label: '数码电子', icon: '📱', color: '#1565C0', bg: '#E3F2FD' },
  '家居生活': { label: '家居生活', icon: '🏠', color: '#FF6F00', bg: '#FFF3E0' },
  '服饰鞋包': { label: '服饰鞋包', icon: '👗', color: '#C62828', bg: '#FFEBEE' },
  '图书音像': { label: '图书音像', icon: '📚', color: '#2E7D32', bg: '#E8F5E9' },
  '运动户外': { label: '运动户外', icon: '🏃', color: '#00695C', bg: '#E0F2F1' },
  '母婴玩具': { label: '母婴玩具', icon: '🧸', color: '#AD1457', bg: '#FCE4EC' },
  '美妆个护': { label: '美妆个护', icon: '💄', color: '#7B1FA2', bg: '#F3E5F5' },
  '其他闲置': { label: '其他闲置', icon: '📦', color: '#795548', bg: '#EFEBE9' }
}

这里定义了 8 个分类,每个分类都有自己专属的颜色方案。

  • 数码电子用蓝色系,象征科技与冷静。
  • 家居生活用橙色系,呼应应用整体的"橙风"主题。
  • 服饰鞋包用红色系,传递时尚与热情。
  • 图书音像用绿色系,让人联想到书本的清新。
  • 运动户外用青色系,给人活力的感觉。
  • 母婴玩具用粉色系,温馨可爱。
  • 美妆个护用紫色系,优雅精致。
  • 其他闲置用棕色系,中性沉稳。

这种"一分类一色调"的设计让用户在浏览时能通过颜色快速识别分类,降低了认知负担。

5.3 STATUS_CONFIG 与 CONDITION_CONFIG

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '在售': { label: '在售', color: '#FF6F00', icon: '🟠' },
  '已售': { label: '已售', color: '#2E7D32', icon: '🟢' },
  '已下架': { label: '已下架', color: '#9E9E9E', icon: '⚫' }
}

const CONDITION_CONFIG: Record<string, ConditionMeta> = {
  '几乎全新': { label: '几乎全新', color: '#2E7D32', desc: '仅拆封未使用' },
  '轻微使用': { label: '轻微使用', color: '#FF6F00', desc: '正常使用痕迹' },
  '明显使用': { label: '明显使用', color: '#FF9800', desc: '有可见磨损' }
}

状态配置用三种颜色区分"在售/已售/已下架"三种生命周期阶段,并用对应颜色的圆点 emoji 作为图标,非常直观。

成色配置则把"几乎全新/轻微使用/明显使用"三个等级与颜色和描述绑定。绿色代表最好、橙色次之、深橙最差,颜色的深浅变化暗合成色的新旧变化。

5.4 PRICE_RANGE_CONFIG 与 CONDITION_LIST

const PRICE_RANGE_CONFIG: PriceRangeMeta[] = [
  { label: '0-50元', min: 0, max: 50, color: '#4CAF50' },
  { label: '50-200元', min: 50, max: 200, color: '#FF6F00' },
  { label: '200-500元', min: 200, max: 500, color: '#FF9800' },
  { label: '500-1000元', min: 500, max: 1000, color: '#E65100' },
  { label: '1000以上', min: 1000, max: 99999, color: '#BF360C' }
]

const CONDITION_LIST: string[] = ['几乎全新', '轻微使用', '明显使用']

价格区间配置用数组而非 Record,因为价格区间是有顺序的(从低到高),数组能保持这种顺序。每个区间都有 min/max 数值边界,最后一个区间的 max 设为 99999,相当于"无上限"。

CONDITION_LIST 是一个纯字符串数组,用于发布商品时成色选择器的选项遍历。把它单独抽出来而不是直接用 Object.keys(CONDITION_CONFIG),是为了让选项顺序可控且语义明确。


六、纯函数工具层:把无副作用逻辑抽成全局函数

6.1 为什么要抽纯函数

在组件内部写逻辑会导致两个问题:一是逻辑无法复用,二是组件代码变得臃肿。把"取颜色"“格式化”"计算"这类纯逻辑抽成全局函数,既能在任意组件中调用,又方便单独测试。

作者定义了一批纯函数,可以分为三类:配置查询类、格式化类、统计类。

6.2 配置查询函数

function getCatColor(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.color ?? '#999999'
}

function getCatBg(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.bg ?? '#F5F5F5'
}

function getCatIcon(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.icon ?? '📦'
}

function getStatusColor(st: string): string {
  return (STATUS_CONFIG[st] as StatusMeta)?.color ?? '#999'
}

function getStatusLabel(st: string): string {
  return (STATUS_CONFIG[st] as StatusMeta)?.label ?? st
}

function getConditionColor(cond: string): string {
  return (CONDITION_CONFIG[cond] as ConditionMeta)?.color ?? '#999'
}

function getConditionDesc(cond: string): string {
  return (CONDITION_CONFIG[cond] as ConditionMeta)?.desc ?? ''
}

这七个函数的模式高度一致:从对应的配置表中按 key 取值,用可选链 ?. 安全访问,再用 ?? 提供默认值。

getCatColor 为例:

  • CATEGORY_CONFIG[cat] 按 key 查表,返回值类型可能是 CategoryMetaundefined
  • as CategoryMeta 是一次类型断言,让编译器在后续访问 .color 时不报错。
  • ?.color 是可选链,如果表里查不到则返回 undefined 而非抛错。
  • ?? '#999999' 是空值合并,如果前面是 undefined 则用灰色兜底。

这种"查表 + 兜底"的写法非常稳健,即使传入了一个不存在的分类名,也不会导致界面崩溃,而是优雅降级为灰色。

6.3 格式化函数

function formatPrice(p: number): string {
  return '¥' + p.toFixed(0)
}

function formatViews(v: number): string {
  if (v >= 1000) {
    return (v / 1000).toFixed(1) + 'k'
  }
  return v.toString()
}

function computeDiscount(price: number, original: number): number {
  if (original <= 0) { return 0 }
  return Math.round((1 - price / original) * 100)
}
  • formatPrice 把数字格式化为"¥5499"这样的字符串,toFixed(0) 表示不保留小数。
  • formatViews 实现了"千位缩写":超过 1000 的浏览量显示为"2.3k",既节省空间又符合阅读习惯。
  • computeDiscount 计算折扣百分比,公式是 (1 - 现价/原价) * 100,并用 Math.round 取整。特别注意它先判断 original <= 0 返回 0,避免了除以零的异常。

6.4 统计函数

function productCount(): number { return 25 }
function soldCount(): number { return 8 }
function activeCount(): number { return 17 }
function messageCount(): number { return 15 }
function totalViews(): number { return 12580 }
function avgPrice(): number { return 186 }

function getCatProductCount(cat: string): number {
  const map: Record<string, number> = {
    '数码电子': 6, '家居生活': 5, '服饰鞋包': 4,
    '图书音像': 3, '运动户外': 3, '母婴玩具': 2,
    '美妆个护': 1, '其他闲置': 1
  }
  return map[cat] ?? 0
}

这些函数返回写死的统计数字。在真实应用中,它们应该是从后端接口获取的,但在这个演示应用中用硬编码代替,让界面有数据可显示。

getCatProductCount 用一个内部 Record 映射分类名到商品数量,与前面的配置查询函数思路一致。注意 8 个分类的数量加起来正好是 25,与 productCount() 一致,说明数据是自洽的。


七、枚举层:用 enum 消除魔法数字

enum MarketTab {
  HOME = 0,
  CATEGORY = 1,
  MESSAGES = 2,
  PROFILE = 3
}

底部 Tab 有四个,如果用 0/1/2/3 这样的裸数字来判断当前在哪个 Tab,代码可读性会很差。用 enum 把它们收敛为有语义的常量后,this.activeTab === MarketTab.HOME 一眼就能看懂。

枚举值从 0 开始递增,与底部 Tab 的排列顺序一一对应。@State activeTab: MarketTab = MarketTab.HOME 表示应用启动时默认显示首页。


八、模拟数据层:用硬编码驱动完整界面

8.1 商品数据:25 条精心设计的真实感数据

应用定义了 25 条商品数据,每一条都经过精心设计,覆盖了不同分类、不同价格、不同成色、不同状态。这里列举几条典型代表:

const mockProducts: ProductItem[] = [
  new ProductItem(1, 'iPhone 14 Pro 256G 暗紫色', '数码电子', 5499, 7999, '轻微使用',
    '在售', '科技达人小王', '🧑‍💻', '深圳·南山区',
    '自用半年,成色95新,配件齐全带原装盒,可小刀',
    ['iPhone', 'Apple', '5G'], ['📱'], 2340, 156, 23, '2026-08-01', false, false),
  new ProductItem(6, '乐高星球大战千年隼号', '母婴玩具', 899, 1699, '几乎全新',
    '已售', '乐高迷老张', '🧱', '上海·浦东新区',
    '7541颗粒,拼完展示半年,说明书完整',
    ['乐高', '星战', '收藏'], ['🧩'], 5600, 423, 89, '2026-07-28', true, false),
  new ProductItem(18, 'Celine Classic Box 中号', '服饰鞋包', 6800, 24000, '轻微使用',
    '在售', '奢侈品寄卖', '👜', '上海·静安区',
    '经典焦糖色-box皮,专柜购买带防尘袋+小票',
    ['Celine', '奢侈品', '包包'], ['👜'], 8900, 567, 123, '2026-08-01', true, false),
  // ... 其余 22 条
]

以第一条 iPhone 数据为例逐参数解读:

  • id = 1,商品编号。
  • title = 'iPhone 14 Pro 256G 暗紫色',标题包含了品牌、型号、容量、颜色,信息密度很高。
  • category = '数码电子',属于数码分类。
  • price = 5499,售价。
  • originalPrice = 7999,原价,与售价对比可算出约 31% 的折扣。
  • condition = '轻微使用',成色。
  • status = '在售',状态。
  • seller = '科技达人小王',卖家昵称带人设感。
  • sellerAvatar = '🧑‍💻',用程序员 emoji 当头像。
  • location = '深圳·南山区',地点精确到区。
  • description = '自用半年,成色95新,配件齐全带原装盒,可小刀',描述里"可小刀"是二手交易黑话,表示可以小幅砍价。
  • tags = ['iPhone', 'Apple', '5G'],三个标签。
  • images = ['📱'],用 emoji 占位。
  • views = 2340,浏览量。
  • likes = 156,收藏数。
  • messages = 23,消息数。
  • publishDate = '2026-08-01',发布日期。
  • isFavorite = false,未被当前用户收藏。
  • barter = false,不支持以物换物。

第二条乐高的 status 是"已售",用于演示已售商品的视觉样式。第三条 Celine 包包原价 24000,售价 6800,折扣力度极大,barter 字段为 false 但 isFavorite 为 true,展示了高价商品的典型形态。

25 条数据覆盖了从 49 元的《三体》到 24000 元的 Celine 包包,价格跨度极大;既有"几乎全新"也有"明显使用";既有深圳、上海等一线城市也有成都、武汉等内陆城市。这种多样性确保了界面在各种边界情况下都能正确渲染。

8.2 消息数据:12 条买卖对话

const mockMessages: MessageItem[] = [
  new MessageItem(1, '科技达人小王', '🧑‍💻', 'iPhone 14 Pro', '最低多少钱出?诚心要', '10分钟前', 3, true, 5200),
  new MessageItem(2, '潮鞋爱好者', '👟', 'AJ1 Low', '码数正好,能在福田面交吗', '30分钟前', 0, true, 380),
  // ... 其余 10 条
]

第一条消息是买家对 iPhone 的砍价:“最低多少钱出?诚心要”,出价 5200(售价 5499),未读 3 条,对方在线。这条数据完美还原了真实二手交易的议价场景。

12 条消息有的有未读、有的没有,有的在线、有的不在线,有的有出价、有的没出价,覆盖了消息列表的各种状态组合。

8.3 分类列表、快捷操作、统计项

const mockCategoryList: CategoryItem[] = [
  new CategoryItem('数码电子', '📱', 6, '#1565C0', '#E3F2FD'),
  // ... 其余 7 个
]

const quickActions: QuickActionMeta[] = [
  { icon: '📝', label: '发布', color: '#FF6F00', bg: '#FFF3E0' },
  { icon: '❤️', label: '收藏', color: '#C62828', bg: '#FFEBEE' },
  { icon: '📦', label: '在售', color: '#FF6F00', bg: '#FFF8E1' },
  { icon: '📊', label: '浏览', color: '#1565C0', bg: '#E3F2FD' }
]

const statItems: StatMeta[] = [
  { label: '在售商品', value: '17', icon: '📦' },
  { label: '已售出', value: '8', icon: '✅' },
  { label: '总浏览', value: '12.6k', icon: '👁️' },
  { label: '消息', value: '15', icon: '💬' }
]

这三个数据源分别驱动分类页的网格、"我的"页的快捷操作区和统计卡片。注意 statItems 的 value 已经是格式化后的字符串(“12.6k”),说明格式化逻辑可以在数据层就完成,视图层只管渲染。


九、主入口组件:SecondHandMarketApp

9.1 组件状态定义

@Entry
@Component
struct SecondHandMarketApp {
  @State activeTab: MarketTab = MarketTab.HOME
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedProduct: ProductItem | null = null
  @State formTitle: string = ''
  @State formCategory: string = '数码电子'
  @State formPrice: string = ''
  @State formCondition: string = '几乎全新'
  @State formDesc: string = ''

@Entry 标记这是应用的入口组件,@Component 标记这是一个自定义组件。

状态变量分三组:

  • Tab 切换:activeTab 记录当前选中的底部 Tab。
  • 弹窗控制:showAddModal/showEditModal/showDeleteModal/showDetailModal 四个布尔值分别控制四个弹窗的显示。每个弹窗用独立的状态变量控制,意味着它们可以独立开关,互不干扰。
  • 表单数据:formTitle/formCategory/formPrice/formCondition/formDesc 用于发布商品表单的输入绑定。formCategory 默认值是"数码电子",formCondition 默认值是"几乎全新",与实体的默认值保持一致。
  • 选中商品:selectedProductProductItem | null 联合类型,记录当前被点击的商品,用于详情弹窗和编辑弹窗展示。

9.2 内容区构建器

@Builder contentArea() {
  Column() {
    if (this.activeTab === MarketTab.HOME) {
      HomeTabContent({
        onProductSelect: (p: ProductItem) => { this.selectedProduct = p; this.showDetailModal = true }
      })
    } else if (this.activeTab === MarketTab.CATEGORY) {
      CategoryTabContent({
        onProductSelect: (p: ProductItem) => { this.selectedProduct = p; this.showDetailModal = true }
      })
    } else if (this.activeTab === MarketTab.MESSAGES) {
      MessagesTabContent()
    } else {
      ProfileTabContent({
        onAddClick: () => { this.showAddModal = true }
      })
    }
  }
  .layoutWeight(1)
}

@Builder 是 ArkUI 中定义"可复用 UI 片段"的装饰器。这个 contentArea 构建器根据 activeTab 的值条件渲染对应的 Tab 子组件。

注意这里使用了"回调传参"的模式:父组件把回调函数通过参数传给子组件(如 onProductSelectonAddClick),子组件在合适时机调用它,从而实现"子到父"的事件通信。这是 ArkUI 中处理组件间交互的标准做法。

onProductSelect 回调做了两件事:把选中的商品存入 selectedProduct,同时打开详情弹窗。这种"数据 + 开关"的二元操作是打开弹窗的典型模式。

.layoutWeight(1) 让内容区占据除底部 Tab 以外的所有剩余空间。

9.3 底部 Tab 项构建器

@Builder bottomTabItem(icon: string, label: string, tab: MarketTab) {
  Column() {
    Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
    Text(label).fontSize(9)
      .fontColor(this.activeTab === tab ? '#FF6F00' : '#795548')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 1 })
    if (this.activeTab === tab) {
      Column()
        .width(20).height(3).backgroundColor('#FF6F00').borderRadius(2)
        .linearGradient({
          angle: 90,
          colors: [['#FF6F00', 0], ['#FFC107', 1]]
        })
        .margin({ top: 2 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 4, bottom: 4 })
  .onClick(() => { this.activeTab = tab })
}

这个构建器把底部 Tab 的渲染逻辑封装成可复用的函数,接收图标、标签、对应的枚举值三个参数。

它的视觉设计很有讲究:

  • 图标的 opacity 在选中时为 1.0、未选中时为 0.4,用透明度差异暗示选中态。
  • 标签文字在选中时为橙色加粗、未选中时为棕色常规,用颜色和字重双重区分。
  • 选中时在标签下方额外渲染一条 20×3 的小渐变条(从橙色到金黄色),这是当前很多 App 的 Tab 指示器设计语言。
  • onClickactiveTab 切换为当前 Tab,触发整个内容区重新渲染。

linearGradientangle: 90 表示渐变方向为水平向右,colors 数组定义了两个色标:0% 处是深橙 #FF6F00,100% 处是金黄 #FFC107,形成一条温暖的渐变指示条。

9.4 发布商品弹窗

@Builder addProductModal() {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(0,0,0,0.55)')
    .onClick(() => { this.showAddModal = false })
    .position({ x: 0, y: 0 }).zIndex(998)

  Column() {
    Row() {
      Text('📝 发布闲置').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3E2723')
      Column().layoutWeight(1)
      Text('✕').fontSize(20).fontColor('#795548')
        .onClick(() => { this.showAddModal = false })
    }
    .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
    Divider().color('#FFE0B2')
    // ... 表单内容
  }
  .width('92%').constraintSize({ maxHeight: '82%' })
  .backgroundColor('#FFFFFF').borderRadius(16)
  .position({ x: '4%', y: '10%' }).zIndex(999)
}

弹窗由两个 Column 叠加而成:

第一个 Column 是遮罩层:

  • width('100%').height('100%') 让它铺满整个屏幕。
  • backgroundColor('rgba(0,0,0,0.55)') 用半透明黑色营造"变暗"的遮罩效果。
  • onClick 点击遮罩区域关闭弹窗,符合用户的直觉操作。
  • zIndex(998) 让遮罩层位于普通内容之上、弹窗面板之下。

第二个 Column 是弹窗面板本体:

  • 标题行用 Row 布局,左侧标题、右侧关闭按钮,中间用 Column().layoutWeight(1) 撑开空间,实现两端对齐。
  • constraintSize({ maxHeight: '82%' }) 限制弹窗最大高度不超过屏幕的 82%,防止内容过多时撑出屏幕。
  • position({ x: '4%', y: '10%' }) 把弹窗定位在距左 4%、距顶 10% 的位置,配合 width('92%') 实现水平居中。
  • zIndex(999) 比遮罩层高一级,确保弹窗面板显示在遮罩之上。

弹窗内部的表单包含了标题输入、分类选择(横向标签流)、价格输入(数字键盘)、成色选择(胶囊标签)、详细描述(多行文本域),覆盖了发布商品的核心字段。

分类选择器是一个亮点设计:

Row() {
  ForEach(this.getCategoryKeys(), (c: string) => {
    if (this.formCategory === c) {
      Text(getCatIcon(c) + ' ' + c).fontSize(11).fontColor('#FFFFFF')
        .backgroundColor('#FF6F00').borderRadius(10)
        .padding({ left: 7, right: 7, top: 4, bottom: 4 }).margin({ left: 2, right: 2, top: 2 })
    } else {
      Text(getCatIcon(c) + ' ' + c).fontSize(11).fontColor('#FF6F00')
        .backgroundColor(getCatBg(c)).borderRadius(10)
        .padding({ left: 7, right: 7, top: 4, bottom: 4 }).margin({ left: 2, right: 2, top: 2 })
        .onClick(() => { this.formCategory = c })
    }
  })
}

ForEach 遍历分类列表,对当前选中的分类用"橙底白字"高亮,未选中的用"分类专属浅底色 + 橙字"。每个标签前面都拼了分类图标 emoji,让选择更直观。点击未选中的标签即可切换选中态,被选中的标签没有 onClick(因为已经选中了,再点没有意义)。

9.5 编辑弹窗、下架确认弹窗、详情弹窗

编辑弹窗的结构与发布弹窗类似,但表单字段的 placeholder 用的是当前选中商品的值:

TextInput({ placeholder: this.selectedProduct?.title ?? '' })
TextInput({ placeholder: formatPrice(this.selectedProduct?.price ?? 0) })
TextArea({ placeholder: this.selectedProduct?.description ?? '' })

这里大量使用 ?. 可选链和 ?? '' 空值合并,因为 selectedProduct 可能是 null(虽然弹窗打开时它一定有值,但类型系统要求做安全处理)。

编辑弹窗还额外提供了一个"下架商品"按钮,点击后关闭编辑弹窗并打开下架确认弹窗:

Text('下架商品').fontSize(13).fontColor('#C62828')
  .backgroundColor('#FFEBEE').borderRadius(16)
  .onClick(() => { this.showEditModal = false; this.showDeleteModal = true })

下架确认弹窗是一个典型的"二次确认"对话框,用大号警告 emoji、红色提示文字和商品标题预览来强化"不可逆操作"的严肃感:

Column() {
  Text('⚠️').fontSize(48).margin({ top: 28 })
  Text('确认下架商品?').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#3E2723')
  Text('下架后其他用户将无法看到此商品').fontSize(13).fontColor('#C62828').margin({ top: 6 })
  // ... 商品标题预览 + 取消/确认按钮
}

商品详情弹窗是信息量最大的一个,它分为价格区、信息行、卖家信息、描述、标签、议价标识、底部操作栏七个区块。其中价格区的设计特别精细:

Row() {
  Column() {
    Text(formatPrice(this.selectedProduct?.price ?? 0))
      .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6F00')
  }
  Column() {
    Text('原价 ' + formatPrice(this.selectedProduct?.originalPrice ?? 0))
      .fontSize(12).fontColor('#795548')
      .decoration({ type: TextDecorationType.LineThrough })
    Text('省 ' + formatPrice((this.selectedProduct?.originalPrice ?? 0) - (this.selectedProduct?.price ?? 0)))
      .fontSize(11).fontColor('#2E7D32').margin({ top: 2 })
  }
  Column().layoutWeight(1)
  Text(computeDiscount(...) + '% off')
    .fontSize(12).fontColor('#FFFFFF')
    .backgroundColor('#FF6F00').borderRadius(12)
}

价格区一行展示了四个信息:当前售价(大号橙字)、原价(小号灰字加删除线)、节省金额(绿色小字)、折扣百分比(橙底白字胶囊)。这种"四件套"价格展示把买家的决策信息一次性给足,能有效促进转化。

9.6 build 方法:整体布局组装

build() {
  Stack() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('🏠', '首页', MarketTab.HOME)
        this.bottomTabItem('📂', '分类', MarketTab.CATEGORY)
        this.bottomTabItem('💬', '消息', MarketTab.MESSAGES)
        this.bottomTabItem('👤', '我的', MarketTab.PROFILE)
      }
      .width('100%').backgroundColor('#FFFFFF')
      .padding({ top: 4, bottom: 6 })
      .border({ width: { top: 1, bottom: 0, left: 0, right: 0 }, color: { top: '#FFE0B2', bottom: '#FFE0B2', left: '#FFE0B2', right: '#FFE0B2' } })
    }
    .width('100%').height('100%').backgroundColor('#FFF8E1')

    if (this.showAddModal) { this.addProductModal() }
    if (this.showEditModal) { this.editProductModal() }
    if (this.showDeleteModal) { this.deleteConfirmModal() }
    if (this.showDetailModal) { this.productDetailModal() }
  }
  .width('100%').height('100%')
}

最外层用 Stack(层叠容器)承载主内容和弹窗。Stack 的特性是子元素按声明顺序堆叠,后声明的覆盖在前面的之上。

主内容是一个 Column:上方是 contentArea()(占据剩余空间),下方是底部 Tab 栏(固定高度)。底部 Tab 栏的顶部边框用浅橙色 #FFE0B2 分隔,与应用主题色呼应。整体背景色是极浅的暖黄 #FFF8E1,营造温馨的"橙风"基调。

四个弹窗用 if 条件渲染叠加在主内容之上。由于 Stack 的堆叠特性,只有状态为 true 的弹窗才会显示,且后声明的弹窗会覆盖先声明的——但因为同一时间通常只有一个弹窗打开,所以覆盖顺序不会造成问题。


十、首页 Tab:双列瀑布流

10.1 组件定义与状态

@Component
struct HomeTabContent {
  onProductSelect: (product: ProductItem) => void = () => {}
  @State searchKeyword: string = ''
  @State selectedFilter: string = '全部'

  getFilterCats(): string[] {
    return ['全部', '数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具']
  }

首页组件接收一个 onProductSelect 回调(默认空函数),用于在点击商品卡片时通知父组件。自身维护两个状态:搜索关键词和选中的筛选分类。

getFilterCats 返回筛选标签列表,注意它比全部分类少了一个"美妆个护"和"其他闲置",可能是为了节省横向空间。

10.2 瀑布流卡片构建器

@Builder productWaterfallCard(p: ProductItem) {
  Column() {
    Row() {
      Text(getCatIcon(p.category)).fontSize(28)
        .width(48).height(48).backgroundColor(getCatBg(p.category)).borderRadius(10)
        .textAlign(TextAlign.Center)
      Column().layoutWeight(1)
      if (p.status === '已售') {
        Text('已售').fontSize(10).fontColor('#FFFFFF')
          .backgroundColor('#2E7D32').borderRadius(6)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      }
      if (p.barter) {
        Text('可换').fontSize(10).fontColor('#FF6F00')
          .backgroundColor('#FFF3E0').borderRadius(6)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 4 })
      }
    }
    .width('100%')

    Text(p.title).fontSize(13).fontWeight(FontWeight.Medium)
      .fontColor('#3E2723').margin({ top: 8 }).width('100%').maxLines(2)

    Row() {
      Text(formatPrice(p.price)).fontSize(17).fontWeight(FontWeight.Bold)
        .fontColor('#FF6F00')
      if (p.originalPrice > p.price) {
        Text(formatPrice(p.originalPrice)).fontSize(11).fontColor('#BCAAA4')
          .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
      }
    }
    .width('100%').margin({ top: 6 })

    Row() {
      Text(p.location ?? '').fontSize(10).fontColor('#795548')
      Column().layoutWeight(1)
      Row() {
        Text('❤️').fontSize(10)
        Text(formatViews(p.likes)).fontSize(10).fontColor('#795548').margin({ left: 2 })
      }
    }
    .width('100%').margin({ top: 4 })
  }
  .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(10)
  .shadow({ radius: 4, color: '#1A000000', offsetY: 1 })
  .onClick(() => { this.onProductSelect(p) })
}

这个卡片构建器是首页的核心视觉单元。从上到下分四层:

第一层是图标与状态徽标行。左侧是 48×48 的圆角方块,用分类专属浅色作背景、分类 emoji 作图标。右侧根据条件显示"已售"(绿底白字)和"可换"(橙底橙字)两个徽标。中间用 Column().layoutWeight(1) 把两端撑开。

第二层是商品标题,最多两行(maxLines(2)),用 FontWeight.Medium 中等字重保证可读性。

第三层是价格行,当前售价用大号橙色粗体,原价用小号灰棕色加删除线。只有当 originalPrice > price 时才显示原价,避免出现"原价等于售价还画删除线"的尴尬。

第四层是地点与收藏数行,左侧地点、右侧收藏数,用 Column().layoutWeight(1) 分隔。

卡片整体用白底圆角加阴影(shadow({ radius: 4, color: '#1A000000', offsetY: 1 })),#1A000000 中的 1A 是透明度(约 10%),让阴影柔和自然而非生硬。

10.3 首页整体布局

build() {
  Column() {
    // 顶部渐变横幅
    Column() {
      Row() {
        Column() {
          Text('🛒 跳蚤市场').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('发现身边的闲置好物').fontSize(12).fontColor('#FFE0B2').margin({ top: 3 })
        }
        Column().layoutWeight(1)
        Text('📝').fontSize(22)
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#FF6F00', 0], ['#E65100', 0.5], ['#FFC107', 1]]
    })
    // ... 搜索栏、分类筛选、双列瀑布流
  }
}

首页从上到下依次是:渐变横幅、搜索栏、横向分类筛选、双列瀑布流。

横幅用三段渐变(深橙→更深橙→金黄),angle: 135 表示从左上到右下方向,营造阳光照射的温暖感。横幅左侧是应用名和 slogan,右侧是发布按钮 emoji。

双列瀑布流的实现方式值得注意——它没有用真正的瀑布流组件,而是手动把 25 条商品按奇偶索引分到两列:

Scroll() {
  Row() {
    Column() {
      this.productWaterfallCard(mockProducts[0])
      Column().height(8)
      this.productWaterfallCard(mockProducts[3])
      // ... 索引 0,3,6,9,12,15,18,21,24
    }
    .width('48%').margin({ left: 8 })
    Column() {
      Column().height(4)
      this.productWaterfallCard(mockProducts[1])
      // ... 索引 1,4,7,10,13,16,19,22
    }
    .width('48%').margin({ left: 4, right: 8 })
  }
}

左列放索引 0,3,6,9…(间隔3),右列放索引 1,4,7,10…,两列之间用 Column().height(8) 作为间距。右列顶部多了一个 Column().height(4),让右列整体下移 4vp,形成参差错落的瀑布流视觉效果。这是一种非常巧妙的"伪瀑布流"实现,在不依赖复杂布局算法的情况下达到了视觉上的错落效果。


十一、分类 Tab:网格 + 列表

@Component
struct CategoryTabContent {
  onProductSelect: (product: ProductItem) => void = () => {}
  @State selectedCat: string = '数码电子'
  catKeys: string[] = ['数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具', '美妆个护', '其他闲置']

  getProductsForCat(cat: string): ProductItem[] {
    return mockProducts.filter((p: ProductItem) => p.category === cat)
  }

分类页组件维护一个 selectedCat 状态记录当前选中的分类,默认是"数码电子"。getProductsForCatfilter 从全部商品中筛选出指定分类的商品,这是一种"派生数据"的思路——不存储筛选结果,而是每次需要时实时计算,保证数据始终与源数据一致。

分类网格卡片:

@Builder categoryGridCard(c: CategoryItem) {
  Column() {
    Text(c.icon).fontSize(32)
    Text(c.name).fontSize(12).fontWeight(FontWeight.Medium).fontColor('#3E2723').margin({ top: 6 })
    Text(c.count + '件商品').fontSize(10).fontColor('#795548').margin({ top: 2 })
  }
  .width('22%').padding({ top: 14, bottom: 14 })
  .backgroundColor(this.selectedCat === c.name ? c.bg : '#FFFFFF')
  .borderRadius(12)
  .border({
    width: this.selectedCat === c.name ? { top: 2, bottom: 2, left: 2, right: 2 } : { top: 0, bottom: 0, left: 0, right: 0 },
    color: this.selectedCat === c.name ? { top: c.color, bottom: c.color, left: c.color, right: c.color } : { ... }
  })
  .shadow({ radius: this.selectedCat === c.name ? 4 : 2, color: '#1A000000', offsetY: 1 })
  .onClick(() => { this.selectedCat = c.name })
}

网格卡片用"选中时填充分类背景色 + 加分类主色边框 + 加重阴影"三重视觉变化来强化选中态。未选中时是白底无边框轻阴影,选中后变成分类浅底色 + 分类主色边框 + 重阴影,对比非常鲜明。

分类下的商品列表用横向卡片(图标在左、信息在右、箭头在最右),与首页的纵向瀑布流卡片形成差异化,避免视觉单调。


十二、消息 Tab:聊天会话列表

@Builder messageCard(m: MessageItem) {
  Column() {
    Row() {
      Column() {
        Text(m.userAvatar).fontSize(28)
        if (m.isOnline) {
          Column().width(8).height(8).backgroundColor('#4CAF50')
            .borderRadius(4).margin({ top: -6 })
        }
      }.alignItems(HorizontalAlign.Center)
      Column() {
        Row() {
          Text(m.userName).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Column().layoutWeight(1)
          Text(m.time).fontSize(10).fontColor('#BCAAA4')
        }
        Text(m.lastMessage).fontSize(12).fontColor('#795548')
          .margin({ top: 3 }).maxLines(1)
        Row() {
          Text('📦 ' + m.productTitle).fontSize(10)
            .fontColor('#FF6F00').backgroundColor('#FFF3E0')
            .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
          if (m.priceOffer > 0) {
            Text('💰 出价¥' + m.priceOffer).fontSize(10)
              .fontColor('#2E7D32').backgroundColor('#E8F5E9')
              .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
              .margin({ left: 6 })
          }
          Column().layoutWeight(1)
        }
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      if (m.unread > 0) {
        Text(m.unread.toString()).fontSize(10).fontColor('#FFFFFF')
          .backgroundColor('#FF6F00').width(18).height(18).borderRadius(9)
          .textAlign(TextAlign.Center)
      } else {
        Column().width(18).height(18)
      }
    }
  }
}

消息卡片的视觉信息非常密集。左侧是头像区,如果对方在线则在头像下方叠加一个绿色小圆点(用 margin({ top: -6 }) 实现负边距上移,让圆点压在头像右下角)。

中间是消息内容区,三行布局:第一行是用户名和时间(两端对齐),第二行是最后一条消息(单行截断),第三行是商品标签和出价标签(用不同颜色背景的胶囊区分)。

右侧是未读数徽标,有未读时显示橙底白字的数字圆,没有未读时用一个同等大小的空 Column 占位——这是为了保持布局稳定,避免有无未读时卡片宽度跳动。


十三、我的 Tab:个人中心

"我的"页面是信息最丰富的页面,包含头部卡片、统计数据、快捷操作、价格区间柱状图、设置菜单五个区块。

头部卡片用渐变背景(橙到金)和圆角下边(borderRadius({ bottomLeft: 24, bottomRight: 24 }))打造沉浸式头部:

Column() {
  Row() {
    Text('🧑‍💻').fontSize(40)
      .width(62).height(62).backgroundColor('#FFF3E0').borderRadius(31)
    Column() {
      Text('跳蚤达人').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#3E2723')
      Text('已加入 365天 · 信用优秀').fontSize(11).fontColor('#795548').margin({ top: 3 })
    }
    Column().layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
    Text('⚙️').fontSize(20).fontColor('#795548')
  }
}
.linearGradient({
  angle: 180,
  colors: [['#FF6F00', 0], ['#FFC107', 1]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })

统计数据卡片用 position({ x: '4%', y: -16 }) 实现了"上浮叠加"在头部卡片之上的效果,y: -16 让它向上偏移 16vp,产生卡片悬浮的层次感。

价格区间柱状图是用纯 Column 模拟的:

Row() {
  Column() {
    Column().width(36).height(60).backgroundColor('#4CAF50').borderRadius(4)
    Text('0-50').fontSize(9).fontColor('#795548').margin({ top: 3 })
  }
  .layoutWeight(1).alignItems(HorizontalAlign.Center)
  // ... 五根柱子,高度递减
}

五根柱子的颜色从浅绿到深红渐变,高度从 60 到 22 递减,直观展示了"低价商品多、高价商品少"的分布规律。这种用纯布局组件模拟图表的方式虽然不如真正的图表库精确,但胜在零依赖、加载快。


十四、补充展示组件:可复用的展示型单元

除了核心的四个 Tab 组件,代码还定义了五个补充展示组件,它们是"展示型"组件的典型代表——只负责渲染数据,不含复杂交互逻辑。

14.1 CategoryStatsBar:分类占比条

@Component
struct CategoryStatsBar {
  catData: string[] = ['数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具', '美妆个护', '其他闲置']
  catBarColors: string[] = ['#1565C0', '#FF6F00', '#C62828', '#2E7D32', '#00695C', '#AD1457', '#7B1FA2', '#795548']

  build() {
    Column() {
      ForEach([0, 1, 2, 3, 4, 5, 6, 7], (idx: number) => {
        Row() {
          Column()
            .width((getCatProductCount(this.catData[idx]) * 100 / 25).toFixed(0) + '%')
            .height(6)
            .backgroundColor(this.catBarColors[idx])
            .borderRadius(3)
            .animation({ duration: 600, curve: Curve.EaseOut })
          Column().layoutWeight(1)
        }
        .width('100%').height(6).backgroundColor('#FFE0B2').borderRadius(3)
      })
    }
  }
}

这个组件用横向进度条展示每个分类的商品占比。进度条宽度通过 (count * 100 / 25).toFixed(0) + '%' 动态计算,animation({ duration: 600, curve: Curve.EaseOut }) 让进度条有 600 毫秒的缓出动画效果,加载时会有"生长"的视觉感受。

14.2 HotProductsSlider:热门推荐横滑

@Component
struct HotProductsSlider {
  hotItems: ProductItem[] = [
    mockProducts[0], mockProducts[6], mockProducts[9],
    mockProducts[11], mockProducts[13], mockProducts[17],
    mockProducts[18], mockProducts[23]
  ]

这个组件从 25 条商品中挑选了 8 条作为"热门推荐",用横向 Scroll 实现左右滑动。挑选的标准可能是浏览量较高或品类有代表性,体现了"编辑推荐"的运营思路。

14.3 OfferProgressBar:议价进度条

@Component
struct OfferProgressBar {
  currentPrice: number = 0
  sellerPrice: number = 0
  tip: string = ''

  build() {
    Row() {
      Column()
        .width((this.currentPrice / this.sellerPrice * 100).toFixed(0) + '%')
        .height(8)
        .backgroundColor('#FF6F00').borderRadius(4)
        .linearGradient({ angle: 90, colors: [['#FF6F00', 0], ['#FFC107', 1]] })
        .animation({ duration: 800, curve: Curve.EaseInOut })
      Column().layoutWeight(1)
    }
  }
}

议价进度条把买家的出价占卖家要价的比例可视化为一条渐变进度条。出价越接近要价,进度条越长,暗示"成交可能性越高"。EaseInOut 缓动曲线让动画先慢后快再慢,比 EaseOut 更有节奏感。

14.4 TradeSummaryCard:交易概览卡片(含入场动画)

@Component
struct TradeSummaryCard {
  @State animValue: number = 0

  aboutToAppear(): void {
    this.animValue = 1
  }

  build() {
    Column() {
      Text('¥186').fontSize(22).fontWeight(FontWeight.Bold)
        .fontColor('#FF6F00').margin({ top: 2 })
        .scale({ x: this.animValue, y: this.animValue })
        .animation({ duration: 400, curve: Curve.EaseOut })
    }
  }
}

这是唯一一个使用了 aboutToAppear 生命周期钩子的组件。aboutToAppear 在组件创建后、build 执行前调用。这里把 animValue 从 0 设为 1,配合 scale 缩放和 animation,实现数字"从无到有放大入场"的动画效果。三个统计数字分别用 400/500/600 毫秒的动画时长,形成"依次浮现"的瀑布式入场。

14.5 PublishTipBanner:发布提示横幅

@Component
struct PublishTipBanner {
  build() {
    Row() {
      Text('💡').fontSize(20)
      Column() {
        Text('发布闲置赚零花钱').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Text('拍照上传,30秒快速发布').fontSize(11).fontColor('#795548').margin({ top: 2 })
      }
      .layoutWeight(1).padding({ left: 8 })
      Text('+').fontSize(18).fontColor('#FFFFFF')
        .backgroundColor('#FF6F00').width(30).height(30).borderRadius(15)
        .textAlign(TextAlign.Center)
    }
    .backgroundColor('#FFF3E0').borderRadius(12)
  }
}

一个简洁的引导横幅,左侧灯泡 emoji + 文案,右侧橙色圆形"+“按钮,整体用浅橙背景。它的作用是引导用户去发布商品,是典型的"行动召唤”(Call to Action)设计。


十五、关键特性对比总结

下面用一张表格横向对比本应用涉及的各类核心组件/机制的关键特性:

组件/机制所属层级核心装饰器/关键字主要职责可复用性是否含状态动画/渐变支持
CategoryMeta 等 interface接口定义层interface约定元数据类型形状高(类型级)
ProductItem 等数据类数据类层@Observed class承载业务实体数据是(可观察)
CATEGORY_CONFIG配置层Record<string, T>枚举值到视觉元数据的映射
getCatColor 等函数工具层纯函数无副作用的查询与格式化
MarketTab枚举层enum消除魔法数字
SecondHandMarketApp主入口@Entry @ComponentTab 调度与弹窗管理单例是(多状态)是(Tab 指示条渐变)
HomeTabContentTab 子组件@Component首页瀑布流浏览是(搜索/筛选)是(横幅渐变)
CategoryTabContentTab 子组件@Component分类网格与列表是(选中分类)
MessagesTabContentTab 子组件@Component聊天会话列表
ProfileTabContentTab 子组件@Component个人中心统计是(头部渐变)
CategoryStatsBar补充组件@Component分类占比可视化是(进度条动画)
HotProductsSlider补充组件@Component热门横滑推荐
OfferProgressBar补充组件@Component议价进度可视化是(渐变+动画)
TradeSummaryCard补充组件@Component @State交易数据概览是(动画值)是(缩放入场动画)
PublishTipBanner补充组件@Component发布行动引导
四个弹窗 Builder主组件内部@Builder发布/编辑/下架/详情低(内聚)否(读外部状态)是(按钮渐变)

十六、详细总结

16.1 架构设计的可取之处

纵观整个应用的代码,最值得称道的是它清晰的分层架构。从顶部的 interface 定义,到 @Observed 数据类,再到 Record 配置表、纯函数工具层、enum 枚举,每一层各司其职、边界分明。这种分层让代码具备了良好的可维护性——如果想新增一个分类,只需在 CATEGORY_CONFIG 里加一行、在 mockCategoryList 里加一项即可,视图代码完全不用动;如果想修改某个分类的颜色,只需改配置表里的一个色值,所有引用该分类颜色的地方都会自动更新。

数据驱动渲染的理念贯穿始终。配置表把"枚举值"和"视觉属性"绑定,纯函数把"查询逻辑"封装,视图层只管"拿到数据怎么画"。这种"配置即数据、函数即逻辑、组件即视图"的三分离模式,是大型应用保持可维护性的关键。

16.2 视觉设计的用心之处

在视觉层面,这个应用做对了很多事情。首先是色彩体系统一而丰富——以橙色(#FF6F00)为主调,辅以金黄(#FFC107)、深棕(#3E2723)、浅暖黄(#FFF8E1)等暖色系,营造出"跳蚤市场"应有的热闹温馨氛围;同时为 8 个分类各自分配了独立的色调,让分类之间有视觉区分但又不脱离整体风格。

其次是渐变的克制使用。横幅、Tab 指示条、按钮、进度条、议价条都用到了 linearGradient,但每次渐变都只在两个相近色之间过渡(橙→金),不会显得花哨。渐变方向也经过精心选择——横幅用 135 度(左上到右下)模拟光照,进度条用 90 度(水平)强调进度推进。

再次是阴影的层次感。卡片阴影的 color 都用了带透明度的黑色(如 #1A000000#0D000000),透明度从 5% 到 10% 不等,让阴影柔和而非生硬。选中态卡片会加重阴影(radius 从 2 到 4),用阴影变化暗示层级提升。

16.3 交互设计的体贴之处

交互层面,这个应用也有很多值得学习的细节。弹窗的"遮罩点击关闭"是最基础但最容易被忽略的体验——用户点击遮罩区域时关闭弹窗,比强迫用户去点那个小小的"✕"要友好得多。二次确认弹窗对"下架"这种不可逆操作做了拦截,用红色文字和警告 emoji 强化风险提示,符合操作安全的设计原则。

消息列表的"出价外显"是一个很有产品思维的设计。在传统的二手交易应用里,买家必须点进聊天对话才能看到对方的还价,而这里把出价直接显示在消息列表项上,让卖家一眼就能扫视所有还价,大幅提升了议价效率。

瀑布流的"伪实现"也值得玩味。真正的瀑布流需要根据每张图片的高度动态计算排列位置,实现复杂且性能开销大。这里用"手动分列 + 错位偏移"的方式,以极低的复杂度达到了相近的视觉效果,是一种很务实的工程取舍。

16.4 可以进一步优化的方向

当然,这个应用作为演示项目,也有一些可以优化的空间。

第一,瀑布流目前是硬编码的索引分配,如果商品数量变化需要手动调整。可以考虑用 ForEach 配合取模运算自动分列,让瀑布流自适应数据量。

第二,四个弹窗的状态变量和构建器都堆在主组件里,导致主组件代码较长。可以考虑用一个统一的 ModalManager 来管理弹窗的开关和选中数据,降低主组件的复杂度。

第三,目前的数据都是硬编码的 mock 数据。在真实场景中,需要接入网络请求层,可以把 mockProducts 替换为从 API 获取的数据,配合 loading 状态和错误处理。

第四,表单目前只做了数据绑定,没有做校验(如标题非空、价格合法等)。在真实应用中应该加入前端校验逻辑,在"立即发布"按钮的 onClick 里先校验再提交。

第五,部分组件(如 CategoryStatsBarHotProductsSlider 等)在代码中定义了但在主组件的 build 中并未被调用。这些组件可以理解为"组件库储备",未来可以灵活地组合到任意页面中。这也从侧面说明了这个应用的组件化程度较高——组件之间是松耦合的,可以按需组装。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============ 智慧二手交易市场 - 跳蚤橙风 ============

// ============ interface 定义层 ============
interface CategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

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

interface ConditionMeta {
  label: string
  color: string
  desc: string
}

interface PriceRangeMeta {
  label: string
  min: number
  max: number
  color: string
}

interface MessageMeta {
  label: string
  unread: number
}

interface QuickActionMeta {
  icon: string
  label: string
  color: string
  bg: string
}

interface StatMeta {
  label: string
  value: string
  icon: string
}

// ============ @Observed 数据类 ============
@Observed
class ProductItem {
  id: number = 0
  title: string = ''
  category: string = ''
  price: number = 0
  originalPrice: number = 0
  condition: string = '几乎全新'
  status: string = '在售'
  seller: string = ''
  sellerAvatar: string = ''
  location: string = ''
  description: string = ''
  tags: string[] = []
  images: string[] = []
  views: number = 0
  likes: number = 0
  messages: number = 0
  publishDate: string = ''
  isFavorite: boolean = false
  barter: boolean = false

  constructor(
    id: number, title: string, category: string, price: number,
    originalPrice: number, condition: string, status: string,
    seller: string, sellerAvatar: string, location: string,
    description: string, tags: string[], images: string[],
    views: number, likes: number, messages: number,
    publishDate: string, isFavorite: boolean, barter: boolean
  ) {
    this.id = id; this.title = title; this.category = category
    this.price = price; this.originalPrice = originalPrice
    this.condition = condition; this.status = status
    this.seller = seller; this.sellerAvatar = sellerAvatar
    this.location = location; this.description = description
    this.tags = tags; this.images = images; this.views = views
    this.likes = likes; this.messages = messages
    this.publishDate = publishDate; this.isFavorite = isFavorite
    this.barter = barter
  }
}

@Observed
class MessageItem {
  id: number = 0
  userName: string = ''
  userAvatar: string = ''
  productTitle: string = ''
  lastMessage: string = ''
  time: string = ''
  unread: number = 0
  isOnline: boolean = false
  priceOffer: number = 0

  constructor(
    id: number, userName: string, userAvatar: string,
    productTitle: string, lastMessage: string, time: string,
    unread: number, isOnline: boolean, priceOffer: number
  ) {
    this.id = id; this.userName = userName; this.userAvatar = userAvatar
    this.productTitle = productTitle; this.lastMessage = lastMessage
    this.time = time; this.unread = unread; this.isOnline = isOnline
    this.priceOffer = priceOffer
  }
}

@Observed
class CategoryItem {
  name: string = ''
  icon: string = ''
  count: number = 0
  color: string = ''
  bg: string = ''

  constructor(name: string, icon: string, count: number, color: string, bg: string) {
    this.name = name; this.icon = icon; this.count = count
    this.color = color; this.bg = bg
  }
}

// ============ Record 配置层 ============
const CATEGORY_CONFIG: Record<string, CategoryMeta> = {
  '数码电子': { label: '数码电子', icon: '📱', color: '#1565C0', bg: '#E3F2FD' },
  '家居生活': { label: '家居生活', icon: '🏠', color: '#FF6F00', bg: '#FFF3E0' },
  '服饰鞋包': { label: '服饰鞋包', icon: '👗', color: '#C62828', bg: '#FFEBEE' },
  '图书音像': { label: '图书音像', icon: '📚', color: '#2E7D32', bg: '#E8F5E9' },
  '运动户外': { label: '运动户外', icon: '🏃', color: '#00695C', bg: '#E0F2F1' },
  '母婴玩具': { label: '母婴玩具', icon: '🧸', color: '#AD1457', bg: '#FCE4EC' },
  '美妆个护': { label: '美妆个护', icon: '💄', color: '#7B1FA2', bg: '#F3E5F5' },
  '其他闲置': { label: '其他闲置', icon: '📦', color: '#795548', bg: '#EFEBE9' }
}

const STATUS_CONFIG: Record<string, StatusMeta> = {
  '在售': { label: '在售', color: '#FF6F00', icon: '🟠' },
  '已售': { label: '已售', color: '#2E7D32', icon: '🟢' },
  '已下架': { label: '已下架', color: '#9E9E9E', icon: '⚫' }
}

const CONDITION_CONFIG: Record<string, ConditionMeta> = {
  '几乎全新': { label: '几乎全新', color: '#2E7D32', desc: '仅拆封未使用' },
  '轻微使用': { label: '轻微使用', color: '#FF6F00', desc: '正常使用痕迹' },
  '明显使用': { label: '明显使用', color: '#FF9800', desc: '有可见磨损' }
}

const PRICE_RANGE_CONFIG: PriceRangeMeta[] = [
  { label: '0-50元', min: 0, max: 50, color: '#4CAF50' },
  { label: '50-200元', min: 50, max: 200, color: '#FF6F00' },
  { label: '200-500元', min: 200, max: 500, color: '#FF9800' },
  { label: '500-1000元', min: 500, max: 1000, color: '#E65100' },
  { label: '1000以上', min: 1000, max: 99999, color: '#BF360C' }
]

const CONDITION_LIST: string[] = ['几乎全新', '轻微使用', '明显使用']

// ============ 全局纯函数层 ============
function getCatColor(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.color ?? '#999999'
}

function getCatBg(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.bg ?? '#F5F5F5'
}

function getCatIcon(cat: string): string {
  return (CATEGORY_CONFIG[cat] as CategoryMeta)?.icon ?? '📦'
}

function getStatusColor(st: string): string {
  return (STATUS_CONFIG[st] as StatusMeta)?.color ?? '#999'
}

function getStatusLabel(st: string): string {
  return (STATUS_CONFIG[st] as StatusMeta)?.label ?? st
}

function getConditionColor(cond: string): string {
  return (CONDITION_CONFIG[cond] as ConditionMeta)?.color ?? '#999'
}

function getConditionDesc(cond: string): string {
  return (CONDITION_CONFIG[cond] as ConditionMeta)?.desc ?? ''
}

function formatPrice(p: number): string {
  return '¥' + p.toFixed(0)
}

function formatViews(v: number): string {
  if (v >= 1000) {
    return (v / 1000).toFixed(1) + 'k'
  }
  return v.toString()
}

function computeDiscount(price: number, original: number): number {
  if (original <= 0) { return 0 }
  return Math.round((1 - price / original) * 100)
}

function productCount(): number { return 25 }
function soldCount(): number { return 8 }
function activeCount(): number { return 17 }
function messageCount(): number { return 15 }
function totalViews(): number { return 12580 }
function avgPrice(): number { return 186 }

function getCatProductCount(cat: string): number {
  const map: Record<string, number> = {
    '数码电子': 6, '家居生活': 5, '服饰鞋包': 4,
    '图书音像': 3, '运动户外': 3, '母婴玩具': 2,
    '美妆个护': 1, '其他闲置': 1
  }
  return map[cat] ?? 0
}

// ============ 枚举定义 ============
enum MarketTab {
  HOME = 0,
  CATEGORY = 1,
  MESSAGES = 2,
  PROFILE = 3
}

// ============ 写死商品数据(25条) ============
const mockProducts: ProductItem[] = [
  new ProductItem(1, 'iPhone 14 Pro 256G 暗紫色', '数码电子', 5499, 7999, '轻微使用',
    '在售', '科技达人小王', '🧑‍💻', '深圳·南山区',
    '自用半年,成色95新,配件齐全带原装盒,可小刀',
    ['iPhone', 'Apple', '5G'], ['📱'], 2340, 156, 23, '2026-08-01', false, false),
  new ProductItem(2, '北欧风实木餐桌1.2米', '家居生活', 299, 899, '几乎全新',
    '在售', '搬家急出', '🏠', '杭州·西湖区',
    '宜家同款,使用不到一个月搬家转让,无划痕',
    ['宜家', '实木', '餐桌'], ['🪑'], 1560, 89, 12, '2026-08-02', true, true),
  new ProductItem(3, 'Nike Air Jordan 1 Low 43码', '服饰鞋包', 399, 1099, '轻微使用',
    '在售', '潮鞋爱好者', '👟', '广州·天河区',
    '正品AJ1低帮,上脚两次码数买小了,可验',
    ['Nike', 'AJ1', '潮鞋'], ['👟'], 3200, 234, 45, '2026-08-03', false, true),
  new ProductItem(4, '《三体》全集精装版三册', '图书音像', 49, 156, '轻微使用',
    '在售', '书虫小李', '📖', '北京·海淀区',
    '刘慈欣签名版,仅翻阅一次,几乎全新',
    ['三体', '科幻', '签名'], ['📚'], 890, 67, 5, '2026-08-03', false, false),
  new ProductItem(5, '小米电动滑板车Pro', '运动户外', 1299, 2799, '轻微使用',
    '在售', '骑行爱好者', '🛴', '成都·高新区',
    '续航45km,每天通勤使用半年,送车锁和头盔',
    ['小米', '滑板车', '通勤'], ['🛴'], 2100, 178, 34, '2026-08-04', false, false),
  new ProductItem(6, '乐高星球大战千年隼号', '母婴玩具', 899, 1699, '几乎全新',
    '已售', '乐高迷老张', '🧱', '上海·浦东新区',
    '7541颗粒,拼完展示半年,说明书完整',
    ['乐高', '星战', '收藏'], ['🧩'], 5600, 423, 89, '2026-07-28', true, false),
  new ProductItem(7, 'MacBook Pro M2 16G+512G', '数码电子', 8799, 12999, '几乎全新',
    '在售', '设计师阿杰', '💻', '深圳·福田区',
    'M2芯片,电池循环仅45次,带AppleCare+到明年',
    ['MacBook', 'M2', '设计师'], ['💻'], 4500, 312, 67, '2026-08-01', true, false),
  new ProductItem(8, 'MUJI 日式简约沙发套组', '家居生活', 599, 1599, '轻微使用',
    '在售', '装修剩品', '🛋️', '上海·徐汇区',
    '三人位+单人位,灰色棉麻面料,可拆洗',
    ['MUJI', '沙发', '日式'], ['🛋️'], 1980, 123, 18, '2026-08-02', false, false),
  new ProductItem(9, 'SK-II 神仙水 230ml 全新', '美妆个护', 799, 1299, '几乎全新',
    '在售', '美妆达人CC', '💅', '北京·朝阳区',
    '朋友送的不适合我肤质,全新未拆封,保质期到2027',
    ['SK-II', '精华', '护肤'], ['🧴'], 1200, 89, 15, '2026-08-04', false, false),
  new ProductItem(10, 'Sony WH-1000XM5 降噪耳机', '数码电子', 1599, 2999, '轻微使用',
    '在售', '音乐发烧友', '🎧', '深圳·宝安区',
    '黑色,续航30小时,送收纳包',
    ['Sony', '降噪', '耳机'], ['🎧'], 3400, 267, 41, '2026-08-03', true, true),
  new ProductItem(11, '戴森V12吸尘器 全套配件', '家居生活', 1999, 3999, '轻微使用',
    '在售', '清理达人', '🧹', '杭州·滨江区',
    '使用8个月,全套吸头都在,送挂墙支架',
    ['戴森', '吸尘器', '无线'], ['🧹'], 2890, 198, 28, '2026-08-02', false, false),
  new ProductItem(12, '任天堂Switch OLED 白色', '数码电子', 1499, 2199, '几乎全新',
    '在售', '游戏玩家小明', '🎮', '成都·武侯区',
    '带塞尔达+动森卡带,箱说全,可换PS5',
    ['Switch', '任天堂', 'OLED'], ['🎮'], 5600, 445, 78, '2026-08-01', false, true),
  new ProductItem(13, '优衣库羽绒服 L码 男款', '服饰鞋包', 199, 599, '轻微使用',
    '在售', '断舍离爱好者', '🧥', '上海·闵行区',
    '黑色轻量羽绒服,只穿一季很保暖',
    ['优衣库', '羽绒服', '男装'], ['🧥'], 670, 45, 3, '2026-08-04', false, false),
  new ProductItem(14, '富士XT30II微单相机', '数码电子', 4599, 6799, '几乎全新',
    '在售', '摄影入门转出', '📷', '广州·海珠区',
    '银色,快门仅2000次,带18-55镜头+UV镜',
    ['富士', '微单', '相机'], ['📷'], 4100, 356, 56, '2026-08-03', true, false),
  new ProductItem(15, 'Babycare婴儿推车 可折叠', '母婴玩具', 399, 1299, '轻微使用',
    '在售', '二胎妈妈清清', '👶', '深圳·龙岗区',
    '轻便伞车,一键折叠,配件齐全干净卫生',
    ['婴儿车', '折叠', '轻便'], ['🚼'], 1230, 78, 9, '2026-08-04', false, false),
  new ProductItem(16, 'Kindle Paperwhite 4 8G', '图书音像', 349, 998, '轻微使用',
    '已售', '阅读达人老刘', '📱', '北京·大兴区',
    '国行版,待机一周,带皮套,屏幕完美',
    ['Kindle', '电纸书', '阅读'], ['📖'], 2100, 134, 22, '2026-07-25', false, false),
  new ProductItem(17, '迪卡侬椭圆机家用款', '运动户外', 599, 1499, '轻微使用',
    '在售', '健身体验官', '🏋️', '武汉·洪山区',
    '静音磁控,可连手机APP,占地不到1平米',
    ['椭圆机', '健身', '家用'], ['🏃'], 980, 67, 11, '2026-08-02', false, false),
  new ProductItem(18, 'Celine Classic Box 中号', '服饰鞋包', 6800, 24000, '轻微使用',
    '在售', '奢侈品寄卖', '👜', '上海·静安区',
    '经典焦糖色-box皮,专柜购买带防尘袋+小票',
    ['Celine', '奢侈品', '包包'], ['👜'], 8900, 567, 123, '2026-08-01', true, false),
  new ProductItem(19, '戴尔27寸4K显示器U2723QE', '数码电子', 2199, 3999, '几乎全新',
    '在售', '程序员老周', '🖥️', '北京·朝阳区',
    'Type-C一线连,出厂校色ΔE<2,适合设计和编程',
    ['戴尔', '4K', '显示器'], ['🖥️'], 1560, 89, 14, '2026-08-04', false, false),
  new ProductItem(20, '象印电饭煲 5.5合', '家居生活', 499, 1299, '轻微使用',
    '在售', '日式生活家', '🍚', '深圳·罗湖区',
    '日本原装,IH加热,煮饭口感一流',
    ['象印', '电饭煲', '日式'], ['🍚'], 1340, 89, 8, '2026-08-03', false, false),
  new ProductItem(21, 'Pato儿童积木桌+100粒积木', '母婴玩具', 129, 399, '轻微使用',
    '已售', '宝妈小雅', '🎨', '广州·番禺区',
    '双面可用,一面乐高一面画画,送100粒大积木',
    ['积木', '儿童', '玩具'], ['🧸'], 780, 45, 6, '2026-07-20', false, false),
  new ProductItem(22, '艾美特塔扇家用静音', '家居生活', 129, 399, '轻微使用',
    '在售', '夏日清仓', '🌀', '成都·锦江区',
    '三档风速,遥控定时,摇头120度,只用了一季',
    ['风扇', '塔扇', '静音'], ['🌀'], 560, 34, 2, '2026-08-04', false, false),
  new ProductItem(23, '周杰伦《范特西》黑胶LP', '图书音像', 299, 499, '几乎全新',
    '在售', '杰迷收藏家', '🎵', '上海·杨浦区',
    '限量编号版,仅拆封试听一次,品相完美',
    ['周杰伦', '黑胶', '收藏'], ['💿'], 890, 67, 12, '2026-08-03', true, false),
  new ProductItem(24, 'Lululemon 瑜伽垫6mm', '运动户外', 199, 580, '轻微使用',
    '在售', '瑜伽练习者', '🧘', '杭州·余杭区',
    '正品lulu,防滑天然橡胶,送绑带',
    ['Lululemon', '瑜伽', '健身'], ['🧘'], 890, 56, 7, '2026-08-04', false, false),
  new ProductItem(25, '全新未拆封 华为FreeBuds Pro3', '数码电子', 799, 1199, '几乎全新',
    '在售', '数码礼品转让', '🎁', '深圳·光明区',
    '年会中奖奖品,全新未拆封,官方质保一年',
    ['华为', '耳机', '降噪'], ['🎧'], 1800, 123, 19, '2026-08-05', false, false)
]

// ============ 写死消息数据(12条) ============
const mockMessages: MessageItem[] = [
  new MessageItem(1, '科技达人小王', '🧑‍💻', 'iPhone 14 Pro', '最低多少钱出?诚心要', '10分钟前', 3, true, 5200),
  new MessageItem(2, '潮鞋爱好者', '👟', 'AJ1 Low', '码数正好,能在福田面交吗', '30分钟前', 0, true, 380),
  new MessageItem(3, '书虫小李', '📖', '三体全集', '签名版还在吗?我要了', '1小时前', 1, false, 49),
  new MessageItem(4, '设计师阿杰', '💻', 'MacBook Pro M2', '电池健康度多少?有发票吗', '2小时前', 0, true, 8500),
  new MessageItem(5, '乐高迷老张', '🧱', '千年隼号', '已经卖出了抱歉', '昨天', 0, false, 0),
  new MessageItem(6, '美妆达人CC', '💅', 'SK-II 神仙水', '保质期具体到几月?包邮吗', '昨天', 2, false, 760),
  new MessageItem(7, '音乐发烧友', '🎧', 'Sony XM5', '和XM4比提升大吗', '2天前', 0, true, 1500),
  new MessageItem(8, '奢侈品寄卖', '👜', 'Celine Box', '能去专柜验货吗?支持鉴定吗', '2天前', 1, false, 6500),
  new MessageItem(9, '程序猿老周', '🖥️', '戴尔显示器', '支持mac吗 雷电口有吗', '3天前', 0, false, 2100),
  new MessageItem(10, '杰迷收藏家', '🎵', '范特西黑胶', '编号多少?包邮吗', '3天前', 0, true, 280),
  new MessageItem(11, '游戏玩家小明', '🎮', 'Switch OLED', '动森卡带单出吗', '4天前', 1, false, 0),
  new MessageItem(12, '瑜伽练习者', '🧘', 'Lululemon垫子', '厚度够吗?做仰卧起坐会滑吗', '5天前', 0, false, 180)
]

// ============ 分类列表数据 ============
const mockCategoryList: CategoryItem[] = [
  new CategoryItem('数码电子', '📱', 6, '#1565C0', '#E3F2FD'),
  new CategoryItem('家居生活', '🏠', 5, '#FF6F00', '#FFF3E0'),
  new CategoryItem('服饰鞋包', '👗', 4, '#C62828', '#FFEBEE'),
  new CategoryItem('图书音像', '📚', 3, '#2E7D32', '#E8F5E9'),
  new CategoryItem('运动户外', '🏃', 3, '#00695C', '#E0F2F1'),
  new CategoryItem('母婴玩具', '🧸', 2, '#AD1457', '#FCE4EC'),
  new CategoryItem('美妆个护', '💄', 1, '#7B1FA2', '#F3E5F5'),
  new CategoryItem('其他闲置', '📦', 1, '#795548', '#EFEBE9')
]

// ============ 快捷操作数据 ============
const quickActions: QuickActionMeta[] = [
  { icon: '📝', label: '发布', color: '#FF6F00', bg: '#FFF3E0' },
  { icon: '❤️', label: '收藏', color: '#C62828', bg: '#FFEBEE' },
  { icon: '📦', label: '在售', color: '#FF6F00', bg: '#FFF8E1' },
  { icon: '📊', label: '浏览', color: '#1565C0', bg: '#E3F2FD' }
]

const statItems: StatMeta[] = [
  { label: '在售商品', value: '17', icon: '📦' },
  { label: '已售出', value: '8', icon: '✅' },
  { label: '总浏览', value: '12.6k', icon: '👁️' },
  { label: '消息', value: '15', icon: '💬' }
]

// ============ @Entry 主组件 ============
@Entry
@Component
struct SecondHandMarketApp {
  @State activeTab: MarketTab = MarketTab.HOME
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedProduct: ProductItem | null = null
  @State formTitle: string = ''
  @State formCategory: string = '数码电子'
  @State formPrice: string = ''
  @State formCondition: string = '几乎全新'
  @State formDesc: string = ''

  getCategoryKeys(): string[] {
    return ['数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具', '美妆个护', '其他闲置']
  }

  @Builder contentArea() {
    Column() {
      if (this.activeTab === MarketTab.HOME) {
        HomeTabContent({
          onProductSelect: (p: ProductItem) => { this.selectedProduct = p; this.showDetailModal = true }
        })
      } else if (this.activeTab === MarketTab.CATEGORY) {
        CategoryTabContent({
          onProductSelect: (p: ProductItem) => { this.selectedProduct = p; this.showDetailModal = true }
        })
      } else if (this.activeTab === MarketTab.MESSAGES) {
        MessagesTabContent()
      } else {
        ProfileTabContent({
          onAddClick: () => { this.showAddModal = true }
        })
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: MarketTab) {
    Column() {
      Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
      Text(label).fontSize(9)
        .fontColor(this.activeTab === tab ? '#FF6F00' : '#795548')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
      if (this.activeTab === tab) {
        Column()
          .width(20).height(3).backgroundColor('#FF6F00').borderRadius(2)
          .linearGradient({
            angle: 90,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .margin({ top: 2 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 4, bottom: 4 })
    .onClick(() => { this.activeTab = tab })
  }

  // ========== 弹框:发布商品 ==========
  @Builder addProductModal() {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(() => { this.showAddModal = false })
      .position({ x: 0, y: 0 }).zIndex(998)

    Column() {
      Row() {
        Text('📝 发布闲置').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Column().layoutWeight(1)
        Text('✕').fontSize(20).fontColor('#795548')
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFE0B2')
      Scroll() {
        Column() {
          Text('商品标题').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '写个吸引人的标题...' })
            .placeholderColor('#D7CCC8').fontSize(14).width('100%')
            .backgroundColor('#FFF8E1').borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formTitle = v })

          Text('商品分类').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
          Row() {
            ForEach(this.getCategoryKeys(), (c: string) => {
              if (this.formCategory === c) {
                Text(getCatIcon(c) + ' ' + c).fontSize(11).fontColor('#FFFFFF')
                  .backgroundColor('#FF6F00').borderRadius(10)
                  .padding({ left: 7, right: 7, top: 4, bottom: 4 }).margin({ left: 2, right: 2, top: 2 })
              } else {
                Text(getCatIcon(c) + ' ' + c).fontSize(11).fontColor('#FF6F00')
                  .backgroundColor(getCatBg(c)).borderRadius(10)
                  .padding({ left: 7, right: 7, top: 4, bottom: 4 }).margin({ left: 2, right: 2, top: 2 })
                  .onClick(() => { this.formCategory = c })
              }
            })
          }
          .margin({ left: 16, right: 16, top: 4 })

          Text('售价 (元)').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
          TextInput({ placeholder: '输入你的心理价位' })
            .placeholderColor('#D7CCC8').fontSize(16).width('100%')
            .backgroundColor('#FFF8E1').borderRadius(8)
            .type(InputType.Number)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formPrice = v })

          Text('成色').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
          Row() {
            ForEach(CONDITION_LIST, (cond: string) => {
              if (this.formCondition === cond) {
                Text(cond).fontSize(12).fontColor('#FFFFFF')
                  .backgroundColor('#FF6F00').borderRadius(14)
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 }).margin({ left: 4, right: 4 })
              } else {
                Text(cond).fontSize(12).fontColor('#FF6F00')
                  .backgroundColor('#FFF3E0').borderRadius(14)
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 }).margin({ left: 4, right: 4 })
                  .onClick(() => { this.formCondition = cond })
              }
            })
          }
          .margin({ left: 12, right: 12, top: 4 })

          Text('详细描述').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
          TextArea({ placeholder: '描述商品特点、瑕疵、交易方式等' })
            .placeholderColor('#D7CCC8').fontSize(13).width('100%').height(80)
            .backgroundColor('#FFF8E1').borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.formDesc = v })
        }
      }
      .layoutWeight(1).scrollBar(BarState.Off)
      Row() {
        Text('取消').fontSize(14).fontColor('#795548')
          .backgroundColor('#FFE0B2').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showAddModal = false })
        Text('立即发布').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6F00').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
          .linearGradient({
            angle: 135,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .onClick(() => { this.showAddModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 12, bottom: 16 })
    }
    .width('92%').constraintSize({ maxHeight: '82%' })
    .backgroundColor('#FFFFFF').borderRadius(16)
    .position({ x: '4%', y: '10%' }).zIndex(999)
  }

  // ========== 弹框:编辑商品 ==========
  @Builder editProductModal() {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(() => { this.showEditModal = false })
      .position({ x: 0, y: 0 }).zIndex(998)

    Column() {
      Row() {
        Text('✏️ 编辑商品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Column().layoutWeight(1)
        Text('✕').fontSize(20).fontColor('#795548')
          .onClick(() => { this.showEditModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#FFE0B2')
      Column() {
        Text('商品标题').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
        TextInput({ placeholder: this.selectedProduct?.title ?? '' })
          .placeholderColor('#D7CCC8').fontSize(14).width('100%')
          .backgroundColor('#FFF8E1').borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })

        Text('售价 (元)').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
        TextInput({ placeholder: formatPrice(this.selectedProduct?.price ?? 0) })
          .placeholderColor('#D7CCC8').fontSize(16).width('100%')
          .backgroundColor('#FFF8E1').borderRadius(8)
          .type(InputType.Number)
          .margin({ left: 20, right: 20, top: 4 })

        Text('描述').fontSize(12).fontColor('#795548').margin({ top: 12, left: 20 })
        TextArea({ placeholder: this.selectedProduct?.description ?? '' })
          .placeholderColor('#D7CCC8').fontSize(13).width('100%').height(60)
          .backgroundColor('#FFF8E1').borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })
      }
      .layoutWeight(1)

      Row() {
        Text('下架商品').fontSize(13).fontColor('#C62828')
          .backgroundColor('#FFEBEE').borderRadius(16)
          .padding({ left: 18, right: 18, top: 8, bottom: 8 })
          .onClick(() => { this.showEditModal = false; this.showDeleteModal = true })
      }
      .width('100%').justifyContent(FlexAlign.Start).padding({ left: 20, top: 8 })

      Row() {
        Text('取消').fontSize(14).fontColor('#795548')
          .backgroundColor('#FFE0B2').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 })
          .onClick(() => { this.showEditModal = false })
        Text('保存修改').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FF6F00').borderRadius(20)
          .padding({ left: 28, right: 28, top: 10, bottom: 10 }).margin({ left: 12 })
          .linearGradient({
            angle: 135,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .onClick(() => { this.showEditModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 12, bottom: 16 })
    }
    .width('88%').constraintSize({ maxHeight: '70%' })
    .backgroundColor('#FFFFFF').borderRadius(16)
    .position({ x: '6%', y: '14%' }).zIndex(999)
  }

  // ========== 弹框:下架确认 ==========
  @Builder deleteConfirmModal() {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(() => { this.showDeleteModal = false })
      .position({ x: 0, y: 0 }).zIndex(998)

    Column() {
      Text('⚠️').fontSize(48).margin({ top: 28 })
      Text('确认下架商品?').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#3E2723')
      Text('下架后其他用户将无法看到此商品').fontSize(13).fontColor('#C62828').margin({ top: 6 })

      Row() {
        Text(this.selectedProduct?.title ?? '').fontSize(13)
          .fontColor('#3E2723').fontWeight(FontWeight.Medium)
      }
      .backgroundColor('#FFF3E0').borderRadius(10)
      .padding({ left: 14, right: 14, top: 10, bottom: 10 }).margin({ top: 16 })

      Row() {
        Text('取消').fontSize(14).fontColor('#795548')
          .backgroundColor('#FFE0B2').borderRadius(20)
          .padding({ left: 32, right: 32, top: 10, bottom: 10 })
          .onClick(() => { this.showDeleteModal = false })
        Text('确认下架').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#C62828').borderRadius(20)
          .padding({ left: 32, right: 32, top: 10, bottom: 10 }).margin({ left: 14 })
          .onClick(() => { this.showDeleteModal = false })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 24, bottom: 24 })
    }
    .width('78%').backgroundColor('#FFFFFF').borderRadius(16)
    .position({ x: '11%', y: '32%' }).zIndex(999)
  }

  // ========== 弹框:商品详情 ==========
  @Builder productDetailModal() {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(() => { this.showDetailModal = false })
      .position({ x: 0, y: 0 }).zIndex(998)

    Column() {
      Row() {
        Text(getCatIcon(this.selectedProduct?.category ?? '')).fontSize(32)
        Column() {
          Text(this.selectedProduct?.title ?? '').fontSize(15)
            .fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Row() {
            Text((STATUS_CONFIG[this.selectedProduct?.status ?? '在售'] as StatusMeta)?.icon ?? '')
              .fontSize(11)
            Text((STATUS_CONFIG[this.selectedProduct?.status ?? '在售'] as StatusMeta)?.label ?? '')
              .fontSize(11).fontColor(getStatusColor(this.selectedProduct?.status ?? '在售'))
              .margin({ left: 3 })
            Text(' · ' + (this.selectedProduct?.location ?? '')).fontSize(11)
              .fontColor('#795548').margin({ left: 4 })
          }
          .margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
        Text('✕').fontSize(18).fontColor('#795548')
          .onClick(() => { this.showDetailModal = false })
      }
      .width('100%').padding({ left: 18, right: 18, top: 18 })
      Divider().color('#FFE0B2').margin({ top: 10 })

      Scroll() {
        Column() {
          // 价格区域
          Row() {
            Column() {
              Text(formatPrice(this.selectedProduct?.price ?? 0))
                .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6F00')
            }
            Column() {
              Text('原价 ' + formatPrice(this.selectedProduct?.originalPrice ?? 0))
                .fontSize(12).fontColor('#795548')
                .decoration({ type: TextDecorationType.LineThrough })
              Text('省 ' + formatPrice((this.selectedProduct?.originalPrice ?? 0) - (this.selectedProduct?.price ?? 0)))
                .fontSize(11).fontColor('#2E7D32').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start).padding({ left: 12 })
            Column().layoutWeight(1)
            Text(computeDiscount(this.selectedProduct?.price ?? 0, this.selectedProduct?.originalPrice ?? 0).toString() + '% off')
              .fontSize(12).fontColor('#FFFFFF')
              .backgroundColor('#FF6F00').borderRadius(12)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          }
          .width('100%').padding({ left: 18, right: 18, top: 10 })

          // 信息行
          Row() {
            Column() { Text('📦 成色').fontSize(10).fontColor('#795548')
              Text(this.selectedProduct?.condition ?? '').fontSize(13).fontColor('#3E2723').margin({ top: 2 })
              Text(getConditionDesc(this.selectedProduct?.condition ?? '')).fontSize(10).fontColor('#AAAAAA').margin({ top: 1 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Start)
            Column() { Text('👁️ 浏览').fontSize(10).fontColor('#795548')
              Text(formatViews(this.selectedProduct?.views ?? 0)).fontSize(13).fontColor('#3E2723').margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Start)
            Column() { Text('❤️ 收藏').fontSize(10).fontColor('#795548')
              Text(formatViews(this.selectedProduct?.likes ?? 0)).fontSize(13).fontColor('#3E2723').margin({ top: 2 })
            }.layoutWeight(1).alignItems(HorizontalAlign.Start)
          }
          .width('100%').padding({ left: 18, right: 18, top: 10 })

          // 卖家信息
          Row() {
            Text(this.selectedProduct?.sellerAvatar ?? '👤').fontSize(24)
            Column() {
              Text(this.selectedProduct?.seller ?? '').fontSize(13).fontColor('#3E2723').fontWeight(FontWeight.Medium)
              Text(this.selectedProduct?.publishDate ?? '').fontSize(10).fontColor('#795548').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
          }
          .width('100%').padding({ left: 18, right: 18, top: 10 })
          .backgroundColor('#FFF8E1').borderRadius(10).margin({ left: 12, right: 12, top: 8 })

          // 描述
          Column() {
            Text('📋 商品描述').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Text(this.selectedProduct?.description ?? '').fontSize(13)
              .fontColor('#5D4037').margin({ top: 6 }).width('100%')
          }
          .width('100%').padding({ left: 18, right: 18, top: 12 })

          // 标签
          Row() {
            ForEach(this.selectedProduct?.tags ?? [], (tag: string) => {
              Text('#' + tag).fontSize(10).fontColor('#FF6F00')
                .backgroundColor('#FFF3E0').borderRadius(8)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .margin({ left: 3, right: 3 })
            })
          }
          .width('100%').padding({ left: 18, right: 18, top: 6 })

          // 议价标识
          if (this.selectedProduct?.barter ?? false) {
            Row() {
              Text('🔄').fontSize(14)
              Text('支持以物换物').fontSize(12).fontColor('#FF6F00').margin({ left: 4 })
            }
            .backgroundColor('#FFF3E0').borderRadius(8)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .margin({ left: 18, top: 8 })
          }
        }
        .padding({ bottom: 16 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)

      // 底部操作栏
      Row() {
        Text('❤️ 收藏').fontSize(13).fontColor('#C62828')
          .backgroundColor('#FFEBEE').borderRadius(20)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .onClick(() => { this.showDetailModal = false })
        Text('💬 聊天').fontSize(13).fontColor('#FFFFFF')
          .layoutWeight(1)
          .backgroundColor('#FF6F00').borderRadius(20)
          .padding({ top: 9, bottom: 9 }).margin({ left: 8, right: 8 })
          .textAlign(TextAlign.Center)
          .linearGradient({
            angle: 135,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .onClick(() => { this.showDetailModal = false })
        Text('✏️').fontSize(13).fontColor('#795548')
          .backgroundColor('#FFE0B2').borderRadius(20)
          .width(36).height(36)
          .textAlign(TextAlign.Center)
          .onClick(() => { this.showEditModal = true })
      }
      .width('100%').padding({ left: 18, right: 18, top: 12, bottom: 14 })
    }
    .width('94%').constraintSize({ maxHeight: '88%' })
    .backgroundColor('#FFFFFF').borderRadius(16)
    .position({ x: '3%', y: '6%' }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        this.contentArea()
        Row() {
          this.bottomTabItem('🏠', '首页', MarketTab.HOME)
          this.bottomTabItem('📂', '分类', MarketTab.CATEGORY)
          this.bottomTabItem('💬', '消息', MarketTab.MESSAGES)
          this.bottomTabItem('👤', '我的', MarketTab.PROFILE)
        }
        .width('100%').backgroundColor('#FFFFFF')
        .padding({ top: 4, bottom: 6 })
        .border({ width: { top: 1, bottom: 0, left: 0, right: 0 }, color: { top: '#FFE0B2', bottom: '#FFE0B2', left: '#FFE0B2', right: '#FFE0B2' } })
      }
      .width('100%').height('100%').backgroundColor('#FFF8E1')

      if (this.showAddModal) { this.addProductModal() }
      if (this.showEditModal) { this.editProductModal() }
      if (this.showDeleteModal) { this.deleteConfirmModal() }
      if (this.showDetailModal) { this.productDetailModal() }
    }
    .width('100%').height('100%')
  }
}

// ============ 首页 Tab - 双列瀑布流 ============
@Component
struct HomeTabContent {
  onProductSelect: (product: ProductItem) => void = () => {}
  @State searchKeyword: string = ''
  @State selectedFilter: string = '全部'

  getFilterCats(): string[] {
    return ['全部', '数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具']
  }

  @Builder productWaterfallCard(p: ProductItem) {
    Column() {
      Row() {
        Text(getCatIcon(p.category)).fontSize(28)
          .width(48).height(48).backgroundColor(getCatBg(p.category)).borderRadius(10)
          .textAlign(TextAlign.Center)
        Column().layoutWeight(1)
        if (p.status === '已售') {
          Text('已售').fontSize(10).fontColor('#FFFFFF')
            .backgroundColor('#2E7D32').borderRadius(6)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        }
        if (p.barter) {
          Text('可换').fontSize(10).fontColor('#FF6F00')
            .backgroundColor('#FFF3E0').borderRadius(6)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 4 })
        }
      }
      .width('100%')

      Text(p.title).fontSize(13).fontWeight(FontWeight.Medium)
        .fontColor('#3E2723').margin({ top: 8 }).width('100%').maxLines(2)

      Row() {
        Text(formatPrice(p.price)).fontSize(17).fontWeight(FontWeight.Bold)
          .fontColor('#FF6F00')
        if (p.originalPrice > p.price) {
          Text(formatPrice(p.originalPrice)).fontSize(11).fontColor('#BCAAA4')
            .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
        }
      }
      .width('100%').margin({ top: 6 })

      Row() {
        Text(p.location ?? '').fontSize(10).fontColor('#795548')
        Column().layoutWeight(1)
        Row() {
          Text('❤️').fontSize(10)
          Text(formatViews(p.likes)).fontSize(10).fontColor('#795548').margin({ left: 2 })
        }
      }
      .width('100%').margin({ top: 4 })
    }
    .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(10)
    .shadow({ radius: 4, color: '#1A000000', offsetY: 1 })
    .onClick(() => { this.onProductSelect(p) })
  }

  build() {
    Column() {
      // 顶部渐变横幅
      Column() {
        Row() {
          Column() {
            Text('🛒 跳蚤市场').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('发现身边的闲置好物').fontSize(12).fontColor('#FFE0B2').margin({ top: 3 })
          }
          Column().layoutWeight(1)
          Text('📝').fontSize(22)
        }
        .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
      }
      .width('100%')
      .linearGradient({
        angle: 135,
        colors: [['#FF6F00', 0], ['#E65100', 0.5], ['#FFC107', 1]]
      })

      // 搜索栏
      Row() {
        Text('🔍').fontSize(14).margin({ left: 12 })
        TextInput({ placeholder: '搜索闲置好物...' })
          .placeholderColor('#D7CCC8').fontSize(13).layoutWeight(1)
          .backgroundColor('#FFF8E1').borderRadius(20).height(36)
          .margin({ left: 6, right: 6 })
          .onChange((v: string) => { this.searchKeyword = v })
      }
      .width('100%').padding({ top: 8, bottom: 6 })

      // 分类筛选
      Scroll() {
        Row() {
          ForEach(this.getFilterCats(), (cat: string) => {
            if (this.selectedFilter === cat) {
              Text(cat).fontSize(11).fontColor('#FFFFFF')
                .backgroundColor('#FF6F00').borderRadius(14)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 }).margin({ left: 3, right: 3 })
            } else {
              Text(cat).fontSize(11).fontColor('#FF6F00')
                .backgroundColor('#FFF3E0').borderRadius(14)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 }).margin({ left: 3, right: 3 })
                .onClick(() => { this.selectedFilter = cat })
            }
          })
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(36)

      // 双列瀑布流
      Scroll() {
        Row() {
          Column() {
            this.productWaterfallCard(mockProducts[0])
            Column().height(8)
            this.productWaterfallCard(mockProducts[3])
            Column().height(8)
            this.productWaterfallCard(mockProducts[6])
            Column().height(8)
            this.productWaterfallCard(mockProducts[9])
            Column().height(8)
            this.productWaterfallCard(mockProducts[12])
            Column().height(8)
            this.productWaterfallCard(mockProducts[15])
            Column().height(8)
            this.productWaterfallCard(mockProducts[18])
            Column().height(8)
            this.productWaterfallCard(mockProducts[21])
            Column().height(8)
            this.productWaterfallCard(mockProducts[24])
          }
          .width('48%').margin({ left: 8 })
          Column() {
            Column().height(4)
            this.productWaterfallCard(mockProducts[1])
            Column().height(8)
            this.productWaterfallCard(mockProducts[4])
            Column().height(8)
            this.productWaterfallCard(mockProducts[7])
            Column().height(8)
            this.productWaterfallCard(mockProducts[10])
            Column().height(8)
            this.productWaterfallCard(mockProducts[13])
            Column().height(8)
            this.productWaterfallCard(mockProducts[16])
            Column().height(8)
            this.productWaterfallCard(mockProducts[19])
            Column().height(8)
            this.productWaterfallCard(mockProducts[22])
          }
          .width('48%').margin({ left: 4, right: 8 })
        }
        .padding({ bottom: 16 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============ 分类 Tab - 网格展示 ============
@Component
struct CategoryTabContent {
  onProductSelect: (product: ProductItem) => void = () => {}
  @State selectedCat: string = '数码电子'
  catKeys: string[] = ['数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具', '美妆个护', '其他闲置']

  // 获取当前分类下的产品
  getProductsForCat(cat: string): ProductItem[] {
    return mockProducts.filter((p: ProductItem) => p.category === cat)
  }

  @Builder categoryGridCard(c: CategoryItem) {
    Column() {
      Text(c.icon).fontSize(32)
      Text(c.name).fontSize(12).fontWeight(FontWeight.Medium).fontColor('#3E2723').margin({ top: 6 })
      Text(c.count + '件商品').fontSize(10).fontColor('#795548').margin({ top: 2 })
    }
    .width('22%').padding({ top: 14, bottom: 14 })
    .backgroundColor(this.selectedCat === c.name ? c.bg : '#FFFFFF')
    .borderRadius(12)
    .border({
      width: this.selectedCat === c.name ? { top: 2, bottom: 2, left: 2, right: 2 } : { top: 0, bottom: 0, left: 0, right: 0 },
      color: this.selectedCat === c.name ? { top: c.color, bottom: c.color, left: c.color, right: c.color } : { top: '#00000000', bottom: '#00000000', left: '#00000000', right: '#00000000' }
    })
    .shadow({ radius: this.selectedCat === c.name ? 4 : 2, color: '#1A000000', offsetY: 1 })
    .onClick(() => { this.selectedCat = c.name })
    .margin({ left: 3, right: 3, top: 4 })
  }

  @Builder catProductCard(p: ProductItem) {
    Column() {
      Row() {
        Text(getCatIcon(p.category)).fontSize(24)
          .width(44).height(44).backgroundColor(getCatBg(p.category)).borderRadius(8)
          .textAlign(TextAlign.Center)
        Column() {
          Text(p.title).fontSize(13).fontWeight(FontWeight.Medium)
            .fontColor('#3E2723').maxLines(1)
          Row() {
            Text(formatPrice(p.price)).fontSize(15).fontWeight(FontWeight.Bold)
              .fontColor('#FF6F00')
            if (p.status === '已售') {
              Text('已售').fontSize(9).fontColor('#FFFFFF')
                .backgroundColor('#2E7D32').borderRadius(4)
                .padding({ left: 4, right: 4, top: 1, bottom: 1 }).margin({ left: 6 })
            }
          }
          .margin({ top: 2 })
          Row() {
            Text(p.location ?? '').fontSize(9).fontColor('#795548')
            Column().layoutWeight(1)
            Text('👁 ' + formatViews(p.views)).fontSize(9).fontColor('#BCAAA4')
          }
          .margin({ top: 2 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
        Text('>').fontSize(14).fontColor('#D7CCC8')
      }
      .width('100%')
    }
    .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(10)
    .margin({ left: 12, right: 12, top: 4 })
    .onClick(() => { this.onProductSelect(p) })
  }

  build() {
    Column() {
      Text('📂 商品分类').fontSize(18).fontWeight(FontWeight.Bold)
        .fontColor('#3E2723').width('100%')
        .padding({ left: 16, top: 14, bottom: 4 })

      // 分类网格
      Row() {
        ForEach(mockCategoryList, (c: CategoryItem) => {
          this.categoryGridCard(c)
        })
      }
      .padding({ left: 6, right: 6 })

      // 当前分类商品列表
      Row() {
        Text(getCatIcon(this.selectedCat) + ' ' + this.selectedCat)
          .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Column().layoutWeight(1)
        Text(getCatProductCount(this.selectedCat) + '件').fontSize(11).fontColor('#795548')
      }
      .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 6 })

      Scroll() {
        Column() {
          ForEach(this.getProductsForCat(this.selectedCat), (p: ProductItem) => {
            this.catProductCard(p)
          })
        }
        .padding({ bottom: 16 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============ 消息 Tab ============
@Component
struct MessagesTabContent {
  @Builder messageCard(m: MessageItem) {
    Column() {
      Row() {
        Column() {
          Text(m.userAvatar).fontSize(28)
          if (m.isOnline) {
            Column().width(8).height(8).backgroundColor('#4CAF50')
              .borderRadius(4).margin({ top: -6 })
          }
        }.alignItems(HorizontalAlign.Center)
        Column() {
          Row() {
            Text(m.userName).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Column().layoutWeight(1)
            Text(m.time).fontSize(10).fontColor('#BCAAA4')
          }
          .width('100%')
          Text(m.lastMessage).fontSize(12).fontColor('#795548')
            .margin({ top: 3 }).maxLines(1)
          Row() {
            Text('📦 ' + m.productTitle).fontSize(10)
              .fontColor('#FF6F00').backgroundColor('#FFF3E0')
              .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
            if (m.priceOffer > 0) {
              Text('💰 出价¥' + m.priceOffer).fontSize(10)
                .fontColor('#2E7D32').backgroundColor('#E8F5E9')
                .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                .margin({ left: 6 })
            }
            Column().layoutWeight(1)
          }
          .width('100%').margin({ top: 3 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
        if (m.unread > 0) {
          Text(m.unread.toString()).fontSize(10).fontColor('#FFFFFF')
            .backgroundColor('#FF6F00').width(18).height(18).borderRadius(9)
            .textAlign(TextAlign.Center)
            .margin({ right: 4 })
        } else {
          Column().width(18).height(18)
        }
      }
      .width('100%').padding(12)
    }
    .width('100%').backgroundColor('#FFFFFF').borderRadius(10)
    .margin({ left: 12, right: 12, top: 4 })
    .shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
  }

  build() {
    Column() {
      // 顶部
      Row() {
        Text('💬 聊天消息').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Column().layoutWeight(1)
        if (messageCount() > 0) {
          Row() {
            Text(messageCount().toString()).fontSize(12).fontColor('#FF6F00')
            Text('条未读').fontSize(12).fontColor('#795548')
          }
        }
      }
      .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })

      Scroll() {
        Column() {
          this.messageCard(mockMessages[0])
          this.messageCard(mockMessages[1])
          this.messageCard(mockMessages[2])
          this.messageCard(mockMessages[3])
          this.messageCard(mockMessages[4])
          this.messageCard(mockMessages[5])
          this.messageCard(mockMessages[6])
          this.messageCard(mockMessages[7])
          this.messageCard(mockMessages[8])
          this.messageCard(mockMessages[9])
          this.messageCard(mockMessages[10])
          this.messageCard(mockMessages[11])
        }
        .padding({ bottom: 16 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============ 我的 Tab ============
@Component
struct ProfileTabContent {
  onAddClick: () => void = () => {}

  @Builder statItemBuilder(s: StatMeta) {
    Column() {
      Text(s.icon).fontSize(18).margin({ top: 4 })
      Text(s.value).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3E2723')
      Text(s.label).fontSize(10).fontColor('#795548')
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 10, bottom: 10 })
  }

  @Builder actionItemBuilder(a: QuickActionMeta) {
    Column() {
      Text(a.icon).fontSize(22)
        .width(44).height(44).backgroundColor(a.bg).borderRadius(22)
        .textAlign(TextAlign.Center)
      Text(a.label).fontSize(11).fontColor('#3E2723').margin({ top: 4 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 4, bottom: 4 })
    .onClick(() => {
      if (a.label === '发布') { this.onAddClick() }
    })
  }

  @Builder settingRow(icon: string, label: string) {
    Row() {
      Text(icon).fontSize(16)
      Text(label).fontSize(13).fontColor('#3E2723').layoutWeight(1).margin({ left: 10 })
      Text('>').fontSize(14).fontColor('#D7CCC8')
    }
    .width('100%').padding({ top: 11, bottom: 11, left: 4 })
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // 头部卡片
          Column() {
            Row() {
              Text('🧑‍💻').fontSize(40)
                .width(62).height(62).backgroundColor('#FFF3E0').borderRadius(31)
                .textAlign(TextAlign.Center)
              Column() {
                Text('跳蚤达人').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                Text('已加入 365天 · 信用优秀').fontSize(11).fontColor('#795548').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
              Text('⚙️').fontSize(20).fontColor('#795548')
            }
            .width('100%').padding({ left: 16, right: 16, top: 18, bottom: 16 })
          }
          .width('100%')
          .linearGradient({
            angle: 180,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .borderRadius({ bottomLeft: 24, bottomRight: 24 })

          // 统计数据
          Row() {
            this.statItemBuilder(statItems[0])
            this.statItemBuilder(statItems[1])
            this.statItemBuilder(statItems[2])
            this.statItemBuilder(statItems[3])
          }
          .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
          .position({ x: '4%', y: -16 })
          .shadow({ radius: 6, color: '#1A000000', offsetY: 2 })

          // 快捷操作
          Row() {
            this.actionItemBuilder(quickActions[0])
            this.actionItemBuilder(quickActions[1])
            this.actionItemBuilder(quickActions[2])
            this.actionItemBuilder(quickActions[3])
          }
          .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
          .shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
          .margin({ left: '4%', right: '4%', top: 8 })

          // 价格区间统计
          Column() {
            Text('📊 价格区间分布').fontSize(13).fontWeight(FontWeight.Bold)
              .fontColor('#3E2723').width('100%').padding({ left: 16, top: 12, bottom: 8 })

            Column() {
              // 柱状图模拟
              Row() {
                Column() {
                  Column().width(36).height(60).backgroundColor('#4CAF50').borderRadius(4)
                  Text('0-50').fontSize(9).fontColor('#795548').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                Column() {
                  Column().width(36).height(40).backgroundColor('#FF6F00').borderRadius(4)
                  Text('50-200').fontSize(9).fontColor('#795548').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                Column() {
                  Column().width(36).height(50).backgroundColor('#FF9800').borderRadius(4)
                  Text('200-500').fontSize(9).fontColor('#795548').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                Column() {
                  Column().width(36).height(30).backgroundColor('#E65100').borderRadius(4)
                  Text('500-1k').fontSize(9).fontColor('#795548').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                Column() {
                  Column().width(36).height(22).backgroundColor('#BF360C').borderRadius(4)
                  Text('1k+').fontSize(9).fontColor('#795548').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
              }
              .width('100%').padding({ left: 8, right: 8, top: 4, bottom: 8 })

              Row() {
                Text('共25件商品,均价 ¥186').fontSize(10).fontColor('#BCAAA4')
                  .width('100%').textAlign(TextAlign.Center)
              }
              .width('100%')
            }
            .padding({ bottom: 12 })
          }
          .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
          .shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
          .margin({ left: '4%', right: '4%', top: 8 })

          // 设置菜单
          Column() {
            Text('⚙️ 设置').fontSize(13).fontWeight(FontWeight.Bold)
              .fontColor('#3E2723').width('100%').padding({ left: 16, top: 12, bottom: 6 })

            Column() {
              this.settingRow('📋', '我的发布')
              Divider().color('#FFF8E1')
              this.settingRow('❤️', '我的收藏')
              Divider().color('#FFF8E1')
              this.settingRow('📊', '交易记录')
              Divider().color('#FFF8E1')
              this.settingRow('🔔', '消息通知')
              Divider().color('#FFF8E1')
              this.settingRow('ℹ️', '关于我们')
            }
            .padding({ left: 16, right: 16 })
          }
          .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
          .shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
          .margin({ left: '4%', right: '4%', top: 8 })
          .padding({ bottom: 8 })

          // 底部版本
          Text('🐛 跳蚤市场 v2.0 · 淘你所爱')
            .fontSize(10).fontColor('#D7CCC8')
            .alignSelf(ItemAlign.Center)
            .padding({ top: 16, bottom: 20 })
        }
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============ 补充:分类占比统计组件 ============
@Component
struct CategoryStatsBar {
  catData: string[] = ['数码电子', '家居生活', '服饰鞋包', '图书音像', '运动户外', '母婴玩具', '美妆个护', '其他闲置']
  catBarColors: string[] = ['#1565C0', '#FF6F00', '#C62828', '#2E7D32', '#00695C', '#AD1457', '#7B1FA2', '#795548']

  build() {
    Column() {
      Text('📊 分类商品占比').fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor('#3E2723').width('100%')
        .padding({ left: 16, top: 12, bottom: 8 })

      Column() {
        ForEach([0, 1, 2, 3, 4, 5, 6, 7], (idx: number) => {
          Column() {
            Row() {
              Text(getCatIcon(this.catData[idx]) + ' ' + this.catData[idx])
                .fontSize(11).fontColor('#3E2723').layoutWeight(1).maxLines(1)
              Text(getCatProductCount(this.catData[idx]) + '件')
                .fontSize(11).fontColor('#795548')
            }
            .width('100%')

            Row() {
              Column()
                .width((getCatProductCount(this.catData[idx]) * 100 / 25).toFixed(0) + '%')
                .height(6)
                .backgroundColor(this.catBarColors[idx])
                .borderRadius(3)
                .animation({ duration: 600, curve: Curve.EaseOut })
              Column().layoutWeight(1)
            }
            .width('100%').height(6).backgroundColor('#FFE0B2').borderRadius(3)
            .margin({ top: 3 })
          }
          .width('100%').padding({ top: 6, bottom: 6 })
        })
      }
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 3, color: '#1A000000', offsetY: 2 })
  }
}

// ============ 补充:热门推荐滚动条 ============
@Component
struct HotProductsSlider {
  hotItems: ProductItem[] = [
    mockProducts[0], mockProducts[6], mockProducts[9],
    mockProducts[11], mockProducts[13], mockProducts[17],
    mockProducts[18], mockProducts[23]
  ]

  @Builder hotCard(p: ProductItem) {
    Column() {
      Text(getCatIcon(p.category)).fontSize(36)
        .width(70).height(70).backgroundColor(getCatBg(p.category)).borderRadius(14)
        .textAlign(TextAlign.Center)
      Text(p.title).fontSize(11).fontWeight(FontWeight.Medium)
        .fontColor('#3E2723').margin({ top: 8 }).width(80).maxLines(1)
      Text(formatPrice(p.price)).fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor('#FF6F00').margin({ top: 3 })
      Row() {
        Text('👁').fontSize(10)
        Text(formatViews(p.views)).fontSize(10).fontColor('#BCAAA4')
      }
      .margin({ top: 2 })
    }
    .padding(8).backgroundColor('#FFFFFF').borderRadius(10)
    .margin({ left: 3, right: 3 })
    .shadow({ radius: 2, color: '#0D000000', offsetY: 1 })
  }

  build() {
    Column() {
      Row() {
        Text('🔥 热门推荐').fontSize(14).fontWeight(FontWeight.Bold)
          .fontColor('#3E2723')
        Column().layoutWeight(1)
        Text('更多 >').fontSize(11).fontColor('#FF6F00')
      }
      .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 6 })

      Scroll() {
        Row() {
          ForEach(this.hotItems, (p: ProductItem) => {
            this.hotCard(p)
          })
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(160)
    }
    .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 3, color: '#1A000000', offsetY: 2 })
    .padding({ bottom: 12 })
  }
}

// ============ 补充:议价进度条组件 ============
@Component
struct OfferProgressBar {
  currentPrice: number = 0
  sellerPrice: number = 0
  tip: string = ''

  build() {
    Column() {
      Row() {
        Text('💰 当前出价 ¥' + this.currentPrice).fontSize(12).fontColor('#FF6F00')
        Column().layoutWeight(1)
        Text('售价 ¥' + this.sellerPrice).fontSize(12).fontColor('#795548')
      }
      .width('100%')
      Row() {
        Column()
          .width((this.currentPrice / this.sellerPrice * 100).toFixed(0) + '%')
          .height(8)
          .backgroundColor('#FF6F00').borderRadius(4)
          .linearGradient({
            angle: 90,
            colors: [['#FF6F00', 0], ['#FFC107', 1]]
          })
          .animation({ duration: 800, curve: Curve.EaseInOut })
        Column().layoutWeight(1)
      }
      .width('100%').height(8).backgroundColor('#FFE0B2').borderRadius(4)
      .margin({ top: 4 })
      Text(this.tip).fontSize(10).fontColor('#BCAAA4').margin({ top: 4 })
    }
    .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
  }
}

// ============ 补充:交易概览卡片(含渐变和动画) ============
@Component
struct TradeSummaryCard {
  @State animValue: number = 0

  aboutToAppear(): void {
    this.animValue = 1
  }

  build() {
    Column() {
      Text('📈 交易概览').fontSize(14).fontWeight(FontWeight.Bold)
        .fontColor('#3E2723').width('100%').padding({ left: 16, top: 12, bottom: 8 })

      Row() {
        Column() {
          Text('平均售价').fontSize(10).fontColor('#795548')
          Text('¥186').fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor('#FF6F00').margin({ top: 2 })
            .scale({ x: this.animValue, y: this.animValue })
            .animation({ duration: 400, curve: Curve.EaseOut })
          Text('较上月 ↑12%').fontSize(9).fontColor('#2E7D32').margin({ top: 1 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })

        Column() {
          Text('成交率').fontSize(10).fontColor('#795548')
          Text('32%').fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor('#1565C0').margin({ top: 2 })
            .scale({ x: this.animValue, y: this.animValue })
            .animation({ duration: 500, curve: Curve.EaseOut })
          Text('8/25件已售').fontSize(9).fontColor('#2E7D32').margin({ top: 1 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })

        Column() {
          Text('平均浏览').fontSize(10).fontColor('#795548')
          Text('2.5k').fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor('#C62828').margin({ top: 2 })
            .scale({ x: this.animValue, y: this.animValue })
            .animation({ duration: 600, curve: Curve.EaseOut })
          Text('每件商品').fontSize(9).fontColor('#795548').margin({ top: 1 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 8, bottom: 8 })
      }
      .width('100%')
    }
    .width('92%').backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 3, color: '#1A000000', offsetY: 2 })
    .padding({ bottom: 8 })
  }
}

// ============ 补充:发布提示横幅 ============
@Component
struct PublishTipBanner {
  build() {
    Row() {
      Text('💡').fontSize(20)
      Column() {
        Text('发布闲置赚零花钱').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
        Text('拍照上传,30秒快速发布').fontSize(11).fontColor('#795548').margin({ top: 2 })
      }
      .layoutWeight(1).padding({ left: 8 })
      Text('+').fontSize(18).fontColor('#FFFFFF')
        .backgroundColor('#FF6F00').width(30).height(30).borderRadius(15)
        .textAlign(TextAlign.Center)
    }
    .width('92%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor('#FFF3E0').borderRadius(12)
    .shadow({ radius: 3, color: '#1A000000', offsetY: 2 })
  }
}


16.5 结语

总而言之,这个"跳蚤橙风"二手交易市场应用虽然是一个演示项目,但它在架构分层、数据驱动、视觉设计、交互细节上都展现出了相当高的完成度。它不仅仅是一堆 UI 代码的堆砌,更是一套"如何用声明式范式构建一个完整移动应用"的范本。对于想要学习 ArkUI 声明式开发、或者想要理解"数据驱动渲染"实践方式的开发者来说,这份代码值得逐行研读、反复揣摩。

在这里插入图片描述

用 interface 约定数据形状、用配置表驱动视觉差异、用纯函数封装无副作用逻辑、用 enum 消除魔法数字、用 @Builder 拆分视图片段、用回调实现子父通信、用可选链和空值合并做防御性编程。这些原则不仅适用于 ArkUI,同样适用于 Flutter、React、Vue 等任何声明式 UI 框架。掌握了它们,就掌握了构建高质量前端应用的通用方法论。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐