一、HarmonyOS技术生态与ArkTS开发范式概述

HarmonyOS作为华为面向全场景智慧生活打造的分布式操作系统,经过多年迭代,在HarmonyOS 6.1.1版本中已经形成了极为成熟的开发者生态。ArkTS作为HarmonyOS应用开发的主力语言,在TypeScript的基础上进行了深度定制和优化,引入了ArkUI声明式框架,使得开发者能够以极简的代码描述复杂的UI结构。在API 24版本中,ArkTS的语言能力和ArkUI框架的组件体系已经覆盖了几乎所有常见的移动应用交互场景,从基础的列表渲染到复杂的动画效果,从简单的状态管理到多组件间的数据同步,都能够通过声明式语法优雅地实现。

在本地生活服务领域,美团类应用是最具代表性的场景类型。这类应用的核心特征包括:多维度的内容分类(如靶道、教练、装备、赛事等)、丰富的列表展示需求、弹框交互的预约流程、会员体系与积分管理。百步穿杨室内射箭馆场景正是这样一个典型的美团类应用,它涵盖了靶道预订、教练预约、装备租售、赛事报名、段位天梯、团建套餐、会员体系等七个核心业务板块,通过箭靶环式的双排标签栏(4+3布局)组织内容,通过深灰金色调(#263238/#F9A825)传达射箭运动的专业沉稳气质。

ArkUI声明式开发的核心优势在于数据驱动的UI更新机制。开发者只需声明UI组件与状态变量之间的依赖关系,当状态变量发生变化时,框架会自动计算最小更新范围并执行精准的UI刷新。这种机制在射箭馆场景中尤为重要——当用户切换标签、切换收藏、调整步进器数值或打开弹框时,@State装饰器标记的状态变量会触发对应UI区域的定向刷新,而不是整个页面的重建,这保证了复杂交互场景下的流畅性能表现。

在视觉设计层面,射箭馆场景采用了深灰色#263238作为主色调,这种接近炭黑的颜色传达了射箭运动所需的专业、沉稳、专注的气质。辅助色#F9A825是一种明亮的金色,呼应了箭靶靶心金色的视觉符号,同时用于选中状态的强调和金色文字标识。色彩搭配方案中还包含了accentSoft浅金色#FEF3D6用于标签背景、gold深金色#C99700用于评分和段位标识,形成了从深到浅的金色梯度,与深灰主色形成了高对比度的视觉层次。

从架构设计角度来看,该场景采用了interface接口定义与@Observed类implements的设计模式。六组数据模型分别定义了靶道、教练、装备、赛事、段位排行和团建套餐的业务实体,每组模型通过interface声明字段契约,通过@Observed修饰的类实现响应式状态管理。这种模式使得数据模型具有类型安全性和响应式更新的双重优势,为列表渲染的动态更新提供了可靠的数据基础。

二、ColorPalette色彩系统设计

色彩系统的设计是射箭馆场景视觉风格统一的基础。通过interface约束和const实例化,确保了所有颜色引用的类型安全。

interface ColorPalette {
  bg: string
  card: string
  white: string
  primary: string
  primaryDeep: string
  accent: string
  accentSoft: string
  gold: string
  textPrimary: string
  textSecond: string
  textThird: string
  line: string
  tagBg: string
  warn: string
  danger: string
  ok: string
}

在这里插入图片描述

ColorPalette接口定义了16个颜色字段,涵盖了背景、卡片、主色系、辅助色系、文字层级、分隔线、标签背景和状态色。这种完整的色彩字段定义确保了应用中所有需要颜色的地方都能从统一来源获取,避免了硬编码色值散落在各处导致的风格不一致问题。

const COLORS: ColorPalette = {
  bg: '#F2F4F5',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#263238',
  primaryDeep: '#102027',
  accent: '#F9A825',
  accentSoft: '#FEF3D6',
  gold: '#C99700',
  textPrimary: '#232B30',
  textSecond: '#5C6B72',
  textThird: '#98A6AD',
  line: '#E6EAEC',
  tagBg: '#EDF1F3',
  warn: '#E65100',
  danger: '#C62828',
  ok: '#2E7D32'
}

主色#263238是Material Design中的Blue Grey 900色值,接近于深灰偏蓝的色调,传达了射箭运动的专业沉稳感。深色变体#102027用于渐变收尾和底部导航的选中状态。辅助色#F9A825是Material Design中的Amber色值,模拟了箭靶靶心的金色,用于选中强调和按钮背景。文字三档色值(#232B30、#5C6B72、#98A6AD)通过明度差异实现了信息层级的视觉区分。背景色#F2F4F5是一种极浅的冷灰色,营造了室内射箭馆的环境氛围。状态色warn橙色#E65100用于繁忙状态提示,danger红色#C62828用于取消操作,ok绿色#2E7D32用于空闲和报名中状态。

三、数据模型体系:六组interface与@Observed类

射箭馆场景定义了六组数据模型,分别对应七个内容板块中的核心实体。每组模型都遵循interface定义字段契约、@Observed类implements实现响应式状态的模式。

首先是靶道模型Lane和LaneItem:

interface Lane {
  id: number
  name: string
  type: string
  price: number
  meters: number
  busy: boolean
  fav: boolean
}

@Observed
class LaneItem implements Lane {
  id: number = 0
  name: string = ''
  type: string = ''
  price: number = 0
  meters: number = 0
  busy: boolean = false
  fav: boolean = false

  constructor(o: Lane) {
    this.id = o.id
    this.name = o.name
    this.type = o.type
    this.price = o.price
    this.meters = o.meters
    this.busy = o.busy ? o.busy : false
    this.fav = o.fav ? o.fav : false
  }
}

Lane接口设计了七个字段:唯一标识id、靶道名称name(如"A1 反曲靶道")、弓种类型type(反曲弓/复合弓/传统弓等)、价格price、距离meters、繁忙状态busy和收藏标记fav。busy字段是该模型的核心设计——它直接驱动UI中的状态显示逻辑:当busy为true时,靶道卡片显示"使用中"标签,预订按钮变为灰色不可用状态。构造函数中busy和fav两个布尔字段都采用了o.xxx ? o.xxx : false的安全赋值模式。

教练模型Coach和CoachItem的设计侧重于教练的专业资质:

interface Coach {
  id: number
  name: string
  title: string
  years: number
  price: number
  students: number
  fav: boolean
}

@Observed
class CoachItem implements Coach {
  id: number = 0
  name: string = ''
  title: string = ''
  years: number = 0
  price: number = 0
  students: number = 0
  fav: boolean = false

  constructor(o: Coach) {
    this.id = o.id
    this.name = o.name
    this.title = o.title
    this.years = o.years
    this.price = o.price
    this.students = o.students
    this.fav = o.fav ? o.fav : false
  }
}

在这里插入图片描述

Coach接口包含了教练职称title(如"国家级教练"“反曲专项”"传统弓师范"等)、执教年限years、学员数量students和课时价格price。title字段的设计将教练按专业方向进行了细分,使不同兴趣方向的射箭爱好者都能找到匹配的教练。students字段为用户选择教练提供了量化参考——学员数量越多通常意味着教练的教学经验和口碑越好。

装备模型Gear和GearItem聚焦于装备租赁业务:

interface Gear {
  id: number
  name: string
  brand: string
  price: number
  stock: number
  fav: boolean
}

@Observed
class GearItem implements Gear {
  id: number = 0
  name: string = ''
  brand: string = ''
  price: number = 0
  stock: number = 0
  fav: boolean = false

  constructor(o: Gear) {
    this.id = o.id
    this.name = o.name
    this.brand = o.brand
    this.price = o.price
    this.stock = o.stock
    this.fav = o.fav ? o.fav : false
  }
}

在这里插入图片描述

Gear接口的设计特点是引入了brand品牌字段和stock库存字段。brand字段(如SF、WIAWIS、Bear、Easton等)为专业用户提供了装备品质的参考依据。stock字段在UI中通过颜色区分显示库存状态——当库存低于5时显示warn橙色提醒,高于5时显示ok绿色正常状态,这种条件渲染设计使库存信息一目了然。

赛事模型MatchInfo和MatchInfoItem的设计包含了赛事的全生命周期信息:

interface MatchInfo {
  id: number
  title: string
  date: string
  entryFee: number
  prize: string
  open: boolean
}

@Observed
class MatchInfoItem implements MatchInfo {
  id: number = 0
  title: string = ''
  date: string = ''
  entryFee: number = 0
  prize: string = ''
  open: boolean = false

  constructor(o: MatchInfo) {
    this.id = o.id
    this.title = o.title
    this.date = o.date
    this.entryFee = o.entryFee
    this.prize = o.prize
    this.open = o.open ? o.open : false
  }
}

在这里插入图片描述

MatchInfo接口的prize字段设计为string类型而非number,这是因为赛事奖品的描述形式多样——有的是金额(“¥3000奖池”)、有的是实物(“箭支套装”)、有的是权益(“冠军免单半年”),使用字符串类型能够灵活容纳这些差异化的奖品描述。open布尔字段控制报名按钮的状态:为true时按钮可用且显示"报名中"绿色标签,为false时按钮灰色不可用且显示"已截止"灰色标签。

段位排行模型Ranker和RankerItem以及团建套餐模型TeamPack和TeamPackItem:

interface Ranker {
  id: number
  name: string
  score: number
  avgRing: number
  club: string
}

@Observed
class RankerItem implements Ranker {
  id: number = 0
  name: string = ''
  score: number = 0
  avgRing: number = 0
  club: string = ''

  constructor(o: Ranker) {
    this.id = o.id
    this.name = o.name
    this.score = o.score
    this.avgRing = o.avgRing
    this.club = o.club
  }
}

interface TeamPack {
  id: number
  name: string
  people: number
  price: number
  includes: string
}

@Observed
class TeamPackItem implements TeamPack {
  id: number = 0
  name: string = ''
  people: number = 0
  price: number = 0
  includes: string = ''

  constructor(o: TeamPack) {
    this.id = o.id
    this.name = o.name
    this.people = o.people
    this.price = o.price
    this.includes = o.includes
  }
}

Ranker接口包含了选手昵称name、积分score、平均环数avgRing和所属俱乐部club。avgRing字段是一个精度为小数点后一位的浮点数(如9.4、8.8等),直观反映了选手的射击精度水平。TeamPack接口的includes字段同样设计为string类型,容纳了套餐内容的描述(如"4条靶道+60支箭+饮品"),使得套餐信息以结构化的方式呈现。这两个模型都没有布尔字段,因此构造函数中不涉及安全赋值模式。

四、静态数据数组与业务常量

射箭馆场景为六组数据模型分别提供了丰富的静态模拟数据,这些数据在UI中通过ForEach渲染为列表或网格。

靶道数据数组LANES提供了12条不同类型的靶道:

const LANES: LaneItem[] = [
  new LaneItem({ id: 1, name: 'A1 反曲靶道', type: '反曲弓', price: 58, meters: 18, busy: false, fav: true }),
  new LaneItem({ id: 2, name: 'A2 反曲靶道', type: '反曲弓', price: 58, meters: 18, busy: true, fav: false }),
  new LaneItem({ id: 3, name: 'B1 复合靶道', type: '复合弓', price: 78, meters: 25, busy: false, fav: false }),
  new LaneItem({ id: 4, name: 'B2 复合靶道', type: '复合弓', price: 78, meters: 25, busy: false, fav: false }),
  new LaneItem({ id: 5, name: 'C1 传统靶道', type: '传统弓', price: 48, meters: 12, busy: false, fav: false }),
  new LaneItem({ id: 6, name: 'C2 传统靶道', type: '传统弓', price: 48, meters: 12, busy: true, fav: false }),
  new LaneItem({ id: 7, name: 'D1 撒放体验道', type: '撒放器', price: 68, meters: 15, busy: false, fav: false }),
  new LaneItem({ id: 8, name: 'D2 儿童软弹道', type: '儿童弓', price: 38, meters: 8, busy: false, fav: false }),
  new LaneItem({ id: 9, name: 'E1 VIP包间', type: '全能', price: 128, meters: 30, busy: false, fav: false }),
  new LaneItem({ id: 10, name: 'E2 VIP包间', type: '全能', price: 128, meters: 30, busy: true, fav: false }),
  new LaneItem({ id: 11, name: 'F1 夜光赛道', type: '反曲弓', price: 88, meters: 18, busy: false, fav: false }),
  new LaneItem({ id: 12, name: 'F2 对抗赛道', type: '复合弓', price: 98, meters: 25, busy: false, fav: false })
]

靶道数据的设计体现了射箭馆的专业分级体系:A系列反曲靶道(18米)适合奥运标准训练,B系列复合靶道(25米)适合远距离射击,C系列传统靶道(12米)适合新手体验,D系列包含了撒放器体验道和儿童软弹道,E系列VIP包间提供30米全能靶道,F系列则包含了夜光赛道和对抗赛道等特色项目。价格从38元到128元,距离从8米到30米,满足了从儿童体验到专业训练的全场景需求。

业务常量的定义同样体现了场景的专业性:

const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']
const WEEK_HITS: number[] = [62, 78, 55, 84, 96, 132, 118]
const LANE_KINDS: string[] = ['反曲弓', '复合弓', '传统弓', '撒放器', '儿童弓']
const BOW_TAGS: string[] = ['反曲弓', '复合弓', '传统弓', '光弓', '美猎']
const GROUP_TAGS: string[] = ['新手组', '进阶组', '公开组', '女子组', '青少年组']

function weekHitBar(i: number): number {
  return WEEK_HITS[i] * 1.05
}

在这里插入图片描述

WEEK_HITS数组记录了一周七天的每日命中环数,数据呈现出明显的周末高峰特征——周六132环、周日118环明显高于工作日。weekHitBar函数通过乘以1.05的系数微调柱状图高度。LANE_KINDS用于订靶道弹框的弓种选择标签。BOW_TAGS用于弓手档案弹框的常用弓种多选标签,其中"光弓"和"美猎"是射箭运动的细分弓种类型。GROUP_TAGS用于报名排位赛弹框的参赛组别选择,五个组别覆盖了从新手到专业、从青少年到女子的不同参赛人群。

五、组件状态管理与生命周期

PageArcheryHall组件定义了丰富的状态变量,支撑七个内容板块和四个弹框的交互需求。

@Entry
@Component
struct PageArcheryHall {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State laneList: LaneItem[] = LANES
  @State coachList: CoachItem[] = COACHES
  @State gearList: GearItem[] = GEARS
  @State matchList: MatchInfoItem[] = MATCHES
  @State rankList: RankerItem[] = RANKERS
  @State teamList: TeamPackItem[] = TEAMS
  @State rev: number = 0
  @State tick: number = 0
  @State laneTags: number[] = [0]
  @State bowTags: number[] = [0]
  @State groupTags: number[] = [0]
  @State hourStep: number = 1
  @State poundStep: number = 24
  @State guardFlag: boolean = true
  @State peopleStep: number = 1
  private timer: number = -1

在这里插入图片描述

状态变量的设计覆盖了该场景的所有交互维度。六个列表状态变量(laneList到teamList)绑定了对应的@Observed数据数组。laneTags、bowTags、groupTags三个数组类型状态分别用于订靶道弹框的弓种选择、弓手档案弹框的弓种选择和报名排位赛弹框的组别选择。hourStep控制订靶道的时长步进器(1-6小时),poundStep控制弓手档案的磅数步进器(10-50磅,步进2),peopleStep控制报名排位赛的同队人数步进器(1-6人)。guardFlag控制全套护具租用开关。

生命周期管理采用了与场景一致的定时器模式:

aboutToAppear(): void {
  this.timer = setInterval(() => {
    this.tick = this.tick + 1
  }, 120)
}

aboutToDisappear(): void {
  if (this.timer >= 0) {
    clearInterval(this.timer)
  }
}

该场景的定时器间隔为120毫秒,略长于陶艺场景的100毫秒,这种差异虽然细微但体现了不同主题对动画节奏的不同需求——射箭运动的节奏感比陶艺制作更沉稳,因此动画更新频率稍低。

业务方法的设计涵盖了列表轮转、收藏切换和标签多选三种核心交互:

refreshLanes(): void {
  this.laneList.unshift(this.laneList[this.laneList.length - 1])
  this.laneList.splice(this.laneList.length - 1, 1)
  this.rev = this.rev + 1
}

toggleLaneFav(i: number): void {
  this.laneList[i].fav = !this.laneList[i].fav
  this.rev = this.rev + 1
}

toggleTag(list: number[], i: number): void {
  if (list.indexOf(i) >= 0) {
    list.splice(list.indexOf(i), 1)
  } else {
    list.push(i)
  }
}

laneTotal(): number {
  return this.hourStep * 58
}

在这里插入图片描述

refreshLanes方法与陶艺场景的refreshCourses采用了相同的轮转策略。laneTotal方法通过hourStep * 58实时计算订靶道费用,58是反曲靶道的基准价格。这种将计费逻辑封装为方法的设计使得费用计算与UI显示解耦,当计费规则变更时只需修改方法实现而无需调整多处UI代码。

六、fxLayer动画特效与箭靶主题

射箭馆场景的fxLayer动画层设计紧贴射箭主题,通过emoji符号的transform变换模拟了射箭运动的动态元素。

@Builder
fxLayer() {
  Stack() {
    Text('🏹')
      .fontSize(26)
      .translate({ x: this.tick % 40 })
      .position({ x: 40, y: 130 })
      .opacity(0.85)
    Text('🎯')
      .fontSize(24)
      .scale({ x: 0.85 + (this.tick % 10) / 12, y: 0.85 + (this.tick % 10) / 12 })
      .position({ x: 300, y: 220 })
      .opacity(0.85)
    Text('💫')
      .fontSize(18)
      .rotate({ angle: (this.tick % 24) * 15 })
      .position({ x: 130, y: 330 })
      .opacity(0.6)
    Text('✨')
      .fontSize(16)
      .position({ x: 500, y: 120 })
      .opacity((this.tick % 8) / 8 + 0.15)
    Text('🏹')
      .fontSize(18)
      .translate({ x: (this.tick + 20) % 50 })
      .position({ x: 520, y: 400 })
      .opacity(0.5)
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer使用了五个emoji符号:🏹(弓箭)出现两次,第一个做水平位移模拟箭矢飞行轨迹,第二个使用(this.tick + 20) % 50的偏移量实现了与第一个错位的飞行效果;🎯(靶心)做缩放呼吸动画模拟命中靶心时的震动效果,缩放范围通过0.85 + (this.tick % 10) / 12控制在0.85到1.68之间;💫(命中闪光)做旋转动画模拟箭矢命中的旋转特效;✨(星光)做透明度闪烁模拟箭靶反光。hitTestBehavior(HitTestMode.None)确保了动画层不拦截触摸事件。

七、header头部与箭靶环式ringTab

射箭馆场景的头部设计采用了深灰渐变背景,配合金色辅助色形成专业沉稳的视觉基调。

@Builder
header() {
  Column() {
    Row() {
      Text('🎯')
        .fontSize(24)
      Text('百步穿杨')
        .fontSize(21)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
      Text('📍武汉·光谷')
        .fontSize(12)
        .fontColor('#CDD6DB')
      Text('🔔')
        .fontSize(19)
        .margin({ left: 12 })
        .onClick(() => {
          this.mainTab = 3
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12 })

    Row() {
      Text('🔍')
        .fontSize(15)
      Text('搜靶道 / 教练 / 赛事')
        .fontSize(13)
        .fontColor('#B4C0C6')
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
      Text('搜索')
        .fontSize(13)
        .fontColor(COLORS.primaryDeep)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .backgroundColor(COLORS.accent)
        .borderRadius(14)
        .onClick(() => {
          this.curTab = 0
        })
    }
    .width('100%')
    .padding({ left: 10, right: 10 })
    .margin({ top: 10 })
    .backgroundColor('rgba(255,255,255,0.16)')
    .borderRadius(20)

    Row({ space: 8 }) {
      Text('首次体验29.9')
        .fontSize(11)
        .fontColor(COLORS.primaryDeep)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor(COLORS.accent)
        .borderRadius(12)
      Text('免费教学15min')
        .fontSize(11)
        .fontColor(COLORS.white)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor('rgba(255,255,255,0.22)')
        .borderRadius(12)
      Text('装备全包')
        .fontSize(11)
        .fontColor(COLORS.white)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor('rgba(255,255,255,0.22)')
        .borderRadius(12)
      Text('赛事报名中')
        .fontSize(11)
        .fontColor(COLORS.white)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor('rgba(255,255,255,0.22)')
        .borderRadius(12)
    }
    .width('100%')
    .padding({ left: 16, right: 16 })
    .margin({ top: 10, bottom: 14 })
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
  .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], ['#37474F', 1]] })
}

在这里插入图片描述

头部渐变从135度方向的#263238渐变到#37474F,营造出深灰色由深到浅的层次感。搜索框中的"搜索"按钮使用了accent金色背景配合primaryDeep深色文字,与陶艺场景中白色文字的做法不同,这种设计利用了金色在深色背景上的高对比度特性。促销标签行中,"首次体验29.9"使用了金色背景和深色文字形成主推效果,其余三个标签使用半透明白色背景保持视觉统一。

ringTab是射箭馆场景的核心布局特色——箭靶环式标签设计:

@Builder
ringTab(name: string, icon: string, idx: number) {
  Column() {
    Stack() {
      Circle({ width: 34, height: 34 })
        .fill(this.curTab === idx ? COLORS.accent : COLORS.tagBg)
      Circle({ width: 22, height: 22 })
        .fill(this.curTab === idx ? COLORS.primaryDeep : COLORS.card)
      Text(icon)
        .fontSize(13)
    }
    Text(name)
      .fontSize(11)
      .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(this.curTab === idx ? COLORS.accent : COLORS.textSecond)
      .margin({ top: 3 })
  }
  .width('23%')
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 8, bottom: 8 })
  .backgroundColor(this.curTab === idx ? COLORS.card : 'rgba(0,0,0,0)')
  .borderRadius(12)
  .onClick(() => {
    this.switchTab(idx)
  })
}

