HarmonyOS作为华为面向万物互联时代打造的分布式操作系统,其ArkTS声明式开发框架在HarmonyOS 6.1.1版本中已经达到了极高的成熟度与工程化水平。ArkTS语言在TypeScript的基础上进行了深度定制化扩展,不仅保留了静态类型系统带来的编译期安全保障,更引入了@State、@Observed、@Builder、@Entry、@Component等一系列装饰器,构建了一套完整的响应式UI开发范式。在HarmonyOS ArkTS API 24的技术栈下,开发者可以借助interface接口与implements实现关系建立严格的数据契约,通过@Observed装饰器使数据模型具备可观察性,再通过@State状态变量驱动组件的自动重渲染,形成从数据定义到界面呈现的完整响应式数据流。本文将以一个沉浸式密室逃脱社区应用——谜域——为案例,全面剖析其在HarmonyOS平台下的架构设计与实现细节。

谜域是一个面向城市密室逃脱爱好者的垂直社区应用,其业务模式参考了美团等本地生活服务平台的经典交互范式,在密室逃脱这一新兴娱乐垂直领域进行了深度的产品定制。从业务覆盖面来看,该应用整合了主题浏览、场次预约、NPC互动、个人战绩、道具商城、拼场社交、逃脱攻略与玩家档案八大核心业务板块,构建了从主题发现到到场体验再到社交分享的完整服务链条。密室逃脱作为一种高度沉浸的线下娱乐形式,其线上社区化运营具有独特的业务价值:用户不仅需要在应用内完成主题筛选与场次预订,还需要在体验过程中获取NPC互动信息、管理拼场队伍、查阅逃脱攻略、记录个人战绩,这些需求共同构成了一个高频互动的垂直社区场景。ArkTS的声明式组件体系与响应式状态管理恰好为这种多页面、多弹框、多列表的复杂交互场景提供了理想的技术支撑。

在技术架构层面,谜域应用的设计体现了HarmonyOS ArkTS API 24的几个关键设计理念。首先是interface+implements模式的全面应用:应用定义了七个接口(ThemeItemInterface/Session/Npc/EscapeRecord/PropInterface/Team/Tip),每个接口对应一个@Observed装饰的实现类,通过构造函数接收接口类型的参数对象完成属性初始化。这种模式将数据契约与实现分离,接口定义了数据的形状,实现类提供了可观察性,两者通过implements关键字建立正式的类型关联。其次是门牌铁牌式标签栏的创新设计:八个内容标签以双排4+4的铁牌面板形式排列,每个标签内部使用Stack叠加Rect铁牌底板与Text图标,选中态通过背景色与字色切换提供视觉反馈。再次是fxLayer动效系统的多元素协同设计:手电筒光斑闪烁、幽灵横向漂移、蜡烛呼吸缩放、钥匙旋转、星光交替闪烁五种动效元素各自由fxTick状态变量的不同取模运算驱动,节奏错位避免了机械感。

从工程实践角度审视,谜域应用的色彩体系以深紫色(#4A148C)为primary主色,以更深的紫色(#260046)为primaryDeep暗色变体,以翠绿色(#00A65A)为accent强调色,以金色(#FFB300)为辅助高亮色。这套色彩体系营造出神秘、悬疑的密室逃脱氛围感——深紫色的暗调传递沉浸式恐怖体验的基调,翠绿色作为希望与通关的象征色出现在可约场次标签与NPC评分中,金色则用于会员权益与价格强调。bg背景色采用极浅的紫灰(#F3EFF9),card卡片色为纯白(#FFFFFF),通过tagBg浅紫色(#EFEAF6)提供标签背景层级,整体构建出层次分明且主题感强烈的视觉体系。四个弹框组件(SESSION BOOKING预约场次、PLAYER FILE玩家档案、退出拼场确认、HINT CARD求助提示卡)通过统一的modalOverlay覆盖层管理,每个弹框都有英文标识与中文标题的双语呈现,体现了品牌识别的系统化设计。

一、色彩体系与ColorPalette接口定义

谜域应用的色彩管理通过ColorPalette接口统一约束,定义了14个语义化颜色字段,确保全局色彩使用的一致性与可维护性。

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
}

在这里插入图片描述

上述接口定义了16个颜色字段(包含warn、danger、ok三个功能语义色),涵盖背景色、卡片色、主色及其深色变体、强调色及其浅色变体、金色辅助色、三级文本色、边框色、标签背景色以及三个功能状态色。与简单的颜色对象不同,使用interface定义色彩结构后,COLORS常量必须完整实现所有字段,TypeScript编译器会在编译阶段验证完整性,任何遗漏字段都会触发编译错误。这种接口驱动的色彩管理方案在大型项目中尤为重要,它确保了色彩体系的完整性与一致性。

COLORS常量的实例化体现了精心的色彩搭配考量。primary为#4A148C——一种深沉的紫色,传递密室逃脱场景的神秘与悬疑氛围;primaryDeep为#260046——更深的暗紫色,用于头部渐变终点与会员卡片渐变;accent为#00A65A——翠绿色,在暗紫色调中作为醒目的对比色,用于可约场次标签、NPC评分强调与攻略编号;accentSoft为#E2F6EA——极浅的薄荷绿,作为选中态标签背景与标签辅助色。gold为#FFB300——派对金色,用于价格强调、会员积分与攻略等级标识。

const COLORS: ColorPalette = {
  bg: '#F3EFF9',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#4A148C',
  primaryDeep: '#260046',
  accent: '#00A65A',
  accentSoft: '#E2F6EA',
  gold: '#FFB300',
  textPrimary: '#2A1A3E',
  textSecond: '#6E5F86',
  textThird: '#A395B8',
  line: '#E6DFF0',
  tagBg: '#EFEAF6',
  warn: '#EF6C00',
  danger: '#C62828',
  ok: '#2E7D32'
}

