攀岩不仅是一项极限运动,更是一种人与岩壁之间的精密对话。从攀岩鞋的脚感到镁粉的防滑,从快挂的破断强度到抱石垫的缓冲厚度,每一件装备都关乎安全与表现。将这些垂直品类的电商体验搬到 HarmonyOS 声明式 UI 框架上,是对 ArkTS 数据驱动能力和多维度参数可视化能力的全面检验。

在声明式 UI 范式中,开发者不操作 DOM,而是描述界面在不同状态下的样子,框架负责将状态变化映射到渲染层。HarmonyOS 6.1.1 的 ArkTS 将这一理念与 TypeScript 的类型系统深度融合,让电商应用的每一处参数展示、筛选交互和弹窗管理都有了类型安全保障。

本文以一个攀岩抱石运动馆装备商城为载体,从岩壁砂岩橙×岩钉蓝双主题色板出发,逐段剖析攀岩鞋、镁粉、快挂、抱石垫四大品类的列表渲染、强度对比图表、多维度筛选和 CRUD 弹窗流程,力求为 HarmonyOS 开发者呈现一份可复用的工程范式参考。

引言

攀岩抱石运动馆是一个集装备销售、课程预约和社交打卡于一体的垂直运动电商平台。不同于通用电商,攀岩装备有着独特的参数维度:攀岩鞋需要区分"脚感"(舒适/精确/超紧)和橡胶厚度;镁粉需要区分类型(散粉/粉球/液镁)和净重;快挂需要标注破断强度(kN)和 UIAA 认证;抱石垫则需要关注缓冲厚度。这些丰富的参数维度使得商品列表的展示方式必须因品类而异——攀岩鞋用横向大卡、镁粉用双列网格、快挂用排行榜、抱石垫用一列大卡——每一种布局都针对该品类的信息密度做了优化。

HarmonyOS ArkTS 的声明式 UI 框架为这种"一应用多布局"的场景提供了理想的开发范式。通过 @Component 装饰器将每个品类页面封装为独立组件,组件内部用 @State 管理各自的数据和弹窗状态,组件之间通过父组件的 @State cur 索引切换。这种"高内聚低耦合"的组件化架构让每个品类页面都可以独立开发、独立测试,不会互相影响。

本文的商城系统采用岩壁砂岩橙(#E2723B)与岩钉蓝(#3D7E9A)的双主题色搭配,背景使用温暖的米沙色(#FAF3EA),营造出户外岩壁的自然质感。系统分为首页推荐、攀岩鞋、镁粉、快挂、抱石垫和个人中心六大模块,每个模块都包含与品类相关的数据可视化图表(价格对比、销量柱状图、强度条形图、厚度条形图)和多维度筛选器,以及完整的新增、编辑、删除和详情弹窗。接下来将从数据模型到视图实现逐段拆解。

一、双主题色板与商品数据模型

1.1 岩壁砂岩主题色板

攀岩运动馆的色彩系统使用温暖的岩壁橙作为主色调,冷调的岩钉蓝作为辅助色,营造出"暖壁冷钉"的视觉对比:

interface ColorPalette {
  bg: string
  card: string
  card2: string
  primary: string
  accent: string
  star: string
  text: string
  sub: string
  line: string
  glow: string
}

const CL: ColorPalette = {
  bg: '#FAF3EA',
  card: '#FFFFFF',
  card2: '#F5E6D3',
  primary: '#E2723B',
  accent: '#3D7E9A',
  star: '#F59E0B',
  text: '#3B322A',
  sub: '#9C8B7A',
  line: '#EAD9C4',
  glow: '#C2562F'
}

在这里插入图片描述

与深色系应用不同,攀岩馆使用暖色系的浅色背景(#FAF3EA),搭配白色卡片和米沙色次级卡片。primary 砂岩橙用于品牌色和价格强调,accent 岩钉蓝用于辅助标签和筛选器选中态,glow 深橙用于折扣和强调数字。这种色彩分配让整个界面既有户外运动的力量感,又不失亲和力。

浅色主题的色板设计比深色主题更具挑战性——需要处理好背景与卡片的层次感,避免大面积白色导致的视觉疲劳。用米沙色作为过渡色是一种常见且有效的策略。

1.2 四大品类商品接口

攀岩馆涉及四类商品,每类都有独特的参数维度。以下是攀岩鞋和快挂的接口定义:

interface ShoeItem {
  name: string
  brand: string
  feel: string
  price: number
  orig: number
  sale: number
  stars: number
  size: string
  rubber: number
}

interface QuickItem {
  name: string
  brand: string
  type: string
  price: number
  orig: number
  sale: number
  strength: number
}

在这里插入图片描述

ShoeItemfeel 字段是攀岩鞋的核心差异化参数——"舒适"适合初学者、"精确"适合技术型攀爬、"超紧"适合竞技选手。size 以 “40-44” 这样的区间字符串存储,rubber 记录橡胶厚度(毫米)。QuickItemstrength 是破断强度,以 kN 为单位,这是安全装备最关键的参数——攀岩快挂通常要求 20kN 以上。

镁粉(ChalkItem)和抱石垫(PadItem)也各有特色:镁粉区分类型(散粉/粉球/液镁)和净重;抱石垫区分类型(抱石垫/防滑垫/护膝)和厚度。这种品类差异化的数据模型设计,使得后续的筛选、图表和详情弹窗能够针对不同参数维度做定制化展示。

二、纯函数工具层

2.1 格式化与条形图宽度计算

与通用电商类似,攀岩馆也定义了一组格式化函数,但额外增加了品类特有的参数计算:

function clPrice(p: number): string {
  return '¥' + p
}

function clOff(orig: number, price: number): string {
  return Math.round((orig - price) / orig * 100) + '%'
}

function clNum(n: number): string {
  return n > 1000 ? (n / 1000).toFixed(1) + 'k' : '' + n
}

function clBarW(v: number, max: number): string {
  return Math.max(8, Math.round(v / max * 100)) + '%'
}

function clStrengthW(s: number): string {
  return Math.max(8, Math.round(s / 28 * 100)) + '%'
}

function clThickW(t: number): string {
  return Math.max(8, Math.round(t / 12 * 100)) + '%'
}

function clMonthBar(v: number): string {
  return Math.max(8, Math.round(v / 150 * 100)) + '%'
}

在这里插入图片描述

clStrengthW 是快挂强度条形图的专用函数——以 28kN 为满刻度基准(攀岩主锁的最高强度通常为 27kN),将当前强度值映射为百分比宽度。clThickW 是抱石垫厚度条形图专用——以 12cm 为满刻度(专业抱石垫最厚 12cm),计算厚度占比。这两个函数体现了"图表刻度因品类而异"的设计思想。

2.2 品类颜色映射

攀岩馆的颜色映射函数更加丰富,因为品类和参数维度更多:

function clFeelColor(f: string): string {
  if (f === '舒适') {
    return '#3D7E9A'
  }
  if (f === '精确') {
    return '#E2723B'
  }
  return '#C2562F'
}

function clTypeColor(t: string): string {
  if (t === '散粉') { return '#3D7E9A' }
  if (t === '粉球') { return '#E2723B' }
  if (t === '液镁') { return '#8A7B5C' }
  if (t === '快挂') { return '#3D7E9A' }
  if (t === '主锁') { return '#E2723B' }
  if (t === '扁带') { return '#8A7B5C' }
  if (t === '抱石垫') { return '#E2723B' }
  if (t === '防滑垫') { return '#3D7E9A' }
  return '#C2562F'
}

function clFavColor(t: string): string {
  if (t === '攀岩鞋') { return '#E2723B' }
  if (t === '镁粉') { return '#3D7E9A' }
  if (t === '快挂') { return '#8A7B5C' }
  return '#C2562F'
}

clFeelColor 将攀岩鞋的脚感映射为颜色——舒适用岩钉蓝、精确用砂岩橙、超紧用深橙。clTypeColor 则是一个多功能映射器,能同时处理镁粉类型、快挂类型和抱石垫类型,返回对应的品类色。这种设计使得同一个函数可以在多个品类的列表和详情弹窗中复用,减少了函数数量。

2.3 筛选与排序函数

四大品类各有独立的筛选函数,但结构一致——遍历数组,按条件过滤:

function clFilterShoes(arr: ShoeItem[], t: string): ShoeItem[] {
  let out: ShoeItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].feel === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clFilterQuicks(arr: QuickItem[], t: string): QuickItem[] {
  let out: QuickItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].type === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clShoeTop(arr: ShoeItem[]): ShoeItem[] {
  return arr.slice().sort((a: ShoeItem, b: ShoeItem): number => {
    return b.price - a.price
  }).slice(0, 6)
}

function clQuickTop(arr: QuickItem[]): QuickItem[] {
  return arr.slice().sort((a: QuickItem, b: QuickItem): number => {
    return b.strength - a.strength
  }).slice(0, 6)
}

在这里插入图片描述

攀岩鞋按"脚感"筛选(舒适/精确/超紧),快挂按"类型"筛选(快挂/主锁/扁带/保护器)。排序方面,攀岩鞋 clShoeTop 按价格降序排列用于价格对比图表,快挂 clQuickTop 按强度降序排列——这体现了安全装备的排序逻辑与普通商品不同,强度比价格更重要。

筛选和排序函数应该是纯函数——不修改输入数组,而是返回新数组。slice() + sort() 的组合保证了这一点,即使调用方后续修改返回值也不会影响原始数据。

2.4 动画驱动函数

攀岩馆的动画函数与键盘工坊类似,但增加了"摆动"效果,用于快挂列表的横向微动:

function clFloatY(w: number, i: number): number {
  return Math.abs(Math.sin((w + i * 2) / 2.4)) * -5
}

function clPulse(w: number, i: number): number {
  return 0.5 + Math.abs(Math.sin((w + i) / 2.6)) * 0.5
}

function clRipple(w: number, i: number): number {
  return 1 + ((w + i) % 10) / 3
}

function clShineO(w: number): number {
  return Math.abs(Math.sin(w / 7))
}

function clBlink(w: number): number {
  if (w % 24 < 12) {
    return 1
  }
  return 0.25
}

function clSwingX(w: number, i: number): number {
  return Math.sin((w + i) / 3) * 5
}

在这里插入图片描述

clSwingX 是攀岩馆特有的动画函数,它使用 Math.sin 产生 -5 到 5 之间的横向位移值,用于快挂列表项的 translate({ x: ... }) 属性,模拟安全装备在岩壁上的轻微摆动效果。clFloatY 产生向上的浮动位移,用于攀岩鞋列表项和 Banner 中的攀岩人图标。这些函数都接受 wave 计数器,通过 setInterval 定时自增来驱动动画。

三、入口组件与底部导航

3.1 主入口 KBApp→CLApp 结构

入口组件 CLApp 负责整体布局框架,通过 @State cur 控制当前显示的品类页面:

@Entry
@Component
struct CLApp {
  @State cur: number = 0
  @State search: string = ''

  @Builder modalOverlay() {
    Column() {}.width('100%').height('100%').backgroundColor('#663B322A')
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Column() {
            Text('🧗 攀岩抱石运动馆').fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.primary)
            Text('ROCK CLIMBING HUB').fontSize(9).fontColor(CL.sub).margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Column() {}.width(34).height(34).borderRadius(17).backgroundColor(CL.card2)
            .overlay(Text('🔔').fontSize(15))
          Column() {}.width(34).height(34).borderRadius(17).backgroundColor(CL.card2)
            .overlay(Text('👤').fontSize(15)).margin({ left: 8 })
        }.width('94%').padding({ top: 10, bottom: 6 })
        Row() {
          Text('🔍').fontSize(14).margin({ right: 6 })
          Text('搜岩鞋 / 镁粉 / 快挂').fontSize(13).fontColor(CL.sub)
          Column().layoutWeight(1)
          Text('V6').fontSize(11).fontColor(CL.primary).fontWeight(FontWeight.Bold)
        }.width('92%').height(38).borderRadius(19).backgroundColor(CL.card)
          .padding({ left: 14, right: 14 }).border({ width: 1, color: CL.line })
        Column() {
          if (this.cur === 0) {
            CLHomeTab()
          } else if (this.cur === 1) {
            CLShoeTab()
          } else if (this.cur === 2) {
            CLChalkTab()
          } else if (this.cur === 3) {
            CLQuickTab()
          } else if (this.cur === 4) {
            CLPadTab()
          } else {
            CLMineTab()
          }
        }.layoutWeight(1)
        // ... 底部 Tab 导航 ...
      }.width('100%').height('100%')
      this.modalOverlay()
    }.width('100%').height('100%').backgroundColor(CL.bg)
  }
}