ringTab的设计灵感直接来源于箭靶的同心圆结构。Stack中嵌套了两个Circle:外圈34x34的圆形在选中状态填充accent金色、未选中状态填充tagBg浅灰色;内圈22x22的圆形在选中状态填充primaryDeep深色、未选中状态填充card白色。两层圆形的嵌套形成了箭靶靶环的视觉效果,中心的emoji图标则模拟了靶心位置。选中状态下整个tab项使用card白色背景配合圆角,未选中状态使用透明背景rgba(0,0,0,0),形成了一种"靶心聚焦"的视觉隐喻。

tabBar将七个ringTab分为双排排列:

@Builder
tabBar() {
  Column() {
    Row({ space: 6 }) {
      this.ringTab('靶道', '🎯', 0)
      this.ringTab('教练', '🧑‍🏫', 1)
      this.ringTab('装备', '🏹', 2)
      this.ringTab('赛事', '🏆', 3)
    }
    .width('100%')
    Row({ space: 6 }) {
      this.ringTab('段位', '🎖', 4)
      this.ringTab('团建', '👥', 5)
      this.ringTab('会员', '👑', 6)
    }
    .width('100%')
  }
  .width('100%')
  .padding({ left: 10, right: 10, top: 10 })
}

双排4+3的布局设计是射箭馆场景的独有特色。第一排四个tab(靶道、教练、装备、赛事)覆盖了核心预约和参与功能,第二排三个tab(段位、团建、会员)覆盖了社交和增值服务功能。每个ringTab的宽度设置为23%,在双排布局中四个tab总宽度约92%加上间距恰好填满屏幕,三个tab时则通过Flex布局自动分配剩余空间。