文本色采用三级紫色梯度:textPrimary(#2A1A3E)为深紫近黑用于标题正文,textSecond(#6E5F86)为中等紫灰用于辅助说明,textThird(#A395B8)为浅紫灰用于占位提示与时间信息。line(#E6DFF0)与tagBg(#EFEAF6)均为极浅紫色调,分别用于分割线与标签背景,在不干扰主内容的前提下提供柔和的层级分隔。warn(#EF6C00)橙色用于库存预警与拼场退出扣费提示,danger(#C62828)红色用于退出确认与清空战绩操作,ok(#2E7D32)绿色用于逃脱成功状态标签。

二、interface+implements数据模型体系

谜域应用在数据层采用了interface接口定义数据契约、@Observed class implements接口实现可观察数据模型的架构模式。这种模式将数据形状的定义与可观察实现分离,接口可以被多个实现类共享,也可以被函数参数类型引用,实现了高度的类型复用。应用共定义了七个接口与七个对应的@Observed实现类。

2.1 主题模型 ThemeItemInterface 与 ThemeItem

ThemeItemInterface定义了密室主题的数据契约,包含id、name、type、players、mins、price、fav共7个字段。type字段为主题类型(恐怖/烧脑/沉浸/欢乐),players字段为适合人数范围文本,mins字段为游戏时长分钟数,fav字段为布尔值收藏标记。

interface ThemeItemInterface {
  id: number
  name: string
  type: string
  players: string
  mins: number
  price: number
  fav: boolean
}

@Observed
class ThemeItem implements ThemeItemInterface {
  id: number = 0
  name: string = ''
  type: string = ''
  players: string = ''
  mins: number = 0
  price: number = 0
  fav: boolean = false

  constructor(o: ThemeItemInterface) {
    this.id = o.id
    this.name = o.name
    this.type = o.type
    this.players = o.players
    this.mins = o.mins
    this.price = o.price
    this.fav = o.fav ? o.fav : false
  }
}

ThemeItem类通过implements关键字声明实现ThemeItemInterface接口,所有字段在声明时都提供了默认值(这是ArkTS的类型安全要求)。构造函数接收一个ThemeItemInterface类型的参数对象o,将o的各属性逐一赋值给实例属性。fav字段使用了o.fav ? o.fav : false的三元表达式,确保当传入的fav为undefined时默认为false。这种通过参数对象初始化的构造函数模式比位置参数构造函数更加灵活,调用时无需记忆参数顺序,且可以只传入部分字段(其余字段使用默认值)。

2.2 场次模型 Session 与 SessionItem

interface Session {
  id: number
  theme: string
  time: string
  left: number
  status: string
}

@Observed
class SessionItem implements Session {
  id: number = 0
  theme: string = ''
  time: string = ''
  left: number = 0
  status: string = ''

  constructor(o: Session) {
    this.id = o.id
    this.theme = o.theme
    this.time = o.time
    this.left = o.left
    this.status = o.status
  }
}

Session接口定义了场次信息,left字段为剩余可选位数(0表示已满),status字段为文本状态(“可约"或"满员”)。SessionItem的构造函数直接将参数对象属性赋值给实例,没有使用三元表达式(因为所有字段都是确定类型的值,不会有undefined风险)。

2.3 NPC模型 Npc 与 NpcItem、战绩模型与道具模型

NPC模型包含scares字段(吓哭玩家次数),当scares大于0时显示"吓哭玩家N次",否则显示"文明演绎不吓人",这一字段设计增添了密室逃脱场景的趣味性。

interface Npc {
  id: number
  name: string
  role: string
  scares: number
  score: number
}

@Observed
class NpcItem implements Npc {
  id: number = 0
  name: string = ''
  role: string = ''
  scares: number = 0
  score: number = 0

  constructor(o: Npc) {
    this.id = o.id
    this.name = o.name
    this.role = o.role
    this.scares = o.scares
    this.score = o.score
  }
}

interface EscapeRecord {
  id: number
  theme: string
  date: string
  result: string
  used: string
}

@Observed
class RecordItem implements EscapeRecord {
  id: number = 0
  theme: string = ''
  date: string = ''
  result: string = ''
  used: string = ''

  constructor(o: EscapeRecord) {
    this.id = o.id
    this.theme = o.theme
    this.date = o.date
    this.result = o.result
    this.used = o.used
  }
}

在这里插入图片描述

RecordItem模型中的result字段记录逃脱结果(“逃脱成功"或"逃脱失败”),used字段记录使用时间(如"58:12")。应用通过recColor函数将结果映射为色彩——成功显示ok绿色,失败显示danger红色,提供了直观的视觉反馈。propItem道具模型则包含use字段(用途说明)与stock字段(库存数量),当库存低于12时显示warn橙色预警。

三、静态数据数组与辅助函数

3.1 主题列表 THEMES

const THEMES: ThemeItem[] = [
  new ThemeItem({ id: 1, name: '午夜病栋', type: '恐怖', players: '2-6人', mins: 70, price: 128, fav: true }),
  new ThemeItem({ id: 2, name: '雾都连环案', type: '烧脑', players: '3-6人', mins: 90, price: 158, fav: false }),
  new ThemeItem({ id: 3, name: '时光当铺', type: '沉浸', players: '2-5人', mins: 75, price: 148, fav: false }),
  new ThemeItem({ id: 4, name: '豪门夜宴', type: '欢乐', players: '4-8人', mins: 60, price: 98, fav: true }),
  new ThemeItem({ id: 5, name: '深潜七千米', type: '沉浸', players: '2-4人', mins: 80, price: 168, fav: false }),
  new ThemeItem({ id: 6, name: '禁忌画室', type: '恐怖', players: '2-5人', mins: 65, price: 138, fav: false }),
  new ThemeItem({ id: 7, name: '机械迷城2077', type: '烧脑', players: '2-6人', mins: 100, price: 188, fav: false }),
  new ThemeItem({ id: 8, name: '西游歪传', type: '欢乐', players: '4-8人', mins: 70, price: 118, fav: false }),
  new ThemeItem({ id: 9, name: '血色古宅', type: '恐怖', players: '3-6人', mins: 85, price: 158, fav: false }),
  new ThemeItem({ id: 10, name: '谎言拍卖会', type: '烧脑', players: '3-5人', mins: 80, price: 148, fav: false }),
  new ThemeItem({ id: 11, name: '雪国列车', type: '沉浸', players: '2-6人', mins: 95, price: 178, fav: false }),
  new ThemeItem({ id: 12, name: '校园怪谈', type: '恐怖', players: '2-4人', mins: 60, price: 108, fav: false })
]

在这里插入图片描述

THEMES数组包含12个密室主题,覆盖恐怖、烧脑、沉浸、欢乐四大类型。每个主题都通过对象字面量传入ThemeItem构造函数,fav字段标记了用户已收藏的主题(午夜病栋与豪门夜宴)。主题名称如"午夜病栋"、“雾都连环案”、"禁忌画室"等充满了悬疑与故事性,价格区间从98元到188元,时长从60分钟到100分钟,为不同偏好的玩家提供了丰富的选择空间。这种通过构造函数接收对象字面量的数据初始化方式,在可读性上远优于位置参数构造函数调用,尤其当字段数量超过5个时优势更加明显。

3.2 辅助函数体系

function gameBar(i: number): number {
  return MONTH_GAMES[i] * 1.5
}

function recColor(s: string): string {
  if (s === '逃脱成功') {
    return COLORS.ok
  }
  return COLORS.danger
}

function sessionColor(s: string): string {
  if (s === '可约') {
    return COLORS.accent
  }
  return COLORS.textThird
}

在这里插入图片描述

gameBar函数将月度场次数映射为柱状图高度(乘以1.5系数),recColor函数将逃脱结果文本映射为颜色值,sessionColor函数将场次状态文本映射为颜色值。这三个辅助函数在UI构建过程中被@Builder函数调用,将业务逻辑与视觉呈现进行了清晰的分离。在ArkTS的声明式UI框架中,辅助函数返回的值会直接参与组件属性设置,当依赖的数据变化时,由于辅助函数在渲染过程中被重新调用,返回值也会自动更新。

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

PageEscapeRoom组件作为应用入口,通过@Entry和@Component装饰器声明。其内部状态管理体现了ArkTS响应式系统的精髓。

@Entry
@Component
struct PageEscapeRoom {
  @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 themeList: ThemeItem[] = THEMES
  @State teamList: TeamItem[] = TEAMS
  @State rev: number = 0
  @State tick: number = 0
  @State themeTags: number[] = [0]
  @State fearTags: number[] = [1]
  @State hintTags: number[] = [0]
  @State peopleStep: number = 3
  @State playedStep: number = 18
  @State heartFlag: boolean = true
  @State hintStep: number = 2
  private timer: number = -1

curTab控制内容区8个页签切换(0-7分别对应主题、场次、NPC、战绩、道具、拼场、攻略、档案),mainTab控制底部5个主tab。themeList和teamList是两个可动态更新的列表状态,rev是一个递增修订号用于驱动ForEach键值变化以触发列表刷新。tick是动画驱动力,themeTags/fearTags/hintTags是三组多选标签索引数组(分别对应主题类型筛选、恐怖耐受度选择、提示类型选择),peopleStep/playedStep/hintStep是三个步进器状态,heartFlag是心跳手环开关布尔值。

4.1 生命周期钩子

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

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

在这里插入图片描述

定时器以105毫秒间隔递增tick变量,比990应用的120ms更快的刷新频率使得动画更加流畅。aboutToDisappear中清除定时器防止内存泄漏,这是HarmonyOS开发的标准资源管理模式。

4.2 核心业务方法

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

toggleThemeFav(i: number): void {
  this.themeList[i].fav = !this.themeList[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)
  }
}

refreshThemes方法通过将列表最后一个元素移到头部并删除末尾的方式实现列表轮转刷新效果,同时递增rev触发ForEach重新渲染。toggleThemeFav方法直接修改@Observed对象的fav属性,配合rev递增确保列表项重新渲染。toggleTag是一个通用的多选标签切换方法,通过indexOf判断标签是否已选中,已选则splice移除,未选则push添加——这种通用化方法设计减少了重复代码,三个标签数组(themeTags、fearTags、hintTags)共用同一套切换逻辑。

五、fxLayer动效系统

fxLayer是谜域应用的动效装饰层,通过5个emoji文本元素构建了密室逃脱场景的氛围动画。该层使用hitTestBehavior(HitTestMode.None)设置事件穿透,确保下层交互不受影响。

@Builder
fxLayer() {
  Stack() {
    Text('🔦')
      .fontSize(24)
      .opacity((this.tick % 8) / 8 + 0.15)
      .position({ x: 60, y: 140 })
    Text('👻')
      .fontSize(22)
      .translate({ x: this.tick % 50 })
      .position({ x: 290, y: 220 })
      .opacity(0.6)
    Text('🕯️')
      .fontSize(20)
      .scale({ x: 1 + (this.tick % 6) * 0.03, y: 1 + (this.tick % 6) * 0.03 })
      .position({ x: 350, y: 340 })
    Text('🗝️')
      .fontSize(18)
      .rotate({ angle: (this.tick % 24) * 15 })
      .position({ x: 140, y: 360 })
      .opacity(0.7)
    Text('✨')
      .fontSize(14)
      .position({ x: 520, y: 150 })
      .opacity((this.tick % 6) / 6 + 0.15)
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

在这里插入图片描述

手电筒emoji通过opacity实现闪烁效果,透明度按(tick % 8) / 8 + 0.15计算,在0.15到1.15(实际限制为1)之间循环变化,模拟密室暗房中手电光束的闪烁感。幽灵emoji通过translate的x方向位移实现横向漂移,位移量按tick % 50计算,在0到50像素之间循环移动,配合0.6的固定透明度营造若隐若现的幽灵效果。蜡烛emoji通过scale实现呼吸式缩放,缩放系数按1 + (tick % 6) * 0.03计算,在1.0到1.15之间循环,模拟烛光的摇曳感。钥匙emoji通过rotate实现持续旋转,角度按(tick % 24) * 15计算,在0到345度之间循环(24步每步15度),模拟钥匙在空中的旋转飘落。星光emoji同样通过opacity实现闪烁,透明度按(tick % 6) / 6 + 0.15计算,节奏与手电筒不同(取模基数为6而非8),形成错位效果。

这种基于tick取模运算的动效驱动方案具有几个显著优势:首先,取模运算保证了所有动画属性在有限的数值空间内循环,不会因tick的持续递增而产生数值溢出;其次,不同元素采用不同的取模基数(8、50、6、24、6),使得各动画元素的运动节奏相互独立,避免了同步带来的机械感;最后,所有动画仅依赖单一状态变量tick,每105ms仅需递增一次,性能开销极低,适合在移动设备上长时间运行。

六、头部渐变与铁牌式标签栏

6.1 header头部组件

头部组件采用深紫色到更深紫色的135度线性渐变,营造密室入口的幽暗氛围感。

@Builder
header() {
  Column() {
    Row() {
      Text('🗝️')
        .fontSize(24)
      Text('谜域')
        .fontSize(21)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
      Text('📍城西·谜域大厦B2')
        .fontSize(12)
        .fontColor('#D6C2F0')
      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('#BFA8DE')
        .margin({ left: 8 })
      Column()
        .layoutWeight(1)
      Text('搜索')
          .fontSize(13)
          .fontColor(COLORS.white)
          .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)

在这里插入图片描述

头部区域包含品牌名称与定位信息、搜索框、三个特色标签三个层级的布局。品牌名称"谜域"配合钥匙emoji,副标题"📍城西·谜域大厦B2"传递了线下门店的地理位置。搜索框使用半透明白色背景(rgba(255,255,255,0.16))的胶囊形设计,右侧设有独立的翠绿色"搜索"按钮。三个特色标签(全npc真人演绎、机械机关联动、拼场免单券、存包免费)以药丸标签形式排列,每个使用相同的半透明白色背景。整个头部通过linearGradient设置为从primary到primaryDeep的135度渐变。

6.2 铁牌式标签栏 plateTab与tabBar

plateTab是谜域应用在标签栏设计上的核心创新,以铁牌为隐喻,通过Stack叠加Rect铁牌底板与Text图标。

@Builder
plateTab(name: string, icon: string, idx: number) {
  Column() {
    Stack() {
      Rect({ width: 34, height: 24 })
        .fill(this.curTab === idx ? COLORS.accent : COLORS.line)
        .borderRadius(5)
      Text(icon)
        .fontSize(11)
    }
    Text(name)
      .fontSize(11)
      .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(this.curTab === idx ? COLORS.primary : COLORS.textSecond)
      .margin({ top: 4 })
  }
  .width('23%')
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 8, bottom: 8 })
  .backgroundColor(this.curTab === idx ? COLORS.accentSoft : COLORS.card)
  .borderRadius(12)
  .margin({ top: 6 })
  .onClick(() => { this.switchTab(idx) })
}

在这里插入图片描述

每个标签的顶部是一个34x24像素的圆角矩形铁牌,选中时填充翠绿色(COLORS.accent),未选中时填充浅紫色线条色(COLORS.line),铁牌上叠加一个emoji图标。下方是标签名称文本,选中时使用粗体与primary深紫色,未选中时使用常规字重与textSecond中等紫灰色。整个标签容器在选中时背景色变为accentSoft浅薄荷绿,未选中时为card纯白色,通过12像素圆角保持柔和外观。宽度设为23%(而非layoutWeight均分),为双排4+4布局留出间距空间。

@Builder
tabBar() {
  Column() {
    Row({ space: 6 }) {
      this.plateTab('主题', '🚪', 0)
      this.plateTab('场次', '🕰', 1)
      this.plateTab('NPC', '🎭', 2)
      this.plateTab('战绩', '🏅', 3)
    }
    .width('100%')
    Row({ space: 6 }) {
      this.plateTab('道具', '🧰', 4)
      this.plateTab('拼场', '🤝', 5)
      this.plateTab('攻略', '📜', 6)
      this.plateTab('档案', '🗃', 7)
    }
    .width('100%')
  }
  .width('100%')
  .padding({ left: 10, right: 10, top: 10 })
}

tabBar通过两个Row分别排列4个plateTab,形成双排4+4的铁牌面板布局。第一排是主题、场次、NPC、战绩四个核心体验类标签,第二排是道具、拼场、攻略、档案四个辅助功能类标签。Row的space参数设为6像素,为铁牌之间提供均匀间距。这种双排布局比单排8标签更加紧凑,也更好地利用了屏幕宽度。

七、业务页面构建器体系

7.1 主题浏览页 pageTheme

pageTheme是应用的首屏页面,顶部是一个渐变推广卡片(从primary到accent的对角渐变),展示"本周新主题·禁忌画室首发"的推广信息。下方通过ForEach遍历themeList渲染主题列表。

ForEach(this.themeList, (it: ThemeItem, i: number) => {
  Row() {
    Stack() {
      Column()
        .width(50)
        .height(50)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
      Text(it.type === '恐怖' ? '👻' : (it.type === '烧脑' ? '🧠' :
           (it.type === '沉浸' ? '🌀' : '🎉')))
        .fontSize(24)
    }
    Column() {
      Text(it.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(it.type + ' · ' + it.players + ' · ' + it.mins + '分钟')
        .fontSize(11)
        .fontColor(COLORS.textSecond)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })
    Column() {
      Text(it.fav ? '❤️' : '🤍')
        .fontSize(15)
        .onClick(() => { this.toggleThemeFav(i) })
      Text('¥' + it.price + '/人')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.primary)
        .margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.End)
    Text('约场')
        .fontSize(12)
        .fontColor(COLORS.white)
        .padding({ left: 12, right: 12, top: 7, bottom: 7 })
        .backgroundColor(COLORS.primary)
        .borderRadius(14)
        .margin({ left: 10 })
        .onClick(() => { this.addOpen = true })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 8 })
}, (it: ThemeItem) => 'th' + it.id.toString() + '_' + this.rev.toString())

主题列表项左侧是一个50x50的圆角方形图标区域,图标emoji根据主题类型动态选择——恐怖用幽灵、烧脑用大脑、沉浸用漩涡、欢乐用派对。中间是主题名称与类型/人数/时长信息。右侧是收藏按钮(❤️/🤍切换,调用toggleThemeFav方法)与价格。最右侧是深紫色"约场"按钮,点击触发预约弹框。ForEach键值生成器返回'th' + id + '_' + rev,将rev修订号纳入键值,确保rev变化时整个列表重新渲染。

7.2 场次预约页 pageSession

场次页展示今日与未来的场次列表,每个场次条目包含时间、主题名称、剩余可选位与订场按钮。

ForEach(SESSIONS, (it: SessionItem, i: number) => {
  Row() {
    Column() {
      Text(it.time)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(it.theme)
        .fontSize(11)
        .fontColor(COLORS.primary)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Text(it.left > 0 ? '余' + it.left + '位' : '已满')
      .fontSize(11)
      .fontColor(it.left > 0 ? COLORS.accent : COLORS.textThird)
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor(COLORS.accentSoft)
      .borderRadius(8)
    Text(it.status === '可约' ? '订场' : '候补')
        .fontSize(12)
        .fontColor(COLORS.white)
        .padding({ left: 12, right: 12, top: 7, bottom: 7 })
        .backgroundColor(it.status === '可约' ? COLORS.primary : COLORS.accent)
        .borderRadius(14)
        .margin({ left: 10 })
        .onClick(() => {
          if (it.status === '可约') { this.addOpen = true }
          else { this.bizOpen = true }
        })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 8 })
  .onClick(() => { this.addOpen = true })
}, (it: SessionItem) => 'se' + it.id.toString())

场次条目的剩余位数标签根据left值动态切换——大于0时显示翠绿色"余N位",等于0时显示浅紫灰"已满"。订场按钮的文案与背景色根据status动态切换——可约时显示深紫色"订场",满员时显示翠绿色"候补"。点击可约场次触发预约弹框,点击满员场次触发候补/求助弹框。场次页底部还设有"订场须知"卡片,列出三条规则(提前到场、年龄限制、贵重物品存柜),为用户提供到场指南。

7.3 个人战绩页 pageRecord

战绩页展示用户的历史战绩记录与统计概览,顶部是三个统计卡片(总场次10、逃脱成功7、逃脱率70%),下方通过ForEach遍历RECORDS数组渲染战绩列表。

ForEach(RECORDS, (it: RecordItem, i: number) => {
  Row() {
    Text('#' + it.id)
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textThird)
      .width(32)
    Column() {
      Text(it.theme)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(it.date + ' · 用时 ' + it.used)
        .fontSize(10)
        .fontColor(COLORS.textSecond)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 6 })
    Text(it.result)
      .fontSize(10)
      .fontColor(recColor(it.result))
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor(COLORS.tagBg)
      .borderRadius(8)
  }
  .width('100%')
  .padding(11)
  .backgroundColor(COLORS.card)
  .borderRadius(12)
  .margin({ left: 16, right: 16, top: 8 })
  .onClick(() => { this.editOpen = true })
}, (it: RecordItem) => 'rc' + it.id.toString() + '_' + this.rev.toString())

