引言

在这里插入图片描述

随着二次元文化在中国年轻消费群体中的持续渗透,动漫周边商品(俗称"谷子")的交易需求催生了垂直细分领域的电商社区平台。从吧唧(徽章)、立牌、色纸到亚克力挂件、棉花娃娃,"吃谷"已经从简单的商品购买演化为涵盖拼团成团、新谷预告、无料交换、晒谷社区等多元场景的完整交易生态。本文将深入剖析一个基于HarmonyOS ArkTS声明式UI框架构建的二次元谷子集市应用,该应用以紫蓝渐变为核心视觉语言,融合了拼多多风格的拼团电商模式与二次元社区的社交互动模式,实现了七大功能模块的完整端侧交互体验。

从技术架构层面来看,该应用采用了HarmonyOS ArkTS API 24所提供的声明式开发范式,以@Entry@Component@Builder@State等核心装饰器构建了高度组件化的UI体系。整个应用基于单页面多Tab切换架构,通过activeTab数字索引实现七个主功能模块的路由分发:吃谷广场、新谷预告、吃谷车、无料交换、晒谷圈、消息和我的。与枚举路由不同,该应用使用数字索引(0-6)配合@Builder contentArea@Builder tabItem两个可复用Builder方法实现Tab切换,入口组件的build方法结构更为精简。应用同时在各Tab组件内部管理弹窗状态,包括商品详情弹窗、加入吃谷车弹窗、发布换物帖弹窗、删除确认弹窗和补款提醒弹窗五大交互场景。

在数据架构设计上,应用采用了"interface接口定义—全局静态数据—工具函数"的分层架构模式。首先通过TypeScript interface定义了PlazaCate、GuziItem、SpecChip、PreorderItem、MonthGroup、CartGroupItem、ExchangeItem、GalleryPost、GridCell、GridRow1、MsgItem、MonthSpend、MyGuziItem、PayOrder、MenuItem1共十五个元数据接口,覆盖了商品、规格、预售、拼团、交换、社区、消息、消费统计和支付订单等全业务实体。随后通过模块级常量数组存放14条谷子商品、10条新谷预告(按3个月分组)、8个拼团、10条换物帖、3条晒谷帖、12条消息、6个月消费数据和2笔待补款订单等丰富数据集。最后定义了fmtPrice、fmtSales、specPrice、specStock、spendBarH、groupPercent、fmtRemain、fmtCount共八个工具函数,封装了价格格式化、销量缩写、规格查找、柱状图高度计算、拼团百分比和倒计时格式化等通用逻辑。

一、接口定义层

在这里插入图片描述

1.1 商品与规格接口

应用首先定义了谷子商品和规格的核心数据结构,这是整个电商交易的基础。

interface PlazaCate {
  label: string
  icon: string
}

interface GuziItem {
  id: number
  name: string
  tag: string
  price: number
  originalPrice: number
  sales: number
  icon: string
  bg: string
  category: string
  stock: number
  rating: number
}

interface SpecChip {
  label: string
  price: number
  stock: number
}

interface PreorderItem {
  id: number
  name: string
  series: string
  price: number
  preCount: number
  deadline: string
  icon: string
  bg: string
  tag: string
}

interface MonthGroup {
  month: string
  tip: string
  items: PreorderItem[]
}

PlazaCate定义了广场分类的标签和图标,用于顶部分类胶囊横滑。GuziItem是谷子商品的核心接口,包含id、商品名、标签(如"热卖"“拼团”“限定”)、现价、原价、销量、emoji图标、背景色、分类、库存和评分共十一个字段,是整个应用字段最丰富的接口。SpecChip定义了商品规格选项(如"单只装"“二连装”“全套五入”),每个规格关联独立的价格和库存。PreorderItem定义了新谷预告的商品信息,包含关联系列名、预约人数、开售时间和标签。MonthGroup将多条预售商品按月份分组,包含月份名、提示语和商品数组,用于构建时间线布局。这种嵌套结构设计使得UI层可以通过双层ForEach遍历实现"月份→商品"的层级展示。

1.2 拼团、交换与社区接口

在这里插入图片描述

interface CartGroupItem {
  id: number
  name: string
  icon: string
  bg: string
  price: number
  joinedCount: number
  totalCount: number
  endTime: string
  members: string[]
  discount: string
}

interface ExchangeItem {
  id: number
  user: string
  avatar: string
  give: string
  want: string
  note: string
  time: string
  isLeft: boolean
}

interface GalleryPost {
  id: number
  user: string
  avatar: string
  content: string
  likes: number
  comments: number
  isHot: boolean
}

interface GridCell {
  icon: string
  bg: string
  label: string
}

interface GridRow1 {
  cells: GridCell[]
}

interface MsgItem {
  id: number
  title: string
  content: string
  icon: string
  time: string
  unread: boolean
}

interface MonthSpend {
  month: string
  amount: number
}

interface MyGuziItem {
  id: number
  name: string
  icon: string
  bg: string
  value: number
}

interface PayOrder {
  id: number
  title: string
  icon: string
  amount: number
  deadlineText: string
  remainMin: number
}

interface MenuItem1 {
  icon: string
  label: string
  badge: string
}

CartGroupItem定义了吃谷车拼团的核心结构,包含拼团名、已参团人数、总人数、截止时间、参与者头像数组和折扣信息。ExchangeItem是无料交换帖的接口,isLeft布尔值控制帖子的左右对齐方向,实现了类似聊天气泡的左右交替展示效果。GalleryPost定义了晒谷帖的内容结构,isHot标记热门帖子。GridCellGridRow1是一组嵌套接口,用于构建晒谷圈的三列图片墙宫格。MonthSpend定义了月度消费数据,用于个人中心的消费柱状图。PayOrder定义了待补款订单,包含截止文案和剩余分钟数,用于倒计时渲染。MenuItem1定义了个人中心的功能菜单项,badge字段用于展示角标提示(如"2张"优惠券)。这些接口共同构成了应用完整的数据类型体系,为所有业务场景提供了类型安全保障。

二、设计令牌与全局数据

在这里插入图片描述

2.1 紫蓝渐变设计令牌

应用通过模块级常量定义了四组渐变配置,以紫蓝渐变为核心视觉基调。

const MAIN_GRADIENT: LinearGradientOptions = {
  angle: 135,
  colors: [['#6C5CE7', 0.0], ['#A29BFE', 1.0]]
}

const ORANGE_GRADIENT: LinearGradientOptions = {
  angle: 135,
  colors: [['#FF9F43', 0.0], ['#FFC048', 1.0]]
}

const BANNER_GRADIENT: LinearGradientOptions = {
  angle: 120,
  colors: [['#6C5CE7', 0.0], ['#9B8CFF', 1.0]]
}

const TAG_GRADIENT: LinearGradientOptions = {
  angle: 90,
  colors: [['#FF9F43', 0.0], ['#FF7E5F', 1.0]]
}