八、靶道页面pageLane与条件状态渲染

pageLane是射箭馆场景的默认展示页面,包含了首射特惠横幅和靶道列表。

@Builder
pageLane() {
  Column() {
    Row() {
      Column() {
        Text('首射特惠 · 29.9元/小时')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDeep)
        Text('含弓+箭+护具+免费教学')
          .fontSize(11)
          .fontColor('#8A6D1A')
          .margin({ top: 4 })
        Text('每日限100个名额')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('🎯')
        .fontSize(40)
        .margin({ right: 12 })
    }
    .width('100%')
    .padding(14)
    .borderRadius(14)
    .backgroundColor(COLORS.accentSoft)
    .onClick(() => {
      this.addOpen = true
    })

    this.sectionTitle('全部靶道', '换一批', 0)
    ForEach(this.laneList, (it: LaneItem, i: number) => {
      Row() {
        Stack() {
          Column()
            .width(52)
            .height(52)
            .backgroundColor(it.busy ? COLORS.tagBg : COLORS.accentSoft)
            .borderRadius(12)
          Text('🎯')
            .fontSize(26)
        }
        Column() {
          Row() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.type)
              .fontSize(10)
              .fontColor(COLORS.accent)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accentSoft)
              .borderRadius(6)
              .margin({ left: 6 })
          }
          Text(it.meters + '米 · ' + (it.busy ? '使用中' : '空闲'))
            .fontSize(11)
            .fontColor(it.busy ? COLORS.warn : COLORS.ok)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
        Column() {
          Text('¥' + it.price)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(it.fav ? '❤️' : '🤍')
            .fontSize(14)
            .margin({ top: 6 })
            .onClick(() => {
              this.toggleLaneFav(i)
            })
        }
        .alignItems(HorizontalAlign.End)
        Text('订道')
          .fontSize(12)
          .fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 7, bottom: 7 })
          .backgroundColor(it.busy ? COLORS.textThird : COLORS.primary)
          .borderRadius(14)
          .margin({ left: 10 })
          .onClick(() => {
            if (it.busy) {
              this.delOpen = true
            } else {
              this.addOpen = true
            }
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 16, right: 16, top: 8 })
    }, (it: LaneItem) => 'l' + it.id.toString() + '_' + this.rev.toString())
  }
  .width('100%')
  .padding({ bottom: 12 })
}

pageLane中最值得关注的设计是条件状态渲染的运用。靶道卡片的左侧图标背景根据busy状态切换:繁忙时使用tagBg浅灰色,空闲时使用accentSoft浅金色。距离和状态文字通过三元表达式it.busy ? '使用中' : '空闲'动态切换,字体颜色也同步变化——繁忙时使用warn橙色,空闲时使用ok绿色。最关键的设计在于"订道"按钮:当靶道繁忙时,按钮背景变为textThird灰色,点击会打开delOpen取消弹框;当靶道空闲时,按钮背景为primary深色,点击会打开addOpen预订弹框。这种根据状态切换按钮行为的设计,体现了数据驱动的交互逻辑——同一个按钮在不同状态下承担不同的功能角色。

九、段位天梯页面pageRank与领奖台设计

pageRank是射箭馆场景中视觉设计最具特色的页面,它通过领奖台布局展示前三名选手。

@Builder
pageRank() {
  Column() {
    this.sectionTitle('段位天梯', '本月', 3)
    Row({ space: 8 }) {
      Column() {
        Stack() {
          Circle({ width: 44, height: 44 })
            .fill(COLORS.tagBg)
          Text('🥈')
            .fontSize(22)
        }
        Text(this.rankName(1))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 4 })
        Text(this.rankScore(1) + '分')
          .fontSize(10)
          .fontColor(COLORS.textSecond)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding(10)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ top: 18 })
      Column() {
        Stack() {
          Circle({ width: 54, height: 54 })
            .fill(COLORS.accentSoft)
          Text('🥇')
            .fontSize(28)
        }
        Text(this.rankName(0))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.gold)
          .margin({ top: 4 })
        Text(this.rankScore(0) + '分')
          .fontSize(10)
          .fontColor(COLORS.textSecond)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding(10)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      Column() {
        Stack() {
          Circle({ width: 44, height: 44 })
            .fill(COLORS.tagBg)
          Text('🥉')
            .fontSize(22)
        }
        Text(this.rankName(2))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 4 })
        Text(this.rankScore(2) + '分')
          .fontSize(10)
          .fontColor(COLORS.textSecond)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding(10)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ top: 26 })
    }
    .width('100%')
    .margin({ top: 4 })

领奖台布局的设计巧妙地通过margin-top值模拟了真实领奖台的高度差。第二名(银牌)的margin-top为18,第一名(金牌)的margin-top为0(即最高位置),第三名(铜牌)的margin-top为26(最低位置)。金牌选手的头像Circle尺寸为54x54,大于银牌和铜牌的44x44,配合更大的emoji字号(28 vs 22),形成了视觉上的突出效果。金牌选手的名称使用gold金色字体,而银牌和铜牌使用普通深色字体。rankName和rankScore方法通过索引访问rankList数组,将数据查询逻辑封装在方法中而非直接在UI中通过下标访问,提升了代码的可读性。

十、弹框系统:订靶道modalBodyAdd

订靶道弹框是该场景预约流程的核心入口,设计包含了弓种选择、时长步进和费用计算三个功能模块。