在这里插入图片描述

注意搜索栏右侧显示的 “V6” 标签——这是攀岩难度等级标识,暗示了应用的攀岩文化定位。modalOverlay 使用 #663B322A 作为遮罩颜色,是 CL.text 颜色(#3B322A)加上 40% 透明度的合成结果,与整体暖色主题保持一致。

品牌微文案(如 “V6” 难度标识)是垂直品类应用建立用户认同感的重要手段。开发者应在设计阶段就考虑如何将品类文化融入 UI 细节。

3.2 底部 Tab 导航

底部导航通过 ForEach 渲染六个 Tab,使用攀岩相关的 Emoji 图标:

Row() {
  ForEach(CL_TABS, (t: string, i: number) => {
    Column() {
      Column() {}.width(30).height(30).borderRadius(15)
        .backgroundColor(this.cur === i ? CL.primary : CL.card2)
        .overlay(Text(CL_TAB_ICONS[i]).fontSize(14)
          .fontColor(this.cur === i ? '#FFFFFF' : CL.sub))
      Text(t).fontSize(10).fontColor(this.cur === i ? CL.primary : CL.sub)
    }.layoutWeight(1).onClick(() => {
      this.cur = i
    })
  }, (t: string) => t)
}.width('100%').height(62).backgroundColor(CL.card)
  .border({ width: { top: 1 }, color: CL.line })

在这里插入图片描述

Tab 选中态使用 CL.primary 砂岩橙作为图标背景色和文字色,未选中态使用 CL.sub 米灰色。图标和文字的颜色变化通过 this.cur === i 的三元表达式控制,点击后 this.cur = i 触发 build() 重新执行。CL_TAB_ICONS 数组定义了六个 Emoji:🏠、👟、👜、🔗、🧱、👤,与 Tab 名称一一对应。

四、首页推荐模块

4.1 岩壁渐变 Banner

首页 CLHomeTab 的 Banner 使用砂岩橙到深橙的渐变,内部叠加了浮动的攀岩人图标和涟漪扩散圆:

aboutToAppear(): void {
  setInterval(() => {
    this.wave = this.wave + 1
  }, 95)
}