每条战绩记录左侧是编号(#1、#2…),中间是主题名称与日期/用时信息,右侧是结果标签——通过recColor函数将"逃脱成功"映射为ok绿色,"逃脱失败"映射为danger红色,使用浅紫色tagBg背景。战绩列表下方还设有一个近6月场次柱状图,通过ForEach遍历MONTH_LABELS和MONTH_GAMES数组渲染12根柱体,当月(8月)使用翠绿色高亮。

八、弹框组件体系

8.1 预约场次弹框 modalBodyAdd (SESSION BOOKING)

预约弹框的标题为"🕰 预约场次 SESSION BOOKING",包含主题类型多选标签(恐怖/烧脑/沉浸/欢乐/不限)、游玩人数步进器(2-8人范围)以及费用计算展示。

@Builder
modalBodyAdd() {
  Column() {
    Row() {
      Text('🕰 预约场次 SESSION BOOKING')
        .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(THEME_TAGS, (t: string, i: number) => {
        Text(t)
          .fontSize(12)
          .fontColor(this.themeTags.indexOf(i) >= 0 ? COLORS.white : COLORS.accent)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.themeTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.accentSoft)
          .borderRadius(14)
          .margin({ right: 8, top: 8 })
          .onClick(() => { this.toggleTag(this.themeTags, i) })
      }, (t: string) => 'tt' + t)
    }

主题类型标签使用Flex的wrap换行布局,选中项使用翠绿色背景配白色文字,未选项使用浅薄荷绿背景配翠绿色文字。点击调用toggleTag方法切换选中状态。人数步进器中,减号按钮使用tagBg浅紫色背景,加号按钮使用翠绿色背景形成视觉区分。费用按peopleStep * 138公式动态计算,随着人数变化实时更新。底部"提交预约"按钮使用翠绿色背景,右侧显示金色合计金额。

8.2 编辑玩家档案弹框 modalBodyEdit (PLAYER FILE)

@Builder
modalBodyEdit() {
  Column() {
    Row() {
      Text('🎭 编辑玩家档案 PLAYER 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(FEAR_TAGS, (f: string, i: number) => {
        Text(f)
          .fontSize(12)
          .fontColor(this.fearTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
          .padding({ left: 12, right: 12, top: 6, bottom: 4 })
          .backgroundColor(this.fearTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .margin({ right: 8, top: 8 })
          .onClick(() => { this.toggleTag(this.fearTags, i) })
      }, (f: string) => 'ft' + f)
    }

编辑弹框的恐怖耐受度标签选中态使用primary深紫色背景配白色文字,与预约弹框的翠绿色选中态形成视觉区分。下方设有累计场次步进器(0-200场范围)和心跳手环开关(布尔值切换)。心跳手环是一个特色功能——“恐怖主题可选·超阈值npc温柔演绎”,当用户心率超过设定阈值时NPC会降低互动强度,这是密室逃脱场景中极具人性化关怀的设计。弹框底部显示"已选N项耐受"的实时计数。

8.3 退出拼场确认弹框 modalBodyDel

退出拼场弹框采用警示设计,标题区使用红色文字"⚠️ 退出拼场确认",内容区以半透明红色背景展示退出警告与扣费规则。

Column() {
  Text('🤝')
    .fontSize(34)
  Text('即将退出「血色古宅」拼场小队')
    .fontSize(13)
    .fontColor(COLORS.textPrimary)
    .margin({ top: 8 })
  Text('开场前1小时退出将扣50%拼场费')
    .fontSize(11)
    .fontColor(COLORS.warn)
    .margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 18, bottom: 18 })
.backgroundColor('#FDECEA')
.borderRadius(14)
.margin({ top: 14 })

弹框展示三个统计指标:4人小队人数、158元拼场费用、1次本月退出次数。底部设有"再想想"(tagBg背景)与"确认退出"(danger红色背景)两个操作按钮,通过色彩对比引导用户谨慎操作。

8.4 求助提示卡弹框 modalBodyBiz (HINT CARD)

求助弹框是该应用最具特色的弹框之一,标题为"💡 求助提示卡 HINT CARD",内容区展示当前场次信息(禁忌画室、剩余时间32:18、已用1次免费提示、会员本月还有1张免费提示卡)。

Row() {
  Column() {
    Text('当前场次 · 禁忌画室')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
    Text('剩余时间 32:18 · 已用1次免费提示')
      .fontSize(11)
      .fontColor('#D9F3E5')
      .margin({ top: 4 })
    Text('会员本月还有 1 张免费提示卡')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.gold)
      .margin({ top: 6 })
  }
  .alignItems(HorizontalAlign.Start)
  .layoutWeight(1)
  Text('💡')
    .fontSize(36)
}
.width('100%')
.padding(14)
.borderRadius(14)
.linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
.margin({ top: 14 })

信息卡片使用从primaryDeep到accent的120度对角渐变背景,营造出从暗到亮的视觉引导效果。提示类型使用HINT_TAGS数组(方向提示、机关提示、剧情提示、直接答案四个选项)的多选标签,购买张数使用步进器(1-9张范围),费用按hintStep * 15公式计算。弹框底部的"立即求助"按钮使用primary深紫色背景,右侧显示金色合计金额。

九、模态覆盖层与底部导航栏

9.1 modalOverlay模态覆盖层

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

遮罩层使用rgba(22,10,36,0.55)——带有紫色调的半透明深色(55%透明度),与应用的紫色主题保持和谐。点击遮罩调用closeAll方法关闭所有弹框。弹框内容通过if条件渲染,根据四个开关状态决定显示哪个弹框。closeAll方法将四个弹框状态一次性设为false,实现了简洁的全局关闭逻辑。

9.2 底部导航栏 bottomBar

底部导航栏设有5个等宽tab:首页、订单、加号、消息、我的。中间的加号按钮使用翠绿色圆形背景,尺寸为24号字配10像素padding配26像素圆角。

Column() {
  Text('➕')
    .fontSize(24)
    .fontColor(COLORS.white)
    .padding(10)
    .backgroundColor(COLORS.accent)
    .borderRadius(26)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.addOpen = true })

加号按钮触发预约弹框,是核心转化入口。两侧tab选中态文字使用primary深紫色,未选中态使用textThird浅紫灰色。底部栏使用card纯白背景,无圆角设计(贴底全宽),与内容区域通过色彩自然分隔。

十、build主架构方法

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

build方法通过Stack堆叠两层:主内容层(Column包含header、mainContent、bottomBar三个垂直排列的构建器)和动效装饰层(fxLayer,事件穿透)。mainContent内部通过Scroll包裹tabBar与条件渲染的8个页面构建器,根据curTab值决定渲染哪个页面。这种两层堆叠架构保证了动效始终可见且不干扰交互。

mainContent中通过if条件链实现了8个页面的切换渲染逻辑:当mainTab为0时根据curTab渲染主题/场次/NPC/战绩/道具/拼场/攻略/档案页面,当mainTab非0时渲染pageMainOther(我的订单页)。

十一、架构流程图

0

1

2

3

4

5

6

7

PageEscapeRoom 入口组件

build 主架构

Stack 两层堆叠

Column 主内容层

fxLayer 动效装饰层

header 头部渐变

mainContent 内容滚动区

bottomBar 底部导航栏

tabBar 铁牌式标签栏

curTab 值判断

pageTheme 主题浏览

pageSession 场次预约

pageNpc NPC互动

pageRecord 个人战绩

pageProp 道具商城

pageTeam 拼场社交

pageTip 逃脱攻略

pageProfile 玩家档案

plateTab 主题x4 第一排

plateTab 道具x4 第二排

手电筒闪烁

幽灵漂移

蜡烛呼吸

钥匙旋转

modalOverlay 模态层

modalBodyAdd SESSION BOOKING

modalBodyEdit PLAYER FILE

modalBodyDel 退出拼场

modalBodyBiz HINT CARD

上图展示了谜域应用从build方法开始的完整组件树。Stack两层堆叠是骨架——主内容层的Column垂直排列header、mainContent与bottomBar,动效层fxLayer覆盖其上提供氛围动画。mainContent内部的tabBar以双排4+4铁牌面板形式排列8个内容标签,配合条件渲染链实现了8个业务页面的按需切换。四个modalBody弹框由各自的布尔状态控制显隐,通过modalOverlay统一管理覆盖层。

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

维度 ThemeItem 主题 SessionItem 场次 NpcItem NPC RecordItem 战绩 PropItem 道具 TeamItem 拼场 TipItem 攻略
接口名 ThemeItemInterface Session Npc EscapeRecord PropInterface Team Tip
字段数量 7 5 5 5 5 5 3
核心状态字段 fav (boolean) status/left scares result stock need/have -
装饰器 @Observed @Observed @Observed @Observed @Observed @Observed @Observed
初始数据量 12条 10条 8条 10条 8条 8条 6条
对应页面 pageTheme pageSession pageNpc pageRecord pageProp pageTeam pageTip
弹框关联 modalBodyAdd modalBodyAdd modalBodyBiz modalBodyEdit modalBodyBiz modalBodyBiz modalBodyEdit
构造函数模式 对象参数 对象参数 对象参数 对象参数 对象参数 对象参数 对象参数
弹框组件 英文标识 渐变背景 功能定位 表单元素 费用公式
modalBodyAdd SESSION BOOKING 预约场次 类型多选/人数步进 peopleStep*138
modalBodyEdit PLAYER FILE tagBg浅紫 编辑玩家档案 耐受度多选/场次步进/心跳开关 -
modalBodyDel 退出拼场确认 FDECEA浅红 退出拼场警示 影响统计展示 -
modalBodyBiz HINT CARD primaryDeep→accent 求助提示卡 类型多选/张数步进 hintStep*15

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 布局风格:门牌铁牌式内容tab(双排4+4,铁牌面板)+ 静态渐变头部
// 弹框:预约场次 / 玩家档案 / 退出拼场警示 / 求助提示卡
// ============================================================

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: '#F3EFF9',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#4A148C',
  primaryDeep: '#260046',
  accent: '#00A65A',
  accentSoft: '#E2F6EA',
  gold: '#FFB300',
  textPrimary: '#2A1A3E',
  textSecond: '#6E5F86',
  textThird: '#A395B8',
  line: '#E6DFF0',
  tagBg: '#EFEAF6',
  warn: '#EF6C00',
  danger: '#C62828',
  ok: '#2E7D32'
}

interface ThemeItemInterface {
  id: number
  name: string
  type: string
  players: string
  mins: number
  price: number
  fav: boolean
}

@Observed
class ThemeItem implements ThemeItemInterface {
  id: number = 0
  name: string = ''
  type: string = ''
  players: string = ''
  mins: number = 0
  price: number = 0
  fav: boolean = false

  constructor(o: ThemeItemInterface) {
    this.id = o.id
    this.name = o.name
    this.type = o.type
    this.players = o.players
    this.mins = o.mins
    this.price = o.price
    this.fav = o.fav ? o.fav : false
  }
}

interface Session {
  id: number
  theme: string
  time: string
  left: number
  status: string
}

@Observed
class SessionItem implements Session {
  id: number = 0
  theme: string = ''
  time: string = ''
  left: number = 0
  status: string = ''

  constructor(o: Session) {
    this.id = o.id
    this.theme = o.theme
    this.time = o.time
    this.left = o.left
    this.status = o.status
  }
}

interface Npc {
  id: number
  name: string
  role: string
  scares: number
  score: number
}

@Observed
class NpcItem implements Npc {
  id: number = 0
  name: string = ''
  role: string = ''
  scares: number = 0
  score: number = 0

  constructor(o: Npc) {
    this.id = o.id
    this.name = o.name
    this.role = o.role
    this.scares = o.scares
    this.score = o.score
  }
}

interface EscapeRecord {
  id: number
  theme: string
  date: string
  result: string
  used: string
}

@Observed
class RecordItem implements EscapeRecord {
  id: number = 0
  theme: string = ''
  date: string = ''
  result: string = ''
  used: string = ''

  constructor(o: EscapeRecord) {
    this.id = o.id
    this.theme = o.theme
    this.date = o.date
    this.result = o.result
    this.used = o.used
  }
}

interface PropInterface {
  id: number
  name: string
  use: string
  price: number
  stock: number
}

@Observed
class PropItem implements PropInterface {
  id: number = 0
  name: string = ''
  use: string = ''
  price: number = 0
  stock: number = 0

  constructor(o: PropInterface) {
    this.id = o.id
    this.name = o.name
    this.use = o.use
    this.price = o.price
    this.stock = o.stock
  }
}

interface Team {
  id: number
  title: string
  theme: string
  need: number
  have: number
}

@Observed
class TeamItem implements Team {
  id: number = 0
  title: string = ''
  theme: string = ''
  need: number = 0
  have: number = 0

  constructor(o: Team) {
    this.id = o.id
    this.title = o.title
    this.theme = o.theme
    this.need = o.need
    this.have = o.have
  }
}

interface Tip {
  id: number
  title: string
  desc: string
}

@Observed
class TipItem implements Tip {
  id: number = 0
  title: string = ''
  desc: string = ''

  constructor(o: Tip) {
    this.id = o.id
    this.title = o.title
    this.desc = o.desc
  }
}

const THEMES: ThemeItem[] = [
  new ThemeItem({ id: 1, name: '午夜病栋', type: '恐怖', players: '2-6人', mins: 70, price: 128, fav: true }),
  new ThemeItem({ id: 2, name: '雾都连环案', type: '烧脑', players: '3-6人', mins: 90, price: 158, fav: false }),
  new ThemeItem({ id: 3, name: '时光当铺', type: '沉浸', players: '2-5人', mins: 75, price: 148, fav: false }),
  new ThemeItem({ id: 4, name: '豪门夜宴', type: '欢乐', players: '4-8人', mins: 60, price: 98, fav: true }),
  new ThemeItem({ id: 5, name: '深潜七千米', type: '沉浸', players: '2-4人', mins: 80, price: 168, fav: false }),
  new ThemeItem({ id: 6, name: '禁忌画室', type: '恐怖', players: '2-5人', mins: 65, price: 138, fav: false }),
  new ThemeItem({ id: 7, name: '机械迷城2077', type: '烧脑', players: '2-6人', mins: 100, price: 188, fav: false }),
  new ThemeItem({ id: 8, name: '西游歪传', type: '欢乐', players: '4-8人', mins: 70, price: 118, fav: false }),
  new ThemeItem({ id: 9, name: '血色古宅', type: '恐怖', players: '3-6人', mins: 85, price: 158, fav: false }),
  new ThemeItem({ id: 10, name: '谎言拍卖会', type: '烧脑', players: '3-5人', mins: 80, price: 148, fav: false }),
  new ThemeItem({ id: 11, name: '雪国列车', type: '沉浸', players: '2-6人', mins: 95, price: 178, fav: false }),
  new ThemeItem({ id: 12, name: '校园怪谈', type: '恐怖', players: '2-4人', mins: 60, price: 108, fav: false })
]

const SESSIONS: SessionItem[] = [
  new SessionItem({ id: 1, theme: '午夜病栋', time: '今天 19:00', left: 3, status: '可约' }),
  new SessionItem({ id: 2, theme: '雾都连环案', time: '今天 20:30', left: 0, status: '满员' }),
  new SessionItem({ id: 3, theme: '时光当铺', time: '明天 14:00', left: 4, status: '可约' }),
  new SessionItem({ id: 4, theme: '豪门夜宴', time: '明天 16:00', left: 6, status: '可约' }),
  new SessionItem({ id: 5, theme: '深潜七千米', time: '明天 19:30', left: 1, status: '可约' }),
  new SessionItem({ id: 6, theme: '禁忌画室', time: '后天 13:00', left: 2, status: '可约' }),
  new SessionItem({ id: 7, theme: '机械迷城2077', time: '后天 15:30', left: 0, status: '满员' }),
  new SessionItem({ id: 8, theme: '血色古宅', time: '周六 20:00', left: 5, status: '可约' }),
  new SessionItem({ id: 9, theme: '谎言拍卖会', time: '周六 21:30', left: 3, status: '可约' }),
  new SessionItem({ id: 10, theme: '雪国列车', time: '周日 14:00', left: 2, status: '可约' })
]

const NPCS: NpcItem[] = [
  new NpcItem({ id: 1, name: '阿骨', role: '病栋护士长', scares: 1200, score: 4.9 }),
  new NpcItem({ id: 2, name: '小雾', role: '雾都侦探助手', scares: 0, score: 4.8 }),
  new NpcItem({ id: 3, name: '老铛', role: '当铺掌柜', scares: 80, score: 5.0 }),
  new NpcItem({ id: 4, name: '画灵', role: '画室女主人', scares: 980, score: 4.9 }),
  new NpcItem({ id: 5, name: '铁手', role: '机械城向导', scares: 0, score: 4.7 }),
  new NpcItem({ id: 6, name: '血伯', role: '古宅管家', scares: 1600, score: 5.0 }),
  new NpcItem({ id: 7, name: '拍姐', role: '拍卖师', scares: 0, score: 4.8 }),
  new NpcItem({ id: 8, name: '雪姐', role: '列车长', scares: 260, score: 4.9 })
]

const RECORDS: RecordItem[] = [
  new RecordItem({ id: 1, theme: '午夜病栋', date: '08-18', result: '逃脱成功', used: '58:12' }),
  new RecordItem({ id: 2, theme: '雾都连环案', date: '08-15', result: '逃脱成功', used: '72:40' }),
  new RecordItem({ id: 3, theme: '豪门夜宴', date: '08-12', result: '逃脱失败', used: '60:00' }),
  new RecordItem({ id: 4, theme: '时光当铺', date: '08-09', result: '逃脱成功', used: '55:03' }),
  new RecordItem({ id: 5, theme: '禁忌画室', date: '08-05', result: '逃脱成功', used: '48:57' }),
  new RecordItem({ id: 6, theme: '机械迷城2077', date: '07-28', result: '逃脱失败', used: '100:00' }),
  new RecordItem({ id: 7, theme: '深潜七千米', date: '07-21', result: '逃脱成功', used: '66:20' }),
  new RecordItem({ id: 8, theme: '血色古宅', date: '07-14', result: '逃脱成功', used: '63:45' }),
  new RecordItem({ id: 9, theme: '西游歪传', date: '07-06', result: '逃脱成功', used: '52:18' }),
  new RecordItem({ id: 10, theme: '谎言拍卖会', date: '06-30', result: '逃脱失败', used: '80:00' })
]

const PROPS: PropItem[] = [
  new PropItem({ id: 1, name: '夜视仪·单目', use: '暗房探索', price: 30, stock: 6 }),
  new PropItem({ id: 2, name: '强光手电', use: '基础照明', price: 10, stock: 20 }),
  new PropItem({ id: 3, name: '对讲机一对', use: '分队联络', price: 20, stock: 12 }),
  new PropItem({ id: 4, name: '剧情典藏卡', use: '纪念收藏', price: 25, stock: 30 }),
  new PropItem({ id: 5, name: '护身符挂件', use: '心理安慰', price: 15, stock: 18 }),
  new PropItem({ id: 6, name: '解谜笔记本', use: '线索记录', price: 8, stock: 40 }),
  new PropItem({ id: 7, name: 'npc合影券', use: '通关合影', price: 12, stock: 25 }),
  new PropItem({ id: 8, name: '主题T恤', use: '周边售卖', price: 68, stock: 10 })
]

const TEAMS: TeamItem[] = [
  new TeamItem({ id: 1, title: '周六夜场拼「血色古宅」', theme: '恐怖', need: 3, have: 4 }),
  new TeamItem({ id: 2, title: '烧脑党集合「机械迷城」', theme: '烧脑', need: 2, have: 4 }),
  new TeamItem({ id: 3, title: '萌新求带「豪门夜宴」', theme: '欢乐', need: 4, have: 4 }),
  new TeamItem({ id: 4, title: '两人开「深潜七千米」', theme: '沉浸', need: 2, have: 0 }),
  new TeamItem({ id: 5, title: '周五晚「午夜病栋」缺2', theme: '恐怖', need: 2, have: 4 }),
  new TeamItem({ id: 6, title: '「谎言拍卖会」侦探局', theme: '烧脑', need: 3, have: 2 }),
  new TeamItem({ id: 7, title: '「雪国列车」首班车', theme: '沉浸', need: 4, have: 2 }),
  new TeamItem({ id: 8, title: '「校园怪谈」胆大者进', theme: '恐怖', need: 2, have: 2 })
]

const TIPS: TipItem[] = [
  new TipItem({ id: 1, title: '进门三件事', desc: '先找光源、再看墙面文字、最后翻抽屉,80%的线索藏在这三处' }),
  new TipItem({ id: 2, title: '卡关就喊监督员', desc: '每场有3次免费提示,别硬磕浪费时间' }),
  new TipItem({ id: 3, title: '录音记线索', desc: '用手机录下进场口播,密码常藏在开场剧情里' }),
  new TipItem({ id: 4, title: '恐怖主题站位', desc: 'NPC从衣柜、床底、镜后出现,贴墙走更安全' }),
  new TipItem({ id: 5, title: '拼场社交礼仪', desc: '拼场局别剧透、别抢道具,出场可互换联系方式' }),
  new TipItem({ id: 6, title: '拍照姿势指南', desc: '通关后背景选主题墙,NPC合影记得比剪刀手' })
]

const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']
const MONTH_GAMES: number[] = [42, 48, 56, 51, 66, 74]
const TYPE_LABELS: string[] = ['恐怖', '烧脑', '沉浸', '欢乐']
const TYPE_PCTS: number[] = [34, 28, 24, 14]
const TYPE_COLORS: string[] = ['#4A148C', '#00A65A', '#FFB300', '#8E6BB8']
const THEME_TAGS: string[] = ['恐怖', '烧脑', '沉浸', '欢乐', '不限']
const FEAR_TAGS: string[] = ['零耐受', '微恐', '中恐', '重恐玩家']
const HINT_TAGS: string[] = ['方向提示', '机关提示', '剧情提示', '直接答案']

function gameBar(i: number): number {
  return MONTH_GAMES[i] * 1.5
}

function recColor(s: string): string {
  if (s === '逃脱成功') {
    return COLORS.ok
  }
  return COLORS.danger
}

function sessionColor(s: string): string {
  if (s === '可约') {
    return COLORS.accent
  }
  return COLORS.textThird
}

@Entry
@Component
struct PageEscapeRoom {
  @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 themeList: ThemeItem[] = THEMES
  @State teamList: TeamItem[] = TEAMS
  @State rev: number = 0
  @State tick: number = 0
  @State themeTags: number[] = [0]
  @State fearTags: number[] = [1]
  @State hintTags: number[] = [0]
  @State peopleStep: number = 3
  @State playedStep: number = 18
  @State heartFlag: boolean = true
  @State hintStep: number = 2
  private timer: number = -1

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

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

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

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

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

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

  refreshRecords(): void {
    this.rev = this.rev + 1
  }

  toggleThemeFav(i: number): void {
    this.themeList[i].fav = !this.themeList[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
  }

  hintTotal(): number {
    return this.hintStep * 15
  }

  @Builder
  fxLayer() {
    Stack() {
      Text('🔦')
        .fontSize(24)
        .opacity((this.tick % 8) / 8 + 0.15)
        .position({ x: 60, y: 140 })
      Text('👻')
        .fontSize(22)
        .translate({ x: this.tick % 50 })
        .position({ x: 290, y: 220 })
        .opacity(0.6)
      Text('🕯️')
        .fontSize(20)
        .scale({ x: 1 + (this.tick % 6) * 0.03, y: 1 + (this.tick % 6) * 0.03 })
        .position({ x: 350, y: 340 })
      Text('🗝️')
        .fontSize(18)
        .rotate({ angle: (this.tick % 24) * 15 })
        .position({ x: 140, y: 360 })
        .opacity(0.7)
      Text('✨')
        .fontSize(14)
        .position({ x: 520, y: 150 })
        .opacity((this.tick % 6) / 6 + 0.15)
    }
    .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('📍城西·谜域大厦B2')
          .fontSize(12)
          .fontColor('#D6C2F0')
        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('#BFA8DE')
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('搜索')
          .fontSize(13)
          .fontColor(COLORS.white)
          .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('全npc真人演绎')
          .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)
        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], [COLORS.primaryDeep, 1]] })
  }

  @Builder
  plateTab(name: string, icon: string, idx: number) {
    Column() {
      Stack() {
        Rect({ width: 34, height: 24 })
          .fill(this.curTab === idx ? COLORS.accent : COLORS.line)
          .borderRadius(5)
        Text(icon)
          .fontSize(11)
      }
      Text(name)
        .fontSize(11)
        .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.curTab === idx ? COLORS.primary : COLORS.textSecond)
        .margin({ top: 4 })
    }
    .width('23%')
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(this.curTab === idx ? COLORS.accentSoft : COLORS.card)
    .borderRadius(12)
    .margin({ top: 6 })
    .onClick(() => {
      this.switchTab(idx)
    })
  }

  @Builder
  tabBar() {
    Column() {
      Row({ space: 6 }) {
        this.plateTab('主题', '🚪', 0)
        this.plateTab('场次', '🕰', 1)
        this.plateTab('NPC', '🎭', 2)
        this.plateTab('战绩', '🏅', 3)
      }
      .width('100%')
      Row({ space: 6 }) {
        this.plateTab('道具', '🧰', 4)
        this.plateTab('拼场', '🤝', 5)
        this.plateTab('攻略', '📜', 6)
        this.plateTab('档案', '🗃', 7)
      }
      .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.accent)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(COLORS.accentSoft)
        .borderRadius(10)
        .onClick(() => {
          if (act === 0) {
            this.refreshThemes()
          } else if (act === 1) {
            this.refreshTeams()
          } else if (act === 2) {
            this.refreshRecords()
          } else {
            this.addOpen = true
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 6 })
  }

  @Builder
  pageTheme() {
    Column() {
      Row() {
        Column() {
          Text('本周新主题 · 禁忌画室首发')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('真人npc追逃 · 全程机关联动')
            .fontSize(11)
            .fontColor('#D9F3E5')
            .margin({ top: 4 })
          Text('首发价 ¥118/人')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('🚪')
          .fontSize(38)
          .margin({ right: 12 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primary, 0], [COLORS.accent, 1]] })
      .onClick(() => {
        this.addOpen = true
      })

      this.sectionTitle('全部主题', '换一批', 0)
      ForEach(this.themeList, (it: ThemeItem, i: number) => {
        Row() {
          Stack() {
            Column()
              .width(50)
              .height(50)
              .backgroundColor(COLORS.tagBg)
              .borderRadius(10)
            Text(it.type === '恐怖' ? '👻' : (it.type === '烧脑' ? '🧠' : (it.type === '沉浸' ? '🌀' : '🎉')))
              .fontSize(24)
          }
          Column() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.type + ' · ' + it.players + ' · ' + it.mins + '分钟')
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Column() {
            Text(it.fav ? '❤️' : '🤍')
              .fontSize(15)
              .onClick(() => {
                this.toggleThemeFav(i)
              })
            Text('¥' + it.price + '/人')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
              .margin({ top: 6 })
          }
          .alignItems(HorizontalAlign.End)
          Text('约场')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(COLORS.primary)
            .borderRadius(14)
            .margin({ left: 10 })
            .onClick(() => {
              this.addOpen = true
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: ThemeItem) => 'th' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageSession() {
    Column() {
      this.sectionTitle('今日场次', '整点开放', 3)
      ForEach(SESSIONS, (it: SessionItem, i: number) => {
        Row() {
          Column() {
            Text(it.time)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.theme)
              .fontSize(11)
              .fontColor(COLORS.primary)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text(it.left > 0 ? '余' + it.left + '位' : '已满')
            .fontSize(11)
            .fontColor(it.left > 0 ? COLORS.accent : COLORS.textThird)
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor(COLORS.accentSoft)
            .borderRadius(8)
          Text(it.status === '可约' ? '订场' : '候补')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(it.status === '可约' ? COLORS.primary : COLORS.accent)
            .borderRadius(14)
            .margin({ left: 10 })
            .onClick(() => {
              if (it.status === '可约') {
                this.addOpen = true
              } else {
                this.bizOpen = true
              }
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.addOpen = true
        })
      }, (it: SessionItem) => 'se' + it.id.toString())

      Column() {
        Text('订场须知')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('① 开场前10分钟到店,迟到10分钟以上不改不退')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 6 })
        Text('② 恐怖主题16岁以下需成人陪同入场')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
        Text('③ 贵重物品免费存柜,场内禁止奔跑打闹')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
      }
      .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
  pageNpc() {
    Column() {
      this.sectionTitle('驻场NPC', '本月在岗', 3)
      ForEach(NPCS, (it: NpcItem, i: number) => {
        Row() {
          Stack() {
            Circle({ width: 46, height: 46 })
              .fill(COLORS.tagBg)
            Text('🎭')
              .fontSize(22)
          }
          Column() {
            Text(it.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('角色 ' + it.role)
              .fontSize(11)
              .fontColor(COLORS.primary)
              .margin({ top: 3 })
            Text(it.scares > 0 ? '吓哭玩家' + it.scares + '次' : '文明演绎不吓人')
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Column() {
            Text('⭐' + it.score)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
            Text('求合影')
              .fontSize(11)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(COLORS.accent)
              .borderRadius(12)
              .margin({ top: 6 })
              .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.editOpen = true
        })
      }, (it: NpcItem) => 'np' + it.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageRecord() {
    Column() {
      this.sectionTitle('我的战绩', '刷新', 2)
      Row({ space: 8 }) {
        Column() {
          Text('10')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('总场次')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('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('70%')
            .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%')
      .padding({ left: 16, right: 16 })

      ForEach(RECORDS, (it: RecordItem, i: number) => {
        Row() {
          Text('#' + it.id)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textThird)
            .width(32)
          Column() {
            Text(it.theme)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.date + ' · 用时 ' + it.used)
              .fontSize(10)
              .fontColor(COLORS.textSecond)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 6 })
          Text(it.result)
            .fontSize(10)
            .fontColor(recColor(it.result))
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor(COLORS.tagBg)
            .borderRadius(8)
        }
        .width('100%')
        .padding(11)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.editOpen = true
        })
      }, (it: RecordItem) => 'rc' + it.id.toString() + '_' + this.rev.toString())

      Column() {
        Text('近6月场次')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row({ space: 12 }) {
          ForEach(MONTH_LABELS, (m: string, i: number) => {
            Column() {
              Text(MONTH_GAMES[i].toString())
                .fontSize(9)
                .fontColor(COLORS.textThird)
              Column()
                .width(16)
                .height(gameBar(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) => 'gm' + 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
  pageProp() {
    Column() {
      this.sectionTitle('道具商城', '通关神器', 3)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(PROPS, (it: PropItem, i: number) => {
          Column() {
            Stack() {
              Column()
                .width(56)
                .height(40)
                .backgroundColor(COLORS.tagBg)
                .borderRadius(8)
              Text('🧰')
                .fontSize(24)
            }
            Text(it.name)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ top: 6 })
            Text(it.use)
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 2 })
            Row() {
              Text('¥' + it.price)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primary)
              Text('租')
                .fontSize(10)
                .fontColor(COLORS.white)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .backgroundColor(COLORS.accent)
                .borderRadius(10)
                .margin({ left: 8 })
                .onClick(() => {
                  this.bizOpen = true
                })
            }
            .margin({ top: 6 })
            Text('库存' + it.stock)
              .fontSize(9)
              .fontColor(it.stock < 12 ? COLORS.warn : COLORS.ok)
              .margin({ top: 2 })
          }
          .width('31%')
          .alignItems(HorizontalAlign.Center)
          .padding(10)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ top: 8 })
          .onClick(() => {
            this.bizOpen = true
          })
        }, (it: PropItem) => 'pr' + it.id.toString())
      }
      .width('94%')
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageTeam() {
    Column() {
      this.sectionTitle('拼场大厅', '换一批', 1)
      ForEach(this.teamList, (it: TeamItem, i: number) => {
        Row() {
          Column() {
            Text(it.title)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Row() {
              Text(it.theme)
                .fontSize(10)
                .fontColor(COLORS.primary)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor(COLORS.accentSoft)
                .borderRadius(8)
              Text('已有' + it.have + '人 · 还缺' + (it.need - it.have > 0 ? it.need - it.have : 0) + '人')
                .fontSize(10)
                .fontColor(COLORS.textSecond)
                .margin({ left: 8 })
            }
            .margin({ top: 5 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('加入')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.primary)
            .borderRadius(16)
            .onClick(() => {
              this.bizOpen = true
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.bizOpen = true
        })
      }, (it: TeamItem) => 'tm' + it.id.toString() + '_' + this.rev.toString())

      Text('发起拼场')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .padding({ left: 20, right: 20, top: 10, bottom: 10 })
        .backgroundColor(COLORS.accent)
        .borderRadius(20)
        .margin({ top: 14 })
        .onClick(() => {
          this.addOpen = true
        })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
    .padding({ bottom: 12 })
  }

  @Builder
  pageTip() {
    Column() {
      this.sectionTitle('逃脱攻略', '店长整理', 3)
      ForEach(TIPS, (it: TipItem, i: number) => {
        Row() {
          Column() {
            Text('0' + it.id)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
          }
          .padding(8)
          Column() {
            Text(it.title)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.desc)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 6 })
        }
        .alignItems(VerticalAlign.Top)
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.editOpen = true
        })
      }, (it: TipItem) => 'tp' + it.id.toString())

      Column() {
        Text('主题类型占比')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          ForEach(TYPE_PCTS, (p: number, i: number) => {
            Column()
              .layoutWeight(p)
              .height(12)
              .backgroundColor(TYPE_COLORS[i])
          }, (p: number, i: number) => 'typ' + i.toString())
        }
        .width('100%')
        .borderRadius(6)
        .clip(true)
        .margin({ top: 10 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(TYPE_LABELS, (l: string, i: number) => {
            Row() {
              Circle({ width: 7, height: 7 })
                .fill(TYPE_COLORS[i])
              Text(l + ' ' + TYPE_PCTS[i] + '%')
                .fontSize(10)
                .fontColor(COLORS.textSecond)
                .margin({ left: 4 })
            }
            .margin({ right: 12, top: 8 })
          }, (l: string) => 'tl' + l)
        }
        .margin({ top: 2 })
      }
      .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
  pageProfile() {
    Column() {
      Row() {
        Column() {
          Text('谜域玩家 · 青铜侦探')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('再通关2次升级「白银名探」享9折')
            .fontSize(11)
            .fontColor('#D9F3E5')
            .margin({ top: 4 })
          Row() {
            Column()
              .width(120)
              .height(6)
              .backgroundColor('rgba(255,255,255,0.24)')
              .borderRadius(3)
            Column()
              .width(80)
              .height(6)
              .backgroundColor(COLORS.gold)
              .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('10')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('累计场次')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('48:57')
            .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('760')
            .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)
        Text('· 生日月主题半价')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 6 })
        Text('· 每月1张免费提示卡')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
        Text('· 新主题提前48小时预约')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ top: 12 })
      .alignItems(HorizontalAlign.Start)

      Text('清空战绩记录')
        .fontSize(12)
        .fontColor(COLORS.danger)
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FDECEA')
        .borderRadius(16)
        .margin({ top: 14 })
        .onClick(() => {
          this.delOpen = true
        })
    }
    .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('明天 19:30 · 深潜七千米 · 3人')
        .fontSize(12)
        .fontColor(COLORS.textSecond)
        .margin({ top: 6 })
      Text('开场前2小时可免费改期一次')
        .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('🕰 预约场次 SESSION BOOKING')
          .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(THEME_TAGS, (t: string, i: number) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.themeTags.indexOf(i) >= 0 ? COLORS.white : COLORS.accent)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.themeTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.themeTags, i)
            })
        }, (t: string) => 'tt' + t)
      }
      .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 > 2) {
              this.peopleStep = this.peopleStep - 1
            }
          })
        Column() {
          Text(this.peopleStep.toString() + ' 人')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.accent)
          .borderRadius(10)
          .onClick(() => {
            if (this.peopleStep < 8) {
              this.peopleStep = this.peopleStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Text('含免费存包 · 3次场内提示 · 通关合影')
        .fontSize(11)
        .fontColor(COLORS.textThird)
        .margin({ top: 8 })

      Row() {
        Text('提交预约')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.accent)
          .borderRadius(20)
          .onClick(() => {
            this.addOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('合计 ¥' + (this.peopleStep * 138))
          .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('🎭 编辑玩家档案 PLAYER 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(FEAR_TAGS, (f: string, i: number) => {
          Text(f)
            .fontSize(12)
            .fontColor(this.fearTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.fearTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.tagBg)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.fearTags, i)
            })
        }, (f: string) => 'ft' + f)
      }
      .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.playedStep > 0) {
              this.playedStep = this.playedStep - 1
            }
          })
        Column() {
          Text(this.playedStep.toString() + ' 场')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.playedStep < 200) {
              this.playedStep = this.playedStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Column() {
          Text('心跳手环')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('恐怖主题可选 · 超阈值npc温柔演绎')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.heartFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.heartFlag ? COLORS.white : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.heartFlag ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.heartFlag = !this.heartFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.tagBg)
      .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.fearTags.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('即将退出「血色古宅」拼场小队')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 8 })
        Text('开场前1小时退出将扣50%拼场费')
          .fontSize(11)
          .fontColor(COLORS.warn)
          .margin({ top: 6 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 18, bottom: 18 })
      .backgroundColor('#FDECEA')
      .borderRadius(14)
      .margin({ top: 14 })

      Row({ space: 8 }) {
        Column() {
          Text('4人')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('小队人数')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('¥158')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warn)
          Text('拼场费用')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('1次')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
          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('💡 求助提示卡 HINT CARD')
          .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('剩余时间 32:18 · 已用1次免费提示')
            .fontSize(11)
            .fontColor('#D9F3E5')
            .margin({ top: 4 })
          Text('会员本月还有 1 张免费提示卡')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('💡')
          .fontSize(36)
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
      .margin({ top: 14 })

      Text('提示类型')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(HINT_TAGS, (h: string, i: number) => {
          Text(h)
            .fontSize(12)
            .fontColor(this.hintTags.indexOf(i) >= 0 ? COLORS.white : COLORS.accent)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.hintTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.hintTags, i)
            })
        }, (h: string) => 'ht' + h)
      }
      .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.hintStep > 1) {
              this.hintStep = this.hintStep - 1
            }
          })
        Column() {
          Text(this.hintStep.toString() + ' 张')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.hintStep < 9) {
              this.hintStep = this.hintStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      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.bizOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('合计 ¥' + this.hintTotal())
          .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
  modalOverlay() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(22,10,36,0.55)')
        .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.white)
          .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.pageTheme()
          }
          if (this.curTab === 1) {
            this.pageSession()
          }
          if (this.curTab === 2) {
            this.pageNpc()
          }
          if (this.curTab === 3) {
            this.pageRecord()
          }
          if (this.curTab === 4) {
            this.pageProp()
          }
          if (this.curTab === 5) {
            this.pageTeam()
          }
          if (this.curTab === 6) {
            this.pageTip()
          }
          if (this.curTab === 7) {
            this.pageProfile()
          }
        } 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 API 24在构建沉浸式娱乐垂直社区应用时的技术表达力与工程成熟度。该应用以密室逃脱为核心场景,通过七个interface+implements数据模型对(覆盖主题、场次、NPC、战绩、道具、拼场、攻略七大业务域)建立了严格的数据契约体系,每个接口定义了清晰的数据形状,每个@Observed实现类提供了可观察性,两者通过implements关键字建立正式的类型关联。这种接口驱动的数据模型设计不仅保证了类型安全,还使得数据形状的定义与可观察实现可以独立演进,在大型项目中尤其有利于团队协作与代码维护。