@Builder
modalBodyAdd() {
  Column() {
    Row() {
      Text('🎯 订靶道 LANE ORDER')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Column()
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor(COLORS.textThird)
        .onClick(() => {
          this.addOpen = false
        })
    }
    .width('100%')

    Text('弓种类型')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 14 })
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(LANE_KINDS, (k: string, i: number) => {
        Text(k)
          .fontSize(12)
          .fontColor(this.laneTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.laneTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
          .borderRadius(14)
          .margin({ right: 8, top: 8 })
          .onClick(() => {
            this.toggleTag(this.laneTags, i)
          })
      }, (k: string) => 'lk' + k)
    }
    .width('100%')
    .margin({ top: 4 })

    Text('时长(小时)')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 16 })
    Row() {
      Text('-')
        .fontSize(16)
        .fontColor(COLORS.textSecond)
        .padding(10)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        .onClick(() => {
          if (this.hourStep > 1) {
            this.hourStep = this.hourStep - 1
          }
        })
      Column() {
        Text(this.hourStep.toString() + ' 小时')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Text('+')
        .fontSize(16)
        .fontColor(COLORS.white)
        .padding(10)
        .backgroundColor(COLORS.primary)
        .borderRadius(10)
        .onClick(() => {
          if (this.hourStep < 6) {
            this.hourStep = this.hourStep + 1
          }
        })
    }
    .width('100%')
    .margin({ top: 8 })

    Row() {
      Text('确认下单')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.primaryDeep)
        .padding({ left: 20, right: 20, top: 10, bottom: 10 })
        .backgroundColor(COLORS.accent)
        .borderRadius(20)
        .onClick(() => {
          this.addOpen = false
        })
      Column()
        .layoutWeight(1)
      Text('合计 ¥' + this.laneTotal())
        .fontSize(13)
        .fontColor(COLORS.gold)
    }
    .width('100%')
    .margin({ top: 20, bottom: 20 })
  }
  .width('100%')
  .padding({ left: 20, right: 20, top: 16 })
  .backgroundColor(COLORS.card)
  .borderRadius(20)
  .constraintSize({ maxHeight: '80%' })
}

订靶道弹框的弓种选择标签使用了与场景主题一致的金色配色方案——选中状态的标签使用accent金色背景配合primaryDeep深色文字,这种"金底深字"的配色比传统的"深底白字"更有射箭运动的高端质感。时长步进器的范围限制在1-6小时,减号按钮使用灰色背景,加号按钮使用primary深色背景。底部的合计金额通过this.laneTotal()方法实时计算,该方法内部执行this.hourStep * 58的乘法运算。确认下单按钮使用了金色背景和深色文字的组合,与标签选中状态保持视觉一致。

十一、弹框系统:弓手档案modalBodyEdit与报名排位赛modalBodyBiz

弓手档案弹框设计用于管理用户的射箭技能档案,包含常用弓种多选、惯用磅数步进和护具租用开关。

@Builder
modalBodyEdit() {
  Column() {
    Row() {
      Text('🏹 编辑弓手档案 ARCHER FILE')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Column()
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor(COLORS.textThird)
        .onClick(() => {
          this.editOpen = false
        })
    }
    .width('100%')

    Text('常用弓种(可多选)')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 14 })
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(BOW_TAGS, (b: string, i: number) => {
        Text(b)
          .fontSize(12)
          .fontColor(this.bowTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.bowTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
          .borderRadius(14)
          .margin({ right: 8, top: 8 })
          .onClick(() => {
            this.toggleTag(this.bowTags, i)
          })
      }, (b: string) => 'bt' + b)
    }
    .width('100%')
    .margin({ top: 4 })

    Text('惯用磅数(lb)')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 16 })
    Row() {
      Text('-')
        .fontSize(16)
        .fontColor(COLORS.textSecond)
        .padding(10)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        .onClick(() => {
          if (this.poundStep > 10) {
            this.poundStep = this.poundStep - 2
          }
        })
      Column() {
        Text(this.poundStep.toString() + ' lb')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Text('+')
        .fontSize(16)
        .fontColor(COLORS.white)
        .padding(10)
        .backgroundColor(COLORS.primary)
        .borderRadius(10)
        .onClick(() => {
          if (this.poundStep < 50) {
            this.poundStep = this.poundStep + 2
          }
        })
    }
    .width('100%')
    .margin({ top: 8 })

    Row() {
      Column() {
        Text('全套护具租用')
          .fontSize(14)
          .fontColor(COLORS.textPrimary)
        Text('护胸+护指+护臂,新手建议开启')
          .fontSize(11)
          .fontColor(COLORS.textThird)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text(this.guardFlag ? '已开启' : '已关闭')
        .fontSize(12)
        .fontColor(this.guardFlag ? COLORS.primaryDeep : COLORS.textSecond)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor(this.guardFlag ? COLORS.accent : COLORS.tagBg)
        .borderRadius(14)
        .onClick(() => {
          this.guardFlag = !this.guardFlag
        })
    }
    .width('100%')
    .margin({ top: 16 })
    .padding(12)
    .backgroundColor(COLORS.accentSoft)
    .borderRadius(12)

    Row() {
      Text('保存档案')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .padding({ left: 20, right: 20, top: 10, bottom: 10 })
        .backgroundColor(COLORS.primary)
        .borderRadius(20)
        .onClick(() => {
          this.editOpen = false
        })
      Column()
        .layoutWeight(1)
      Text('已选 ' + this.bowTags.length + ' 种弓')
        .fontSize(11)
        .fontColor(COLORS.textThird)
    }
    .width('100%')
    .margin({ top: 20, bottom: 20 })
  }
  .width('100%')
  .padding({ left: 20, right: 20, top: 16 })
  .backgroundColor(COLORS.card)
  .borderRadius(20)
  .constraintSize({ maxHeight: '80%' })
}

弓手档案弹框的设计体现了射箭运动的专业性。惯用磅数步进器采用了步进值为2的设计(this.poundStep - 2this.poundStep + 2),这是因为弓的磅数通常以偶数递增(如22lb、24lb、26lb等),步进值2比步进值1更符合实际装备规格。范围限制在10-50磅之间,覆盖了从儿童弓到专业竞技弓的磅数范围。全套护具租用开关区域使用了accentSoft浅金色背景,在弹框中形成了一个信息焦点区域,文案"护胸+护指+护臂,新手建议开启"既说明了护具内容又给出了使用建议。底部的统计文字"已选 X 种弓"通过this.bowTags.length实时反映当前选择的弓种数量。

报名排位赛弹框modalBodyBiz的设计:

@Builder
modalBodyBiz() {
  Column() {
    Row() {
      Text('🏆 报名排位赛 RANK ENTRY')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Column()
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor(COLORS.textThird)
        .onClick(() => {
          this.bizOpen = false
        })
    }
    .width('100%')

    Row() {
      Column() {
        Text('月度排位赛 · 反曲组')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('08-30 周六 14:00 · 60箭资格赛')
          .fontSize(11)
          .fontColor('#CDD6DB')
          .margin({ top: 4 })
        Text('报名费 ¥88 · 冠军入馆名人堂')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('🏆')
        .fontSize(36)
    }
    .width('100%')
    .padding(14)
    .borderRadius(14)
    .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], ['#455A64', 1]] })
    .margin({ top: 14 })

    Text('参赛组别')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 14 })
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(GROUP_TAGS, (g: string, i: number) => {
        Text(g)
          .fontSize(12)
          .fontColor(this.groupTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.groupTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
          .borderRadius(14)
          .margin({ right: 8, top: 8 })
          .onClick(() => {
            this.toggleTag(this.groupTags, i)
          })
      }, (g: string) => 'gt' + g)
    }
    .width('100%')
    .margin({ top: 4 })

    Text('同队人数')
      .fontSize(13)
      .fontColor(COLORS.textSecond)
      .margin({ top: 16 })
    Row() {
      Text('-')
        .fontSize(16)
        .fontColor(COLORS.textSecond)
        .padding(10)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        .onClick(() => {
          if (this.peopleStep > 1) {
            this.peopleStep = this.peopleStep - 1
          }
        })
      Column() {
        Text(this.peopleStep.toString() + ' 人')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      Text('+')
        .fontSize(16)
        .fontColor(COLORS.white)
        .padding(10)
        .backgroundColor(COLORS.primary)
        .borderRadius(10)
        .onClick(() => {
          if (this.peopleStep < 6) {
            this.peopleStep = this.peopleStep + 1
          }
        })
    }
    .width('100%')
    .margin({ top: 8 })

    Row() {
      Text('提交报名')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.primaryDeep)
        .padding({ left: 20, right: 20, top: 10, bottom: 10 })
        .backgroundColor(COLORS.accent)
        .borderRadius(20)
        .onClick(() => {
          this.bizOpen = false
        })
      Column()
        .layoutWeight(1)
      Text('已报 ' + (32 + this.peopleStep) + '/64 人')
        .fontSize(11)
        .fontColor(COLORS.warn)
    }
    .width('100%')
    .margin({ top: 20, bottom: 20 })
  }
  .width('100%')
  .padding({ left: 20, right: 20, top: 16 })
  .backgroundColor(COLORS.card)
  .borderRadius(20)
  .constraintSize({ maxHeight: '80%' })
}

报名排位赛弹框的顶部信息卡片使用了从primaryDeep到#455A64的深色渐变,模拟了射箭馆内部的灯光氛围。信息卡片中的文案设计具有强烈的赛事氛围感——“60箭资格赛”"冠军入馆名人堂"等文案传达了赛事的专业规格和荣誉激励。参赛组别标签使用了GROUP_TAGS五个分组,与订靶道弹框的弓种标签保持一致的视觉风格。同队人数步进器范围1-6人,底部"已报 X/64 人"的文案通过32 + this.peopleStep的实时计算展示了当前报名进度,64人的容量上限和warn橙色的文字颜色营造了报名紧迫感。

十二、Mermaid架构流程图

以下是射箭馆场景的组件结构与数据流架构图:

mainTab=0 首页

其他

curTab=0

curTab=1

curTab=2

curTab=3

curTab=4

curTab=5

curTab=6

addOpen

editOpen

delOpen

bizOpen

PageArcheryHall 入口组件

build 主构建器

Stack 堆叠层

Column 主内容列

fxLayer 动画装饰层

header 头部区域

mainContent 可滚动内容区

bottomBar 底部导航栏

品牌名称 + 定位

搜索框

促销标签行

tabBar 箭靶环式标签

第一排: 靶道/教练/装备/赛事

第二排: 段位/团建/会员

mainTab 判断

curTab 判断

pageMainOther

pageLane 靶道

pageCoach 教练

pageGear 装备

pageMatch 赛事

pageRank 段位

pageTeam 团建

pageVip 会员

LaneItem 数据

CoachItem 数据

GearItem 数据

MatchInfoItem 数据

RankerItem 数据

TeamPackItem 数据

modalOverlay 弹框层

弹框状态判断

modalBodyAdd 订靶道

modalBodyEdit 弓手档案

modalBodyDel 取消警示