build() {
  Stack() {
    Scroll() {
      Column() {
        Stack() {
          Column() {
            Row() {
              Text('🧗').fontSize(22)
              Text('抱石挑战赛').fontSize(15).fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF').margin({ left: 6 })
            }
            Text('本周六 14:00 · 岩馆 B 区').fontSize(10).fontColor('#FFE3D0')
              .margin({ top: 8 })
            Row() {
              Text('冠军赢 ¥2000 装备券').fontSize(11).fontColor('#FFFFFF')
                .backgroundColor('#00000026').borderRadius(10)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            }.margin({ top: 12 })
            Text('🧗').fontSize(20).opacity(0.85)
              .translate({ y: clFloatY(this.wave, 0) }).margin({ left: 250, top: 30 })
            Text('🧗').fontSize(14).opacity(0.7)
              .translate({ y: clFloatY(this.wave, 3) }).margin({ left: 200, top: 6 })
          }.alignItems(HorizontalAlign.Start).margin({ left: 16 })
          Column() {}.width(80).height(80).borderRadius(40).backgroundColor('#FFFFFF')
            .opacity(0.18).scale({ x: clRipple(this.wave, 1), y: clRipple(this.wave, 1) })
            .margin({ left: 240, top: 14 })
        }.width('92%').height(148).borderRadius(18)
          .linearGradient({ angle: 135, colors: [['#E2723B', 0], ['#C2562F', 1]] })
          .margin({ top: 12 })
        // ...

在这里插入图片描述

Banner 中的两个攀岩人 Emoji 通过 clFloatY(this.wave, i) 驱动 translate 的 y 轴位移,产生上下浮动的效果——大号图标浮动幅度大、小号图标浮动幅度小,形成层次感。右侧的白色圆形通过 clRipple 驱动 scale,模拟岩壁上光线扩散的涟漪效果。这些动效与"抱石挑战赛"的活动主题相呼应,营造出运动竞技的动态氛围。

4.2 课程卡与教练预约

首页独有的功能是攀岩课程预约。课程卡展示本周课程安排,点击"预约"按钮弹出教练预约弹窗:

Row() {
  Column() {
    Text('周三 19:00').fontSize(10).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    Text('新手抱石入门').fontSize(9).fontColor('#FFE3D0').margin({ top: 5 })
    Text('教练 · 阿岩').fontSize(8).fontColor('#FFE3D0').margin({ top: 4 })
  }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
  Text('预约').fontSize(10).fontColor('#FFFFFF').backgroundColor('#C2562F')
    .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
    .margin({ right: 12 }).onClick(() => {
    this.showCoach = true
  })
}.width('100%').borderRadius(12).backgroundColor(CL.primary).padding({ top: 10, bottom: 10 })
  .margin({ top: 10 })

教练预约弹窗展示了教练信息、可预约时段和确认按钮:

if (this.showCoach) {
  Stack() {
    this.modalOverlay()
    Column() {
      Text('🧗 教练预约').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
      Row() {
        Column() {}.width(52).height(52).borderRadius(26).backgroundColor('#FDEBD2')
          .overlay(Text('岩').fontSize(20).fontColor(CL.primary))
        Column() {
          Text('阿岩 · 金牌教练').fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.text)
          Text('8 年攀龄 · 2000+ 学员').fontSize(9).fontColor(CL.sub).margin({ top: 4 })
        }.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
      }.width('100%').margin({ top: 12 })
      Text('可预约时段').fontSize(10).fontColor(CL.sub).width('100%').margin({ top: 10 })
      Row() {
        ForEach(['18:00', '19:00', '20:00', '21:00'], (t: string) => {
          Text(t).fontSize(10).fontColor(CL.primary).backgroundColor('#FDEBD2')
            .borderRadius(12).padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .margin({ right: 6 })
        }, (t: string) => t)
      }.width('100%').margin({ top: 6 })
      Text('确认预约').fontSize(14).fontColor('#FFFFFF').width('100%')
        .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
        .backgroundColor(CL.primary).borderRadius(22).margin({ top: 14 }).onClick(() => {
        this.showCoach = false
      })
    }.width('84%').borderRadius(16).backgroundColor(CL.card)
      .padding({ left: 16, right: 16, top: 16, bottom: 16 })
      .constraintSize({ maxHeight: '74%' })
  }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
}

教练头像用 #FDEBD2 浅橙色背景配合一个"岩"字,时段选择器用 Chip 标签展示 4 个可选时段。这种课程预约功能是攀岩馆应用区别于纯电商应用的重要特征——它融合了 O2O(线上到线下)的服务预约能力。

课程预约是运动场馆应用的核心转化路径。通过在首页 Banner 下方放置课程入口,可以显著提升用户的线下到馆率。

4.3 课程表弹框

点击"全部课程"链接后弹出完整的周课程表:

if (this.showCourse) {
  Stack() {
    this.modalOverlay()
    Column() {
      Text('📋 岩馆课程表').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
      Column() {
        Row() {
          Text('周一').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
          Text('速度攀爬训练').fontSize(10).fontColor(CL.sub)
          Column().layoutWeight(1)
          Text('19:00').fontSize(10).fontColor(CL.primary)
        }.width('100%').padding({ top: 8, bottom: 8 })
        Row() {
          Text('周三').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
          Text('新手抱石入门').fontSize(10).fontColor(CL.sub)
          Column().layoutWeight(1)
          Text('19:00').fontSize(10).fontColor(CL.primary)
        }.width('100%').padding({ top: 8, bottom: 8 })
        // ... 周五、周六 ...
      }.width('100%').borderRadius(12).backgroundColor(CL.card2)
        .padding({ left: 12, right: 12 }).margin({ top: 12 })
      Text('知道了').fontSize(14).fontColor('#FFFFFF').width('100%')
        .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
        .backgroundColor(CL.accent).borderRadius(22).margin({ top: 14 }).onClick(() => {
        this.showCourse = false
      })
    }.width('82%').borderRadius(16).backgroundColor(CL.card)
      .padding({ left: 18, right: 18, top: 18, bottom: 18 })
      .constraintSize({ maxHeight: '80%' })
  }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
}

课程表使用简单的 Row 布局,每行包含星期、课程名称和上课时间。关闭按钮使用 CL.accent 岩钉蓝背景,与签到弹窗的 CL.primary 砂岩橙背景形成色彩区分,帮助用户通过颜色辨识不同弹窗的功能类型。

五、攀岩鞋模块——横向大卡列表

5.1 价格对比与脚感筛选

CLShoeTab 组件使用横向大卡列表展示攀岩鞋,每张卡片包含图标、名称、脚感标签、尺码、橡胶厚度和价格:

@Component
struct CLShoeTab {
  @State shoes: ShoeItem[] = []
  @State wave: number = 0
  @State filter: string = '全部'
  @State selIdx: number = -1
  @State showDetail: boolean = false
  @State showAdd: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  @State addName: string = '入门岩 初学者款'
  @State addFeel: string = '舒适'
  @State addSize: string = '40-42'
  @State addPrice: string = '329'

  aboutToAppear(): void {
    this.shoes = SHOE_LIST.slice()
    setInterval(() => {
      this.wave = this.wave + 1
    }, 115)
  }

状态管理方面,CLShoeTab 维护了筛选条件 filter、选中索引 selIdx 和四个弹窗显隐布尔值。新增表单变量 addFeeladdSize 是攀岩鞋特有的参数——脚感(舒适/精确/超紧)和尺码区间。

价格对比图表通过 clShoeTop 获取价格前 6 的攀岩鞋,每行用条形图展示价格占比:

Column() {
  ForEach(clShoeTop(this.shoes), (s: ShoeItem, i: number) => {
    Row() {
      Text(s.name).fontSize(10).fontColor(CL.text).width(118).maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Column() {
        Column() {}.height(6).borderRadius(3)
          .width(clBarW(s.price, clMaxShoe(this.shoes)))
          .backgroundColor(CL.primary)
        Text('¥' + s.price).fontSize(9).fontColor(CL.glow).margin({ top: 2 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 6 })
    }.width('100%').margin({ top: 8 })
  }, (s: ShoeItem) => s.name)
}

5.2 攀岩鞋大卡列表

攀岩鞋使用横向大卡布局(而非双列网格),因为攀岩鞋的参数信息较多,需要更宽的展示空间:

ForEach(clFilterShoes(this.shoes, this.filter), (s: ShoeItem, i: number) => {
  Row() {
    Column() {}.width(76).height(76).borderRadius(14).backgroundColor(CL.card2)
      .overlay(Text('👟').fontSize(28).fontColor(CL.primary))
      .translate({ y: clFloatY(this.wave, i) })
    Column() {
      Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.text)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Row() {
        Text(s.feel).fontSize(9).fontColor('#FFFFFF')
          .backgroundColor(clFeelColor(s.feel)).borderRadius(4)
          .padding({ left: 4, right: 4 })
        Text(s.size + ' 码').fontSize(9).fontColor(CL.sub).margin({ left: 6 })
        Text('橡胶 ' + s.rubber + 'mm').fontSize(9).fontColor(CL.sub).margin({ left: 6 })
      }.margin({ top: 5 })
      Row() {
        Text(clPrice(s.price)).fontSize(14).fontWeight(FontWeight.Bold)
          .fontColor(CL.primary)
        Text(clPrice(s.orig)).fontSize(9).fontColor(CL.sub)
          .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
        Column().layoutWeight(1)
        Text('售' + clNum(s.sale)).fontSize(9).fontColor(CL.sub)
      }.width('100%').margin({ top: 6 })
    }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
  }.width('100%').borderRadius(14).backgroundColor(CL.card)
    .border({ width: 1, color: CL.line }).padding(10).margin({ bottom: 10 })
    .onClick(() => {
      this.selIdx = clFindShoeIdx(this.shoes, s.name)
      this.showDetail = true
    })
}, (s: ShoeItem) => s.name)

每张卡片左侧是 76x76 的圆角图标区,叠加了鞋 Emoji 和 clFloatY 驱动的上下浮动效果。右侧信息区包含鞋名、脚感标签(带 clFeelColor 映射的颜色背景)、尺码、橡胶厚度和价格。textOverflow({ overflow: TextOverflow.Ellipsis }) 确保长名称自动截断并显示省略号。

5.3 攀岩鞋详情弹窗

详情弹窗展示了脚感、尺码、橡胶和评分四宫格参数,并提供编辑和删除入口:

if (this.showDetail) {
  Stack() {
    this.modalOverlay()
    Column() {
      Column() {}.width('100%').height(92).borderRadius(12).backgroundColor(CL.card2)
        .overlay(Text('👟').fontSize(32).fontColor(CL.primary))
      Text(this.shoes[this.selIdx].name).fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(CL.text).margin({ top: 10 })
      Row() {
        Text(clPrice(this.shoes[this.selIdx].price)).fontSize(18)
          .fontWeight(FontWeight.Bold).fontColor(CL.primary)
        Text(clPrice(this.shoes[this.selIdx].orig)).fontSize(11).fontColor(CL.sub)
          .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 8 })
        Text('省 ' + clOff(this.shoes[this.selIdx].orig, this.shoes[this.selIdx].price))
          .fontSize(9).fontColor(CL.glow).margin({ left: 6 })
      }.margin({ top: 8 })
      Row() {
        Column() {
          Text(this.shoes[this.selIdx].feel).fontSize(11).fontWeight(FontWeight.Bold)
            .fontColor(clFeelColor(this.shoes[this.selIdx].feel))
          Text('脚感').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(this.shoes[this.selIdx].size).fontSize(11)
            .fontWeight(FontWeight.Bold).fontColor(CL.text)
          Text('尺码').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text(this.shoes[this.selIdx].rubber + 'mm').fontSize(11)
            .fontWeight(FontWeight.Bold).fontColor(CL.text)
          Text('橡胶').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        Column() {
          Text('' + this.shoes[this.selIdx].stars).fontSize(11)
            .fontWeight(FontWeight.Bold).fontColor(CL.star)
          Text('评分').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }.width('100%').borderRadius(12).backgroundColor(CL.card2)
        .padding({ top: 12, bottom: 12 }).margin({ top: 12 })
      // ... 编辑/删除按钮 ...
    }
  }
}

四宫格参数的脚感值使用 clFeelColor 映射的颜色——舒适显示蓝色、精确显示橙色、超紧显示深橙。这种颜色编码让用户能一眼判断鞋子的脚感类型,比纯文字更直观。

六、快挂安全装备模块——强度排行榜

6.1 强度对比条形图

快挂是攀岩安全装备的核心品类,其最关键参数是破断强度(kN)。CLQuickTab 组件使用强度条形图作为首要可视化元素:

Column() {
  Row() {
    Text('💪 强度对比 kN').fontSize(12).fontWeight(FontWeight.Bold).fontColor(CL.text)
    Column().layoutWeight(1)
    Text('数字越大越安全').fontSize(9).fontColor(CL.sub)
  }.width('100%')
  Column() {
    ForEach(clQuickTop(this.quicks), (q: QuickItem, i: number) => {
      Row() {
        Text(q.name).fontSize(10).fontColor(CL.text).width(118).maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Column() {
          Column() {}.height(6).borderRadius(3)
            .width(clStrengthW(q.strength))
            .backgroundColor(clTypeColor(q.type))
          Text(q.strength + 'kN').fontSize(9).fontColor(CL.glow).margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 6 })
      }.width('100%').margin({ top: 8 })
    }, (q: QuickItem) => q.name)
  }.width('100%').margin({ top: 2 })
}.width('92%').borderRadius(14).backgroundColor(CL.card)
  .padding({ left: 12, right: 12, top: 12, bottom: 12 }).margin({ top: 10 })

clQuickTop 按强度降序排列,强度最高的排在最前面。条形图宽度由 clStrengthW(q.strength) 计算——以 28kN 为满刻度。右侧标注 “数字越大越安全”,提醒用户这是安全装备的核心指标。条形图颜色使用 clTypeColor 映射——快挂用蓝色、主锁用橙色、扁带用棕色。

6.2 安全装备排行榜

快挂列表使用排行榜样式,前三名使用奖牌色的排名标签:

ForEach(clFilterQuicks(this.quicks, this.filter), (q: QuickItem, i: number) => {
  Row() {
    Column() {
      Text(i < 3 ? 'TOP' + (i + 1) : '' + (i + 1)).fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor(i === 0 ? '#FFFFFF' : i === 1 ? '#FFFFFF' : i === 2 ? '#FFFFFF' : CL.sub)
        .backgroundColor(i === 0 ? '#D97706' : i === 1 ? '#8A7B5C' : i === 2 ? '#C2562F' : CL.card2)
        .borderRadius(4).padding({ left: 5, right: 5, top: 3, bottom: 3 })
      Column() {}.width(3).height(18).borderRadius(2)
        .backgroundColor(clTypeColor(q.type)).margin({ top: 6 })
        .translate({ x: clSwingX(this.wave, i) })
    }.alignItems(HorizontalAlign.Center)
    Column() {
      Text(q.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.text)
      Row() {
        Text(q.type).fontSize(9).fontColor('#FFFFFF')
          .backgroundColor(clTypeColor(q.type)).borderRadius(4)
          .padding({ left: 4, right: 4 })
        Text(q.brand).fontSize(9).fontColor(CL.sub).margin({ left: 6 })
      }.margin({ top: 4 })
      Row() {
        Text('强度 ' + q.strength + 'kN').fontSize(9).fontColor(CL.text)
        Text('已售 ' + clNum(q.sale)).fontSize(9).fontColor(CL.sub).margin({ left: 10 })
      }.margin({ top: 4 })
    }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
    Column() {
      Text(clPrice(q.price)).fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(CL.primary)
      Text(clOff(q.orig, q.price)).fontSize(9).fontColor(CL.glow).margin({ top: 3 })
    }.alignItems(HorizontalAlign.End)
  }.width('100%').borderRadius(14).backgroundColor(CL.card)
    .border({ width: 1, color: CL.line }).padding(10).margin({ bottom: 10 })
    .onClick(() => {
      this.selIdx = clFindQuickIdx(this.quicks, q.name)
      this.showDetail = true
    })
}, (q: QuickItem) => q.name)

排行榜左侧的排名标签使用金棕铜三色奖牌色(#D97706 金、#8A7B5C 银/铜棕、#C2562F 铜橙),非前三名使用灰色。排名标签下方的竖条通过 clSwingX(this.wave, i) 驱动 translate 的 x 轴位移,产生轻微的左右摆动效果——这种动效与安全装备"悬挂在岩壁上"的意象相呼应。

6.3 快挂详情弹窗——UIAA 认证标识

快挂详情弹窗特别增加了 UIAA 认证标识和强度条形图:

Row() {
  Text(this.quicks[this.selIdx].type).fontSize(9).fontColor('#FFFFFF')
    .backgroundColor(clTypeColor(this.quicks[this.selIdx].type)).borderRadius(4)
    .padding({ left: 5, right: 5, top: 2, bottom: 2 })
  Text(this.quicks[this.selIdx].brand).fontSize(9).fontColor(CL.sub).margin({ left: 6 })
  Text('UIAA 认证').fontSize(9).fontColor('#FFFFFF')
    .backgroundColor('#D97706').borderRadius(4)
    .padding({ left: 5, right: 5, top: 2, bottom: 2 }).margin({ left: 6 })
}.margin({ top: 8 })
Text('破断强度').fontSize(10).fontColor(CL.sub).width('100%').margin({ top: 12 })
Row() {
  Column() {}.height(8).borderRadius(4).layoutWeight(1)
    .width(clStrengthW(this.quicks[this.selIdx].strength))
    .backgroundColor(clTypeColor(this.quicks[this.selIdx].type))
  Text(this.quicks[this.selIdx].strength + 'kN').fontSize(11)
    .fontColor(CL.glow).fontWeight(FontWeight.Bold).margin({ left: 8 })
}.width('100%').margin({ top: 4 })

"UIAA 认证"标签使用金色背景(#D97706),是国际攀岩联合会认证的视觉标识。强度条形图在详情弹窗中以更大的 8px 高度展示,配合 kN 数值标签,让用户直观感受到装备的安全等级。三宫格参数区显示单价、累计销量和折扣信息。

安全装备的详情弹窗必须突出安全认证标识和强度参数。将这些信息放在弹窗顶部黄金区域,是降低用户决策焦虑的有效设计。

七、抱石垫模块——一列大卡

7.1 厚度对比与缩放动效

抱石垫模块 CLPadTab 使用厚度对比条形图和一列大卡列表。每张卡片的图标区域使用 clRipple 驱动的缩放动效,模拟垫子的回弹效果:

ForEach(clFilterPads(this.pads, this.filter), (p: PadItem, i: number) => {
  Row() {
    Column() {}.width(84).height(84).borderRadius(14).backgroundColor(CL.card2)
      .overlay(Text(clImg(p.type)).fontSize(30).fontColor(clTypeColor(p.type)))
      .scale({ x: clRipple(this.wave, i) * 0.5 + 0.6, y: clRipple(this.wave, i) * 0.5 + 0.6 })
    Column() {
      Text(p.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(CL.text)
      Row() {
        Text(p.type).fontSize(9).fontColor('#FFFFFF')
          .backgroundColor(clTypeColor(p.type)).borderRadius(4)
          .padding({ left: 4, right: 4 })
        Text(p.brand).fontSize(9).fontColor(CL.sub).margin({ left: 6 })
        Text('厚 ' + p.thick + 'cm').fontSize(9).fontColor(CL.sub).margin({ left: 6 })
      }.margin({ top: 6 })
      Row() {
        Text(clPrice(p.price)).fontSize(15).fontWeight(FontWeight.Bold)
          .fontColor(CL.primary)
        Text(clPrice(p.orig)).fontSize(10).fontColor(CL.sub)
          .decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
        Column().layoutWeight(1)
        Text('售' + clNum(p.sale)).fontSize(9).fontColor(CL.sub)
      }.width('100%').margin({ top: 7 })
    }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 14 })
  }.width('100%').borderRadius(14).backgroundColor(CL.card)
    .border({ width: 1, color: CL.line }).padding(10).margin({ bottom: 10 })
    .onClick(() => {
      this.selIdx = clFindPadIdx(this.pads, p.name)
      this.showDetail = true
    })
}, (p: PadItem) => p.name)

clRipple(this.wave, i) * 0.5 + 0.6 的计算将涟漪值(1~4.33)映射到 0.6~2.76 的缩放范围,使垫子图标产生呼吸式的放大缩小效果——这与抱石垫"受压回弹"的物理特性形成了巧妙的视觉隐喻。

八、系统数据流架构

以下是整个攀岩馆应用的状态流转和组件关系:

0

1

2

3

4

5

@Entry CLApp
入口组件

@State cur
Tab索引

cur === ?

CLHomeTab
首页推荐

CLShoeTab
攀岩鞋

CLChalkTab
镁粉

CLQuickTab
快挂安全

CLPadTab
抱石护具

CLMineTab
个人中心

showCheck
签到弹窗

showCoupon
优惠券弹窗

showCourse
课程表弹窗

showCoach
教练预约弹窗

shoes数组
脚感筛选

showDetail
详情弹窗

showAdd/Edit/Del
CRUD弹窗

quicks数组
强度排序

clStrengthW
强度条形图

UIAA认证标识

pads数组
厚度对比

clRipple缩放
回弹动效

会员卡光泽特效

月度消费柱状图

收藏/订单列表

纯函数工具层
clPrice/clOff/clNum
clBarW/clStrengthW/clThickW
clFeelColor/clTypeColor/clFavColor
clFloatY/clPulse/clRipple/clSwingX

静态数据
SHOE_LIST/CHALK_LIST
QUICK_LIST/PAD_LIST

九、个人中心模块

9.1 岩馆黑卡会员

个人中心 CLMineTab 的会员卡使用三色渐变(深橙→砂岩橙→岩钉蓝),配合光泽扫过特效:

Stack() {
  Column() {
    Row() {
      Text('岩馆黑卡会员').fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor('#FFE3D0')
      Column().layoutWeight(1)
      Text('V3').fontSize(10).fontColor('#FFE3D0')
    }.width('100%')
    Row() {
      Text('剩余积分 2380').fontSize(10).fontColor('#F5E6D3').margin({ top: 10 })
      Column().layoutWeight(1)
      Text('到期 2027-09').fontSize(10).fontColor('#F5E6D3').margin({ top: 10 })
    }.width('100%')
    Row() {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column() {}.width(3).borderRadius(2).backgroundColor('#FFE3D0')
          .opacity(clPulse(this.wave, i)).height(10 + i * 3)
      }, (i: number) => '' + i)
    }.width(90).alignItems(VerticalAlign.Bottom).height(24).margin({ top: 10 })
  }.alignItems(HorizontalAlign.Start).margin({ left: 14, top: 12 })
  Column() {}.width(120).height(120).borderRadius(60).backgroundColor('#FFFFFF')
    .opacity(clShineO(this.wave) * 0.2).position({ x: -40, y: -20 })
}.width('92%').height(108).borderRadius(16)
  .linearGradient({ angle: 120, colors: [['#C2562F', 0], ['#E2723B', 0.55], ['#3D7E9A', 1]] })
  .margin({ top: 12 }).clip(true)

与键盘工坊的黑金会员卡相比,岩馆黑卡使用暖色系渐变(深橙到橙到蓝),白色光斑的透明度上限设为 0.2(键盘工坊为 0.25),因为浅色背景上的光斑不需要太强。底部的音波柱使用 10 + i * 3 的递增高度设计,每根柱子比前一根高 3px,形成阶梯式上升的视觉效果。

9.2 数据统计与消费图表

数据统计区展示收藏数、订单数、打卡次数和积分四个指标:

Row() {
  Column() {
    Text('12').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.primary)
    Text('收藏').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column() {
    Text('6').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
    Text('订单').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column() {
    Text('36').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
    Text('打卡').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column() {
    Text('2380').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.star)
    Text('积分').fontSize(9).fontColor(CL.sub).margin({ top: 3 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
}.width('92%').borderRadius(14).backgroundColor(CL.card)
  .padding({ top: 14, bottom: 14 }).margin({ top: 12 })

攀岩馆的统计指标中有一个独特的"打卡"项——记录用户到馆打卡的次数。这是攀岩馆 O2O 属性的体现,与纯电商的"足迹"概念不同。月度消费柱状图使用 clMonthBar 计算高度,当月使用 CL.primary 砂岩橙高亮,其余月份使用 CL.accent 岩钉蓝半透明显示。

十、技术对比与总结

技术点对比表

技术维度本项目实现方案技术特点适用场景
双主题色板暖色砂岩橙+冷色岩钉蓝暖冷对比,运动感强户外运动品类应用
品类差异化布局鞋=横向大卡/粉=网格/挂=排行/垫=大卡信息密度匹配品类特征多品类垂直电商
安全参数可视化clStrengthW强度条形图+UIAA标识突出安全装备核心指标安全装备品类
课程预约弹窗showCoach+时段Chip选择O2O服务预约能力运动场馆类应用
动画语义化clSwingX悬挂摆动/clRipple回弹缩放动效与品类意象对应品类化动效设计
脚感颜色编码clFeelColor映射舒适/精确/超紧颜色辅助参数辨识多维度参数展示
排行榜奖牌色金棕铜三色TOP标签视觉强化排名竞争感排行榜类列表
CRUD全流程push/splice+@State响应声明式数据操作商品管理功能
会员卡光泽clShineO透明白色圆形高级感视觉标识会员体系展示
浅色主题米沙色背景+白色卡片层次分明不刺眼日常/运动类应用
弹窗层级管理Stack+zIndex(999)+条件渲染无需DialogAPI模态交互系统
纯函数工具层品类特化函数(clStrengthW/clThickW)函数与品类参数绑定多品类参数可视化

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

import { display } from '@kit.ArkUI'

interface ColorPalette {
  bg: string
  card: string
  card2: string
  primary: string
  accent: string
  star: string
  text: string
  sub: string
  line: string
  glow: string
}

const CL: ColorPalette = {
  bg: '#FAF3EA',
  card: '#FFFFFF',
  card2: '#F5E6D3',
  primary: '#E2723B',
  accent: '#3D7E9A',
  star: '#F59E0B',
  text: '#3B322A',
  sub: '#9C8B7A',
  line: '#EAD9C4',
  glow: '#C2562F'
}

interface ShoeItem {
  name: string
  brand: string
  feel: string
  price: number
  orig: number
  sale: number
  stars: number
  size: string
  rubber: number
}

const SHOE_LIST: ShoeItem[] = [
  { name: 'SRT 岩石之魂 攀岩鞋', brand: 'La Sportiva', feel: '精确', price: 899, orig: 1080, sale: 420, stars: 5, size: '40-44', rubber: 4 },
  { name: '幻影 舒适款 攀岩鞋', brand: 'Scarpa', feel: '舒适', price: 699, orig: 820, sale: 560, stars: 5, size: '38-43', rubber: 3 },
  { name: '陡壁 高性能 攀岩鞋', brand: 'Evolv', feel: '超紧', price: 1099, orig: 1280, sale: 260, stars: 5, size: '39-45', rubber: 4 },
  { name: '入门岩 初学者款', brand: 'ClimbX', feel: '舒适', price: 329, orig: 399, sale: 880, stars: 4, size: '37-44', rubber: 3 },
  { name: '女款 柔韧岩鞋', brand: 'Scarpa', feel: '舒适', price: 759, orig: 899, sale: 310, stars: 4, size: '35-40', rubber: 3 },
  { name: '扁平 平衡型 岩鞋', brand: 'La Sportiva', feel: '精确', price: 968, orig: 1120, sale: 190, stars: 5, size: '40-45', rubber: 3 },
  { name: '竞速 薄底 岩鞋', brand: 'Evolv', feel: '超紧', price: 1249, orig: 1450, sale: 140, stars: 5, size: '39-44', rubber: 2 },
  { name: '儿童 迷你岩鞋', brand: 'ClimbX', feel: '舒适', price: 249, orig: 299, sale: 420, stars: 4, size: '30-36', rubber: 3 },
  { name: '全能 岩馆鞋', brand: 'Mad Rock', feel: '精确', price: 538, orig: 628, sale: 350, stars: 4, size: '38-44', rubber: 4 },
  { name: '极限 弯弓岩鞋', brand: 'Tenaya', feel: '超紧', price: 998, orig: 1150, sale: 120, stars: 5, size: '39-44', rubber: 2 }
]

interface ChalkItem {
  name: string
  brand: string
  type: string
  price: number
  orig: number
  sale: number
  weight: number
}

const CHALK_LIST: ChalkItem[] = [
  { name: '纯净镁粉 300g 罐装', brand: 'RockIt', type: '散粉', price: 49, orig: 62, sale: 1200, weight: 300 },
  { name: '镁粉球 200g 防漏袋', brand: 'Meteor', type: '粉球', price: 69, orig: 85, sale: 860, weight: 200 },
  { name: '液体镁粉 100ml', brand: 'ClimbOn', type: '液镁', price: 58, orig: 72, sale: 640, weight: 100 },
  { name: '新手镁粉礼盒 粉+袋', brand: 'RockIt', type: '散粉', price: 118, orig: 148, sale: 520, weight: 500 },
  { name: '无味运动镁粉 500g', brand: 'Friction', type: '散粉', price: 62, orig: 78, sale: 700, weight: 500 },
  { name: '便携镁粉球 双颗装', brand: 'Meteor', type: '粉球', price: 39, orig: 49, sale: 980, weight: 100 },
  { name: '液体粉 防汗款 80ml', brand: 'ClimbOn', type: '液镁', price: 52, orig: 66, sale: 430, weight: 80 },
  { name: '岩馆联名镁粉袋', brand: 'RockIt', type: '粉球', price: 88, orig: 108, sale: 330, weight: 150 }
]

interface QuickItem {
  name: string
  brand: string
  type: string
  price: number
  orig: number
  sale: number
  strength: number
}

const QUICK_LIST: QuickItem[] = [
  { name: '钢丝门快挂 12cm', brand: 'Petzl', type: '快挂', price: 68, orig: 82, sale: 760, strength: 25 },
  { name: '自动锁主锁 防脱', brand: 'DMM', type: '主锁', price: 158, orig: 188, sale: 480, strength: 27 },
  { name: '直门锁 O 型主锁', brand: 'Petzl', type: '主锁', price: 118, orig: 138, sale: 620, strength: 26 },
  { name: '扁带 60cm 尼龙', brand: 'Wild Country', type: '扁带', price: 88, orig: 105, sale: 390, strength: 22 },
  { name: '快挂套装 6 连', brand: 'Black Diamond', type: '快挂', price: 388, orig: 458, sale: 210, strength: 25 },
  { name: '管式保护器', brand: 'Petzl', type: '保护器', price: 138, orig: 168, sale: 350, strength: 20 },
  { name: '梨形主锁 自动锁', brand: 'DMM', type: '主锁', price: 178, orig: 208, sale: 280, strength: 27 },
  { name: '超轻扁带 120cm', brand: 'Wild Country', type: '扁带', price: 128, orig: 148, sale: 260, strength: 23 }
]

interface PadItem {
  name: string
  brand: string
  type: string
  price: number
  orig: number
  sale: number
  thick: number
}

const PAD_LIST: PadItem[] = [
  { name: '专业抱石垫 10cm', brand: 'Ocun', type: '抱石垫', price: 1280, orig: 1480, sale: 160, thick: 10 },
  { name: '轻便折叠垫 6cm', brand: 'Meteor', type: '抱石垫', price: 688, orig: 799, sale: 320, thick: 6 },
  { name: '入门防滑垫 4cm', brand: 'ClimbX', type: '防滑垫', price: 358, orig: 428, sale: 480, thick: 4 },
  { name: '护膝 加厚硅胶', brand: 'Friction', type: '护膝', price: 128, orig: 158, sale: 560, thick: 2 },
  { name: '抱石袋 耐磨帆布', brand: 'RockIt', type: '抱石垫', price: 218, orig: 258, sale: 300, thick: 3 },
  { name: '双层大垫 12cm', brand: 'Ocun', type: '抱石垫', price: 1880, orig: 2180, sale: 90, thick: 12 }
]

interface OrderItem {
  id: string
  name: string
  price: number
  date: string
  status: string
}

const ORDER_LIST: OrderItem[] = [
  { id: 'CL20260823001', name: '入门岩 初学者款', price: 329, date: '08-23', status: '已签收' },
  { id: 'CL20260819002', name: '纯净镁粉 300g 罐装', price: 49, date: '08-19', status: '运输中' },
  { id: 'CL20260811003', name: '直门锁 O 型主锁', price: 118, date: '08-11', status: '已签收' },
  { id: 'CL20260801004', name: '入门防滑垫 4cm', price: 358, date: '08-01', status: '待发货' },
  { id: 'CL20260725005', name: '便携镁粉球 双颗装', price: 39, date: '07-25', status: '已签收' },
  { id: 'CL20260718006', name: '幻影 舒适款 攀岩鞋', price: 699, date: '07-18', status: '退款中' }
]

interface FavItem {
  name: string
  type: string
  price: number
}

const FAV_LIST: FavItem[] = [
  { name: '幻影 舒适款 攀岩鞋', type: '攀岩鞋', price: 699 },
  { name: '镁粉球 200g 防漏袋', type: '镁粉', price: 69 },
  { name: '自动锁主锁 防脱', type: '快挂', price: 158 },
  { name: '轻便折叠垫 6cm', type: '抱石垫', price: 688 },
  { name: 'SRT 岩石之魂 攀岩鞋', type: '攀岩鞋', price: 899 },
  { name: '扁带 60cm 尼龙', type: '快挂', price: 88 }
]

interface HeroCat {
  name: string
  icon: string
  desc: string
}

const HERO_CATS: HeroCat[] = [
  { name: '攀岩鞋', icon: '👟', desc: '舒适/精确/超紧三种脚感,贴合岩壁' },
  { name: '镁粉', icon: '👜', desc: '散粉/粉球/液镁,防滑吸汗持久' },
  { name: '快挂', icon: '🔗', desc: '快挂/主锁/扁带,25kN 以上强度' },
  { name: '抱石垫', icon: '🧱', desc: '专业缓冲垫,落点保护不受伤' }
]

const CL_TABS: string[] = ['首页', '攀岩鞋', '镁粉', '快挂', '抱石垫', '我的']
const CL_TAB_ICONS: string[] = ['🏠', '👟', '👜', '🔗', '🧱', '👤']
const SHOE_FILTER: string[] = ['全部', '舒适', '精确', '超紧']
const CHALK_FILTER: string[] = ['全部', '散粉', '粉球', '液镁']
const QUICK_FILTER: string[] = ['全部', '快挂', '主锁', '扁带', '保护器']
const PAD_FILTER: string[] = ['全部', '抱石垫', '防滑垫', '护膝']
const MONTH_VALS: number[] = [88, 156, 72, 210, 168, 268]
const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']

function clPrice(p: number): string {
  return '¥' + p
}

function clOff(orig: number, price: number): string {
  return Math.round((orig - price) / orig * 100) + '%'
}

function clNum(n: number): string {
  return n > 1000 ? (n / 1000).toFixed(1) + 'k' : '' + n
}

function clBarW(v: number, max: number): string {
  return Math.max(8, Math.round(v / max * 100)) + '%'
}

function clStrengthW(s: number): string {
  return Math.max(8, Math.round(s / 28 * 100)) + '%'
}

function clThickW(t: number): string {
  return Math.max(8, Math.round(t / 12 * 100)) + '%'
}

function clMonthBar(v: number): string {
  return Math.max(8, Math.round(v / 150 * 100)) + '%'
}

function clStatusColor(s: string): string {
  if (s === '已签收') {
    return '#16A34A'
  }
  if (s === '运输中') {
    return '#E2723B'
  }
  if (s === '待发货') {
    return '#D97706'
  }
  return '#DC2626'
}

function clFeelColor(f: string): string {
  if (f === '舒适') {
    return '#3D7E9A'
  }
  if (f === '精确') {
    return '#E2723B'
  }
  return '#C2562F'
}

function clTypeColor(t: string): string {
  if (t === '散粉') {
    return '#3D7E9A'
  }
  if (t === '粉球') {
    return '#E2723B'
  }
  if (t === '液镁') {
    return '#8A7B5C'
  }
  if (t === '快挂') {
    return '#3D7E9A'
  }
  if (t === '主锁') {
    return '#E2723B'
  }
  if (t === '扁带') {
    return '#8A7B5C'
  }
  if (t === '抱石垫') {
    return '#E2723B'
  }
  if (t === '防滑垫') {
    return '#3D7E9A'
  }
  return '#C2562F'
}

function clFavColor(t: string): string {
  if (t === '攀岩鞋') {
    return '#E2723B'
  }
  if (t === '镁粉') {
    return '#3D7E9A'
  }
  if (t === '快挂') {
    return '#8A7B5C'
  }
  return '#C2562F'
}

function clImg(k: string): string {
  if (k === '攀岩鞋') {
    return '👟'
  }
  if (k === '镁粉') {
    return '👜'
  }
  if (k === '快挂') {
    return '🔗'
  }
  if (k === '主锁') {
    return '⭕'
  }
  if (k === '扁带') {
    return '🎗️'
  }
  if (k === '保护器') {
    return '🧰'
  }
  if (k === '抱石垫') {
    return '🧱'
  }
  if (k === '防滑垫') {
    return '🧩'
  }
  return '🛡️'
}

function clFilterShoes(arr: ShoeItem[], t: string): ShoeItem[] {
  let out: ShoeItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].feel === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clFilterChalks(arr: ChalkItem[], t: string): ChalkItem[] {
  let out: ChalkItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].type === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clFilterQuicks(arr: QuickItem[], t: string): QuickItem[] {
  let out: QuickItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].type === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clFilterPads(arr: PadItem[], t: string): PadItem[] {
  let out: PadItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (t === '全部' || arr[i].type === t) {
      out.push(arr[i])
    }
  }
  return out
}

function clShoeTop(arr: ShoeItem[]): ShoeItem[] {
  return arr.slice().sort((a: ShoeItem, b: ShoeItem): number => {
    return b.price - a.price
  }).slice(0, 6)
}

function clChalkTop(arr: ChalkItem[]): ChalkItem[] {
  return arr.slice().sort((a: ChalkItem, b: ChalkItem): number => {
    return b.sale - a.sale
  }).slice(0, 8)
}

function clQuickTop(arr: QuickItem[]): QuickItem[] {
  return arr.slice().sort((a: QuickItem, b: QuickItem): number => {
    return b.strength - a.strength
  }).slice(0, 6)
}

function clPadTop(arr: PadItem[]): PadItem[] {
  return arr.slice().sort((a: PadItem, b: PadItem): number => {
    return b.sale - a.sale
  }).slice(0, 6)
}

function clHotTop(arr: ShoeItem[]): ShoeItem[] {
  return arr.slice().sort((a: ShoeItem, b: ShoeItem): number => {
    return b.sale - a.sale
  }).slice(0, 5)
}

function clMaxShoe(arr: ShoeItem[]): number {
  let m = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].price > m) {
      m = arr[i].price
    }
  }
  return m
}

function clMaxChalk(arr: ChalkItem[]): number {
  let m = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].sale > m) {
      m = arr[i].sale
    }
  }
  return m
}