在UI架构层面,铁牌式标签栏(plateTab)以密室门牌铁牌为隐喻,通过Stack叠加Rect铁牌底板与Text图标构建了视觉上富有金属质感的标签面板。双排4+4的布局将8个内容标签紧凑排列,第一排聚焦核心体验(主题/场次/NPC/战绩),第二排覆盖辅助功能(道具/拼场/攻略/档案),层次清晰。fxLayer动效系统通过手电筒闪烁、幽灵漂移、蜡烛呼吸、钥匙旋转、星光交替五种动效元素营造了密室逃脱场景的悬疑氛围感,全部由tick变量的取模运算驱动,不同元素采用不同取模基数实现节奏错位,以105ms的定时器间隔保证了动画的流畅性。

在状态管理方面,rev修订号机制是该应用的一个精妙设计——每当列表数据发生变化(如收藏切换、列表轮转)时,rev递增1,ForEach的键值生成器将rev纳入键值计算(如'th' + id + '_' + rev),确保了即使数据对象的id未变,只要rev变化就能触发列表项的重新渲染。toggleTag通用方法通过indexOf判断与splice/push操作实现了三组多选标签(themeTags、fearTags、hintTags)的统一切换逻辑,体现了ArkTS中方法复用的简洁性。closeAll方法将四个弹框状态一次性重置为false,提供了简洁的全局关闭入口。