modalBodyBiz 报名排位赛

tick 定时器 120ms

translate 箭矢飞行

scale 靶心呼吸

rotate 旋转闪光

opacity 星光闪烁

十三、数据模型与组件对比表格

维度 LaneItem CoachItem GearItem MatchInfoItem RankerItem TeamPackItem
接口定义 Lane Coach Gear MatchInfo Ranker TeamPack
字段数量 7个 7个 6个 6个 5个 5个
核心字段 name/type/meters name/title/years name/brand/stock title/date/prize name/score/avgRing name/people/includes
布尔字段 busy/fav fav fav open
数据数量 12条 10条 10条 8条 10条 8条
关联页面 pageLane pageCoach pageGear pageMatch pageRank pageTeam
列表布局 Row横向列表 Flex双列网格 Row横向列表 Column纵向列表 领奖台+列表 Row横向列表
刷新方法 refreshLanes refreshCoaches refreshGear
收藏功能 toggleLaneFav toggleGearFav
维度 modalBodyAdd modalBodyEdit modalBodyDel modalBodyBiz
功能定位 订靶道 弓手档案 取消预订警示 报名排位赛
标签选择 LANE_KINDS弓种 BOW_TAGS弓种 GROUP_TAGS组别
步进器 hourStep 1-6小时 poundStep 10-50磅 peopleStep 1-6人
步进值 1 2 1
开关控件 guardFlag护具
金额计算 laneTotal() 固定¥156/¥46 固定¥88
信息卡片 accentSoft浅金 FFF8E1浅金 深色渐变
主色调 accent金色 accent金色 danger红色 accent金色

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 布局风格:箭靶环式内容tab(双排4+3)+ 静态渐变头部
// 弹框:订靶道 / 弓手档案 / 取消警示 / 报名排位赛
// ============================================================

interface ColorPalette {
  bg: string
  card: string
  white: string
  primary: string
  primaryDeep: string
  accent: string
  accentSoft: string
  gold: string
  textPrimary: string
  textSecond: string
  textThird: string
  line: string
  tagBg: string
  warn: string
  danger: string
  ok: string
}

const COLORS: ColorPalette = {
  bg: '#F2F4F5',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#263238',
  primaryDeep: '#102027',
  accent: '#F9A825',
  accentSoft: '#FEF3D6',
  gold: '#C99700',
  textPrimary: '#232B30',
  textSecond: '#5C6B72',
  textThird: '#98A6AD',
  line: '#E6EAEC',
  tagBg: '#EDF1F3',
  warn: '#E65100',
  danger: '#C62828',
  ok: '#2E7D32'
}

interface Lane {
  id: number
  name: string
  type: string
  price: number
  meters: number
  busy: boolean
  fav: boolean
}

@Observed
class LaneItem implements Lane {
  id: number = 0
  name: string = ''
  type: string = ''
  price: number = 0
  meters: number = 0
  busy: boolean = false
  fav: boolean = false

  constructor(o: Lane) {
    this.id = o.id
    this.name = o.name
    this.type = o.type
    this.price = o.price
    this.meters = o.meters
    this.busy = o.busy ? o.busy : false
    this.fav = o.fav ? o.fav : false
  }
}

interface Coach {
  id: number
  name: string
  title: string
  years: number
  price: number
  students: number
  fav: boolean
}

@Observed
class CoachItem implements Coach {
  id: number = 0
  name: string = ''
  title: string = ''
  years: number = 0
  price: number = 0
  students: number = 0
  fav: boolean = false

  constructor(o: Coach) {
    this.id = o.id
    this.name = o.name
    this.title = o.title
    this.years = o.years
    this.price = o.price
    this.students = o.students
    this.fav = o.fav ? o.fav : false
  }
}

interface Gear {
  id: number
  name: string
  brand: string
  price: number
  stock: number
  fav: boolean
}

@Observed
class GearItem implements Gear {
  id: number = 0
  name: string = ''
  brand: string = ''
  price: number = 0
  stock: number = 0
  fav: boolean = false

  constructor(o: Gear) {
    this.id = o.id
    this.name = o.name
    this.brand = o.brand
    this.price = o.price
    this.stock = o.stock
    this.fav = o.fav ? o.fav : false
  }
}

interface MatchInfo {
  id: number
  title: string
  date: string
  entryFee: number
  prize: string
  open: boolean
}

@Observed
class MatchInfoItem implements MatchInfo {
  id: number = 0
  title: string = ''
  date: string = ''
  entryFee: number = 0
  prize: string = ''
  open: boolean = false

  constructor(o: MatchInfo) {
    this.id = o.id
    this.title = o.title
    this.date = o.date
    this.entryFee = o.entryFee
    this.prize = o.prize
    this.open = o.open ? o.open : false
  }
}

interface Ranker {
  id: number
  name: string
  score: number
  avgRing: number
  club: string
}

@Observed
class RankerItem implements Ranker {
  id: number = 0
  name: string = ''
  score: number = 0
  avgRing: number = 0
  club: string = ''

  constructor(o: Ranker) {
    this.id = o.id
    this.name = o.name
    this.score = o.score
    this.avgRing = o.avgRing
    this.club = o.club
  }
}

interface TeamPack {
  id: number
  name: string
  people: number
  price: number
  includes: string
}

@Observed
class TeamPackItem implements TeamPack {
  id: number = 0
  name: string = ''
  people: number = 0
  price: number = 0
  includes: string = ''

  constructor(o: TeamPack) {
    this.id = o.id
    this.name = o.name
    this.people = o.people
    this.price = o.price
    this.includes = o.includes
  }
}

const LANES: LaneItem[] = [
  new LaneItem({ id: 1, name: 'A1 反曲靶道', type: '反曲弓', price: 58, meters: 18, busy: false, fav: true }),
  new LaneItem({ id: 2, name: 'A2 反曲靶道', type: '反曲弓', price: 58, meters: 18, busy: true, fav: false }),
  new LaneItem({ id: 3, name: 'B1 复合靶道', type: '复合弓', price: 78, meters: 25, busy: false, fav: false }),
  new LaneItem({ id: 4, name: 'B2 复合靶道', type: '复合弓', price: 78, meters: 25, busy: false, fav: false }),
  new LaneItem({ id: 5, name: 'C1 传统靶道', type: '传统弓', price: 48, meters: 12, busy: false, fav: false }),
  new LaneItem({ id: 6, name: 'C2 传统靶道', type: '传统弓', price: 48, meters: 12, busy: true, fav: false }),
  new LaneItem({ id: 7, name: 'D1 撒放体验道', type: '撒放器', price: 68, meters: 15, busy: false, fav: false }),
  new LaneItem({ id: 8, name: 'D2 儿童软弹道', type: '儿童弓', price: 38, meters: 8, busy: false, fav: false }),
  new LaneItem({ id: 9, name: 'E1 VIP包间', type: '全能', price: 128, meters: 30, busy: false, fav: false }),
  new LaneItem({ id: 10, name: 'E2 VIP包间', type: '全能', price: 128, meters: 30, busy: true, fav: false }),
  new LaneItem({ id: 11, name: 'F1 夜光赛道', type: '反曲弓', price: 88, meters: 18, busy: false, fav: false }),
  new LaneItem({ id: 12, name: 'F2 对抗赛道', type: '复合弓', price: 98, meters: 25, busy: false, fav: false })
]

const COACHES: CoachItem[] = [
  new CoachItem({ id: 1, name: '秦锋', title: '国家级教练', years: 16, price: 288, students: 3200, fav: true }),
  new CoachItem({ id: 2, name: '罗小满', title: '反曲专项', years: 9, price: 198, students: 2100, fav: false }),
  new CoachItem({ id: 3, name: '白羽', title: '复合专项', years: 8, price: 208, students: 1780, fav: false }),
  new CoachItem({ id: 4, name: '霍东', title: '传统弓师范', years: 21, price: 268, students: 2650, fav: false }),
  new CoachItem({ id: 5, name: '陆离', title: '青训导师', years: 6, price: 158, students: 990, fav: false }),
  new CoachItem({ id: 6, name: '祁彩', title: '女子防身弓术', years: 7, price: 178, students: 1240, fav: false }),
  new CoachItem({ id: 7, name: '韩商', title: '竞技反曲', years: 12, price: 238, students: 1980, fav: false }),
  new CoachItem({ id: 8, name: '木辛', title: '亲子教练', years: 4, price: 138, students: 660, fav: false }),
  new CoachItem({ id: 9, name: '聂风', title: '光弓专项', years: 10, price: 218, students: 1420, fav: false }),
  new CoachItem({ id: 10, name: '唐门', title: '国家队退役', years: 18, price: 388, students: 2880, fav: false })
]

const GEARS: GearItem[] = [
  new GearItem({ id: 1, name: '入门反曲弓 22lb', brand: 'SF', price: 30, stock: 8, fav: true }),
  new GearItem({ id: 2, name: '进阶反曲弓 30lb', brand: 'WIAWIS', price: 68, stock: 4, fav: false }),
  new GearItem({ id: 3, name: '复合猎弓 40lb', brand: 'Bear', price: 98, stock: 3, fav: false }),
  new GearItem({ id: 4, name: '美式传统长弓', brand: 'Ragim', price: 45, stock: 6, fav: false }),
  new GearItem({ id: 5, name: '护指三指手套', brand: 'ATS', price: 10, stock: 20, fav: false }),
  new GearItem({ id: 6, name: '竞技护胸板', brand: 'Kayan', price: 15, stock: 15, fav: false }),
  new GearItem({ id: 7, name: '箭壶6支装', brand: 'Easton', price: 12, stock: 18, fav: false }),
  new GearItem({ id: 8, name: '碳素箭3D靶', brand: 'Victory', price: 20, stock: 10, fav: false }),
  new GearItem({ id: 9, name: '儿童安全弓 12lb', brand: 'PSE', price: 25, stock: 12, fav: false }),
  new GearItem({ id: 10, name: '瞄准镜镜组', brand: 'Axcel', price: 40, stock: 5, fav: false })
]