function clMaxQuick(arr: QuickItem[]): number {
  let m = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].strength > m) {
      m = arr[i].strength
    }
  }
  return m
}

function clMaxPad(arr: PadItem[]): number {
  let m = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].sale > m) {
      m = arr[i].sale
    }
  }
  return m
}

function clMaxHot(arr: ShoeItem[]): number {
  let m = 0
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].sale > m) {
      m = arr[i].sale
    }
  }
  return m
}

function clShoeSizeChips(): string[] {
  return ['38-40', '40-42', '42-44', '44-46']
}

function clChalkTypeChips(): string[] {
  return ['散粉', '粉球', '液镁']
}

function clQuickTypeChips(): string[] {
  return ['快挂', '主锁', '扁带', '保护器']
}

function clPadTypeChips(): string[] {
  return ['抱石垫', '防滑垫', '护膝']
}

function clFloatY(w: number, i: number): number {
  return Math.abs(Math.sin((w + i * 2) / 2.4)) * -5
}

function clPulse(w: number, i: number): number {
  return 0.5 + Math.abs(Math.sin((w + i) / 2.6)) * 0.5
}

function clRipple(w: number, i: number): number {
  return 1 + ((w + i) % 10) / 3
}

function clShineO(w: number): number {
  return Math.abs(Math.sin(w / 7))
}