四组渐变分别对应不同的视觉场景:MAIN_GRADIENT(紫蓝主渐变,从深紫#6C5CE7到浅紫#A29BFE)用于所有Tab的顶部头部区域,形成统一的品牌视觉基调;ORANGE_GRADIENT(橙色渐变,从#FF9F43#FFC048)用于补款提醒弹窗的头部和操作按钮,以暖色暗示紧迫感;BANNER_GRADIENT(横幅渐变,120度方向,从#6C5CE7#9B8CFF)用于吃谷广场的促销Banner卡片;TAG_GRADIENT(标签渐变,90度横向,从橙到红#FF7E5F)用于价格标签和强调元素。整体配色策略是紫蓝为主色调(冷色系代表二次元文化),橙色为强调色(暖色系用于价格和紧迫提示),形成冷暖对比的视觉层次。所有渐变均使用LinearGradientOptions类型约束,确保配置参数的类型安全。

2.2 谷子商品与拼团数据

const GUZI_ITEMS: GuziItem[] = [
  { id: 1, name: '五条悟·涩谷事变 吧唧', tag: '🔥热卖', price: 25, originalPrice: 39, sales: 12000, icon: '📛', bg: '#FDE8F0', category: '吧唧', stock: 60, rating: 4.9 },
  { id: 2, name: '灶门炭治郎·锻刀村 立牌', tag: '拼团', price: 45, originalPrice: 68, sales: 8600, icon: '🖼️', bg: '#FFF4E0', category: '立牌', stock: 35, rating: 4.8 },
  { id: 3, name: '绫波丽·新剧场版 色纸', tag: '限定', price: 32, originalPrice: 49, sales: 21000, icon: '🎨', bg: '#E4F0FF', category: '色纸', stock: 88, rating: 4.9 },
  { id: 4, name: '蓝色监狱·糸师凛 亚克力', tag: '爆款', price: 38, originalPrice: 55, sales: 34000, icon: '💠', bg: '#E7F7FF', category: '亚克力', stock: 120, rating: 4.7 },
  { id: 5, name: '星野爱·小豆泥 棉花娃娃', tag: '现货', price: 129, originalPrice: 189, sales: 4600, icon: '🧸', bg: '#FDF0E6', category: '棉花娃娃', stock: 15, rating: 5.0 },
  { id: 6, name: '芙莉莲·勇者组 吧唧托', tag: '新谷', price: 28, originalPrice: 42, sales: 9800, icon: '🎀', bg: '#F3EBFF', category: '吧唧托', stock: 70, rating: 4.8 },
  { id: 7, name: '三丽鸥·库洛米 明信片', tag: '福袋', price: 15, originalPrice: 25, sales: 56000, icon: '💌', bg: '#FBE4FB', category: '明信片', stock: 200, rating: 4.6 },
  { id: 8, name: '五条悟·夏服ver 立牌', tag: '预售', price: 55, originalPrice: 79, sales: 7200, icon: '🖼️', bg: '#E9F6FF', category: '立牌', stock: 40, rating: 4.8 },
  { id: 12, name: '鬼灭·炼狱杏寿郎 吧唧', tag: '绝版', price: 88, originalPrice: 128, sales: 3200, icon: '📛', bg: '#FFEFE0', category: '吧唧', stock: 12, rating: 5.0 },
  { id: 13, name: '孤独摇滚·后藤一里 娃娃', tag: '预售', price: 149, originalPrice: 209, sales: 2100, icon: '🧸', bg: '#FDF2F7', category: '棉花娃娃', stock: 8, rating: 4.9 },
  { id: 14, name: '蓝锁·凪诚士郎 明信片', tag: '特典', price: 12, originalPrice: 20, sales: 42000, icon: '💌', bg: '#EFF9F0', category: '明信片', stock: 300, rating: 4.6 }
]

const SPEC_CHIPS: SpecChip[] = [
  { label: '单只装', price: 25, stock: 60 },
  { label: '二连装', price: 48, stock: 30 },
  { label: '全套五入', price: 118, stock: 8 }
]

const CART_GROUPS: CartGroupItem[] = [
  { id: 1, name: '五条悟 涩谷吧唧拼团', icon: '📛', bg: '#FDE8F0', price: 19, joinedCount: 5, totalCount: 8, endTime: '23:59', members: ['🐱', '🐹', '🐰', '🐻', '🐨'], discount: '团价¥19' },
  { id: 2, name: '芙莉莲 勇者组吧唧拼团', icon: '🎀', bg: '#F3EBFF', price: 22, joinedCount: 3, totalCount: 6, endTime: '22:30', members: ['🐰', '🐱', '🐹'], discount: '团价¥22' },
  { id: 4, name: '阿尼亚 棉花娃娃拼团', icon: '🧸', bg: '#FDF2F7', price: 99, joinedCount: 2, totalCount: 5, endTime: '23:00', members: ['🦊', '🐱'], discount: '团价¥99' },
  { id: 8, name: '炼狱杏寿郎 绝版吧唧团', icon: '📛', bg: '#FFEFE0', price: 68, joinedCount: 1, totalCount: 4, endTime: '18:00', members: ['🦊'], discount: '团价¥68' }
]

const EXCHANGES: ExchangeItem[] = [
  { id: 1, user: '谷子猫猫头', avatar: '🐱', give: '烫金色纸 × 2', want: '咒回吧唧', note: '五条/夏油都行,全新未拆优先', time: '10分钟前', isLeft: true },
  { id: 2, user: '阿宅小星', avatar: '✨', give: '多余吧唧 × 3', want: '芙莉莲色纸', note: '同城优先,可面交互换', time: '25分钟前', isLeft: false },
  { id: 3, user: '咸鱼交换酱', avatar: '🐟', give: '亚克力挂件', want: '棉花娃娃', note: '可加钱补差换,小豆泥可谈', time: '1小时前', isLeft: true },
  { id: 9, user: '吃谷喵', avatar: '🐈', give: '二连吧唧', want: '透卡', note: '可互选款式,私信详聊', time: '前天', isLeft: true },
  { id: 10, user: '阿宅小星', avatar: '✨', give: '蓝锁徽章', want: '明日方舟吧唧', note: '走平台交易更放心', time: '前天', isLeft: false }
]

const PAY_ORDERS: PayOrder[] = [
  { id: 1, title: '五条悟 涩谷吧唧拼团', icon: '📛', amount: 19, deadlineText: '今日 23:59 截止', remainMin: 45 },
  { id: 2, title: '乙骨忧太 烫金色纸拼团', icon: '🎨', amount: 35, deadlineText: '明日 18:00 截止', remainMin: 120 }
]

14条谷子商品数据覆盖了吧唧、立牌、色纸、亚克力、棉花娃娃、吧唧托和明信片七大品类,每条数据都关联了IP角色名(如五条悟、炭治郎、绫波丽等)和标签(热卖、拼团、限定、爆款、绝版等)。商品背景色与品类相关:吧唧用粉色系、立牌用蓝色系、色纸用浅蓝、亚克力用天蓝、棉花娃娃用暖色,通过色彩暗示商品类型。8个拼团数据中joinedCounttotalCount的差值表示还差多少人成团,endTime字段用于倒计时展示。10条换物帖通过isLeft字段控制左右气泡方向,奇数索引为左侧(用户头像在左),偶数索引为右侧(用户头像在右),形成聊天式的交替布局。2笔待补款订单的remainMin字段存储剩余分钟数,用于渲染倒计时数字。

三、工具函数与入口组件

在这里插入图片描述

3.1 工具函数集

应用定义了八个工具函数,封装了数据格式化和查找逻辑,在UI渲染中被频繁调用。

function fmtPrice(p: number): string {
  return '¥' + p.toFixed(2)
}

function fmtSales(s: number): string {
  if (s >= 10000) {
    return (s / 10000).toFixed(1) + '万'
  }
  return s.toString()
}

function specPrice(label: string): number {
  for (let i = 0; i < SPEC_CHIPS.length; i++) {
    if (SPEC_CHIPS[i].label === label) {
      return SPEC_CHIPS[i].price
    }
  }
  return 0
}

function specStock(label: string): number {
  for (let i = 0; i < SPEC_CHIPS.length; i++) {
    if (SPEC_CHIPS[i].label === label) {
      return SPEC_CHIPS[i].stock
    }
  }
  return 0
}

function spendBarH(a: number): string {
  return (a / MAX_SPEND * 90).toFixed(0) + 'vp'
}

function groupPercent(j: number, t: number): string {
  return ((j / t) * 100).toFixed(0) + '%'
}

function fmtRemain(m: number): string {
  const h = Math.floor(m / 60)
  const min = m % 60
  return h.toString().padStart(2, '0') + ':' + min.toString().padStart(2, '0')
}

function fmtCount(c: number): string {
  if (c >= 10000) {
    return (c / 10000).toFixed(1) + '万'
  }
  return c.toString()
}

fmtPrice将数字价格格式化为带人民币符号和两位小数的字符串(如19→"¥19.00")。fmtSalesfmtCount都实现了万级缩写,超过一万时转换为"X.X万"格式,但分别用于销量和预约人数的展示。specPricespecStock通过标签名在SPEC_CHIPS数组中查找对应的规格价格和库存,使用for循环遍历匹配,返回0作为未找到的默认值。spendBarH将消费金额映射到柱状图高度(除以MAX_SPEND再乘以90得到vp高度字符串),是数据可视化的核心计算逻辑。groupPercent计算拼团成团百分比并格式化为"X%"字符串。fmtRemain将剩余分钟数转换为"HH:MM"格式的倒计时字符串,使用padStart(2, '0')确保时和分都是两位数。这些工具函数的设计体现了"关注点分离"原则——将数据格式化逻辑从UI渲染中抽离,使得Builder方法的代码更加简洁。

3.2 入口组件与Tab构建

在这里插入图片描述

入口组件采用数字索引路由和可复用Builder方法,构建了精简的Tab切换架构。

@Entry
@Component
struct GuziMarketPage {
  @State activeTab: number = 0

  @Builder contentArea() {
    Column() {
      if (this.activeTab === 0) {
        PlazaContent()
      } else if (this.activeTab === 1) {
        PreorderContent()
      } else if (this.activeTab === 2) {
        CartContent()
      } else if (this.activeTab === 3) {
        ExchangeContent()
      } else if (this.activeTab === 4) {
        GalleryContent()
      } else if (this.activeTab === 5) {
        MessageContent()
      } else {
        ProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder tabItem(icon: string, label: string, tab: number, badge: number) {
    Column() {
      Stack() {
        Text(icon).fontSize(18).opacity(this.activeTab === tab ? 1.0 : 0.45)
        if (badge > 0) {
          Text(badge.toString()).fontSize(8).fontColor('#FFFFFF').backgroundColor('#FF4D4F')
            .borderRadius(7).width(15).height(15).textAlign(TextAlign.Center)
            .position({ x: 13, y: -3 })
        }
      }
      .width(32).height(26)
      Text(label).fontSize(9)
        .fontColor(this.activeTab === tab ? '#6C5CE7' : '#999999')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 4, bottom: 4 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.tabItem('🏙️', '吃谷广场', 0, 0)
        this.tabItem('📅', '新谷预告', 1, 0)
        this.tabItem('🛒', '吃谷车', 2, 2)
        this.tabItem('🎁', '无料交换', 3, 0)
        this.tabItem('📸', '晒谷圈', 4, 0)
        this.tabItem('💬', '消息', 5, 3)
        this.tabItem('👤', '我的', 6, 0)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 4, bottom: 6 })
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor('#F4F2FF')
  }
}

入口组件的结构设计非常精炼:@State activeTab管理当前Tab索引(0-6),@Builder contentArea封装了if-else if-else链式条件判断的路由分发逻辑,@Builder tabItem封装了底部Tab项的渲染逻辑。tabItem Builder接收图标、标签、Tab索引和角标数四个参数,激活状态用品牌紫#6C5CE7文字加粗、图标全透明度,非激活用灰色文字和0.45透明度。角标通过badge > 0条件判断,当有未读数时在图标右上角渲染红色圆形角标(position({ x: 13, y: -3 })定位)。build方法只需调用this.contentArea()和七个this.tabItem(...),代码极其简洁。这种Builder复用模式使得入口组件的代码量大幅减少,同时保持了完整的路由和交互功能。

四、吃谷广场与商品详情

在这里插入图片描述

4.1 吃谷广场Tab

吃谷广场作为首页Tab,集成了渐变头部、分类胶囊横滑、促销Banner和双列瀑布流商品展示。

@Component
struct PlazaContent {
  @State activeCate: string = '全部'
  @State showDetail: boolean = false
  @State selectedGuzi: GuziItem | null = null
  @State selectedSpec: string = '单只装'
  @State joinedDetail: boolean = false

  @Builder guziCard(item: GuziItem, big: boolean) {
    Column() {
      Stack() {
        Column() {
          Text(item.icon).fontSize(big ? 52 : 38)
        }
        .width('100%').height(big ? 120 : 84)
        .backgroundColor(item.bg)
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        if (item.tag !== '') {
          Text(item.tag).fontSize(8).fontColor('#FFFFFF')
            .backgroundColor('#FF9F43')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius({ topRight: 8, bottomLeft: 8 })
            .position({ x: 0, y: 0 })
        }
      }
      .width('100%').height(big ? 120 : 84)

      Column() {
        Text(item.name).fontSize(11).fontWeight(FontWeight.Medium).fontColor('#212121')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
        Row() {
          Text(fmtPrice(item.price)).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
          Text(fmtPrice(item.originalPrice)).fontSize(9).fontColor('#BBBBBB')
            .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 3 })
          Column().layoutWeight(1)
        }
        .width('100%').margin({ top: 4 })
        Row() {
          Text('⭐' + item.rating.toString()).fontSize(9).fontColor('#FF9F43')
          Column().layoutWeight(1)
          Text(fmtSales(item.sales) + '人拼').fontSize(9).fontColor('#999999')
        }
        .width('100%').margin({ top: 4 })
      }
      .padding(8).alignItems(HorizontalAlign.Start)
    }
    .width('100%').backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 6, right: 6, top: 6 })
    .shadow({ radius: 6, color: '#0D000000', offsetY: 2 })
    .onClick(() => { this.selectedGuzi = item; this.selectedSpec = '单只装'; this.joinedDetail = false; this.showDetail = true })
  }

  build() {
    Stack() {
      Column() {
        // 渐变头部:电商搜索风
        Column() {
          Row() {
            Text('🎪').fontSize(20)
            Text('二次元谷子集市').fontSize(17).fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF').margin({ left: 6 })
            Column().layoutWeight(1)
            Text('🔔').fontSize(19).margin({ left: 10 })
            Text('🛒').fontSize(19).margin({ left: 10 })
          }
          .width('100%').padding({ left: 12, right: 12, top: 12 })

          Row() {
            Text('🔍').fontSize(13).margin({ left: 8 })
            Text('搜索吧唧、立牌、色纸、角色IP').fontSize(12).fontColor('rgba(255,255,255,0.7)')
              .margin({ left: 6 })
            Column().layoutWeight(1)
            Text('搜索').fontSize(11).fontColor('#6C5CE7').backgroundColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
              .margin({ right: 6 })
          }
          .height(36).backgroundColor('rgba(255,255,255,0.18)').borderRadius(18)
          .margin({ left: 12, right: 12, top: 10 })

          // 分类胶囊横滑
          Scroll() {
            Row() {
              ForEach(PLAZA_CATES, (c: PlazaCate) => {
                Row() {
                  Text(c.icon).fontSize(11)
                    .fontColor(this.activeCate === c.label ? '#6C5CE7' : '#FFFFFF')
                  Text(c.label).fontSize(11).margin({ left: 3 })
                    .fontColor(this.activeCate === c.label ? '#6C5CE7' : '#FFFFFF')
                }
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(this.activeCate === c.label ? '#FFFFFF' : 'rgba(255,255,255,0.15)')
                .borderRadius(16)
                .margin({ left: 6, right: 6 })
                .onClick(() => { this.activeCate = c.label })
              })
            }
            .padding({ left: 6, right: 6 })
          }
          .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
          .height(40).width('100%').margin({ top: 10, bottom: 10 })
        }
        .width('100%').linearGradient(MAIN_GRADIENT)

        Scroll() {
          Column() {
            // 静态banner
            Row() {
              Column() {
                Text('🎡 周五谷子集市日').fontSize(14).fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                Text('全场满 300 减 30 · 拼团再享折上折').fontSize(10)
                  .fontColor('rgba(255,255,255,0.85)').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
              Text('去逛逛 >').fontSize(11).fontColor('#FFFFFF')
                .backgroundColor('rgba(255,255,255,0.22)')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
            }
            .width('100%').padding(14)
            .linearGradient(BANNER_GRADIENT).borderRadius(12)
            .margin({ left: 12, right: 12, top: 10 })
            .shadow({ radius: 6, color: '#336C5CE7', offsetY: 3 })

            // 双列卡片流(大小交替错落)
            Row() {
              Column() {
                this.guziCard(GUZI_ITEMS[0], true)
                this.guziCard(GUZI_ITEMS[2], true)
                this.guziCard(GUZI_ITEMS[4], true)
                this.guziCard(GUZI_ITEMS[6], true)
                this.guziCard(GUZI_ITEMS[8], true)
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              Column() {
                this.guziCard(GUZI_ITEMS[1], false)
                this.guziCard(GUZI_ITEMS[3], false)
                this.guziCard(GUZI_ITEMS[5], false)
                this.guziCard(GUZI_ITEMS[7], false)
                this.guziCard(GUZI_ITEMS[9], false)
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center).margin({ top: 40 })
            }
            .width('100%').alignItems(VerticalAlign.Top)
            .padding({ left: 6, right: 6, bottom: 16 })
          }
          .width('100%')
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showDetail) { this.detailModal() }
    }
    .width('100%').height('100%')
  }
}

吃谷广场Tab的头部使用MAIN_GRADIENT紫蓝渐变背景,包含品牌名、通知和购物车图标、搜索栏和分类胶囊横滑。搜索栏使用半透明白色背景(rgba(255,255,255,0.18))的胶囊形态,右侧"搜索"按钮用白色背景紫色文字形成对比。分类胶囊通过ForEach遍历PLAZA_CATES数组渲染8个分类标签,选中分类用白色背景紫色文字、未选中用半透明白色背景白字,点击切换activeCate状态。瀑布流商品展示是该Tab的核心亮点:左列使用big=true(大卡片,图标52vp、高度120vp),右列使用big=false(小卡片,图标38vp、高度84vp),右列额外添加margin({ top: 40 })偏移,实现了大小交替的错落瀑布流效果。guziCard Builder的big参数控制卡片尺寸,标签通过position({ x: 0, y: 0 })定位在卡片左上角,使用borderRadius({ topRight: 8, bottomLeft: 8 })形成不对称圆角标签。点击商品卡片时同时设置selectedGuziselectedSpecjoinedDetail三个状态并打开详情弹窗。

4.2 商品详情弹窗

商品详情弹窗采用底部滑出样式,集成了大图展示、规格选择、价格库存和操作按钮。

@Builder modalOverlay(onClose: () => void = () => {}) {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(0,0,0,0.55)')
    .onClick(onClose)
}

@Builder detailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetail = false })
    Column() {
      Stack() {
        Column() {
          Text(this.selectedGuzi?.icon ?? '📛').fontSize(72)
        }
        .width('100%').height(150)
        .backgroundColor(this.selectedGuzi?.bg ?? '#FDE8F0')
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        if (this.selectedGuzi?.tag !== '') {
          Text(this.selectedGuzi?.tag ?? '🔥').fontSize(10).fontColor('#FFFFFF')
            .backgroundColor('#FF9F43')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius({ topRight: 10, bottomLeft: 10 })
            .position({ x: 0, y: 0 })
        }
      }
      .width('100%').height(150).borderRadius({ topLeft: 18, topRight: 18 })

      Column() {
        Text(this.selectedGuzi?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor('#212121').width('100%')
        Row() {
          Text(fmtPrice(specPrice(this.selectedSpec))).fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor('#FF9F43')
          Text(fmtPrice(this.selectedGuzi?.originalPrice ?? 0)).fontSize(11)
            .fontColor('#BBBBBB')
            .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
          Column().layoutWeight(1)
          Text('库存 ' + specStock(this.selectedSpec).toString() + ' 件')
            .fontSize(10).fontColor('#999999')
        }
        .width('100%').margin({ top: 8 })

        Row() {
          Text('⭐ ' + (this.selectedGuzi?.rating ?? 0).toString())
            .fontSize(10).fontColor('#FF9F43').backgroundColor('#FFF6E8')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
          Text('已拼 ' + fmtSales(this.selectedGuzi?.sales ?? 0) + ' 件')
            .fontSize(10).fontColor('#6C5CE7').backgroundColor('#F0EDFF')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
            .margin({ left: 6 })
          Text(this.selectedGuzi?.category ?? '')
            .fontSize(10).fontColor('#888888').backgroundColor('#F5F5F5')
            .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
            .margin({ left: 6 })
        }
        .width('100%').margin({ top: 10 })

        Text('选择规格').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#333333')
          .width('100%').margin({ top: 14 })
        Row() {
          ForEach(SPEC_CHIPS, (s: SpecChip) => {
            Column() {
              Text(s.label).fontSize(11)
                .fontColor(this.selectedSpec === s.label ? '#FFFFFF' : '#6C5CE7')
                .fontWeight(this.selectedSpec === s.label ? FontWeight.Bold : FontWeight.Normal)
              Text('¥' + s.price.toString()).fontSize(9)
                .fontColor(this.selectedSpec === s.label ? '#FFFFFF' : '#FF9F43')
                .margin({ top: 2 })
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(this.selectedSpec === s.label ? '#6C5CE7' : '#F0EDFF')
            .borderRadius(12).margin({ left: 4, right: 4 })
            .alignItems(HorizontalAlign.Center)
            .onClick(() => { this.selectedSpec = s.label })
          })
        }
        .width('100%').margin({ top: 6 })

        if (this.joinedDetail) {
          Row() {
            Text('✅ 已加入吃谷车,去「吃谷车」查看进度').fontSize(11).fontColor('#4CAF50')
          }
          .width('100%').padding({ top: 8, bottom: 8 }).justifyContent(FlexAlign.Center)
          .backgroundColor('#E8F8F0').borderRadius(8).margin({ top: 10 })
        } else {
          Row() {
            Text('加入购物车').fontSize(12).fontColor('#FFFFFF')
              .backgroundColor('#6C5CE7').borderRadius(18)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .onClick(() => { this.joinedDetail = true })
            Text('立即拼团').fontSize(12).fontColor('#FFFFFF')
              .backgroundColor('#FF9F43').borderRadius(18)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .margin({ left: 10 })
              .onClick(() => { this.joinedDetail = true })
          }
          .width('100%').justifyContent(FlexAlign.Center).margin({ top: 12, bottom: 8 })
        }
      }
      .padding({ left: 16, right: 16, top: 12, bottom: 16 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .position({ x: 0, y: '36%' })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

商品详情弹窗使用position({ x: 0, y: '36%' })从屏幕36%位置开始渲染,形成底部滑出效果。大图区域使用selectedGuzi?.bg动态背景色配合72vp大字号emoji图标,标签通过position定位在左上角。价格区域通过specPrice(this.selectedSpec)工具函数动态获取当前选中规格的价格,原价使用删除线装饰。规格选择区通过ForEach遍历SPEC_CHIPS渲染三个规格选项(单只装、二连装、全套五入),选中规格用品牌紫背景白字、未选中用浅紫背景紫字,每个规格下方显示对应价格。joinedDetail状态控制底部操作区:未加入时显示"加入购物车"(紫色)和"立即拼团"(橙色)双按钮,已加入时显示绿色成功提示条。使用??空值合并运算符处理selectedGuzi可能为null的情况,确保UI不会因空引用崩溃。

五、新谷预告与吃谷车

5.1 新谷预告Tab

新谷预告Tab通过月份分组时间线布局展示即将开售的谷子商品。

@Component
struct PreorderContent {
  @State subscribedIds: number[] = []
  @State showTip: boolean = false

  @Builder preCard(p: PreorderItem) {
    Row() {
      Column() {
        Text(p.icon).fontSize(32)
      }
      .width(64).height(64).backgroundColor(p.bg).borderRadius(12)
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(p.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#212121')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
          if (this.subscribedIds.indexOf(p.id) >= 0) {
            Text('✓ 已订阅').fontSize(9).fontColor('#FFFFFF').backgroundColor('#6C5CE7')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          }
        }
        .width('100%')

        Text(p.series).fontSize(10).fontColor('#888888').width('100%').margin({ top: 3 })
        Row() {
          Text(fmtPrice(p.price)).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
          Text('已预约 ' + fmtCount(p.preCount)).fontSize(9).fontColor('#999999').margin({ left: 8 })
          Column().layoutWeight(1)
          Text(p.deadline).fontSize(9).fontColor('#6C5CE7')
        }
        .width('100%').margin({ top: 4 })

        Row() {
          Text(p.tag).fontSize(9).fontColor('#FF9F43').backgroundColor('#FFF6E8')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          Text('订阅提醒').fontSize(9).fontColor('#FFFFFF')
            .backgroundColor(this.subscribedIds.indexOf(p.id) >= 0 ? '#B8B2E8' : '#6C5CE7')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
            .margin({ left: 8 })
        }
        .width('100%').margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
    }
    .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ top: 6 })
    .shadow({ radius: 4, color: '#0D000000', offsetY: 1 })
    .onClick(() => {
      const idx = this.subscribedIds.indexOf(p.id)
      if (idx >= 0) {
        this.subscribedIds.splice(idx, 1)
      } else {
        this.subscribedIds.push(p.id)
      }
    })
  }

  build() {
    Column() {
      Column() {
        Row() {
          Text('📅').fontSize(20)
          Text('新谷预告').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .margin({ left: 6 })
          Column().layoutWeight(1)
          Text('共 ' + MONTH_GROUPS.length.toString() + ' 个月档期')
            .fontSize(10).fontColor('rgba(255,255,255,0.85)')
        }
        .width('100%').padding({ left: 14, right: 14, top: 12 })
        Row() {
          Text('💡 订阅开售提醒,开售不错过').fontSize(10).fontColor('rgba(255,255,255,0.8)')
            .padding({ top: 6 })
          Column().layoutWeight(1)
          Text('订阅 ' + this.subscribedIds.length.toString() + ' 个')
            .fontSize(10).fontColor('#FFFFFF').backgroundColor('rgba(0,0,0,0.2)')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%').linearGradient(MAIN_GRADIENT)

      Scroll() {
        Column() {
          ForEach(MONTH_GROUPS, (g: MonthGroup) => {
            Row() {
              Column() {
                Text(g.month).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#6C5CE7')
                Text(g.tip).fontSize(9).fontColor('#A29BFE').margin({ top: 2 })
                Column().width(2).height(110).backgroundColor('#E6E0FF').margin({ top: 8 })
              }
              .width(48).alignItems(HorizontalAlign.Center)

              Column() {
                ForEach(g.items, (p: PreorderItem) => {
                  this.preCard(p)
                })
              }
              .layoutWeight(1).padding({ left: 4, right: 12 })
            }
            .alignItems(VerticalAlign.Top)
            .margin({ top: 12 })
          })

          Column() {
            Row() {
              Text('📌').fontSize(14)
              Text('开售时间均为北京时间,实际以官谷为准').fontSize(10).fontColor('#888888')
                .margin({ left: 4 })
            }
            .width('100%').justifyContent(FlexAlign.Center)
            .padding({ top: 12, bottom: 12 })
          }
          .width('100%')
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

新谷预告Tab的核心亮点是月份分组时间线布局。外层ForEach遍历MONTH_GROUPS数组(3个月份分组),内层ForEach遍历每个分组的items数组渲染商品卡片。时间线通过左侧48vp宽的Column实现:月份大字+提示小字+2vp宽110vp高的紫色竖线(#E6E0FF),竖线连接同月的多个商品卡片形成垂直时间轴。每个商品卡片的订阅功能通过subscribedIds数组管理:点击卡片时通过indexOf查找是否已订阅,已订阅则splice移除、未订阅则push添加。已订阅状态通过右上角的"✓ 已订阅"紫色标签和底部"订阅提醒"按钮变为浅紫#B8B2E8色来双重提示。头部统计栏实时显示当前订阅总数(this.subscribedIds.length.toString()),数据驱动更新。

5.2 吃谷车Tab

吃谷车Tab展示了进行中的拼团列表和加入拼团弹窗,是拼团交易的核心组件。

@Component
struct CartContent {
  @State showCartModal: boolean = false
  @State selectedGroup: CartGroupItem | null = null
  @State cartQty: number = 1
  @State cartNote: string = ''
  @State joinedCart: boolean = false

  @Builder groupCard(g: CartGroupItem) {
    Column() {
      Row() {
        Column() {
          Text(g.icon).fontSize(30)
        }
        .width(58).height(58).backgroundColor(g.bg).borderRadius(12)
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

        Column() {
          Row() {
            Text(g.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
            Text(g.discount).fontSize(9).fontColor('#FF9F43').backgroundColor('#FFF6E8')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          }
          .width('100%')
          Row() {
            Text(fmtPrice(g.price)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
            Text('/人').fontSize(10).fontColor('#999999')
            Column().layoutWeight(1)
            Text('截止 ' + g.endTime).fontSize(9).fontColor('#999999')
          }
          .width('100%').margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
      }
      .width('100%')

      // 成员头像槽位(空槽灰色虚线圆)
      Row() {
        ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
          if (i < g.totalCount) {
            Column() {
              if (i < g.joinedCount) {
                Text(g.members[i % g.members.length]).fontSize(12)
                  .width(26).height(26).borderRadius(13).backgroundColor('#F0EDFF')
                  .textAlign(TextAlign.Center)
              } else {
                Column() {
                  Text('+').fontSize(12).fontColor('#C9C4F0')
                }
                .width(26).height(26).borderRadius(13)
                .border({ width: 1, color: '#C9C4F0' })
                .borderStyle(BorderStyle.Dashed)
                .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
              }
            }
            .margin({ left: 2, right: 2 })
          }
        })
        Column().layoutWeight(1)
        Text('去参团').fontSize(11).fontColor('#FFFFFF')
          .backgroundColor('#6C5CE7')
          .padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(14)
          .onClick(() => {
            this.selectedGroup = g
            this.cartQty = 1
            this.cartNote = ''
            this.joinedCart = false
            this.showCartModal = true
          })
      }
      .width('100%').margin({ top: 10 })

      // 成团进度条
      Row() {
        Column()
          .width(groupPercent(g.joinedCount, g.totalCount))
          .height(5).backgroundColor('#FF9F43').borderRadius(3)
        Column().layoutWeight(1)
      }
      .width('100%').height(5).backgroundColor('#F0EDFF').borderRadius(3).margin({ top: 8 })

      Row() {
        Text('已拼 ' + g.joinedCount.toString() + '/' + g.totalCount.toString() + ' 人 · 还差 ' +
          (g.totalCount - g.joinedCount).toString() + ' 人成团')
          .fontSize(9).fontColor('#6C5CE7').layoutWeight(1)
        Text('⚡ 成团价').fontSize(9).fontColor('#FF9F43')
      }
      .width('100%').margin({ top: 4 })
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 5, color: '#0D000000', offsetY: 2 })
  }
}

吃谷车Tab的拼团卡片核心亮点是成员头像槽位设计。通过ForEach遍历一个0-11的数字数组,使用if (i < g.totalCount)条件判断当前槽位是否在总人数范围内,再用if (i < g.joinedCount)判断该槽位是否已被占用。已占用的槽位显示成员头像(通过g.members[i % g.members.length]取模获取头像emoji,防止数组越界),未占用的槽位显示虚线边框的"+"号占位符(borderStyle(BorderStyle.Dashed)),形成直观的拼团进度可视化。成团进度条通过groupPercent(g.joinedCount, g.totalCount)计算百分比宽度,橙色填充在浅紫轨道中。点击"去参团"按钮时同时重置四个状态(selectedGroup、cartQty、cartNote、joinedCart)并打开拼团弹窗,确保每次打开弹窗时表单都是初始状态。

六、无料交换与晒谷圈

6.1 无料交换Tab

无料交换Tab通过左右交替的气泡布局展示换物帖,并支持发布换物帖的表单弹窗。

@Component
struct ExchangeContent {
  @State showEditModal: boolean = false
  @State formTitle: string = ''
  @State formGive: string = ''
  @State formWant: string = ''
  @State formDesc: string = ''
  @State published: boolean = false
  @State requestedIds: number[] = []

  @Builder exchangeCard(e: ExchangeItem) {
    Row() {
      if (e.isLeft) {
        Column() {
          Text(e.avatar).fontSize(22)
        }
        .width(38).height(38).borderRadius(19).backgroundColor('#F0EDFF')
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

        Column() {
          Row() {
            Text(e.user).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#333333')
            Column().layoutWeight(1)
            Text(e.time).fontSize(9).fontColor('#CCCCCC')
          }
          .width('100%')
          Text('出:' + e.give).fontSize(11).fontColor('#6C5CE7').fontWeight(FontWeight.Medium)
            .width('100%').margin({ top: 4 })
          Text('求:' + e.want).fontSize(11).fontColor('#FF9F43').fontWeight(FontWeight.Medium)
            .width('100%').margin({ top: 2 })
          Text(e.note).fontSize(10).fontColor('#666666').width('100%').margin({ top: 4 })
          Row() {
            Text(this.requestedIds.indexOf(e.id) >= 0 ? '✅ 已请求交换' : '💌 求交换')
              .fontSize(10)
              .fontColor(this.requestedIds.indexOf(e.id) >= 0 ? '#4CAF50' : '#FFFFFF')
              .backgroundColor(this.requestedIds.indexOf(e.id) >= 0 ? '#E8F8F0' : '#6C5CE7')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
              .onClick(() => {
                if (this.requestedIds.indexOf(e.id) < 0) {
                  this.requestedIds.push(e.id)
                }
              })
          }
          .width('100%').margin({ top: 6 })
        }
        .layoutWeight(1).padding({ left: 10, right: 10, top: 8, bottom: 8 })
        .backgroundColor('#FFFFFF').borderRadius(12)
        .shadow({ radius: 3, color: '#0A000000', offsetY: 1 })
      } else {
        Column() {
          Text(this.requestedIds.indexOf(e.id) >= 0 ? '✅ 已请求交换' : '💌 求交换')
            .fontSize(10)
            .fontColor(this.requestedIds.indexOf(e.id) >= 0 ? '#4CAF50' : '#6C5CE7')
            .backgroundColor(this.requestedIds.indexOf(e.id) >= 0 ? '#E8F8F0' : '#F0EDFF')
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
            .onClick(() => {
              if (this.requestedIds.indexOf(e.id) < 0) {
                this.requestedIds.push(e.id)
              }
            })
          Text(e.note).fontSize(10).fontColor('#666666').width('100%').margin({ top: 4 })
          Text('求:' + e.want).fontSize(11).fontColor('#FF9F43').fontWeight(FontWeight.Medium)
            .width('100%').margin({ top: 2 })
          Text('出:' + e.give).fontSize(11).fontColor('#6C5CE7').fontWeight(FontWeight.Medium)
            .width('100%').margin({ top: 2 })
          Row() {
            Text(e.time).fontSize(9).fontColor('#CCCCCC')
            Column().layoutWeight(1)
            Text(e.user).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#333333')
          }
          .width('100%').margin({ top: 4 })
        }
        .layoutWeight(1).padding({ left: 10, right: 10, top: 8, bottom: 8 })
        .backgroundColor('#F0EDFF').borderRadius(12)
        .shadow({ radius: 3, color: '#0A000000', offsetY: 1 })

        Column() {
          Text(e.avatar).fontSize(22)
        }
        .width(38).height(38).borderRadius(19).backgroundColor('#D9D2FF')
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        .margin({ left: 8 })
      }
    }
    .width('100%').alignItems(VerticalAlign.Top)
    .padding({ left: 12, right: 12, top: 8 })
  }
}

无料交换Tab的exchangeCard Builder通过e.isLeft条件判断实现了左右交替的聊天气泡布局。当isLeft为true时,头像在左侧、内容卡片在右侧(白色背景),信息排列顺序为用户名→给出→求换→备注→求交换按钮。当isLeft为false时,布局镜像翻转:内容卡片在左侧(浅紫背景#F0EDFF)、头像在右侧(深紫背景#D9D2FF),信息排列顺序也相应反转(求交换按钮→备注→求换→给出→时间+用户名)。这种镜像布局设计使得换物帖列表呈现出聊天对话般的视觉效果,增强了社交互动感。求交换按钮通过requestedIds数组管理状态:点击时push当前帖子id到数组,已请求时按钮变为绿色"✅ 已请求交换",未请求时为紫色"💌 求交换"。发布换物帖弹窗包含标题、给出谷子、想换谷子和补充描述四个表单字段,通过@State变量实时同步输入内容。

6.2 晒谷圈Tab

晒谷圈Tab展示了热门帖子卡片和三列图片墙宫格,支持点赞和删除交互。

@Component
struct GalleryContent {
  @State showDeleteModal: boolean = false
  @State deletedPostIds: number[] = []
  @State likedIds: number[] = []

  @Builder hotPostCard(p: GalleryPost) {
    Column() {
      Row() {
        Column() {
          Text(p.avatar).fontSize(20)
        }
        .width(36).height(36).borderRadius(18).backgroundColor('#F0EDFF')
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

        Column() {
          Row() {
            Text(p.user).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#333333')
            Text('🔥 热帖').fontSize(9).fontColor('#FFFFFF').backgroundColor('#FF9F43')
              .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6)
              .margin({ left: 6 })
          }
          Text('刚刚发布 · 二次元谷圈').fontSize(9).fontColor('#BBBBBB').margin({ top: 2 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })

        Text('🗑️').fontSize(16).fontColor('#CCCCCC')
          .onClick(() => { this.showDeleteModal = true })
      }
      .width('100%')

      Text(p.content).fontSize(12).fontColor('#333333').width('100%').margin({ top: 10 })

      Row() {
        ForEach(GALLERY_GRID[0].cells, (c: GridCell) => {
          Column() {
            Text(c.icon).fontSize(26)
          }
          .layoutWeight(1).height(64).backgroundColor(c.bg).borderRadius(8)
          .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
          .margin({ left: 3, right: 3 })
        })
      }
      .width('100%').margin({ top: 10 })

      Row() {
        Text(this.likedIds.indexOf(p.id) >= 0 ? '❤️ ' + (p.likes + 1).toString() : '🤍 ' + p.likes.toString())
          .fontSize(11).fontColor(this.likedIds.indexOf(p.id) >= 0 ? '#FF4D4F' : '#888888')
          .backgroundColor('#F5F5F5').borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .onClick(() => {
            if (this.likedIds.indexOf(p.id) < 0) {
              this.likedIds.push(p.id)
            }
          })
        Text('💬 ' + p.comments.toString()).fontSize(11).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text('查看全部 >').fontSize(10).fontColor('#A29BFE')
      }
      .width('100%').margin({ top: 10 })
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 12, right: 12, top: 10 })
    .shadow({ radius: 6, color: '#0D000000', offsetY: 2 })
  }

  @Builder GridRow1(r: GridRow1) {
    Row() {
      ForEach(r.cells, (c: GridCell) => {
        Column() {
          Text(c.icon).fontSize(24)
          Text(c.label).fontSize(9).fontColor('#555555').margin({ top: 4 })
        }
        .layoutWeight(1).padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF').borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        .margin({ left: 5, right: 5, top: 8 })
        .shadow({ radius: 3, color: '#0A000000', offsetY: 1 })
      })
    }
    .width('100%').padding({ left: 7, right: 7 })
  }
}

晒谷圈Tab的热门帖子卡片集成了用户信息、热帖标签、帖子内容、三列图片预览和互动按钮。图片预览区通过ForEach遍历GALLERY_GRID[0].cells(嵌套GridRow1接口的cells数组)渲染三个GridCell,每个cell包含图标和背景色,以等分宽度展示。点赞按钮通过likedIds数组管理状态:未点赞时显示"🤍"空心心和原始点赞数,点赞后显示"❤️"实心心和+1的点赞数,文字颜色从灰色变为红色#FF4D4F。删除按钮触发删除确认弹窗,确认后通过deletedPostIds.push(GALLERY_POSTS[0].id)记录已删除帖子id,UI层通过if (this.deletedPostIds.length > 0)条件判断显示"该热帖已删除"提示。三列图片墙通过ForEach遍历GALLERY_GRID数组(4行3列),每行通过GridRow1 Builder渲染三个GridCell(图标+标签),形成完整的图片墙宫格布局。

七、应用交互流程

以下流程图展示了应用的主要交互路径和各Tab组件的状态管理关系:

0

1

2

3

4

5

6

点击商品

加入购物车

立即拼团

订阅提醒

取消订阅

点击去参团

数量步进

确认参团

发布换物帖

求交换

点赞

删除帖子

确认删除

待补款

选择支付方式

立即补款

应用启动

GuziMarketPage入口

activeTab路由

吃谷广场

新谷预告

吃谷车

无料交换

晒谷圈

消息中心

个人中心

商品详情弹窗

joinedDetail状态切换

subscribedIds数组管理

加入吃谷车弹窗

cartQty状态管理

joinedCart状态切换

换物帖编辑弹窗

requestedIds数组管理

likedIds数组管理

删除确认弹窗

deletedPostIds数组管理

补款提醒弹窗

payMethod状态管理

关闭弹窗

渐变头部+搜索栏

分类胶囊横滑

促销Banner

大小交替瀑布流

月份分组时间线

订阅状态计数

成员头像槽位

虚线空槽占位

成团进度条

左右交替气泡布局

镜像翻转信息排列

消费柱状图

我的谷子横滑

功能菜单列表

流程图清晰展示了应用的状态驱动架构:每个Tab组件内部独立管理自己的弹窗状态和交互状态,入口组件仅负责Tab路由分发。各Tab通过数组(subscribedIds、requestedIds、likedIds、deletedPostIds)管理多选交互状态,通过布尔值(joinedDetail、joinedCart、published)管理单次操作状态,通过对象引用(selectedGuzi、selectedGroup、selectedOrder)管理弹窗选中数据。这种"Tab内自治"的状态管理模式减少了入口组件的复杂度,每个Tab组件都是自包含的交互单元。

八、消息中心与个人中心

8.1 消息中心Tab

消息中心Tab按系统通知、交易消息和互动消息三类分组展示消息列表。

@Component
struct MessageContent {
  @State clearedIds: number[] = []
  @State readAll: boolean = false

  @Builder msgCard(m: MsgItem) {
    Row() {
      Stack() {
        Column() {
          Text(m.icon).fontSize(20)
        }
        .width(44).height(44).backgroundColor('#F0EDFF').borderRadius(22)
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

        if (m.unread && this.clearedIds.indexOf(m.id) < 0) {
          Column().width(9).height(9).backgroundColor('#FF4D4F').borderRadius(5)
            .position({ x: 34, y: 0 })
        }
      }
      .width(46).height(46)

      Column() {
        Row() {
          Text(m.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
            .layoutWeight(1)
          Text(m.time).fontSize(9).fontColor('#CCCCCC')
        }
        .width('100%')
        Text(m.content).fontSize(11).fontColor('#666666')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%').margin({ top: 3 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 12, right: 12, top: 6 })
    .shadow({ radius: 4, color: '#0D000000', offsetY: 1 })
    .onClick(() => {
      if (this.clearedIds.indexOf(m.id) < 0) {
        this.clearedIds.push(m.id)
      }
    })
  }

  @Builder sectionTitle(icon: string, title: string, count: number) {
    Row() {
      Text(icon).fontSize(13)
      Text(title).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ left: 4 })
      Text(count.toString() + ' 条').fontSize(9).fontColor('#A29BFE').margin({ left: 6 })
      Column().layoutWeight(1)
      if (count > 0) {
        Text(this.readAll || this.clearedIds.length >= count ? '已读' : '未读')
          .fontSize(9).fontColor('#6C5CE7')
      }
    }
    .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 4 })
  }

  build() {
    Column() {
      Column() {
        Row() {
          Text('💬').fontSize(20)
          Text('消息中心').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .margin({ left: 6 })
          Column().layoutWeight(1)
          Text('全部已读').fontSize(11).fontColor('#FFFFFF')
            .backgroundColor('rgba(255,255,255,0.22)')
            .padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
            .onClick(() => {
              this.readAll = true
              for (let i = 0; i < SYSTEM_MSGS.length; i++) {
                this.clearedIds.push(SYSTEM_MSGS[i].id)
              }
              for (let j = 0; j < TRADE_MSGS.length; j++) {
                this.clearedIds.push(TRADE_MSGS[j].id)
              }
              for (let k = 0; k < INTERACT_MSGS.length; k++) {
                this.clearedIds.push(INTERACT_MSGS[k].id)
              }
            })
        }
        .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 14 })
      }
      .width('100%').linearGradient(MAIN_GRADIENT)

      Scroll() {
        Column() {
          this.sectionTitle('🔔', '系统通知', SYSTEM_MSGS.length)
          ForEach(SYSTEM_MSGS, (m: MsgItem) => {
            this.msgCard(m)
          })

          this.sectionTitle('💰', '交易消息', TRADE_MSGS.length)
          ForEach(TRADE_MSGS, (m: MsgItem) => {
            this.msgCard(m)
          })

          this.sectionTitle('🌟', '互动消息', INTERACT_MSGS.length)
          ForEach(INTERACT_MSGS, (m: MsgItem) => {
            this.msgCard(m)
          })
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

消息中心Tab的消息卡片使用Stack容器叠加头像和未读红点:未读消息右上角通过position({ x: 34, y: 0 })定位一个9x9vp的红色圆点,点击消息卡片时将消息idpushclearedIds数组,红点通过this.clearedIds.indexOf(m.id) < 0条件判断隐藏。"全部已读"按钮通过三个for循环将三组消息的id全部push到clearedIds数组,同时设置readAll为true,实现一键清除所有未读标记。分组标题通过sectionTitle Builder渲染,包含图标、标题、消息条数和已读/未读状态,其中已读状态通过this.readAll || this.clearedIds.length >= count复合条件判断。消息内容使用maxLines(1)单行省略显示,避免长内容破坏列表布局。

8.2 个人中心Tab

个人中心Tab集成了用户资料卡、消费柱状图、我的谷子横滑、待补款订单和功能菜单。

@Component
struct ProfileContent {
  @State showPayModal: boolean = false
  @State payMethod: string = 'alipay'
  @State selectedOrder: PayOrder | null = null

  @Builder payModal() {
    Column() {
      this.modalOverlay(() => { this.showPayModal = false })
      Column() {
        Column() {
          Text('⏰').fontSize(40).margin({ top: 16 })
          Text('补款提醒').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .margin({ top: 4 })
          Text('拼团即将超时,请尽快补款').fontSize(10).fontColor('rgba(255,255,255,0.85)')
            .margin({ top: 4 })
        }
        .width('100%').linearGradient(ORANGE_GRADIENT)
        .borderRadius({ topLeft: 16, topRight: 16 })
        .padding({ bottom: 18 })

        Column() {
          Row() {
            Column() {
              Text(this.selectedOrder?.icon ?? '📛').fontSize(28)
            }
            .width(52).height(52).backgroundColor('#FFF6E8').borderRadius(12)
            .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

            Column() {
              Text(this.selectedOrder?.title ?? '').fontSize(13).fontWeight(FontWeight.Bold)
                .fontColor('#212121')
              Text(this.selectedOrder?.deadlineText ?? '').fontSize(10).fontColor('#FF9F43')
                .margin({ top: 3 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
          }
          .width('100%').margin({ top: 14 })

          // 左侧倒计时 + 右侧金额
          Row() {
            Column() {
              Text('剩余时间').fontSize(9).fontColor('#888888')
              Row() {
                Text(fmtRemain(this.selectedOrder?.remainMin ?? 0).substring(0, 2))
                  .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                  .width(30).height(30).backgroundColor('#FF9F43').borderRadius(6)
                  .textAlign(TextAlign.Center)
                Text(':').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
                  .margin({ left: 2, right: 2 })
                Text(fmtRemain(this.selectedOrder?.remainMin ?? 0).substring(3, 5))
                  .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                  .width(30).height(30).backgroundColor('#FF9F43').borderRadius(6)
                  .textAlign(TextAlign.Center)
              }
              .margin({ top: 4 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)

            Column() {
              Text('需补金额').fontSize(9).fontColor('#888888')
              Text(fmtPrice(this.selectedOrder?.amount ?? 0))
                .fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
                .margin({ top: 4 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
          }
          .width('100%').backgroundColor('#FFF9F0').borderRadius(10)
          .padding({ top: 12, bottom: 12 }).margin({ top: 12 })

          Text('选择支付方式').fontSize(11).fontColor('#888888').width('100%').margin({ top: 12 })
          Row() {
            Row() {
              Text('🅰️').fontSize(14)
              Text('支付宝').fontSize(12).margin({ left: 4 })
                .fontColor(this.payMethod === 'alipay' ? '#FFFFFF' : '#333333')
            }
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(this.payMethod === 'alipay' ? '#1677FF' : '#F5F5F5')
            .borderRadius(14)
            .onClick(() => { this.payMethod = 'alipay' })

            Row() {
              Text('💚').fontSize(14)
              Text('微信支付').fontSize(12).margin({ left: 4 })
                .fontColor(this.payMethod === 'wechat' ? '#FFFFFF' : '#333333')
            }
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(this.payMethod === 'wechat' ? '#07C160' : '#F5F5F5')
            .borderRadius(14)
            .margin({ left: 8 })
            .onClick(() => { this.payMethod = 'wechat' })
          }
          .width('100%').margin({ top: 6 })

          Text('立即补款').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FF9F43').borderRadius(22)
            .padding({ left: 56, right: 56, top: 11, bottom: 11 })
            .margin({ top: 14, bottom: 16 })
            .onClick(() => { this.showPayModal = false })
        }
        .padding({ left: 16, right: 16 })
      }
      .width('86%').backgroundColor('#FFFFFF').borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '7%', y: '22%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Column() {
        // 个人资料渐变卡
        Column() {
          Row() {
            Column() {
              Text('✨').fontSize(30)
            }
            .width(56).height(56).backgroundColor('rgba(255,255,255,0.28)').borderRadius(28)
            .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

            Column() {
              Row() {
                Text('阿宅小星').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('LV.8').fontSize(9).fontColor('#FFFFFF').backgroundColor('rgba(0,0,0,0.25)')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                  .margin({ left: 8 })
              }
              Text('已吃谷 236 件 · 本命五条悟 & 芙莉莲')
                .fontSize(10).fontColor('rgba(255,255,255,0.9)').margin({ top: 4 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
          }
          .width('100%').padding({ left: 16, right: 16, top: 14 })

          // 数据统计行
          Row() {
            Column() {
              Text('236').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('收藏谷子').fontSize(9).fontColor('rgba(255,255,255,0.85)').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
            .onClick(() => { this.showPayModal = false })

            Column() {
              Text('2').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFD9B3')
              Text('待补款').fontSize(9).fontColor('rgba(255,255,255,0.85)').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
            .onClick(() => {
              this.selectedOrder = PAY_ORDERS[0]
              this.payMethod = 'alipay'
              this.showPayModal = true
            })
          }
          .width('100%').margin({ top: 8 })
          .backgroundColor('rgba(255,255,255,0.12)')
        }
        .width('100%').linearGradient(MAIN_GRADIENT)

        Scroll() {
          Column() {
            // 近6月吃谷消费柱状图
            Column() {
              Row() {
                Text('📊 近6月吃谷消费').fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor('#212121')
                Column().layoutWeight(1)
                Text('本月 ¥1050').fontSize(10).fontColor('#FF9F43')
              }
              .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 8 })

              Row() {
                ForEach(SPEND_DATA, (s: MonthSpend) => {
                  Column() {
                    Text(s.amount.toString()).fontSize(9).fontColor('#6C5CE7')
                    Column()
                      .width(22)
                      .height(spendBarH(s.amount))
                      .backgroundColor(s.amount >= 900 ? '#FF9F43' : '#A29BFE')
                      .borderRadius({ topLeft: 5, topRight: 5 })
                    Text(s.month).fontSize(9).fontColor('#888888').margin({ top: 3 })
                  }
                  .layoutWeight(1).alignItems(HorizontalAlign.Center)
                })
              }
              .width('100%').padding({ left: 10, right: 10, bottom: 12 })
            }
            .width('100%').backgroundColor('#FFFFFF').borderRadius(12)
            .margin({ left: 12, right: 12, top: 8 })

            // 功能菜单
            Column() {
              ForEach(MY_MENUS, (m: MenuItem1) => {
                Column() {
                  Row() {
                    Text(m.icon).fontSize(18)
                    Text(m.label).fontSize(12).fontColor('#333333').margin({ left: 10 })
                    Column().layoutWeight(1)
                    if (m.badge !== '') {
                      Text(m.badge).fontSize(9).fontColor('#FF9F43')
                        .backgroundColor('#FFF6E8')
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                    }
                    Text('>').fontSize(13).fontColor('#CCCCCC').margin({ left: 8 })
                  }
                  .width('100%').padding({ left: 16, right: 16, top: 13, bottom: 13 })
                  if (m.label !== '客服中心') {
                    Divider().color('#F5F5F5').margin({ left: 16, right: 16 })
                  }
                }
                .width('100%')
              })
            }
            .width('100%').backgroundColor('#FFFFFF').borderRadius(12)
            .margin({ left: 12, right: 12, top: 8 })
          }
          .padding({ bottom: 20 })
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showPayModal) { this.payModal() }
    }
    .width('100%').height('100%')
  }
}

补款提醒弹窗是该Tab的核心交互组件,头部使用ORANGE_GRADIENT橙色渐变配合时钟emoji和紧迫文案,形成时间警示氛围。倒计时区域通过fmtRemain工具函数将剩余分钟数格式化为"HH:MM"字符串,再通过substring(0, 2)substring(3, 5)分别提取时和分,渲染在两个独立的橙色方块中,中间用冒号分隔,模拟数字时钟的视觉效果。支付方式选择区提供支付宝(蓝色#1677FF)和微信支付(绿色#07C160)两个选项,选中时背景变为对应品牌色、文字变白,未选中时灰色背景。消费柱状图通过spendBarH(s.amount)工具函数计算柱子高度(金额映射到vp高度),消费超过900元用橙色#FF9F43、低于900用浅紫#A29BFE,通过颜色区分高消费月份。功能菜单列表通过ForEach遍历MY_MENUS渲染6个菜单项,每项包含图标、标签、角标和右箭头,菜单项之间使用Divider分隔(最后一项"客服中心"不显示分隔线)。待补款统计区的数字使用橙色#FFD9B3突出,点击触发补款弹窗。

九、技术点对比

技术维度 实现方案 特点分析 适用场景
Tab路由 数字索引+@Builder复用 contentArea和tabItem两个Builder封装路由和渲染 七Tab多模块应用
空值处理 ??空值合并运算符 处理selectedGuzi等可能为null的对象引用 可选对象的安全访问
多选状态 数组+indexOf管理 subscribedIds/requestedIds/likedIds数组追踪选中项 订阅/点赞/请求交换
瀑布流 big参数+margin偏移 左列大卡片右列小卡片+40vp偏移实现错落 电商商品双列展示
气泡布局 isLeft条件+镜像翻转 根据布尔值切换头像和内容卡片的左右位置 聊天式换物帖展示
成员槽位 ForEach+双重条件判断 已占用显示头像、未占用显示虚线占位符 拼团进度可视化
倒计时 fmtRemain+substring 分钟转HH:MM字符串后截取时和分分别渲染 支付倒计时显示
数据可视化 spendBarH函数+动态vp高度 金额映射到vp高度字符串,超阈值变色 消费柱状图
进度条 groupPercent函数+百分比宽度 计算百分比转为字符串作为Column宽度 拼团成团进度
全部已读 三组for循环push 遍历三组消息数组将id全部加入clearedIds 一键已读消息

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================================
// 拼多多风格·二次元谷子集市
// 动漫周边"吃谷"交易社区:吧唧/立牌/色纸/亚克力/棉花娃娃等谷子买卖
// 拼团"吃谷车"/无料交换/晒谷社区
// 配色:二次元紫蓝渐变 主色 #6C5CE7→#A29BFE / 强调橙 #FF9F43 / 背景 #F4F2FF
// ============================================================================

// ============================ Interface 定义 ================================

interface PlazaCate {
  label: string
  icon: string
}

interface GuziItem {
  id: number
  name: string
  tag: string
  price: number
  originalPrice: number
  sales: number
  icon: string
  bg: string
  category: string
  stock: number
  rating: number
}

interface SpecChip {
  label: string
  price: number
  stock: number
}

interface PreorderItem {
  id: number
  name: string
  series: string
  price: number
  preCount: number
  deadline: string
  icon: string
  bg: string
  tag: string
}

interface MonthGroup {
  month: string
  tip: string
  items: PreorderItem[]
}

interface CartGroupItem {
  id: number
  name: string
  icon: string
  bg: string
  price: number
  joinedCount: number
  totalCount: number
  endTime: string
  members: string[]
  discount: string
}

interface ExchangeItem {
  id: number
  user: string
  avatar: string
  give: string
  want: string
  note: string
  time: string
  isLeft: boolean
}

interface GalleryPost {
  id: number
  user: string
  avatar: string
  content: string
  likes: number
  comments: number
  isHot: boolean
}

interface GridCell {
  icon: string
  bg: string
  label: string
}

interface GridRow1 {
  cells: GridCell[]
}

interface MsgItem {
  id: number
  title: string
  content: string
  icon: string
  time: string
  unread: boolean
}

interface MonthSpend {
  month: string
  amount: number
}

interface MyGuziItem {
  id: number
  name: string
  icon: string
  bg: string
  value: number
}

interface PayOrder {
  id: number
  title: string
  icon: string
  amount: number
  deadlineText: string
  remainMin: number
}

interface MenuItem1 {
  icon: string
  label: string
  badge: string
}

// ============================ 设计令牌 ================================

const MAIN_GRADIENT: LinearGradientOptions = {
  angle: 135,
  colors: [['#6C5CE7', 0.0], ['#A29BFE', 1.0]]
}

const ORANGE_GRADIENT: LinearGradientOptions = {
  angle: 135,
  colors: [['#FF9F43', 0.0], ['#FFC048', 1.0]]
}

const BANNER_GRADIENT: LinearGradientOptions = {
  angle: 120,
  colors: [['#6C5CE7', 0.0], ['#9B8CFF', 1.0]]
}

const TAG_GRADIENT: LinearGradientOptions = {
  angle: 90,
  colors: [['#FF9F43', 0.0], ['#FF7E5F', 1.0]]
}

// ============================ 广场分类 ================================

const PLAZA_CATES: PlazaCate[] = [
  { label: '全部', icon: '🎪' },
  { label: '吧唧', icon: '📛' },
  { label: '立牌', icon: '🖼️' },
  { label: '色纸', icon: '🎨' },
  { label: '亚克力', icon: '💠' },
  { label: '棉花娃娃', icon: '🧸' },
  { label: '吧唧托', icon: '🎀' },
  { label: '明信片', icon: '💌' }
]

// ============================ 谷子商品数据 ================================

const GUZI_ITEMS: GuziItem[] = [
  { id: 1, name: '五条悟·涩谷事变 吧唧', tag: '🔥热卖', price: 25, originalPrice: 39, sales: 12000, icon: '📛', bg: '#FDE8F0', category: '吧唧', stock: 60, rating: 4.9 },
  { id: 2, name: '灶门炭治郎·锻刀村 立牌', tag: '拼团', price: 45, originalPrice: 68, sales: 8600, icon: '🖼️', bg: '#FFF4E0', category: '立牌', stock: 35, rating: 4.8 },
  { id: 3, name: '绫波丽·新剧场版 色纸', tag: '限定', price: 32, originalPrice: 49, sales: 21000, icon: '🎨', bg: '#E4F0FF', category: '色纸', stock: 88, rating: 4.9 },
  { id: 4, name: '蓝色监狱·糸师凛 亚克力', tag: '爆款', price: 38, originalPrice: 55, sales: 34000, icon: '💠', bg: '#E7F7FF', category: '亚克力', stock: 120, rating: 4.7 },
  { id: 5, name: '星野爱·小豆泥 棉花娃娃', tag: '现货', price: 129, originalPrice: 189, sales: 4600, icon: '🧸', bg: '#FDF0E6', category: '棉花娃娃', stock: 15, rating: 5.0 },
  { id: 6, name: '芙莉莲·勇者组 吧唧托', tag: '新谷', price: 28, originalPrice: 42, sales: 9800, icon: '🎀', bg: '#F3EBFF', category: '吧唧托', stock: 70, rating: 4.8 },
  { id: 7, name: '三丽鸥·库洛米 明信片', tag: '福袋', price: 15, originalPrice: 25, sales: 56000, icon: '💌', bg: '#FBE4FB', category: '明信片', stock: 200, rating: 4.6 },
  { id: 8, name: '五条悟·夏服ver 立牌', tag: '预售', price: 55, originalPrice: 79, sales: 7200, icon: '🖼️', bg: '#E9F6FF', category: '立牌', stock: 40, rating: 4.8 },
  { id: 9, name: '明日方舟·德克萨斯 吧唧', tag: '补货', price: 22, originalPrice: 35, sales: 18000, icon: '📛', bg: '#EAEAFF', category: '吧唧', stock: 90, rating: 4.7 },
  { id: 10, name: '咒回·乙骨忧太 烫金色纸', tag: '烫金', price: 45, originalPrice: 68, sales: 6400, icon: '🎨', bg: '#FFF0F5', category: '色纸', stock: 50, rating: 4.9 },
  { id: 11, name: '排球少年·及川彻 亚克力', tag: '活动', price: 35, originalPrice: 52, sales: 15000, icon: '💠', bg: '#E0F7FA', category: '亚克力', stock: 75, rating: 4.8 },
  { id: 12, name: '鬼灭·炼狱杏寿郎 吧唧', tag: '绝版', price: 88, originalPrice: 128, sales: 3200, icon: '📛', bg: '#FFEFE0', category: '吧唧', stock: 12, rating: 5.0 },
  { id: 13, name: '孤独摇滚·后藤一里 娃娃', tag: '预售', price: 149, originalPrice: 209, sales: 2100, icon: '🧸', bg: '#FDF2F7', category: '棉花娃娃', stock: 8, rating: 4.9 },
  { id: 14, name: '蓝锁·凪诚士郎 明信片', tag: '特典', price: 12, originalPrice: 20, sales: 42000, icon: '💌', bg: '#EFF9F0', category: '明信片', stock: 300, rating: 4.6 }
]

const SPEC_CHIPS: SpecChip[] = [
  { label: '单只装', price: 25, stock: 60 },
  { label: '二连装', price: 48, stock: 30 },
  { label: '全套五入', price: 118, stock: 8 }
]

// ============================ 新谷预告数据 ================================

const MONTH_GROUPS: MonthGroup[] = [
  {
    month: '7月',
    tip: '7月新谷',
    items: [
      { id: 1, name: '迷宫饭·莱欧斯 系列吧唧', series: '迷宫饭新剧场版', price: 28, preCount: 3450, deadline: '7/28 开售', icon: '🍳', bg: '#FFF6E8', tag: '剧场版' },
      { id: 2, name: '蓝色监狱 剧场版 亚克力立牌', series: '剧场版特典', price: 42, preCount: 2100, deadline: '7/30 开售', icon: '⚽', bg: '#E7F7FF', tag: '特典' },
      { id: 3, name: 'JOJO·承太郎 发绳吧唧', series: 'JOJO第7部', price: 35, preCount: 980, deadline: '7/31 开售', icon: '⭐', bg: '#F3EBFF', tag: '联名' }
    ]
  },
  {
    month: '8月',
    tip: '8月新谷',
    items: [
      { id: 4, name: '间谍过家家·阿尼亚 棉花娃娃', series: '剧场版', price: 139, preCount: 5600, deadline: '8/10 开售', icon: '🧸', bg: '#FDF2F7', tag: '爆款' },
      { id: 5, name: '文豪野犬·太宰治 色纸', series: '2026夏', price: 30, preCount: 1500, deadline: '8/15 开售', icon: '🎨', bg: '#FFF0F5', tag: '夏日' },
      { id: 6, name: '魔卡少女樱 透明牌 亚克力挂件', series: '复刻', price: 26, preCount: 2400, deadline: '8/18 开售', icon: '💠', bg: '#FBE4FB', tag: '复刻' },
      { id: 7, name: '咒术回战·夏油杰 吧唧', series: '怀玉篇', price: 25, preCount: 3100, deadline: '8/22 开售', icon: '📛', bg: '#EAEAFF', tag: '怀玉' }
    ]
  },
  {
    month: '9月',
    tip: '9月新谷',
    items: [
      { id: 8, name: '排球少年 垃圾场决战 全套吧唧', series: '剧场版', price: 168, preCount: 4200, deadline: '9/5 开售', icon: '🏐', bg: '#FFEFE0', tag: '全套' },
      { id: 9, name: '电锯人·玛奇玛 立牌', series: '电次生日', price: 58, preCount: 1300, deadline: '9/12 开售', icon: '🖼️', bg: '#FFF4E0', tag: '生日' },
      { id: 10, name: '进击的巨人·兵长 手办钥匙扣', series: '完结篇', price: 49, preCount: 870, deadline: '9/20 开售', icon: '🔑', bg: '#EFEFEF', tag: '完结' }
    ]
  }
]

// ============================ 吃谷车拼团数据 ================================

const CART_GROUPS: CartGroupItem[] = [
  { id: 1, name: '五条悟 涩谷吧唧拼团', icon: '📛', bg: '#FDE8F0', price: 19, joinedCount: 5, totalCount: 8, endTime: '23:59', members: ['🐱', '🐹', '🐰', '🐻', '🐨'], discount: '团价¥19' },
  { id: 2, name: '芙莉莲 勇者组吧唧拼团', icon: '🎀', bg: '#F3EBFF', price: 22, joinedCount: 3, totalCount: 6, endTime: '22:30', members: ['🐰', '🐱', '🐹'], discount: '团价¥22' },
  { id: 3, name: '蓝色监狱 剧场版亚克力团', icon: '💠', bg: '#E7F7FF', price: 29, joinedCount: 6, totalCount: 10, endTime: '21:00', members: ['🐻', '🐨', '🐰', '🐱', '🐹', '🦊'], discount: '团价¥29' },
  { id: 4, name: '阿尼亚 棉花娃娃拼团', icon: '🧸', bg: '#FDF2F7', price: 99, joinedCount: 2, totalCount: 5, endTime: '23:00', members: ['🦊', '🐱'], discount: '团价¥99' },
  { id: 5, name: '库洛米 周边福袋拼团', icon: '💌', bg: '#FBE4FB', price: 12, joinedCount: 4, totalCount: 8, endTime: '20:30', members: ['🐹', '🐰', '🐻', '🐱'], discount: '团价¥12' },
  { id: 6, name: '乙骨忧太 烫金色纸拼团', icon: '🎨', bg: '#FFF0F5', price: 35, joinedCount: 7, totalCount: 12, endTime: '19:00', members: ['🐰', '🐻', '🐨', '🦊', '🐱', '🐹', '🐼'], discount: '团价¥35' },
  { id: 7, name: '及川彻 亚克力拼团', icon: '💠', bg: '#E0F7FA', price: 28, joinedCount: 3, totalCount: 6, endTime: '22:00', members: ['🐼', '🐨', '🐰'], discount: '团价¥28' },
  { id: 8, name: '炼狱杏寿郎 绝版吧唧团', icon: '📛', bg: '#FFEFE0', price: 68, joinedCount: 1, totalCount: 4, endTime: '18:00', members: ['🦊'], discount: '团价¥68' }
]

// ============================ 无料交换数据 ================================

const EXCHANGES: ExchangeItem[] = [
  { id: 1, user: '谷子猫猫头', avatar: '🐱', give: '烫金色纸 × 2', want: '咒回吧唧', note: '五条/夏油都行,全新未拆优先', time: '10分钟前', isLeft: true },
  { id: 2, user: '阿宅小星', avatar: '✨', give: '多余吧唧 × 3', want: '芙莉莲色纸', note: '同城优先,可面交互换', time: '25分钟前', isLeft: false },
  { id: 3, user: '咸鱼交换酱', avatar: '🐟', give: '亚克力挂件', want: '棉花娃娃', note: '可加钱补差换,小豆泥可谈', time: '1小时前', isLeft: true },
  { id: 4, user: '阿宅小星', avatar: '✨', give: '盲抽重复款吧唧', want: '蓝锁亚克力', note: '互换为主,不接受刀', time: '2小时前', isLeft: false },
  { id: 5, user: '咕咕机', avatar: '🐦', give: '明信片福袋', want: '吧唧托', note: '拆剩的谷子,多出几包都可以', time: '3小时前', isLeft: true },
  { id: 6, user: '阿宅小星', avatar: '✨', give: '三丽鸥文件夹', want: '吧唧夹', note: '全新未拆,包邮互换', time: '4小时前', isLeft: false },
  { id: 7, user: '谷仓鼠', avatar: '🐹', give: '全套吧唧(重复款)', want: '小豆泥娃娃', note: '支持补差,走平台交易', time: '昨天', isLeft: true },
  { id: 8, user: '阿宅小星', avatar: '✨', give: '咒回立牌', want: '排球立牌', note: '成色要好,带盒互换', time: '昨天', isLeft: false },
  { id: 9, user: '吃谷喵', avatar: '🐈', give: '二连吧唧', want: '透卡', note: '可互选款式,私信详聊', time: '前天', isLeft: true },
  { id: 10, user: '阿宅小星', avatar: '✨', give: '蓝锁徽章', want: '明日方舟吧唧', note: '走平台交易更放心', time: '前天', isLeft: false }
]

// ============================ 晒谷圈数据 ================================

const GALLERY_POSTS: GalleryPost[] = [
  { id: 1, user: '阿宅小星', avatar: '✨', content: '拆谷快乐!一口气开到五条悟烫金吧唧,还摸到隐藏款!欧气拉满的一天~', likes: 328, comments: 46, isHot: true },
  { id: 2, user: '谷子猫猫头', avatar: '🐱', content: '吧唧墙终于贴满了,全是夏油杰!', likes: 156, comments: 18, isHot: false },
  { id: 3, user: '咸鱼交换酱', avatar: '🐟', content: '今日谷阵摆盘,色纸配吧唧托绝了', likes: 89, comments: 12, isHot: false }
]

const GALLERY_GRID: GridRow1[] = [
  {
    cells: [
      { icon: '📛', bg: '#FDE8F0', label: '吧唧晒图' },
      { icon: '🖼️', bg: '#FFF4E0', label: '立牌墙' },
      { icon: '🎨', bg: '#E4F0FF', label: '色纸收藏' }
    ]
  },
  {
    cells: [
      { icon: '💠', bg: '#E7F7FF', label: '亚克力' },
      { icon: '🧸', bg: '#FDF0E6', label: '棉花娃娃' },
      { icon: '🎀', bg: '#F3EBFF', label: '吧唧托' }
    ]
  },
  {
    cells: [
      { icon: '💌', bg: '#FBE4FB', label: '明信片' },
      { icon: '🗿', bg: '#EFEFEF', label: '手办' },
      { icon: '🎪', bg: '#FFF0E0', label: '谷阵摆盘' }
    ]
  },
  {
    cells: [
      { icon: '👜', bg: '#E8F8F0', label: '痛包' },
      { icon: '🔖', bg: '#FDF2F7', label: '挂件' },
      { icon: '🪪', bg: '#EFF3FF', label: '透卡' }
    ]
  }
]

// ============================ 消息数据 ================================

const SYSTEM_MSGS: MsgItem[] = [
  { id: 1, title: '系统公告', content: '平台维护通知:今晚 24:00 - 02:00 停机维护', icon: '🔔', time: '10分钟前', unread: true },
  { id: 2, title: '新功能上线', content: '吃谷车新增拼团补款提醒功能,快去体验', icon: '🆕', time: '2小时前', unread: false },
  { id: 3, title: '活动通知', content: '本周五谷子集市日:全场满300减30', icon: '🎡', time: '昨天', unread: false },
  { id: 4, title: '物流通知', content: '您的谷子包裹已到达中转站,请留意', icon: '📦', time: '昨天', unread: true }
]

const TRADE_MSGS: MsgItem[] = [
  { id: 5, title: '交易成功', content: '您购买的《蓝色监狱剧场版》亚克力已确认收货', icon: '✅', time: '1小时前', unread: false },
  { id: 6, title: '补款提醒', content: '拼团「五条悟 涩谷吧唧」需补尾款 ¥19', icon: '💰', time: '3小时前', unread: true },
  { id: 7, title: '退款到账', content: '「库洛米明信片」退款 ¥15 已到账', icon: '💳', time: '5小时前', unread: false },
  { id: 8, title: '砍价成功', content: '卖家已接受您的砍价 ¥8,请尽快付款', icon: '🤝', time: '6小时前', unread: true }
]

const INTERACT_MSGS: MsgItem[] = [
  { id: 9, title: '收到点赞', content: '谷仓鼠 赞了您的晒谷帖「拆谷快乐!」', icon: '👍', time: '20分钟前', unread: true },
  { id: 10, title: '新评论', content: '咕咕机 评论了您的帖子:同款吧唧 +1', icon: '💬', time: '40分钟前', unread: true },
  { id: 11, title: '关注通知', content: '吃谷喵 关注了您', icon: '🌟', time: '1小时前', unread: false },
  { id: 12, title: '拼团邀请', content: '谷子猫猫头 邀请您加入「乙骨忧太 色纸团」', icon: '🎉', time: '2小时前', unread: false }
]

// ============================ 我的页面数据 ================================

const SPEND_DATA: MonthSpend[] = [
  { month: '3月', amount: 268 },
  { month: '4月', amount: 512 },
  { month: '5月', amount: 340 },
  { month: '6月', amount: 899 },
  { month: '7月', amount: 620 },
  { month: '8月', amount: 1050 }
]

const MAX_SPEND: number = 1100

const MY_GUZI: MyGuziItem[] = [
  { id: 1, name: '五条悟烫金吧唧', icon: '📛', bg: '#FDE8F0', value: 68 },
  { id: 2, name: '芙莉莲色纸', icon: '🎨', bg: '#F3EBFF', value: 45 },
  { id: 3, name: '蓝锁亚克力', icon: '💠', bg: '#E7F7FF', value: 39 },
  { id: 4, name: '小豆泥娃娃', icon: '🧸', bg: '#FDF0E6', value: 129 },
  { id: 5, name: '库洛米明信片', icon: '💌', bg: '#FBE4FB', value: 15 },
  { id: 6, name: '及川彻立牌', icon: '🖼️', bg: '#E0F7FA', value: 35 },
  { id: 7, name: '德克萨斯吧唧', icon: '📛', bg: '#EAEAFF', value: 22 },
  { id: 8, name: '炼狱杏寿郎吧唧', icon: '📛', bg: '#FFEFE0', value: 88 }
]

const PAY_ORDERS: PayOrder[] = [
  { id: 1, title: '五条悟 涩谷吧唧拼团', icon: '📛', amount: 19, deadlineText: '今日 23:59 截止', remainMin: 45 },
  { id: 2, title: '乙骨忧太 烫金色纸拼团', icon: '🎨', amount: 35, deadlineText: '明日 18:00 截止', remainMin: 120 }
]

const MY_MENUS: MenuItem1[] = [
  { icon: '🎫', label: '优惠券', badge: '2张' },
  { icon: '📦', label: '我的吃谷车', badge: '' },
  { icon: '❤️', label: '我的收藏', badge: '' },
  { icon: '📍', label: '地址管理', badge: '' },
  { icon: '📊', label: '吃谷账单', badge: '' },
  { icon: '💬', label: '客服中心', badge: '' }
]

// ============================ 工具函数 ================================

function fmtPrice(p: number): string {
  return '¥' + p.toFixed(2)
}

function fmtSales(s: number): string {
  if (s >= 10000) {
    return (s / 10000).toFixed(1) + '万'
  }
  return s.toString()
}

function specPrice(label: string): number {
  for (let i = 0; i < SPEC_CHIPS.length; i++) {
    if (SPEC_CHIPS[i].label === label) {
      return SPEC_CHIPS[i].price
    }
  }
  return 0
}

function specStock(label: string): number {
  for (let i = 0; i < SPEC_CHIPS.length; i++) {
    if (SPEC_CHIPS[i].label === label) {
      return SPEC_CHIPS[i].stock
    }
  }
  return 0
}

function spendBarH(a: number): string {
  return (a / MAX_SPEND * 90).toFixed(0) + 'vp'
}

function groupPercent(j: number, t: number): string {
  return ((j / t) * 100).toFixed(0) + '%'
}

function fmtRemain(m: number): string {
  const h = Math.floor(m / 60)
  const min = m % 60
  return h.toString().padStart(2, '0') + ':' + min.toString().padStart(2, '0')
}

function fmtCount(c: number): string {
  if (c >= 10000) {
    return (c / 10000).toFixed(1) + '万'
  }
  return c.toString()
}

// ============================ 入口页面 ================================

@Entry
@Component
struct GuziMarketPage {
  @State activeTab: number = 0

  @Builder contentArea() {
    Column() {
      if (this.activeTab === 0) {
        PlazaContent()
      } else if (this.activeTab === 1) {
        PreorderContent()
      } else if (this.activeTab === 2) {
        CartContent()
      } else if (this.activeTab === 3) {
        ExchangeContent()
      } else if (this.activeTab === 4) {
        GalleryContent()
      } else if (this.activeTab === 5) {
        MessageContent()
      } else {
        ProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder tabItem(icon: string, label: string, tab: number, badge: number) {
    Column() {
      Stack() {
        Text(icon).fontSize(18).opacity(this.activeTab === tab ? 1.0 : 0.45)
        if (badge > 0) {
          Text(badge.toString()).fontSize(8).fontColor('#FFFFFF').backgroundColor('#FF4D4F')
            .borderRadius(7).width(15).height(15).textAlign(TextAlign.Center)
            .position({ x: 13, y: -3 })
        }
      }
      .width(32).height(26)
      Text(label).fontSize(9)
        .fontColor(this.activeTab === tab ? '#6C5CE7' : '#999999')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 4, bottom: 4 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.tabItem('🏙️', '吃谷广场', 0, 0)
        this.tabItem('📅', '新谷预告', 1, 0)
        this.tabItem('🛒', '吃谷车', 2, 2)
        this.tabItem('🎁', '无料交换', 3, 0)
        this.tabItem('📸', '晒谷圈', 4, 0)
        this.tabItem('💬', '消息', 5, 3)
        this.tabItem('👤', '我的', 6, 0)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 4, bottom: 6 })
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor('#F4F2FF')
  }
}

// ============================ Tab1:吃谷广场 ================================

@Component
struct PlazaContent {
  @State activeCate: string = '全部'
  @State showDetail: boolean = false
  @State selectedGuzi: GuziItem | null = null
  @State selectedSpec: string = '单只装'
  @State joinedDetail: boolean = false

  @Builder modalOverlay(onClose: () => void = () => {}) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  // 弹框1:谷子详情(底部滑出样式:大图+规格chip+价格+库存)
  @Builder detailModal() {
    Column() {
      this.modalOverlay(() => { this.showDetail = false })
      Column() {
        Stack() {
          Column() {
            Text(this.selectedGuzi?.icon ?? '📛').fontSize(72)
          }
          .width('100%').height(150)
          .backgroundColor(this.selectedGuzi?.bg ?? '#FDE8F0')
          .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
          if (this.selectedGuzi?.tag !== '') {
            Text(this.selectedGuzi?.tag ?? '🔥').fontSize(10).fontColor('#FFFFFF')
              .backgroundColor('#FF9F43')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius({ topRight: 10, bottomLeft: 10 })
              .position({ x: 0, y: 0 })
          }
        }
        .width('100%').height(150).borderRadius({ topLeft: 18, topRight: 18 })

        Column() {
          Text(this.selectedGuzi?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold)
            .fontColor('#212121').width('100%')
          Row() {
            Text(fmtPrice(specPrice(this.selectedSpec))).fontSize(22).fontWeight(FontWeight.Bold)
              .fontColor('#FF9F43')
            Text(fmtPrice(this.selectedGuzi?.originalPrice ?? 0)).fontSize(11)
              .fontColor('#BBBBBB')
              .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
            Column().layoutWeight(1)
            Text('库存 ' + specStock(this.selectedSpec).toString() + ' 件')
              .fontSize(10).fontColor('#999999')
          }
          .width('100%').margin({ top: 8 })

          Row() {
            Text('⭐ ' + (this.selectedGuzi?.rating ?? 0).toString())
              .fontSize(10).fontColor('#FF9F43').backgroundColor('#FFF6E8')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
            Text('已拼 ' + fmtSales(this.selectedGuzi?.sales ?? 0) + ' 件')
              .fontSize(10).fontColor('#6C5CE7').backgroundColor('#F0EDFF')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
              .margin({ left: 6 })
            Text(this.selectedGuzi?.category ?? '')
              .fontSize(10).fontColor('#888888').backgroundColor('#F5F5F5')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
              .margin({ left: 6 })
          }
          .width('100%').margin({ top: 10 })

          Text('选择规格').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#333333')
            .width('100%').margin({ top: 14 })
          Row() {
            ForEach(SPEC_CHIPS, (s: SpecChip) => {
              Column() {
                Text(s.label).fontSize(11)
                  .fontColor(this.selectedSpec === s.label ? '#FFFFFF' : '#6C5CE7')
                  .fontWeight(this.selectedSpec === s.label ? FontWeight.Bold : FontWeight.Normal)
                Text('¥' + s.price.toString()).fontSize(9)
                  .fontColor(this.selectedSpec === s.label ? '#FFFFFF' : '#FF9F43')
                  .margin({ top: 2 })
              }
              .padding({ left: 12, right: 12, top: 8, bottom: 8 })
              .backgroundColor(this.selectedSpec === s.label ? '#6C5CE7' : '#F0EDFF')
              .borderRadius(12).margin({ left: 4, right: 4 })
              .alignItems(HorizontalAlign.Center)
              .onClick(() => { this.selectedSpec = s.label })
            })
          }
          .width('100%').margin({ top: 6 })

          Row() {
            Text('🚚 拼团包邮 · 48小时内发货').fontSize(10).fontColor('#999999')
            Column().layoutWeight(1)
            Text('七天无理由退换').fontSize(10).fontColor('#999999')
          }
          .width('100%').margin({ top: 12 })

          if (this.joinedDetail) {
            Row() {
              Text('✅ 已加入吃谷车,去「吃谷车」查看进度').fontSize(11).fontColor('#4CAF50')
            }
            .width('100%').padding({ top: 8, bottom: 8 }).justifyContent(FlexAlign.Center)
            .backgroundColor('#E8F8F0').borderRadius(8).margin({ top: 10 })
          } else {
            Row() {
              Text('加入购物车').fontSize(12).fontColor('#FFFFFF')
                .backgroundColor('#6C5CE7').borderRadius(18)
                .padding({ left: 18, right: 18, top: 10, bottom: 10 })
                .onClick(() => { this.joinedDetail = true })
              Text('立即拼团').fontSize(12).fontColor('#FFFFFF')
                .backgroundColor('#FF9F43').borderRadius(18)
                .padding({ left: 18, right: 18, top: 10, bottom: 10 })
                .margin({ left: 10 })
                .onClick(() => { this.joinedDetail = true })
            }
            .width('100%').justifyContent(FlexAlign.Center).margin({ top: 12, bottom: 8 })
          }
        }
        .padding({ left: 16, right: 16, top: 12, bottom: 16 })
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%').backgroundColor('#FFFFFF')
      .borderRadius({ topLeft: 18, topRight: 18 })
      .position({ x: 0, y: '36%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder guziCard(item: GuziItem, big: boolean) {
    Column() {
      Stack() {
        Column() {
          Text(item.icon).fontSize(big ? 52 : 38)
        }
        .width('100%').height(big ? 120 : 84)
        .backgroundColor(item.bg)
        .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
        if (item.tag !== '') {
          Text(item.tag).fontSize(8).fontColor('#FFFFFF')
            .backgroundColor('#FF9F43')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius({ topRight: 8, bottomLeft: 8 })
            .position({ x: 0, y: 0 })
        }
      }
      .width('100%').height(big ? 120 : 84)

      Column() {
        Text(item.name).fontSize(11).fontWeight(FontWeight.Medium).fontColor('#212121')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
        Row() {
          Text(fmtPrice(item.price)).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
          Text(fmtPrice(item.originalPrice)).fontSize(9).fontColor('#BBBBBB')
            .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 3 })
          Column().layoutWeight(1)
        }
        .width('100%').margin({ top: 4 })
        Row() {
          Text('⭐' + item.rating.toString()).fontSize(9).fontColor('#FF9F43')
          Column().layoutWeight(1)
          Text(fmtSales(item.sales) + '人拼').fontSize(9).fontColor('#999999')
        }
        .width('100%').margin({ top: 4 })
      }
      .padding(8).alignItems(HorizontalAlign.Start)
    }
    .width('100%').backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ left: 6, right: 6, top: 6 })
    .shadow({ radius: 6, color: '#0D000000', offsetY: 2 })
    .onClick(() => { this.selectedGuzi = item; this.selectedSpec = '单只装'; this.joinedDetail = false; this.showDetail = true })
  }

  build() {
    Stack() {
      Column() {
        // 渐变头部:电商搜索风
        Column() {
          Row() {
            Text('🎪').fontSize(20)
            Text('二次元谷子集市').fontSize(17).fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF').margin({ left: 6 })
            Column().layoutWeight(1)
            Text('🔔').fontSize(19).margin({ left: 10 })
            Text('🛒').fontSize(19).margin({ left: 10 })
          }
          .width('100%').padding({ left: 12, right: 12, top: 12 })

          Row() {
            Text('🔍').fontSize(13).margin({ left: 8 })
            Text('搜索吧唧、立牌、色纸、角色IP').fontSize(12).fontColor('rgba(255,255,255,0.7)')
              .margin({ left: 6 })
            Column().layoutWeight(1)
            Text('搜索').fontSize(11).fontColor('#6C5CE7').backgroundColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
              .margin({ right: 6 })
          }
          .height(36).backgroundColor('rgba(255,255,255,0.18)').borderRadius(18)
          .margin({ left: 12, right: 12, top: 10 })

          // 分类胶囊横滑
          Scroll() {
            Row() {
              ForEach(PLAZA_CATES, (c: PlazaCate) => {
                Row() {
                  Text(c.icon).fontSize(11)
                    .fontColor(this.activeCate === c.label ? '#6C5CE7' : '#FFFFFF')
                  Text(c.label).fontSize(11).margin({ left: 3 })
                    .fontColor(this.activeCate === c.label ? '#6C5CE7' : '#FFFFFF')
                }
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(this.activeCate === c.label ? '#FFFFFF' : 'rgba(255,255,255,0.15)')
                .borderRadius(16)
                .margin({ left: 6, right: 6 })
                .onClick(() => { this.activeCate = c.label })
              })
            }
            .padding({ left: 6, right: 6 })
          }
          .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
          .height(40).width('100%').margin({ top: 10, bottom: 10 })
        }
        .width('100%').linearGradient(MAIN_GRADIENT)

        Scroll() {
          Column() {
            // 静态banner
            Row() {
              Column() {
                Text('🎡 周五谷子集市日').fontSize(14).fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                Text('全场满 300 减 30 · 拼团再享折上折').fontSize(10)
                  .fontColor('rgba(255,255,255,0.85)').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
              Text('去逛逛 >').fontSize(11).fontColor('#FFFFFF')
                .backgroundColor('rgba(255,255,255,0.22)')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14)
            }
            .width('100%').padding(14)
            .linearGradient(BANNER_GRADIENT).borderRadius(12)
            .margin({ left: 12, right: 12, top: 10 })
            .shadow({ radius: 6, color: '#336C5CE7', offsetY: 3 })

            Row() {
              Text('⚡ 吃谷爆款').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#212121')
              Text(this.activeCate).fontSize(10).fontColor('#6C5CE7').margin({ left: 6 })
              Column().layoutWeight(1)
              Text('全部 >').fontSize(10).fontColor('#999999')
            }
            .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 4 })

            // 双列卡片流(大小交替错落)
            Row() {
              Column() {
                this.guziCard(GUZI_ITEMS[0], true)
                this.guziCard(GUZI_ITEMS[2], true)
                this.guziCard(GUZI_ITEMS[4], true)
                this.guziCard(GUZI_ITEMS[6], true)
                this.guziCard(GUZI_ITEMS[8], true)
                this.guziCard(GUZI_ITEMS[10], true)
                this.guziCard(GUZI_ITEMS[12], true)
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
              Column() {
                Column() {
                  this.guziCard(GUZI_ITEMS[1], false)
                  this.guziCard(GUZI_ITEMS[3], false)
                  this.guziCard(GUZI_ITEMS[5], false)
                  this.guziCard(GUZI_ITEMS[7], false)
                  this.guziCard(GUZI_ITEMS[9], false)
                  this.guziCard(GUZI_ITEMS[11], false)
                  this.guziCard(GUZI_ITEMS[13], false)
                }
                .width('100%')
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center).margin({ top: 40 })
            }
            .width('100%').alignItems(VerticalAlign.Top)
            .padding({ left: 6, right: 6, bottom: 16 })
          }
          .width('100%')
        }
        .layoutWeight(1).scrollBar(BarState.Off)
      }
      .width('100%').height('100%')

      if (this.showDetail) { this.detailModal() }
    }
    .width('100%').height('100%')
  }
}

// ============================ Tab2:新谷预告 ================================

@Component
struct PreorderContent {
  @State subscribedIds: number[] = []
  @State showTip: boolean = false

  @Builder preCard(p: PreorderItem) {
    Row() {
      Column() {
        Text(p.icon).fontSize(32)
      }
      .width(64).height(64).backgroundColor(p.bg).borderRadius(12)
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(p.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#212121')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
          if (this.subscribedIds.indexOf(p.id) >= 0) {
            Text('✓ 已订阅').fontSize(9).fontColor('#FFFFFF').backgroundColor('#6C5CE7')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          }
        }
        .width('100%')

        Text(p.series).fontSize(10).fontColor('#888888').width('100%').margin({ top: 3 })
        Row() {
          Text(fmtPrice(p.price)).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF9F43')
          Text('已预约 ' + fmtCount(p.preCount)).fontSize(9).fontColor('#999999').margin({ left: 8 })
          Column().layoutWeight(1)
          Text(p.deadline).fontSize(9).fontColor('#6C5CE7')
        }
        .width('100%').margin({ top: 4 })

        Row() {
          Text(p.tag).fontSize(9).fontColor('#FF9F43').backgroundColor('#FFF6E8')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
          Text('订阅提醒').fontSize(9).fontColor('#FFFFFF')
            .backgroundColor(this.subscribedIds.indexOf(p.id) >= 0 ? '#B8B2E8' : '#6C5CE7')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
            .margin({ left: 8 })
        }
        .width('100%').margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
    }
    .width('100%').padding(10).backgroundColor('#FFFFFF').borderRadius(12)
    .margin({ top: 6 })
    .shadow({ radius: 4, color: '#0D000000', offsetY: 1 })
    .onClick(() => {
      const idx = this.subscribedIds.indexOf(p.id)
      if (idx >= 0) {
        this.subscribedIds.splice(idx, 1)
      } else {
        this.subscribedIds.push(p.id)
      }
    })
  }

  build() {
    Column() {
      Column() {
        Row() {
          Text('📅').fontSize(20)
          Text('新谷预告').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .margin({ left: 6 })
          Column().layoutWeight(1)
          Text('共 ' + MONTH_GROUPS.length.toString() + ' 个月档期')
            .fontSize(10).fontColor('rgba(255,255,255,0.85)')
        }
        .width('100%').padding({ left: 14, right: 14, top: 12 })
        Row() {
          Text('💡 订阅开售提醒,开售不错过').fontSize(10).fontColor('rgba(255,255,255,0.8)')
            .padding({ top: 6 })
          Column().layoutWeight(1)
          Text('订阅 ' + this.subscribedIds.length.toString() + ' 个')
            .fontSize(10).fontColor('#FFFFFF').backgroundColor('rgba(0,0,0,0.2)')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%').linearGradient(MAIN_GRADIENT)

      Scroll() {
        Column() {
          // 按月份分组的时间线
          ForEach(MONTH_GROUPS, (g: MonthGroup) => {
            Row() {
              Column() {
                Text(g.month).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#6C5CE7')
                Text(g.tip).fontSize(9).fontColor('#A29BFE').margin({ top: 2 })
                Column().width(2).height(110).backgroundColor('#E6E0FF').margin({ top: 8 })
              }
              .width(48).alignItems(HorizontalAlign.Center)

              Column() {
                ForEach(g.items, (p: PreorderItem) => {
                  this.preCard(p)
                })
              }
              .layoutWeight(1).padding({ left: 4, right: 12 })
            }
            .alignItems(VerticalAlign.Top)
            .margin({ top: 12 })
          })

          Column() {
            Row() {
              Text('📌').fontSize(14)
              Text('开售时间均为北京时间,实际以官谷为准').fontSize(10).fontColor('#888888')
                .margin({ left: 4 })
            }
            .width('100%').justifyContent(FlexAlign.Center)
            .padding({ top: 12, bottom: 12 })
          }
          .width('100%')
        }
        .padding({ bottom: 20 })
      }
      .layoutWeight(1).scrollBar(BarState.Off)
    }
    .width('100%').height('100%')
  }
}

// ============================ Tab3:吃谷车 ================================

@Component
struct CartContent {
  @State showCartModal: boolean = false
  @State selectedGroup: CartGroupItem | null = null
  @State cartQty: number = 1
  @State cartNote: string = ''
  @State joinedCart: boolean = false

  @Builder modalOverlay(onClose: () => void = () => {}) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  // 弹框2:加入吃谷车(数量stepper + 备注输入 + 预估费用明细)
  @Builder cartModal() {
    Column() {
      this.modalOverlay(() => { this.showCartModal = false })
      Column() {
        Row() {
          Text('🛒 加入吃谷车').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showCartModal = false })
        }
        .width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
        Divider().color('#F0F0F0')

      

---

## 总结
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/76daaa1aa43f487e8f2564089aa34f7b.png#pic_center)

本文深入剖析了一个基于HarmonyOS ArkTS声明式UI框架的二次元谷子集市应用的完整实现。从十五个interface接口定义到四组渐变设计令牌,从十四条谷子商品到八组拼团、十条换物帖、三组消息等全局数据集,从八个工具函数到入口组件的Builder复用路由,从吃谷广场的瀑布流到新谷预告的时间线、从吃谷车的成员槽位到无料交换的气泡布局、从晒谷圈的点赞交互到消息中心的全部已读、从个人中心的消费柱状图到补款弹窗的倒计时,整个应用展现了HarmonyOS声明式开发范式在二次元垂直电商社区场景下的设计能力。应用通过@State的状态追踪实现了数据驱动的自动重渲染,通过@Builder方法实现了Tab路由和Tab项的代码复用,通过空值合并运算符实现了可选对象的安全访问,通过数组+indexOf模式实现了多选交互状态管理。

从业务设计角度看,该应用巧妙地将拼多多风格的拼团电商模式(吃谷车拼团、成团进度、补款提醒)与二次元社区的内容互动模式(新谷预告订阅、无料交换、晒谷圈点赞)融合为一体。七大Tab覆盖了二次元谷子用户的完整交易和社交链条:吃谷广场(搜索+分类+Banner+瀑布流商品)、新谷预告(月份时间线+订阅提醒)、吃谷车(成员槽位+进度条+参团弹窗)、无料交换(左右气泡+发布表单+求交换)、晒谷圈(热帖+图片墙+点赞删除)、消息中心(三组分类+全部已读+未读红点)和个人中心(资料卡+消费图表+谷子横滑+补款+菜单)。紫蓝渐变为主色调贯穿全应用,橙色为强调色用于价格和紧迫提示,形成了二次元文化的视觉识别体系。每个Tab都集成了独特的UI设计:大小交替的错落瀑布流、月份分组的垂直时间线、虚线占位的成员槽位、镜像翻转的聊天气泡、双数字方块的倒计时时钟,展现了丰富的UI创新力。

从工程实践角度看,该应用采用了"interface→设计令牌→全局数据→工具函数→入口→Tab组件→弹窗"的分层代码组织结构,每一层都有明确的职责边界。特别值得关注的几个设计亮点:入口组件使用`@Builder contentArea``@Builder tabItem`两个可复用Builder方法将路由逻辑和Tab渲染逻辑封装,使得build方法极其精简;商品详情弹窗使用`??`空值合并运算符处理`selectedGuzi`可能为null的所有属性访问,确保UI在未选中商品时不会崩溃;吃谷车的成员槽位通过双重条件判断(`i < g.totalCount``i < g.joinedCount`)实现了已占用和未占用槽位的差异化渲染,使用`BorderStyle.Dashed`虚线边框标识空槽位;无料交换的`exchangeCard` Builder通过`isLeft`布尔值实现完整的镜像布局翻转(头像位置、背景色、信息排列顺序全部镜像),使得左右交替的气泡效果仅需一个Builder即可完成;补款弹窗的倒计时通过`fmtRemain`函数格式化后使用`substring`截取时和分分别渲染在独立方块中,模拟了数字时钟的视觉效果。对于希望深入学习HarmonyOS ArkTS声明式UI开发的开发者而言,该应用的Builder复用模式、空值安全处理、多选状态管理、镜像布局和倒计时渲染都具有直接的参考价值。

Logo

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

更多推荐