const MATCHES: MatchInfoItem[] = [
  new MatchInfoItem({ id: 1, title: '月度排位赛·反曲组', date: '08-30', entryFee: 88, prize: '¥3000奖池', open: true }),
  new MatchInfoItem({ id: 2, title: '复合弓挑战赛', date: '09-06', entryFee: 128, prize: '冠军免单半年', open: true }),
  new MatchInfoItem({ id: 3, title: '新手友好·光弓赛', date: '08-28', entryFee: 58, prize: '箭支套装', open: true }),
  new MatchInfoItem({ id: 4, title: '亲子软弹趣味赛', date: '08-24', entryFee: 38, prize: '玩具弓套装', open: false }),
  new MatchInfoItem({ id: 5, title: '传统弓全国分站', date: '09-15', entryFee: 198, prize: '¥10000奖池', open: true }),
  new MatchInfoItem({ id: 6, title: '夜光对抗3v3', date: '08-29', entryFee: 68, prize: '夜光赛道月卡', open: true }),
  new MatchInfoItem({ id: 7, title: '团体接力联赛', date: '09-01', entryFee: 158, prize: '团建5折券', open: false }),
  new MatchInfoItem({ id: 8, title: '段位考核日', date: '08-31', entryFee: 0, prize: '段位证书', open: true })
]

const RANKERS: RankerItem[] = [
  new RankerItem({ id: 1, name: '一箭封喉', score: 9862, avgRing: 9.4, club: '百步穿杨馆' }),
  new RankerItem({ id: 2, name: '离弦Wind', score: 9518, avgRing: 9.2, club: '锋芒俱乐部' }),
  new RankerItem({ id: 3, name: '桃夭', score: 9204, avgRing: 9.1, club: '百步穿杨馆' }),
  new RankerItem({ id: 4, name: '北辰', score: 8877, avgRing: 8.9, club: '逐箭堂' }),
  new RankerItem({ id: 5, name: '白羽书生', score: 8501, avgRing: 8.8, club: '锋芒俱乐部' }),
  new RankerItem({ id: 6, name: 'Sara', score: 8120, avgRing: 8.6, club: '百步穿杨馆' }),
  new RankerItem({ id: 7, name: '老树盘弓', score: 7790, avgRing: 8.4, club: '古弓社' }),
  new RankerItem({ id: 8, name: '小满', score: 7410, avgRing: 8.2, club: '逐箭堂' }),
  new RankerItem({ id: 9, name: '阿修罗', score: 7022, avgRing: 8.0, club: '古弓社' }),
  new RankerItem({ id: 10, name: '新手小陈', score: 6588, avgRing: 7.8, club: '百步穿杨馆' })
]

const TEAMS: TeamPackItem[] = [
  new TeamPackItem({ id: 1, name: '双人对抗套餐', people: 2, price: 128, includes: '2条靶道+护具+教学30min' }),
  new TeamPackItem({ id: 2, name: '亲友4人局', people: 4, price: 238, includes: '4条靶道+60支箭+饮品' }),
  new TeamPackItem({ id: 3, name: '团建10人标准', people: 10, price: 568, includes: '分组对抗赛+奖牌+教练' }),
  new TeamPackItem({ id: 4, name: '团建20人旗舰', people: 20, price: 1088, includes: '全场包场+定制奖杯+摄影' }),
  new TeamPackItem({ id: 5, name: '亲子家庭票', people: 3, price: 158, includes: '儿童软弹道+家长反曲道' }),
  new TeamPackItem({ id: 6, name: '生日派对包场', people: 12, price: 798, includes: '夜光赛道+蛋糕台+布置' }),
  new TeamPackItem({ id: 7, name: '企业年度联赛', people: 30, price: 1888, includes: '赛季制积分榜+颁奖礼' }),
  new TeamPackItem({ id: 8, name: '毕业季专享', people: 6, price: 328, includes: '纪念箭支+合影跟拍' })
]

const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']
const WEEK_HITS: number[] = [62, 78, 55, 84, 96, 132, 118]
const LANE_KINDS: string[] = ['反曲弓', '复合弓', '传统弓', '撒放器', '儿童弓']
const BOW_TAGS: string[] = ['反曲弓', '复合弓', '传统弓', '光弓', '美猎']
const GROUP_TAGS: string[] = ['新手组', '进阶组', '公开组', '女子组', '青少年组']

function weekHitBar(i: number): number {
  return WEEK_HITS[i] * 1.05
}

@Entry
@Component
struct PageArcheryHall {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State laneList: LaneItem[] = LANES
  @State coachList: CoachItem[] = COACHES
  @State gearList: GearItem[] = GEARS
  @State matchList: MatchInfoItem[] = MATCHES
  @State rankList: RankerItem[] = RANKERS
  @State teamList: TeamPackItem[] = TEAMS
  @State rev: number = 0
  @State tick: number = 0
  @State laneTags: number[] = [0]
  @State bowTags: number[] = [0]
  @State groupTags: number[] = [0]
  @State hourStep: number = 1
  @State poundStep: number = 24
  @State guardFlag: boolean = true
  @State peopleStep: number = 1
  private timer: number = -1

  aboutToAppear(): void {
    this.timer = setInterval(() => {
      this.tick = this.tick + 1
    }, 120)
  }

  aboutToDisappear(): void {
    if (this.timer >= 0) {
      clearInterval(this.timer)
    }
  }

  switchTab(i: number): void {
    this.curTab = i
  }

  switchMain(i: number): void {
    this.mainTab = i
  }

  refreshLanes(): void {
    this.laneList.unshift(this.laneList[this.laneList.length - 1])
    this.laneList.splice(this.laneList.length - 1, 1)
    this.rev = this.rev + 1
  }

  refreshCoaches(): void {
    this.coachList.unshift(this.coachList[this.coachList.length - 1])
    this.coachList.splice(this.coachList.length - 1, 1)
    this.rev = this.rev + 1
  }

  refreshGear(): void {
    this.gearList.unshift(this.gearList[this.gearList.length - 1])
    this.gearList.splice(this.gearList.length - 1, 1)
    this.rev = this.rev + 1
  }

  toggleLaneFav(i: number): void {
    this.laneList[i].fav = !this.laneList[i].fav
    this.rev = this.rev + 1
  }

  toggleGearFav(i: number): void {
    this.gearList[i].fav = !this.gearList[i].fav
    this.rev = this.rev + 1
  }

  toggleTag(list: number[], i: number): void {
    if (list.indexOf(i) >= 0) {
      list.splice(list.indexOf(i), 1)
    } else {
      list.push(i)
    }
  }

  closeAll(): void {
    this.addOpen = false
    this.editOpen = false
    this.delOpen = false
    this.bizOpen = false
  }

  laneTotal(): number {
    return this.hourStep * 58
  }

  rankName(i: number): string {
    return this.rankList[i].name
  }

  rankScore(i: number): string {
    return this.rankList[i].score.toString()
  }