function clBlink(w: number): number {
  if (w % 24 < 12) {
    return 1
  }
  return 0.25
}

function clSwingX(w: number, i: number): number {
  return Math.sin((w + i) / 3) * 5
}

function clFindShoeIdx(arr: ShoeItem[], name: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      return i
    }
  }
  return 0
}

function clFindChalkIdx(arr: ChalkItem[], name: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      return i
    }
  }
  return 0
}

function clFindQuickIdx(arr: QuickItem[], name: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      return i
    }
  }
  return 0
}

function clFindPadIdx(arr: PadItem[], name: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      return i
    }
  }
  return 0
}

function clFindOrderIdx(arr: OrderItem[], name: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === name) {
      return i
    }
  }
  return 0
}

@Entry
@Component
struct CLApp {
  @State cur: number = 0
  @State search: string = ''

  @Builder modalOverlay() {
    Column() {}.width('100%').height('100%').backgroundColor('#663B322A')
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Column() {
            Text('🧗 攀岩抱石运动馆').fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.primary)
            Text('ROCK CLIMBING HUB').fontSize(9).fontColor(CL.sub).margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Column() {}.width(34).height(34).borderRadius(17).backgroundColor(CL.card2)
          Column() {}.width(34).height(34).borderRadius(17).backgroundColor(CL.card2)
        }.width('94%').padding({ top: 10, bottom: 6 })
        Row() {
          Text('🔍').fontSize(14).margin({ right: 6 })
          Text('搜岩鞋 / 镁粉 / 快挂').fontSize(13).fontColor(CL.sub)
          Column().layoutWeight(1)
          Text('V6').fontSize(11).fontColor(CL.primary).fontWeight(FontWeight.Bold)
        }.width('92%').height(38).borderRadius(19).backgroundColor(CL.card)
        .padding({ left: 14, right: 14 }).border({ width: 1, color: CL.line })
        Column() {
          if (this.cur === 0) {
            CLHomeTab()
          } else if (this.cur === 1) {
            CLShoeTab()
          } else if (this.cur === 2) {
            CLChalkTab()
          } else if (this.cur === 3) {
            CLQuickTab()
          } else if (this.cur === 4) {
            CLPadTab()
          } else {
            CLMineTab()
          }
        }.layoutWeight(1)
        Row() {
          ForEach(CL_TABS, (t: string, i: number) => {
            Column() {
              Column() {}.width(30).height(30).borderRadius(15)
              .backgroundColor(this.cur === i ? CL.primary : CL.card2)
              Text(t).fontSize(10).fontColor(this.cur === i ? CL.primary : CL.sub)
            }.layoutWeight(1).onClick(() => {
              this.cur = i
            })
          }, (t: string) => t)
        }.width('100%').height(62).backgroundColor(CL.card)
        .border({ width: { top: 1 }, color: CL.line })
      }.width('100%').height('100%')

    }.width('100%').height('100%').backgroundColor(CL.bg)
  }
}