在弹框设计层面,四个弹框组件各具特色:预约场次弹框以主题类型多选与人数步进为核心表单,费用按peopleStep * 138动态计算;玩家档案弹框的恐怖耐受度多选与心跳手环开关体现了密室场景的人性化设计——心跳手环在恐怖主题中可根据心率阈值调节NPC互动强度;退出拼场弹框通过红色警示背景与扣费规则提示引导用户谨慎操作;求助提示卡弹框以深紫到翠绿的对角渐变信息卡片展示当前场次状态,配合提示类型多选与张数步进,费用按hintStep * 15计算。每个弹框都拥有英文标识与中文标题的双语呈现,体现了品牌识别的系统性。

通过interface+implements数据模型体系、@State状态变量驱动、@Builder构建器封装、定时器+取模运算的轻量级动效驱动,无需引入任何第三方框架即可构建出具备专业级视觉效果与复杂业务逻辑的垂直社区应用。105ms间隔的定时器是整个动效系统唯一的定时驱动源,hitTestBehavior(HitTestMode.None)确保动效层不干扰交互,constraintSize限制弹框最大高度,scrollBar(BarState.Off)隐藏滚动条——这些细节处理共同营造了沉浸式的用户体验。随着HarmonyOS API 24的持续演进与HarmonyOS生态的蓬勃发展,我们有理由期待更多沉浸式娱乐社区应用基于这一技术栈实现快速落地,为用户带来更加丰富、流畅、沉浸的数字体验。

Logo

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

更多推荐