  @Builder
  fxLayer() {
    Stack() {
      Text('🏹')
        .fontSize(26)
        .translate({ x: this.tick % 40 })
        .position({ x: 40, y: 130 })
        .opacity(0.85)
      Text('🎯')
        .fontSize(24)
        .scale({ x: 0.85 + (this.tick % 10) / 12, y: 0.85 + (this.tick % 10) / 12 })
        .position({ x: 300, y: 220 })
        .opacity(0.85)
      Text('💫')
        .fontSize(18)
        .rotate({ angle: (this.tick % 24) * 15 })
        .position({ x: 130, y: 330 })
        .opacity(0.6)
      Text('✨')
        .fontSize(16)
        .position({ x: 500, y: 120 })
        .opacity((this.tick % 8) / 8 + 0.15)
      Text('🏹')
        .fontSize(18)
        .translate({ x: (this.tick + 20) % 50 })
        .position({ x: 520, y: 400 })
        .opacity(0.5)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column() {
      Row() {
        Text('🎯')
          .fontSize(24)
        Text('百步穿杨')
          .fontSize(21)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('📍武汉·光谷')
          .fontSize(12)
          .fontColor('#CDD6DB')
        Text('🔔')
          .fontSize(19)
          .margin({ left: 12 })
          .onClick(() => {
            this.mainTab = 3
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Row() {
        Text('🔍')
          .fontSize(15)
        Text('搜靶道 / 教练 / 赛事')
          .fontSize(13)
          .fontColor('#B4C0C6')
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('搜索')
          .fontSize(13)
          .fontColor(COLORS.primaryDeep)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.curTab = 0
          })
      }
      .width('100%')
      .padding({ left: 10, right: 10 })
      .margin({ top: 10 })
      .backgroundColor('rgba(255,255,255,0.16)')
      .borderRadius(20)

      Row({ space: 8 }) {
        Text('首次体验29.9')
          .fontSize(11)
          .fontColor(COLORS.primaryDeep)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor(COLORS.accent)
          .borderRadius(12)
        Text('免费教学15min')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('装备全包')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('赛事报名中')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .margin({ top: 10, bottom: 14 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], ['#37474F', 1]] })
  }

  @Builder
  ringTab(name: string, icon: string, idx: number) {
    Column() {
      Stack() {
        Circle({ width: 34, height: 34 })
          .fill(this.curTab === idx ? COLORS.accent : COLORS.tagBg)
        Circle({ width: 22, height: 22 })
          .fill(this.curTab === idx ? COLORS.primaryDeep : COLORS.card)
        Text(icon)
          .fontSize(13)
      }
      Text(name)
        .fontSize(11)
        .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.curTab === idx ? COLORS.accent : COLORS.textSecond)
        .margin({ top: 3 })
    }
    .width('23%')
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(this.curTab === idx ? COLORS.card : 'rgba(0,0,0,0)')
    .borderRadius(12)
    .onClick(() => {
      this.switchTab(idx)
    })
  }

  @Builder
  tabBar() {
    Column() {
      Row({ space: 6 }) {
        this.ringTab('靶道', '🎯', 0)
        this.ringTab('教练', '🧑‍🏫', 1)
        this.ringTab('装备', '🏹', 2)
        this.ringTab('赛事', '🏆', 3)
      }
      .width('100%')
      Row({ space: 6 }) {
        this.ringTab('段位', '🎖', 4)
        this.ringTab('团建', '👥', 5)
        this.ringTab('会员', '👑', 6)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 10, right: 10, top: 10 })
  }

  @Builder
  sectionTitle(title: string, sub: string, act: number) {
    Row() {
      Column() {
        Text(title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Rect({ width: 22, height: 3 })
          .fill(COLORS.accent)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      Column()
        .layoutWeight(1)
      Text(sub)
        .fontSize(11)
        .fontColor(COLORS.primaryDeep)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(COLORS.accentSoft)
        .borderRadius(10)
        .onClick(() => {
          if (act === 0) {
            this.refreshLanes()
          } else if (act === 1) {
            this.refreshCoaches()
          } else if (act === 2) {
            this.refreshGear()
          } else {
            this.curTab = 4
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 6 })
  }

  @Builder
  pageLane() {
    Column() {
      Row() {
        Column() {
          Text('首射特惠 · 29.9元/小时')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('含弓+箭+护具+免费教学')
            .fontSize(11)
            .fontColor('#8A6D1A')
            .margin({ top: 4 })
          Text('每日限100个名额')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.danger)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('🎯')
          .fontSize(40)
          .margin({ right: 12 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .backgroundColor(COLORS.accentSoft)
      .onClick(() => {
        this.addOpen = true
      })

      this.sectionTitle('全部靶道', '换一批', 0)
      ForEach(this.laneList, (it: LaneItem, i: number) => {
        Row() {
          Stack() {
            Column()
              .width(52)
              .height(52)
              .backgroundColor(it.busy ? COLORS.tagBg : COLORS.accentSoft)
              .borderRadius(12)
            Text('🎯')
              .fontSize(26)
          }
          Column() {
            Row() {
              Text(it.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.type)
                .fontSize(10)
                .fontColor(COLORS.accent)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.accentSoft)
                .borderRadius(6)
                .margin({ left: 6 })
            }
            Text(it.meters + '米 · ' + (it.busy ? '使用中' : '空闲'))
              .fontSize(11)
              .fontColor(it.busy ? COLORS.warn : COLORS.ok)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Column() {
            Text('¥' + it.price)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.fav ? '❤️' : '🤍')
              .fontSize(14)
              .margin({ top: 6 })
              .onClick(() => {
                this.toggleLaneFav(i)
              })
          }
          .alignItems(HorizontalAlign.End)
          Text('订道')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(it.busy ? COLORS.textThird : COLORS.primary)
            .borderRadius(14)
            .margin({ left: 10 })
            .onClick(() => {
              if (it.busy) {
                this.delOpen = true
              } else {
                this.addOpen = true
              }
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: LaneItem) => 'l' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageCoach() {
    Column() {
      this.sectionTitle('驻馆教练', '换一批', 1)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.coachList, (it: CoachItem, i: number) => {
          Column() {
            Stack() {
              Circle({ width: 50, height: 50 })
                .fill(COLORS.tagBg)
              Text('🧑‍🏫')
                .fontSize(24)
            }
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ top: 6 })
            Text(it.title)
              .fontSize(10)
              .fontColor(COLORS.primaryDeep)
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accentSoft)
              .borderRadius(8)
              .margin({ top: 4 })
            Text('执教' + it.years + '年 · 学员' + it.students)
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 4 })
            Text('¥' + it.price + '/课时')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ top: 6 })
          }
          .width('48%')
          .alignItems(HorizontalAlign.Center)
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ top: 8 })
          .onClick(() => {
            this.editOpen = true
          })
        }, (it: CoachItem) => 'ch' + it.id.toString() + '_' + this.rev.toString())
      }
      .width('94%')
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageGear() {
    Column() {
      this.sectionTitle('装备租售', '换一批', 2)
      ForEach(this.gearList, (it: GearItem, i: number) => {
        Row() {
          Column() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.brand)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('库存' + it.stock)
            .fontSize(11)
            .fontColor(it.stock < 5 ? COLORS.warn : COLORS.ok)
          Text('¥' + it.price + '/次')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ left: 10 })
          Text(it.fav ? '❤️' : '🤍')
            .fontSize(15)
            .margin({ left: 10 })
            .onClick(() => {
              this.toggleGearFav(i)
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.editOpen = true
        })
      }, (it: GearItem) => 'g' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageMatch() {
    Column() {
      this.sectionTitle('赛事日历', '看段位', 3)
      ForEach(this.matchList, (it: MatchInfoItem, i: number) => {
        Column() {
          Row() {
            Text('NO.' + it.id)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text(it.title)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ left: 8 })
            Column()
              .layoutWeight(1)
            Text(it.open ? '报名中' : '已截止')
              .fontSize(10)
              .fontColor(it.open ? COLORS.ok : COLORS.textThird)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor(it.open ? '#E8F5E9' : COLORS.tagBg)
              .borderRadius(8)
          }
          .width('100%')
          Row() {
            Text('📅 ' + it.date)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
            Text('报名费 ¥' + it.entryFee)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ left: 12 })
            Text('🏆 ' + it.prize)
              .fontSize(11)
              .fontColor(COLORS.gold)
              .margin({ left: 12 })
            Column()
              .layoutWeight(1)
            Text('去报名')
              .fontSize(12)
              .fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(it.open ? COLORS.primary : COLORS.textThird)
              .borderRadius(14)
              .onClick(() => {
                if (it.open) {
                  this.bizOpen = true
                } else {
                  this.delOpen = true
                }
              })
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: MatchInfoItem) => 'mt' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageRank() {
    Column() {
      this.sectionTitle('段位天梯', '本月', 3)
      Row({ space: 8 }) {
        Column() {
          Stack() {
            Circle({ width: 44, height: 44 })
              .fill(COLORS.tagBg)
            Text('🥈')
              .fontSize(22)
          }
          Text(this.rankName(1))
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 4 })
          Text(this.rankScore(1) + '分')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ top: 18 })
        Column() {
          Stack() {
            Circle({ width: 54, height: 54 })
              .fill(COLORS.accentSoft)
            Text('🥇')
              .fontSize(28)
          }
          Text(this.rankName(0))
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 4 })
          Text(this.rankScore(0) + '分')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Stack() {
            Circle({ width: 44, height: 44 })
              .fill(COLORS.tagBg)
            Text('🥉')
              .fontSize(22)
          }
          Text(this.rankName(2))
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 4 })
          Text(this.rankScore(2) + '分')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ top: 26 })
      }
      .width('100%')
      .margin({ top: 4 })