@Component
struct CLHomeTab {
  @State wave: number = 0
  @State showCheck: boolean = false
  @State showCoupon: boolean = false
  @State showCourse: boolean = false
  @State showCoach: boolean = false

  aboutToAppear(): void {
    setInterval(() => {
      this.wave = this.wave + 1
    }, 95)
  }

  @Builder modalOverlay() {
    Column() {}.width('100%').height('100%').backgroundColor('#663B322A')
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 岩壁主 banner
          Stack() {
            Column() {
              Row() {
                Text('🧗').fontSize(22)
                Text('抱石挑战赛').fontSize(15).fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF').margin({ left: 6 })
              }
              Text('本周六 14:00 · 岩馆 B 区').fontSize(10).fontColor('#FFE3D0')
                .margin({ top: 8 })
              Row() {
                Text('冠军赢 ¥2000 装备券').fontSize(11).fontColor('#FFFFFF')
                  .backgroundColor('#00000026').borderRadius(10)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              }.margin({ top: 12 })
              Text('🧗').fontSize(20).opacity(0.85)
                .translate({ y: clFloatY(this.wave, 0) }).margin({ left: 250, top: 30 })
              Text('🧗').fontSize(14).opacity(0.7)
                .translate({ y: clFloatY(this.wave, 3) }).margin({ left: 200, top: 6 })
            }.alignItems(HorizontalAlign.Start).margin({ left: 16 })
            Column() {}.width(80).height(80).borderRadius(40).backgroundColor('#FFFFFF')
            .opacity(0.18).scale({ x: clRipple(this.wave, 1), y: clRipple(this.wave, 1) })
            .margin({ left: 240, top: 14 })
          }.width('92%').height(148).borderRadius(18)
          .linearGradient({ angle: 135, colors: [['#E2723B', 0], ['#C2562F', 1]] })
          .margin({ top: 12 })
          // 四类目入口
          Row() {
            ForEach(HERO_CATS, (c: HeroCat, i: number) => {
              Column() {
                Column() {}.width(46).height(46).borderRadius(23).backgroundColor(CL.card2)
                Text(c.name).fontSize(11).fontColor(CL.text).fontWeight(FontWeight.Bold)
                  .margin({ top: 6 })
              }.layoutWeight(1).alignItems(HorizontalAlign.Center)
            }, (c: HeroCat) => c.name)
          }.width('92%').padding({ top: 14, bottom: 14 }).margin({ top: 12 })
          .borderRadius(14).backgroundColor(CL.card)
          // 岩馆人气装备榜
          Column() {
            Row() {
              Text('🔥 岩馆人气装备榜').fontSize(14).fontWeight(FontWeight.Bold).fontColor(CL.text)
              Column().layoutWeight(1)
              Text('按销量').fontSize(10).fontColor(CL.sub)
            }.width('100%')
            Column() {
              ForEach(clHotTop(SHOE_LIST), (s: ShoeItem, i: number) => {
                Row() {
                  Text('TOP' + (i + 1)).fontSize(9).fontColor(i === 0 ? '#FFFFFF' : CL.sub)
                    .backgroundColor(i === 0 ? CL.primary : CL.card2).borderRadius(4)
                    .padding({ left: 4, right: 4, top: 2, bottom: 2 }).width(40).textAlign(TextAlign.Center)
                  Column() {
                    Text(s.name).fontSize(11).fontColor(CL.text).fontWeight(FontWeight.Bold)
                    Row() {
                      Column() {}.height(6).borderRadius(3)
                      .width(clBarW(s.sale, clMaxHot(SHOE_LIST)))
                      .backgroundColor(CL.accent)
                      Column().layoutWeight(1)
                      Text(clNum(s.sale)).fontSize(9).fontColor(CL.sub)
                    }.width('100%').margin({ top: 4 })
                  }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
                }.width('100%').margin({ top: 10 })
              }, (s: ShoeItem) => s.name)
            }.width('100%').margin({ top: 4 })
          }.width('92%').borderRadius(14).backgroundColor(CL.card)
          .padding({ left: 12, right: 12, top: 12, bottom: 12 }).margin({ top: 12 })
          // 课程卡
          Column() {
            Row() {
              Text('📋 本周课程').fontSize(14).fontWeight(FontWeight.Bold).fontColor(CL.text)
              Column().layoutWeight(1)
              Text('全部课程 >').fontSize(10).fontColor(CL.sub).onClick(() => {
                this.showCourse = true
              })
            }.width('100%')
            Row() {
              Column() {
                Text('周三 19:00').fontSize(10).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('新手抱石入门').fontSize(9).fontColor('#FFE3D0').margin({ top: 5 })
                Text('教练 · 阿岩').fontSize(8).fontColor('#FFE3D0').margin({ top: 4 })
              }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
              Text('预约').fontSize(10).fontColor('#FFFFFF').backgroundColor('#C2562F')
                .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .margin({ right: 12 }).onClick(() => {
                this.showCoach = true
              })
            }.width('100%').borderRadius(12).backgroundColor(CL.primary).padding({ top: 10, bottom: 10 })
            .margin({ top: 10 })
          }.width('92%').borderRadius(14).backgroundColor(CL.card)
          .padding({ left: 12, right: 12, top: 12, bottom: 12 }).margin({ top: 12 })
          // 优惠券入口
          Row() {
            Column() {
              Text('🎁 新人岩友礼包').fontSize(12).fontWeight(FontWeight.Bold).fontColor(CL.text)
              Text('满299减40 · 全场通用').fontSize(9).fontColor(CL.sub).margin({ top: 4 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 14 })
            Text('领取').fontSize(11).fontColor('#FFFFFF').backgroundColor(CL.accent)
              .borderRadius(12).padding({ left: 12, right: 12, top: 5, bottom: 5 }).margin({ right: 14 })
              .onClick(() => {
                this.showCoupon = true
              })
          }.width('92%').height(56).borderRadius(14).backgroundColor(CL.card2)
          .margin({ top: 12 }).alignItems(VerticalAlign.Center)
          Row() {
            Column() {
              Text('📅 岩馆签到').fontSize(12).fontWeight(FontWeight.Bold).fontColor(CL.text)
              Text('到馆打卡领积分').fontSize(9).fontColor(CL.sub).margin({ top: 4 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 14 })
            Text('打卡').fontSize(11).fontColor('#FFFFFF').backgroundColor(CL.primary)
              .borderRadius(12).padding({ left: 12, right: 12, top: 5, bottom: 5 }).margin({ right: 14 })
              .onClick(() => {
                this.showCheck = true
              })
          }.width('92%').height(56).borderRadius(14).backgroundColor(CL.card)
          .border({ width: 1, color: CL.line }).margin({ top: 10 }).alignItems(VerticalAlign.Center)
          Column() {}.height(10).width('100%')
        }.width('100%')
      }.layoutWeight(1).scrollBar(BarState.Off)
      // 签到弹框
      if (this.showCheck) {
        Stack() {

          Column() {
            Text('📅 到馆打卡').fontSize(17).fontWeight(FontWeight.Bold).fontColor(CL.text)
            Text('连续打卡 5 天送 50 积分').fontSize(10).fontColor(CL.sub).margin({ top: 4 })
            Row() {
              ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
                Column() {
                  Column() {}.width(34).height(34).borderRadius(17)
                  .backgroundColor(i < 3 ? CL.primary : CL.card2)
                  Text('D' + (i + 1)).fontSize(8).fontColor(CL.sub).margin({ top: 4 })
                }.layoutWeight(1).alignItems(HorizontalAlign.Center)
              }, (i: number) => '' + i)
            }.width('100%').margin({ top: 14 })
            Text('今日打卡 +10 积分').fontSize(11).fontColor(CL.primary).margin({ top: 12 })
            Text('立即打卡').fontSize(14).fontColor('#FFFFFF').width('100%')
              .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
              .backgroundColor(CL.primary).borderRadius(22).margin({ top: 16 }).onClick(() => {
              this.showCheck = false
            })
            Text('打卡规则:每天限一次,积分可兑换装备券').fontSize(9).fontColor(CL.sub)
              .margin({ top: 10 }).textAlign(TextAlign.Center)
          }.width('82%').borderRadius(16).backgroundColor(CL.card)
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .constraintSize({ maxHeight: '78%' })
        }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
      }
      // 优惠券弹框
      if (this.showCoupon) {
        Stack() {

          Column() {
            Text('🎁 新人岩友礼包').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
            Text('注册 7 天内领取,全场通用').fontSize(10).fontColor(CL.sub).margin({ top: 4 })
            Column() {
              Row() {
                Text('¥40').fontSize(26).fontWeight(FontWeight.Bold).fontColor(CL.primary)
                Text('满299可用 · 鞋类通用').fontSize(11).fontColor(CL.sub).margin({ left: 8 })
              }
              Text('有效期 30 天').fontSize(10).fontColor(CL.glow).margin({ top: 6 })
            }.width('100%').borderRadius(12).backgroundColor('#FDEBD2')
            .padding({ top: 14, bottom: 14 }).margin({ top: 12 })
            Column() {
              Row() {
                Text('¥100').fontSize(26).fontWeight(FontWeight.Bold).fontColor(CL.primary)
                Text('满699可用 · 装备通用').fontSize(11).fontColor(CL.sub).margin({ left: 8 })
              }
              Text('有效期 30 天').fontSize(10).fontColor(CL.glow).margin({ top: 6 })
            }.width('100%').borderRadius(12).backgroundColor('#FDEBD2')
            .padding({ top: 14, bottom: 14 }).margin({ top: 10 })
            Text('立即领取').fontSize(14).fontColor('#FFFFFF').width('100%')
              .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
              .backgroundColor(CL.primary).borderRadius(22).margin({ top: 16 }).onClick(() => {
              this.showCoupon = false
            })
          }.width('82%').borderRadius(16).backgroundColor(CL.card)
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .constraintSize({ maxHeight: '78%' })
        }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
      }
      // 课程表弹框
      if (this.showCourse) {
        Stack() {

          Column() {
            Text('📋 岩馆课程表').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
            Column() {
              Row() {
                Text('周一').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
                Text('速度攀爬训练').fontSize(10).fontColor(CL.sub)
                Column().layoutWeight(1)
                Text('19:00').fontSize(10).fontColor(CL.primary)
              }.width('100%').padding({ top: 8, bottom: 8 })
              Row() {
                Text('周三').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
                Text('新手抱石入门').fontSize(10).fontColor(CL.sub)
                Column().layoutWeight(1)
                Text('19:00').fontSize(10).fontColor(CL.primary)
              }.width('100%').padding({ top: 8, bottom: 8 })
              Row() {
                Text('周五').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
                Text('难度线进阶').fontSize(10).fontColor(CL.sub)
                Column().layoutWeight(1)
                Text('20:00').fontSize(10).fontColor(CL.primary)
              }.width('100%').padding({ top: 8, bottom: 8 })
              Row() {
                Text('周六').fontSize(10).fontWeight(FontWeight.Bold).fontColor(CL.text).width(44)
                Text('抱石挑战赛').fontSize(10).fontColor(CL.sub)
                Column().layoutWeight(1)
                Text('14:00').fontSize(10).fontColor(CL.primary)
              }.width('100%').padding({ top: 8, bottom: 8 })
            }.width('100%').borderRadius(12).backgroundColor(CL.card2)
            .padding({ left: 12, right: 12 }).margin({ top: 12 })
            Text('知道了').fontSize(14).fontColor('#FFFFFF').width('100%')
              .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
              .backgroundColor(CL.accent).borderRadius(22).margin({ top: 14 }).onClick(() => {
              this.showCourse = false
            })
          }.width('82%').borderRadius(16).backgroundColor(CL.card)
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .constraintSize({ maxHeight: '80%' })
        }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
      }
      // 教练预约弹框
      if (this.showCoach) {
        Stack() {

          Column() {
            Text('🧗 教练预约').fontSize(16).fontWeight(FontWeight.Bold).fontColor(CL.text)
            Row() {
              Column() {}.width(52).height(52).borderRadius(26).backgroundColor('#FDEBD2')
              Column() {
                Text('阿岩 · 金牌教练').fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.text)
                Text('8 年攀龄 · 2000+ 学员').fontSize(9).fontColor(CL.sub).margin({ top: 4 })
              }.alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)
            }.width('100%').margin({ top: 12 })
            Text('可预约时段').fontSize(10).fontColor(CL.sub).width('100%').margin({ top: 10 })
            Row() {
              ForEach(['18:00', '19:00', '20:00', '21:00'], (t: string) => {
                Text(t).fontSize(10).fontColor(CL.primary).backgroundColor('#FDEBD2')
                  .borderRadius(12).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .margin({ right: 6 })
              }, (t: string) => t)
            }.width('100%').margin({ top: 6 })
            Text('确认预约').fontSize(14).fontColor('#FFFFFF').width('100%')
              .textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 })
              .backgroundColor(CL.primary).borderRadius(22).margin({ top: 14 }).onClick(() => {
              this.showCoach = false
            })
          }.width('84%').borderRadius(16).backgroundColor(CL.card)
          .padding({ left: 16, right: 16, top: 16, bottom: 16 })
          .constraintSize({ maxHeight: '74%' })
        }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
      }

    }.width('100%').height('100%')
  }
}