      ForEach(this.rankList, (it: RankerItem, i: number) => {
        Row() {
          Text((i + 1).toString())
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(i < 3 ? COLORS.gold : COLORS.textThird)
            .width(24)
          Text(it.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(it.club)
            .fontSize(10)
            .fontColor(COLORS.textThird)
            .margin({ left: 8 })
          Column()
            .layoutWeight(1)
          Text('均环 ' + it.avgRing)
            .fontSize(11)
            .fontColor(COLORS.accent)
          Text(it.score + '分')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ left: 10 })
        }
        .width('100%')
        .padding(11)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 6 })
        .onClick(() => {
          this.editOpen = true
        })
      }, (it: RankerItem) => 'r' + it.id.toString() + '_' + this.rev.toString())

      Column() {
        Text('本周每日命中环数')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row({ space: 10 }) {
          ForEach(WEEK_LABELS, (m: string, i: number) => {
            Column() {
              Text(WEEK_HITS[i].toString())
                .fontSize(9)
                .fontColor(COLORS.textThird)
              Column()
                .width(16)
                .height(weekHitBar(i))
                .backgroundColor(i === 5 ? COLORS.accent : COLORS.tagBg)
                .borderRadius(4)
                .margin({ top: 3 })
              Text(m)
                .fontSize(10)
                .fontColor(COLORS.textSecond)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
          }, (m: string) => 'wk' + m)
        }
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 16, right: 16, top: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageTeam() {
    Column() {
      this.sectionTitle('团建套餐', '定制', 3)
      ForEach(this.teamList, (it: TeamPackItem, i: number) => {
        Row() {
          Column()
            .width(4)
            .height(52)
            .backgroundColor(i % 2 === 0 ? COLORS.accent : COLORS.primary)
            .borderRadius(2)
          Column() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.includes)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 4 })
            Text(it.people + '人适用')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Column() {
            Text('¥' + it.price)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('咨询')
              .fontSize(11)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(12)
              .margin({ top: 4 })
              .onClick(() => {
                this.bizOpen = true
              })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.bizOpen = true
        })
      }, (it: TeamPackItem) => 't' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageVip() {
    Column() {
      Row() {
        Column() {
          Text('神射手卡 · BLACK')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text('再积320分升级 GOLD 射手')
            .fontSize(11)
            .fontColor('#CDD6DB')
            .margin({ top: 4 })
          Row() {
            Column()
              .width(120)
              .height(6)
              .backgroundColor('rgba(255,255,255,0.2)')
              .borderRadius(3)
            Column()
              .width(84)
              .height(6)
              .backgroundColor(COLORS.accent)
              .borderRadius(3)
          }
          .margin({ top: 8 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('👑')
          .fontSize(38)
      }
      .width('100%')
      .padding(16)
      .borderRadius(16)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.primary, 1]] })
      .margin({ top: 8 })

      Row({ space: 8 }) {
        Column() {
          Text('42')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('累计场次')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('8.7')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text('平均环数')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('L4')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('当前段位')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
      }
      .width('100%')
      .margin({ top: 12 })

      Column() {
        Text('会员权益')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          Text('🎯')
            .fontSize(16)
          Text('靶道时租85折,装备免押金')
            .fontSize(13)
            .fontColor(COLORS.textSecond)
            .margin({ left: 10 })
          Column()
            .layoutWeight(1)
          Text('生效中')
            .fontSize(10)
            .fontColor(COLORS.ok)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.accentSoft)
        .borderRadius(12)
        .margin({ top: 8 })
        Row() {
          Text('🏆')
            .fontSize(16)
          Text('赛事报名费9折 + 专属储物柜')
            .fontSize(13)
            .fontColor(COLORS.textSecond)
            .margin({ left: 10 })
          Column()
            .layoutWeight(1)
          Text('生效中')
            .fontSize(10)
            .fontColor(COLORS.ok)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ top: 8 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .margin({ top: 14 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 12 })
  }

  @Builder
  pageMainOther() {
    Column() {
      Text('🎯')
        .fontSize(40)
        .margin({ top: 60 })
      Text('我的靶道订单')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 10 })
      Text('今日 18:00 · B1复合靶道 · 2小时')
        .fontSize(12)
        .fontColor(COLORS.textSecond)
        .margin({ top: 6 })
      Text('含装备租借 · ¥156 已支付')
        .fontSize(12)
        .fontColor(COLORS.ok)
        .margin({ top: 4 })
      Text('取消预订')
        .fontSize(12)
        .fontColor(COLORS.danger)
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FDECEA')
        .borderRadius(16)
        .margin({ top: 12 })
        .onClick(() => {
          this.delOpen = true
        })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  modalBodyAdd() {
    Column() {
      Row() {
        Text('🎯 订靶道 LANE ORDER')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.addOpen = false
          })
      }
      .width('100%')

      Text('弓种类型')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(LANE_KINDS, (k: string, i: number) => {
          Text(k)
            .fontSize(12)
            .fontColor(this.laneTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.laneTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.laneTags, i)
            })
        }, (k: string) => 'lk' + k)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('时长(小时)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.hourStep > 1) {
              this.hourStep = this.hourStep - 1
            }
          })
        Column() {
          Text(this.hourStep.toString() + ' 小时')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.hourStep < 6) {
              this.hourStep = this.hourStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('确认下单')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDeep)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.accent)
          .borderRadius(20)
          .onClick(() => {
            this.addOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('合计 ¥' + this.laneTotal())
          .fontSize(13)
          .fontColor(COLORS.gold)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyEdit() {
    Column() {
      Row() {
        Text('🏹 编辑弓手档案 ARCHER FILE')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.editOpen = false
          })
      }
      .width('100%')

      Text('常用弓种(可多选)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(BOW_TAGS, (b: string, i: number) => {
          Text(b)
            .fontSize(12)
            .fontColor(this.bowTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.bowTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.bowTags, i)
            })
        }, (b: string) => 'bt' + b)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('惯用磅数(lb)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.poundStep > 10) {
              this.poundStep = this.poundStep - 2
            }
          })
        Column() {
          Text(this.poundStep.toString() + ' lb')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.poundStep < 50) {
              this.poundStep = this.poundStep + 2
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Column() {
          Text('全套护具租用')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('护胸+护指+护臂,新手建议开启')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.guardFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.guardFlag ? COLORS.primaryDeep : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.guardFlag ? COLORS.accent : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.guardFlag = !this.guardFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.accentSoft)
      .borderRadius(12)

      Row() {
        Text('保存档案')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.editOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('已选 ' + this.bowTags.length + ' 种弓')
          .fontSize(11)
          .fontColor(COLORS.textThird)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyDel() {
    Column() {
      Row() {
        Text('⚠️ 取消预订确认')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.delOpen = false
          })
      }
      .width('100%')

      Column() {
        Text('🎯')
          .fontSize(34)
        Text('B1 复合靶道 · 今日18:00 即将取消')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 8 })
        Text('开始前1小时内取消将收取30%费用')
          .fontSize(11)
          .fontColor(COLORS.warn)
          .margin({ top: 6 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 18, bottom: 18 })
      .backgroundColor('#FFF8E1')
      .borderRadius(14)
      .margin({ top: 14 })

      Row({ space: 8 }) {
        Column() {
          Text('¥156')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('订单金额')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('¥46')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.danger)
          Text('取消费用')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('2小时')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('原时长')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
      }
      .width('100%')
      .margin({ top: 12 })

      Row() {
        Text('再想想')
          .fontSize(14)
          .fontColor(COLORS.textSecond)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.tagBg)
          .borderRadius(20)
          .onClick(() => {
            this.delOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('确认取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.danger)
          .borderRadius(20)
          .onClick(() => {
            this.delOpen = false
          })
      }
      .width('100%')
      .margin({ top: 18, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyBiz() {
    Column() {
      Row() {
        Text('🏆 报名排位赛 RANK ENTRY')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.bizOpen = false
          })
      }
      .width('100%')

      Row() {
        Column() {
          Text('月度排位赛 · 反曲组')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('08-30 周六 14:00 · 60箭资格赛')
            .fontSize(11)
            .fontColor('#CDD6DB')
            .margin({ top: 4 })
          Text('报名费 ¥88 · 冠军入馆名人堂')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('🏆')
          .fontSize(36)
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], ['#455A64', 1]] })
      .margin({ top: 14 })

      Text('参赛组别')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(GROUP_TAGS, (g: string, i: number) => {
          Text(g)
            .fontSize(12)
            .fontColor(this.groupTags.indexOf(i) >= 0 ? COLORS.primaryDeep : COLORS.textSecond)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.groupTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.tagBg)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.groupTags, i)
            })
        }, (g: string) => 'gt' + g)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('同队人数')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.peopleStep > 1) {
              this.peopleStep = this.peopleStep - 1
            }
          })
        Column() {
          Text(this.peopleStep.toString() + ' 人')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.peopleStep < 6) {
              this.peopleStep = this.peopleStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Text('提交报名')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDeep)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.accent)
          .borderRadius(20)
          .onClick(() => {
            this.bizOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('已报 ' + (32 + this.peopleStep) + '/64 人')
          .fontSize(11)
          .fontColor(COLORS.warn)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalOverlay() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(10,18,22,0.5)')
        .onClick(() => {
          this.closeAll()
        })
      if (this.addOpen) {
        this.modalBodyAdd()
      }
      if (this.editOpen) {
        this.modalBodyEdit()
      }
      if (this.delOpen) {
        this.modalBodyDel()
      }
      if (this.bizOpen) {
        this.modalBodyBiz()
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  bottomBar() {
    Row() {
      Column() {
        Text('🏠')
          .fontSize(20)
        Text('首页')
          .fontSize(10)
          .fontColor(this.mainTab === 0 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(0)
      })
      Column() {
        Text('🎫')
          .fontSize(20)
        Text('订单')
          .fontSize(10)
          .fontColor(this.mainTab === 1 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(1)
      })
      Column() {
        Text('➕')
          .fontSize(24)
          .fontColor(COLORS.primaryDeep)
          .padding(10)
          .backgroundColor(COLORS.accent)
          .borderRadius(26)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.addOpen = true
      })
      Column() {
        Text('💬')
          .fontSize(20)
        Text('消息')
          .fontSize(10)
          .fontColor(this.mainTab === 3 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(3)
      })
      Column() {
        Text('👤')
          .fontSize(20)
        Text('我的')
          .fontSize(10)
          .fontColor(this.mainTab === 4 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(4)
      })
    }
    .width('100%')
    .padding({ top: 6, bottom: 6 })
    .backgroundColor(COLORS.card)
  }

  @Builder
  mainContent() {
    Scroll() {
      Column() {
        this.tabBar()
        if (this.mainTab === 0) {
          if (this.curTab === 0) {
            this.pageLane()
          }
          if (this.curTab === 1) {
            this.pageCoach()
          }
          if (this.curTab === 2) {
            this.pageGear()
          }
          if (this.curTab === 3) {
            this.pageMatch()
          }
          if (this.curTab === 4) {
            this.pageRank()
          }
          if (this.curTab === 5) {
            this.pageTeam()
          }
          if (this.curTab === 6) {
            this.pageVip()
          }
        } else {
          this.pageMainOther()
        }
      }
      .width('100%')
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
  }

  build() {
    Stack() {
      Column() {
        this.header()
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      this.fxLayer()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


在这里插入图片描述

十四、总结

通过对百步穿杨室内射箭馆场景的完整代码分析,我们可以清晰地看到HarmonyOS ArkTS在处理复杂业务场景时的架构能力和表达力。该场景涵盖了七个内容板块和四个弹框交互,代码量庞大但结构清晰,这得益于ArkTS声明式编程范式的模块化能力——@Builder装饰器将复杂的UI逻辑拆分为独立的构建器函数,每个函数专注于一个特定的UI区域,使得开发者能够以"搭积木"的方式组装完整的页面。从header到tabBar到各内容页面再到弹框系统,每个模块都有明确的职责边界,修改一个模块不会影响其他模块的正常运行。

在数据模型设计方面,六组interface与@Observed类的配合使用展现了ArkTS类型系统在复杂业务场景中的价值。每组模型都针对其业务领域进行了精细的字段设计:Lane模型的busy字段直接驱动UI状态切换,Coach模型的title字段实现了教练的专业方向的细粒度分类,Gear模型的stock字段通过条件渲染展示库存预警,MatchInfo模型的open字段控制报名按钮的可用性。这些设计看似简单,但体现了"数据即界面"的设计哲学——UI的状态和行为完全由数据模型驱动,开发者只需关注数据的正确性,UI的更新由框架自动完成。

箭靶环式ringTab的设计是该场景最具创新性的UI元素。通过两个Circle的同心圆嵌套,在标签项中模拟了箭靶的靶环结构,选中状态的金色外圈与深色内圈形成了"命中靶心"的视觉隐喻。双排4+3的标签布局相比单排布局能够容纳更多的功能入口(七个板块),同时通过第一排核心功能、第二排增值服务的分组逻辑,引导用户优先关注核心业务。这种布局设计在美团类应用中具有广泛的适用性——当业务板块超过五个时,双排布局是一个优秀的解决方案。

在性能与体验优化方面,代码中的多处设计值得关注。ForEach的keyGenerator使用id + rev复合键确保列表轮转后的正确刷新;定时器在aboutToDisappear中清理防止内存泄漏;constraintSize限制弹框最大高度防止溢出;hitTestBehavior(HitTestMode.None)确保动画层不拦截触摸;条件渲染if (it.busy)if (it.hot)避免了不必要的UI元素创建。这些细节优化共同保证了应用在处理大量列表数据和复杂交互时的流畅性能。

Logo

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

更多推荐