@Component
struct CLShoeTab {
  @State shoes: ShoeItem[] = []
  @State wave: number = 0
  @State filter: string = '全部'
  @State selIdx: number = -1
  @State showDetail: boolean = false
  @State showAdd: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  @State addName: string = '入门岩 初学者款'
  @State addFeel: string = '舒适'
  @State addSize: string = '40-42'
  @State addPrice: string = '329'
  @State editName: string = ''
  @State editBrand: string = ''
  @State editFeel: string = '舒适'
  @State editPrice: string = ''

  aboutToAppear(): void {
    this.shoes = SHOE_LIST.slice()
    setInterval(() => {
      this.wave = this.wave + 1
    }, 115)
  }

  @Builder modalOverlay() {
    Column() {}.width('100%').height('100%').backgroundColor('#663B322A')
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Text('👟 攀岩鞋馆').fontSize(15).fontWeight(FontWeight.Bold).fontColor(CL.text)
          Column().layoutWeight(1)
          Text('+ 上新岩鞋').fontSize(11).fontColor('#FFFFFF').backgroundColor(CL.primary)
            .borderRadius(13).padding({ left: 10, right: 10, top: 5, bottom: 5 }).onClick(() => {
            this.showAdd = true
          })
        }.width('92%').margin({ top: 10 })
        // 价格对比 TOP6 条形图
        Column() {
          Row() {
            Text('📊 岩鞋价格对比').fontSize(12).fontWeight(FontWeight.Bold).fontColor(CL.text)
            Column().layoutWeight(1)
            Text('单位 ¥').fontSize(9).fontColor(CL.sub)
          }.width('100%')
          Column() {
            ForEach(clShoeTop(this.shoes), (s: ShoeItem, i: number) => {
              Row() {
                Text(s.name).fontSize(10).fontColor(CL.text).width(118).maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Column() {
                  Column() {}.height(6).borderRadius(3)
                  .width(clBarW(s.price, clMaxShoe(this.shoes)))
                  .backgroundColor(CL.primary)
                  Text('¥' + s.price).fontSize(9).fontColor(CL.glow).margin({ top: 2 })
                }.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 6 })
              }.width('100%').margin({ top: 8 })
            }, (s: ShoeItem) => s.name)
          }.width('100%').margin({ top: 2 })
        }.width('92%').borderRadius(14).backgroundColor(CL.card)
        .padding({ left: 12, right: 12, top: 12, bottom: 12 }).margin({ top: 10 })
        // 脚感筛选
        Scroll() {
          Row() {
            ForEach(SHOE_FILTER, (t: string) => {
              Text(t).fontSize(11).fontColor(this.filter === t ? '#FFFFFF' : CL.sub)
                .backgroundColor(this.filter === t ? CL.primary : CL.card2)
                .borderRadius(14).padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .margin({ right: 8 }).onClick(() => {
                this.filter = t
              })
            }, (t: string) => t)
          }.padding({ left: 12, right: 12 })
        }.height(38).scrollBar(BarState.Off).width('100%').margin({ top: 10 })
        // 岩鞋大卡列表
        Scroll() {
          Column() {
            ForEach(clFilterShoes(this.shoes, this.filter), (s: ShoeItem, i: number) => {
              Row() {
                Column() {}.width(76).height(76).borderRadius(14).backgroundColor(CL.card2)
                .translate({ y: clFloatY(this.wave, i) })
                Column() {
                  Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(CL.text)
                    .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row() {
                    Text(s.feel).fontSize(9).fontColor('#FFFFFF')
                      .backgroundColor(clFeelColor(s.feel)).borderRadius(4)
                      .padding({ left: 4, right: 4 })
                    Text(s.size + ' 码').fontSize(9).fontColor(CL.sub).margin({ left: 6 })
                
              this.showAbout = false
            })
          }.width('82%').borderRadius(16).backgroundColor(CL.card)
          .padding({ left: 18, right: 18, top: 18, bottom: 18 })
          .constraintSize({ maxHeight: '80%' })
        }.position({ x: 0, y: 0 }).zIndex(999).width('100%').height('100%')
      }

    }.width('100%').height('100%')
  }
}

在这里插入图片描述

总结

本文以攀岩抱石运动馆装备商城为例,深入剖析了 HarmonyOS ArkTS 在垂直运动品类电商场景下的工程实践。从岩壁砂岩橙×岩钉蓝双主题色板的设计理念,到攀岩鞋的脚感颜色编码、快挂的强度条形图与 UIAA 认证标识、抱石垫的厚度对比与回弹缩放动效,每一个技术细节都体现了"品类差异化"的设计思想——不同品类的商品使用不同的列表布局、不同的可视化图表和不同的动画语义,让界面为品类服务而非千篇一律。

在工程架构层面,本项目展示了几个值得借鉴的实践:第一,纯函数工具层包含品类特化函数(如 clStrengthW 以 28kN 为基准、clThickW 以 12cm 为基准),使图表刻度与品类实际值域匹配;第二,快挂列表使用 clSwingX 驱动的横向摆动动效,抱石垫使用 clRipple 驱动的回弹缩放动效,让动画语义与品类物理特性对应;第三,首页集成了课程预约和教练预约功能,使应用从纯电商扩展为 O2O 服务预约平台;第四,详情弹窗中安全装备的 UIAA 认证标识和强度条形图被放在黄金区域,降低了用户的决策焦虑。

展望未来,攀岩馆应用可以进一步引入实时岩馆容量监测、攀岩路线难度可视化、AI 装备推荐等功能。在技术层面,可以引入 @Observed 管理嵌套商品对象的深度响应、使用 Navigation 实现页面路由和转场动画、接入 animateTo 优化 60fps 动画体验。但核心的"品类差异化布局 + 纯函数工具层 + 组件化拆分 + 状态驱动弹窗"架构理念,在 HarmonyOS ArkTS 生态中始终是构建垂直品类应用的基础范式。希望本文的逐段拆解能够帮助开发者在自己的运动品类应用中实现品类化设计与技术工程的深度融合。

Logo

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

更多推荐